purpose => mailbox key */ public array $purposes = []; public string $testRecipient = ''; /** @var array{ok: bool, error: ?string}|null */ public ?array $testResult = null; public ?string $testedKey = null; /** * Whether credentials can be stored at all on this installation. * * Public so the page can say it rather than throwing on the first password * a mailbox tries to decrypt — the same courtesy the secrets page already * extends. A missing SECRETS_KEY is a setup state, not an error. */ public bool $usable = true; protected function confirmationGuard(): string { return 'operator'; } public function mount(): void { $this->authorize('mail.manage'); $this->host = (string) Settings::get('mail.host', ''); $this->port = (int) Settings::get('mail.port', 587); $this->encryption = (string) Settings::get('mail.encryption', 'tls'); foreach (MailPurpose::ALL as $purpose) { $this->purposes[$purpose] = (string) Settings::get(MailPurpose::settingKey($purpose), ''); } } public function saveServer(): void { $this->authorize('mail.manage'); // $this->host is the platform's outbound relay for every purpose // mailbox at once — pointing it at an attacker's server intercepts // everything CluPilot sends. The capability decides who may open this // page; a recent password decides whether THIS session may repoint // it, the same second gate Admin\Secrets uses and for the same // reason: the realistic threat is an unlocked machine, not a // stranger. savePurposes() and test() stay on the capability alone — // this is the "changing an address" split's one exception. abort_unless($this->passwordRecentlyConfirmed(), 403); // Rules on the ACTION, not on the property: a #[Validate] attribute on // a Livewire property applies class-wide, and savePurposes() below // would drag these along. $this->validate([ 'host' => ['required', 'string', 'max:255'], 'port' => ['required', 'integer', 'min:1', 'max:65535'], 'encryption' => ['required', 'in:tls,ssl,none'], ]); // Codex R15#6, P2: last_verified_at on EVERY mailbox proves a test // against THIS server config specifically — the shared-server mirror // of EditMailbox::save()'s guard on a single mailbox's own address/ // username/authenticates. Compared against the STORED values, not // just "did the operator touch the field": re-opening the card and // saving without editing anything must not wipe a legitimate // verification. Read before Settings::set() overwrites them below, // and deliberately not merged with EditMailbox's own comparison — that // one diffs a single loaded model's in-memory attributes, this one // diffs persisted Settings against incoming scalars for a config that // has no single row to load at all; forcing one comparison function // over both shapes would add an abstraction with nothing genuinely // shared to justify it. What IS shared is the actual clear itself: // both this method and EditMailbox::save() delegate to a Mailbox:: // invalidate*() method rather than touching the column directly. $serverChanged = $this->host !== (string) Settings::get('mail.host', '') || (int) $this->port !== (int) Settings::get('mail.port', 587) || $this->encryption !== (string) Settings::get('mail.encryption', 'tls'); Settings::set('mail.host', $this->host); Settings::set('mail.port', (int) $this->port); Settings::set('mail.encryption', $this->encryption); if ($serverChanged) { Mailbox::invalidateAllVerifications(); } $this->dispatch('notify', message: __('mail_settings.server_saved')); } public function savePurposes(): void { $this->authorize('mail.manage'); // Reachable by posting straight to /livewire/update, past whatever // the