202 lines
8.0 KiB
PHP
202 lines
8.0 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;
|
|
|
|
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 <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(),
|
|
]);
|
|
}
|
|
}
|