CluPilotCloud/app/Livewire/Admin/Mail.php

174 lines
6.4 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Models\Mailbox;
use App\Services\Mail\MailboxTester;
use App\Services\Mail\MailPurpose;
use App\Services\Secrets\SecretCipher;
use App\Support\Settings;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The sending addresses, and which kind of mail leaves from which.
*
* The server sits at the top because there is one of it; the mailboxes are a
* list because there are several; the mapping is last because it only makes
* sense once both exist. The test-send button lives with the mailboxes: it
* proves one specific mailbox can actually send, which is the only thing
* that makes the rest of this page more than a form.
*/
#[Layout('layouts.admin')]
class Mail extends Component
{
use ConfirmsPassword;
public string $host = '';
public int|string $port = 587;
public string $encryption = 'tls';
/** @var array<string, string> 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;
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'],
]);
Settings::set('mail.host', $this->host);
Settings::set('mail.port', (int) $this->port);
Settings::set('mail.encryption', $this->encryption);
$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 <select> in the view would ever submit — so "is a nonempty
// string" is not enough. Every purpose's key must name a mailbox
// that actually exists; system's additionally must be ACTIVE, because
// MailboxResolver::for() falls every unmapped (or inactive) OTHER
// purpose through to system, and there is nothing left to fall back
// to when system itself is the broken one.
$activeKeys = Mailbox::query()->where('active', true)->pluck('key')->all();
$allKeys = Mailbox::query()->pluck('key')->all();
$rules = [
// system is the fallback, so it is the one that may not be empty
// — there is nothing left to fall back to.
'purposes.system' => ['required', 'string', function ($attribute, $value, $fail) use ($activeKeys) {
if ($value !== '' && ! in_array($value, $activeKeys, true)) {
$fail(__('mail_settings.system_inactive'));
}
}],
];
foreach (MailPurpose::ALL as $purpose) {
if ($purpose === MailPurpose::SYSTEM) {
continue;
}
// Blank is a legitimate choice for every OTHER purpose —
// MailboxResolver already falls an unmapped (or inactive)
// purpose through to system on its own, so only a key that names
// NO mailbox at all (a deleted row, or a tampered payload) is
// refused here.
$rules["purposes.{$purpose}"] = ['nullable', 'string', function ($attribute, $value, $fail) use ($allKeys) {
if ($value !== '' && ! in_array($value, $allKeys, true)) {
$fail(__('mail_settings.purpose_unknown_mailbox'));
}
}];
}
$this->validate($rules, [
'purposes.system.required' => __('mail_settings.system_required'),
]);
foreach (MailPurpose::ALL as $purpose) {
Settings::set(MailPurpose::settingKey($purpose), $this->purposes[$purpose] ?? '');
}
$this->dispatch('notify', message: __('mail_settings.purposes_saved'));
}
public function test(string $uuid): void
{
$this->authorize('mail.manage');
$this->validate(
['testRecipient' => ['required', 'email']],
['testRecipient.required' => __('mail_settings.test_recipient_required')],
);
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
$this->testedKey = $box->key;
$this->testResult = app(MailboxTester::class)->run($box, $this->testRecipient);
}
public function render()
{
$this->usable = app(SecretCipher::class)->isUsable();
return view('livewire.admin.mail', [
'mailboxes' => Mailbox::query()->orderBy('key')->get(),
'purposeList' => MailPurpose::ALL,
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
]);
}
}