153 lines
6.0 KiB
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;
|
|
}
|
|
}
|