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 { // The stored switch first, MAIL_MAILER behind it — see // App\Support\MailDelivery. Read here, at the point of use, so a // long-running queue worker follows a change made in the console // instead of keeping whatever was true when it started. $default = MailDelivery::transport(); 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)); // Neither this transport nor MailboxTester bounded its socket // before this fix: a filtered outbound port (587 dropped by a // firewall, unlike a closed one that refuses instantly) hung // until PHP's default_socket_timeout (60s ini default) — for a // queue worker, that is one send blocking every other job behind // it. 30s, not MailboxTester's 10s: a worker can afford to wait // longer than an operator watching a browser, and a send that // times out is retried by the queue, not lost. $stream = $transport->getStream(); if ($stream instanceof SocketStream) { $stream->setTimeout(30); } // 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; } }