161 lines
6.1 KiB
PHP
161 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Livewire\Concerns\ConfirmsPassword;
|
|
use App\Models\Mailbox;
|
|
use App\Services\Mail\MailPurpose;
|
|
use App\Services\Secrets\SecretCipher;
|
|
use App\Support\Settings;
|
|
use LivewireUI\Modal\ModalComponent;
|
|
|
|
/**
|
|
* Editing a mailbox, in a modal (R20).
|
|
*
|
|
* A modal is reachable WITHOUT the page's route middleware, so it authorises
|
|
* itself and re-reads the record instead of trusting a property the browser
|
|
* hydrated. save() re-authorises independently of mount() for the same reason
|
|
* Secrets::guard() checks on every action rather than once: a Livewire action
|
|
* is reachable by anyone who can post to /livewire/update, and a mount that
|
|
* succeeded earlier in the session proves nothing about the request now.
|
|
*/
|
|
class EditMailbox extends ModalComponent
|
|
{
|
|
use ConfirmsPassword;
|
|
|
|
public string $uuid = '';
|
|
|
|
public string $address = '';
|
|
|
|
public string $displayName = '';
|
|
|
|
public string $username = '';
|
|
|
|
/** Always starts empty: a stored password never travels to the browser. */
|
|
public string $password = '';
|
|
|
|
public bool $noReply = false;
|
|
|
|
public bool $active = true;
|
|
|
|
/** Whether this mailbox logs in before it sends — Codex R15#4, P1b. */
|
|
public bool $authenticates = true;
|
|
|
|
protected function confirmationGuard(): string
|
|
{
|
|
return 'operator';
|
|
}
|
|
|
|
public function mount(string $uuid): void
|
|
{
|
|
$this->authorize('mail.manage');
|
|
|
|
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
|
|
|
|
$this->uuid = $box->uuid;
|
|
$this->address = $box->address;
|
|
$this->displayName = (string) $box->display_name;
|
|
$this->username = (string) $box->username;
|
|
$this->noReply = $box->no_reply;
|
|
$this->active = $box->active;
|
|
$this->authenticates = $box->authenticates;
|
|
}
|
|
|
|
public function save(): void
|
|
{
|
|
$this->authorize('mail.manage');
|
|
|
|
$this->validate([
|
|
'address' => ['required', 'email', 'max:255'],
|
|
'displayName' => ['nullable', 'string', 'max:255'],
|
|
'username' => ['nullable', 'string', 'max:255'],
|
|
'password' => ['nullable', 'string', 'max:255'],
|
|
]);
|
|
|
|
$box = Mailbox::query()->where('uuid', $this->uuid)->firstOrFail();
|
|
|
|
// Deactivating the mailbox "system" currently points at is not just
|
|
// this purpose losing its sender: MailboxResolver::active() filters an
|
|
// inactive mailbox at BOTH the direct lookup AND the system fallback,
|
|
// so every OTHER purpose that has no mailbox of its own falls through
|
|
// to nothing too. savePurposes() already refuses to leave system
|
|
// empty; this closes the other way to reach the same broken state.
|
|
if (! $this->active && $box->key === (string) Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM))) {
|
|
$this->addError('active', __('mail_settings.cannot_deactivate_system'));
|
|
|
|
return;
|
|
}
|
|
|
|
// Setting a new SMTP password is one of the two actions the
|
|
// mail.manage split still leaves able to intercept mail outright (the
|
|
// other is Admin\Mail::saveServer()) — gated the same second way
|
|
// Admin\Integrations' vault entries are: a capability decides who
|
|
// may open this modal at all, a recent password decides whether
|
|
// THIS session may point the
|
|
// outgoing account at new credentials.
|
|
if ($this->password !== '') {
|
|
abort_unless($this->passwordRecentlyConfirmed(), 403);
|
|
|
|
// A password can only be STORED where SECRETS_KEY exists to
|
|
// encrypt it under — the same condition the page's own banner
|
|
// already names. Checked here rather than left for
|
|
// Mailbox::setPasswordAttribute() to throw through: an uncaught
|
|
// RuntimeException would render Laravel's debug page, whose
|
|
// request-payload inspector can show the very password just
|
|
// typed. The page must say so, not crash on it.
|
|
if (! app(SecretCipher::class)->isUsable()) {
|
|
$this->addError('password', __('mail_settings.no_key'));
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
$oldAddress = $box->address;
|
|
$oldUsername = $box->username;
|
|
$oldAuthenticates = $box->authenticates;
|
|
|
|
$box->address = $this->address;
|
|
$box->display_name = $this->displayName ?: null;
|
|
$box->username = $this->username ?: null;
|
|
$box->no_reply = $this->noReply;
|
|
$box->active = $this->active;
|
|
$box->authenticates = $this->authenticates;
|
|
|
|
// Either one changes the identity smtpUsername() authenticates as:
|
|
// username directly, or address as username's fallback when none is
|
|
// set (including username being CLEARED back to that fallback).
|
|
// authenticates itself is the fourth: whether that identity gets
|
|
// sent at all. Leaving last_verified_at standing after any of the
|
|
// three would keep showing a successful verification for a
|
|
// connection nothing has actually tested since. Mailbox::
|
|
// invalidateVerification() is the same clear Admin\Mail::saveServer()
|
|
// uses for the server card, in its single-row shape — see that
|
|
// method's own comment for why the two do NOT also share the "did
|
|
// this actually change" comparison above this block.
|
|
if ($box->address !== $oldAddress || $box->username !== $oldUsername || $box->authenticates !== $oldAuthenticates) {
|
|
$box->invalidateVerification();
|
|
}
|
|
|
|
// Empty means "leave it alone", not "delete it" — otherwise every edit
|
|
// of an address would silently drop the password.
|
|
if ($this->password !== '') {
|
|
$box->password = $this->password;
|
|
$box->invalidateVerification();
|
|
}
|
|
|
|
$box->save();
|
|
|
|
$this->password = '';
|
|
$this->dispatch('notify', message: __('mail_settings.saved'));
|
|
$this->dispatch('mailbox-saved');
|
|
$this->closeModal();
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.edit-mailbox', [
|
|
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
|
|
]);
|
|
}
|
|
}
|