CluPilotCloud/app/Mail/Transport/MailboxTransport.php

202 lines
8.9 KiB
PHP

<?php
namespace App\Mail\Transport;
use App\Models\Mailbox;
use App\Services\Mail\MailboxResolver;
use App\Services\Mail\MailTlsPolicy;
use App\Support\Settings;
use Illuminate\Mail\Transport\ArrayTransport;
use Illuminate\Mail\Transport\LogTransport;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\NullTransport;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\RawMessage;
/**
* Resolves its credentials when it sends, not when it is built.
*
* The mails are queued. A worker builds its mailer once and lives for hours, so
* anything decided in a constructor is decided for the rest of that worker's
* life — including a password the operator has since corrected in the console.
*
* The delegate is cached against a FINGERPRINT of the credentials rather than
* rebuilt per message: correct when they change, and no new SMTP connection per
* mail when they do not.
*/
class MailboxTransport implements TransportInterface
{
/**
* mail.default values that must never open a real connection.
*
* 'array' is here because phpunit.xml sets it as the suite-wide test
* default — the first feature test that sends through a purpose mailer
* without Mail::fake() must not reach real SMTP just because 'array' is
* not the exact string 'log'. `null` is here because a stored setting can
* decode to it just as easily as to a real value (see the port note in
* delegate()): an unconfigured mailer must fail SAFE, not fail open.
*/
private const NON_DELIVERING = ['log', 'array', null];
private ?TransportInterface $delegate = null;
private ?string $fingerprint = null;
public function __construct(private readonly string $purpose) {}
public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage
{
return $this->delegate()->send($message, $envelope);
}
public function __toString(): string
{
return 'mailbox://'.$this->purpose.'/'.($this->describe() ?? 'unconfigured');
}
/** What this transport currently points at — used by __toString and tests. */
private function describe(): ?string
{
[$mode, $box] = $this->resolution();
return match ($mode) {
'mailbox' => $box?->address,
// Same verdict delegate() throws on, not a second opinion: a DSN
// that still read as a plain address here is the exact harm this
// exists to prevent — it looked healthy right up until send()
// threw. The address stays visible so an operator can tell WHICH
// mailbox needs a password, rather than just that something does.
'unconfigured' => $box === null ? null : $box->address.' [unconfigured]',
default => $mode,
};
}
/**
* The ONE place that decides "log, array, null-default, an unconfigured
* mailbox, or a real one" — describe() and delegate() both branch on this
* result rather than each running their own copy of a check. Two copies
* of the same guard is what let a mutation that broke only ONE of them
* hide behind a passing suite: __toString() kept reporting "log" (and
* later, a plain address) while delegate() quietly did something else —
* built a real SMTP transport regardless of MAIL_MAILER, then threw on a
* mailbox describe() had just shown as fine.
*
* @return array{0: string, 1: ?Mailbox}
*/
private function resolution(): array
{
$default = config('mail.default');
if (in_array($default, self::NON_DELIVERING, true)) {
return [$default ?? 'null', null];
}
$box = app(MailboxResolver::class)->for($this->purpose);
return $box !== null && $box->isConfigured()
? ['mailbox', $box]
: ['unconfigured', $box];
}
private function delegate(): TransportInterface
{
[$mode, $box] = $this->resolution();
if ($mode === 'unconfigured') {
// Loud, not silent: a mail with no mailbox behind it must not look
// like it was sent. A missing or deactivated MAPPING already fell
// back to system inside MailboxResolver; reaching this line means
// the mailbox we ended up with — possibly system itself — has no
// usable credentials (no row at all, or one with no password),
// and switching to yet another address would only hide that.
throw new RuntimeException(
"No configured mailbox for mail purpose [{$this->purpose}]."
);
}
if ($mode !== 'mailbox') {
return match ($mode) {
'log' => new LogTransport(Log::channel(config('mail.mailers.log.channel'))),
'array' => new ArrayTransport,
default => new NullTransport, // 'null' — mail.default not configured at all
};
}
// $mode === 'mailbox' only when resolution() already confirmed $box
// is non-null and isConfigured() — nothing left to check here.
$host = (string) Settings::get('mail.host', '');
$port = (int) Settings::get('mail.port', 587);
$encryption = (string) Settings::get('mail.encryption', 'tls');
// Guarded the same way as the missing mailbox above. Tasks 6-7 are
// what write these settings, so today they default to blank/absent —
// and a STORED null port json-decodes to null, which casts to 0; left
// unguarded, EsmtpTransport turns port 0 into plaintext port 25
// rather than refusing (the 587 default only applies when the row is
// absent, not when it holds null). Loud beats a silently downgraded
// connection.
if ($host === '') {
throw new RuntimeException('Mail server host is not configured — cannot send mail.');
}
if ($port < 1) {
throw new RuntimeException('Mail server port is not configured — cannot send mail.');
}
// authenticates is part of the fingerprint too, not only host/port/
// encryption/username/password: Mailbox::smtpUsername() always
// returns something non-empty (it falls back to the address), so an
// operator toggling authenticates off with everything else unchanged
// must still invalidate the cached delegate below — otherwise a
// long-running queue worker would keep the OLD delegate, which
// already has setUsername()/setPassword() called on it from before
// the toggle, still attempting AUTH the operator just turned off.
//
// Codex R15#5, P2 comparison pass: the password component is read
// the same way setPassword() below is gated — only when this mailbox
// authenticates. $box->password decrypts under SECRETS_KEY, and an
// unauthenticated mailbox that once had a password (an operator
// unchecked "requires a password" without clearing the field, which
// EditMailbox::save() deliberately leaves alone) still carries that
// ciphertext in the column. Decrypting it here regardless would
// crash a real send the moment SECRETS_KEY became unusable, purely
// to fingerprint a value setUsername()/setPassword() below is never
// going to read either.
$fingerprint = md5(implode('|', [
$host, $port, $encryption, (int) $box->authenticates, $box->smtpUsername(),
$box->authenticates ? (string) $box->password : '',
]));
if ($this->delegate === null || $this->fingerprint !== $fingerprint) {
// MailTlsPolicy is the ONE place "ssl, tls, or none" turns into
// EsmtpTransport's three TLS switches — see its docblock. The
// third constructor argument here is implicit TLS (smtps, port
// 465), not STARTTLS: passing true for 'tls' would open an SSL
// socket against a server expecting STARTTLS and hang.
$policy = MailTlsPolicy::for($encryption, $port);
$transport = $policy->apply(new EsmtpTransport($host, $port, $policy->implicit));
// Codex R15#4, P1b: only called when this mailbox actually
// authenticates. smtpUsername()'s address fallback means it is
// NEVER empty, so calling setUsername() unconditionally would
// make Symfony attempt AUTH against any server that advertises
// it — exactly what a trusted, unauthenticated relay was never
// asked to answer.
if ($box->authenticates) {
$transport->setUsername($box->smtpUsername());
$transport->setPassword((string) $box->password);
}
$this->delegate = $transport;
$this->fingerprint = $fingerprint;
}
return $this->delegate;
}
}