202 lines
8.9 KiB
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;
|
|
}
|
|
}
|