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.'); } $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; } }