CluPilotCloud/app/Mail/Transport/MailboxTransport.php

153 lines
6.0 KiB
PHP

<?php
namespace App\Mail\Transport;
use App\Models\Mailbox;
use App\Services\Mail\MailboxResolver;
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 $mode === 'mailbox' ? $box?->address : $mode;
}
/**
* The ONE place that decides "log, array, null, or a real mailbox" —
* describe() and delegate() both branch on this result rather than each
* running their own copy of the 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" while delegate() quietly
* built a real SMTP transport regardless of MAIL_MAILER.
*
* @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];
}
return ['mailbox', app(MailboxResolver::class)->for($this->purpose)];
}
private function delegate(): TransportInterface
{
[$mode, $box] = $this->resolution();
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
};
}
if ($box === null || ! $box->isConfigured()) {
// 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, and switching to yet another address would
// only hide that.
throw new RuntimeException(
"No configured mailbox for mail purpose [{$this->purpose}]."
);
}
$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.');
}
$fingerprint = md5(implode('|', [
$host, $port, $encryption, $box->smtpUsername(), (string) $box->password,
]));
if ($this->delegate === null || $this->fingerprint !== $fingerprint) {
// The third argument is IMPLICIT TLS (smtps, port 465) — not
// STARTTLS. EsmtpTransport negotiates STARTTLS by itself on a
// plain connection, which is what port 587 wants. Passing true for
// 'tls' here would open an SSL socket against a server expecting
// STARTTLS and hang.
$transport = new EsmtpTransport($host, $port, $encryption === 'ssl' || $port === 465);
$transport->setUsername($box->smtpUsername());
$transport->setPassword((string) $box->password);
$this->delegate = $transport;
$this->fingerprint = $fingerprint;
}
return $this->delegate;
}
}