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; } }