setting key. The password is separate: it is stored encrypted. */ public const FIELDS = [ 'mailer' => 'mail.mailer', 'host' => 'mail.host', 'port' => 'mail.port', 'encryption' => 'mail.encryption', 'username' => 'mail.username', 'from_address' => 'mail.from_address', 'from_name' => 'mail.from_name', ]; public const PASSWORD_KEY = 'mail.password'; /** * Set when an installation administrator saved the page SMTP settings: only * then may the host be internal. Anyone else saving clears it. */ public const PAGE_HOST_TRUSTED_KEY = 'mail.host_trusted'; public const PAGE_FIELDS = [ 'mode' => 'mail.mode', 'host' => 'mail.host', 'port' => 'mail.port', 'encryption' => 'mail.encryption', 'username' => 'mail.username', 'from_address' => 'mail.from_address', 'from_name' => 'mail.from_name', 'reply_to' => 'mail.reply_to', ]; public const MAILERS = ['smtp', 'sendmail', 'log']; /** none = plain (STARTTLS still taken when offered); tls = STARTTLS required, on 587; ssl = implicit TLS on 465. */ public const ENCRYPTIONS = ['none', 'tls', 'ssl']; public function apply(): void { try { $stored = $this->stored(); $password = $this->password(); } catch (\Throwable) { // No database yet (installer, fresh clone, `migrate` on an empty // schema, a test without the table): .env is all there is. return; } if ($stored['mailer'] !== '') { config(['mail.default' => $stored['mailer']]); } foreach (['host', 'username'] as $field) { if ($stored[$field] !== '') { config(["mail.mailers.smtp.{$field}" => $stored[$field]]); } } if ($stored['port'] !== '') { config(['mail.mailers.smtp.port' => (int) $stored['port']]); } // Laravel picks the transport from the scheme: smtps is implicit TLS, // smtp is plain with STARTTLS whenever the server offers it. if ($stored['encryption'] !== '') { config([ 'mail.mailers.smtp.scheme' => $stored['encryption'] === 'ssl' ? 'smtps' : 'smtp', // TLS (STARTTLS) is a promise: without it a server that does not // offer STARTTLS would get the password in plain text. 'mail.mailers.smtp.require_tls' => $stored['encryption'] === 'tls', ]); } if ($password !== null) { config(['mail.mailers.smtp.password' => $password]); } if ($stored['from_address'] !== '') { config(['mail.from.address' => $stored['from_address']]); } if ($stored['from_name'] !== '') { config(['mail.from.name' => $stored['from_name']]); } } /** * What the form shows: every stored value as a string, empty when unset. * * @return array */ public function stored(): array { $values = []; foreach (self::FIELDS as $field => $key) { $values[$field] = trim((string) Setting::get($key, '')); } return $values; } /** The plaintext, or null when none is stored or the app key changed since. */ public function password(): ?string { $stored = Setting::get(self::PASSWORD_KEY); if (! is_string($stored) || $stored === '') { return null; } try { return Crypt::decryptString($stored); } catch (DecryptException) { // A rotated APP_KEY: the value is unreadable, not wrong. Falling back // to .env beats failing every boot; the form asks for it again. return null; } } public function hasPassword(): bool { return filled(Setting::get(self::PASSWORD_KEY)); } /** * @param array $data validated form input */ public function save(array $data): void { foreach (self::FIELDS as $field => $key) { Setting::put($key, trim((string) ($data[$field] ?? ''))); } // Empty means "keep": the field is never rendered back, so a plain // re-save must not wipe it. if (filled($data['password'] ?? null)) { Setting::put(self::PASSWORD_KEY, Crypt::encryptString($data['password'])); } // Setting::put dropped the cache keys; this request must see them too. $this->apply(); } /** * What a mail would go out with right now, database and .env combined. * No password: this is shown on a screen. * * @return array */ /** Can a mail leave this install at all? SMTP without a host cannot; every other transport can. */ public function configured(): bool { $page = $this->storedPage(); if (($page['mode'] ?: 'central') === 'custom') { if ($this->pageHasPassword()) { try { $this->pagePassword(); } catch (RuntimeException) { return false; } } return $page['host'] !== '' && (int) $page['port'] > 0 && $this->sender()['address'] !== ''; } $effective = $this->effective(); return $effective['mailer'] !== 'smtp' || $effective['host'] !== ''; } public function effective(): array { $mailer = (string) config('mail.default'); $transport = config("mail.mailers.{$mailer}", []); return [ 'mailer' => $mailer, 'host' => (string) ($transport['host'] ?? ''), 'port' => (string) ($transport['port'] ?? ''), 'from' => (string) config('mail.from.address'), 'from_name' => (string) (config('mail.from.name') ?: app(Branding::class)->name()), ]; } /** * @param array $data * @param bool $hostTrusted saved by an installation administrator, who may use an internal relay */ public function savePage(array $data, bool $hostTrusted = false): void { $pageId = app(PageContext::class)->id(); StatusPageSetting::updateOrCreate( ['status_page_id' => $pageId, 'key' => self::PAGE_HOST_TRUSTED_KEY], ['value' => $hostTrusted ? '1' : '0'], ); foreach (self::PAGE_FIELDS as $field => $key) { StatusPageSetting::updateOrCreate( ['status_page_id' => $pageId, 'key' => $key], ['value' => trim((string) ($data[$field] ?? ''))], ); } if (filled($data['password'] ?? null)) { StatusPageSetting::updateOrCreate( ['status_page_id' => $pageId, 'key' => self::PASSWORD_KEY], ['value' => Crypt::encryptString((string) $data['password'])], ); } } /** @return array */ public function storedPage(): array { $values = []; foreach (self::PAGE_FIELDS as $field => $key) { $values[$field] = trim((string) $this->pageValue($key, '')); } return $values; } /** Was the page SMTP host set by an installation administrator? */ public function pageHostTrusted(): bool { return $this->pageValue(self::PAGE_HOST_TRUSTED_KEY) === '1'; } public function pageHasPassword(): bool { return filled($this->pageValue(self::PASSWORD_KEY)); } /** @return array */ public function effectivePage(): array { $stored = $this->storedPage(); $sender = $this->sender(); if (($stored['mode'] ?: 'central') === 'custom') { return [ 'mode' => 'custom', 'mailer' => 'smtp', 'host' => $stored['host'], 'port' => $stored['port'], 'from' => $sender['address'], 'from_name' => $sender['name'], 'reply_to' => $sender['reply_to'], ]; } return array_merge($this->effective(), [ 'mode' => 'central', 'from' => $sender['address'], 'from_name' => $sender['name'], 'reply_to' => $sender['reply_to'], ]); } /** @return array{address: string, name: string, reply_to: string} */ public function sender(): array { $stored = $this->storedPage(); return [ 'address' => $stored['from_address'] ?: (string) config('mail.from.address'), 'name' => $stored['from_name'] ?: ((string) config('mail.from.name') ?: app(Branding::class)->name()), 'reply_to' => $stored['reply_to'], ]; } /** * Mail is for someone else, so it is rendered in the installation zone even * when an admin with a personal zone triggers it. */ public function sendTo(string $address, Mailable $mail): void { Clock::withInstallationZone(fn () => $this->deliver($address, $mail)); } protected function deliver(string $address, Mailable $mail): void { if (($this->storedPage()['mode'] ?: 'central') === 'central') { Mail::to($address)->send($mail); return; } $mailer = 'pharos_page_'.app(PageContext::class)->id(); $stored = $this->storedPage(); config(["mail.mailers.$mailer" => [ 'transport' => 'smtp', 'scheme' => $stored['encryption'] === 'ssl' ? 'smtps' : 'smtp', 'require_tls' => $stored['encryption'] === 'tls', 'host' => $stored['host'], 'port' => (int) $stored['port'], 'username' => $stored['username'] ?: null, 'password' => $this->pagePassword(), 'timeout' => 10, 'local_domain' => parse_url((string) config('app.url'), PHP_URL_HOST), ]]); Mail::purge($mailer); try { Mail::mailer($mailer)->to($address)->send($mail); } finally { Mail::purge($mailer); } } public function pagePassword(): ?string { $encrypted = $this->pageValue(self::PASSWORD_KEY); if (! is_string($encrypted) || $encrypted === '') { return null; } try { return Crypt::decryptString($encrypted); } catch (DecryptException) { throw new RuntimeException('Stored page SMTP password cannot be decrypted.'); } } private function pageValue(string $key, mixed $default = null): mixed { return StatusPageSetting::query() ->where('status_page_id', app(PageContext::class)->id()) ->where('key', $key) ->value('value') ?? $default; } }