384 lines
15 KiB
PHP
384 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Livewire\Concerns\ConfirmsPassword;
|
|
use App\Models\Mailbox;
|
|
use App\Services\Mail\MailboxResolver;
|
|
use App\Services\Mail\MailboxTester;
|
|
use App\Services\Mail\MailCatalogue;
|
|
use App\Services\Mail\MailPurpose;
|
|
use App\Services\Mail\MailRoute;
|
|
use App\Services\Secrets\SecretCipher;
|
|
use App\Support\MailDelivery;
|
|
use App\Support\Settings;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\Url;
|
|
use Livewire\Component;
|
|
|
|
/**
|
|
* The sending addresses, and which kind of mail leaves from which.
|
|
*
|
|
* The server sits first because there is one of it; the mailboxes come next
|
|
* 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.
|
|
*
|
|
* Diese Reihenfolge waren vier Karten in einer schmalen Spalte, und die
|
|
* letzten beiden allein zweiundzwanzig gleich aussehende Zeilen — fünf Zwecke,
|
|
* siebzehn Mailarten, jede „Beschriftung, Auswahlfeld", darunter zweimal
|
|
* derselbe Speichern-Knopf. Wer die Zuordnung einer einzelnen Mailart ändern
|
|
* wollte, scrollte an allem anderen vorbei und fand am Ende eine Wand.
|
|
*
|
|
* Jetzt drei Reiter nach dem Muster von Admin\Integrations, das aus demselben
|
|
* Grund umgebaut wurde: WOMIT gesendet wird, WER sendet, und WAS von wo
|
|
* rausgeht. Der offene Reiter steht in der Adresszeile, damit ein Neuladen
|
|
* oder ein Lesezeichen dort landet, wo der Betreiber war.
|
|
*/
|
|
#[Layout('layouts.admin')]
|
|
class Mail extends Component
|
|
{
|
|
use ConfirmsPassword;
|
|
|
|
/**
|
|
* Die Reiter, in dieser Reihenfolge. Die Liste IST das Schema: sie prüft
|
|
* die Adresszeile, baut die Leiste und entscheidet, was gerendert wird.
|
|
*/
|
|
public const TABS = ['versand', 'postfaecher', 'zuordnung'];
|
|
|
|
/** Welcher Reiter offen ist — mit `history: true` ein Schritt zurück. */
|
|
#[Url(history: true)]
|
|
public string $tab = 'versand';
|
|
|
|
public string $host = '';
|
|
|
|
public int|string $port = 587;
|
|
|
|
public string $encryption = 'tls';
|
|
|
|
/**
|
|
* Ob diese Installation wirklich zustellt.
|
|
*
|
|
* Zwei Zustände, keine Treiberauswahl — siehe App\Support\MailDelivery.
|
|
* Steht im Serverformular, weil er über genau dessen Felder entscheidet.
|
|
*/
|
|
public bool $deliver = false;
|
|
|
|
/** @var array<string, string> purpose => mailbox key */
|
|
public array $purposes = [];
|
|
|
|
/**
|
|
* Die Wegwahl je Mailart — leer heißt „wie der Zweck".
|
|
*
|
|
* Eine Ebene unter $purposes, nicht daneben: dieselbe Seite, dieselbe
|
|
* Berechtigung, aber eine eigene Tabelle, weil hier sechzehn Zeilen statt
|
|
* fünf stehen.
|
|
*
|
|
* @var array<string, string> Katalog-Schlüssel => Postfach-Schlüssel
|
|
*/
|
|
public array $routes = [];
|
|
|
|
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');
|
|
$this->deliver = MailDelivery::delivers();
|
|
|
|
foreach (MailPurpose::ALL as $purpose) {
|
|
$this->purposes[$purpose] = (string) Settings::get(MailPurpose::settingKey($purpose), '');
|
|
}
|
|
|
|
foreach (MailCatalogue::all() as $key => $entry) {
|
|
$this->routes[$key] = (string) Settings::get(MailRoute::settingKey($key), '');
|
|
}
|
|
}
|
|
|
|
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\Integrations' vault entries use 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);
|
|
|
|
// Der Hauptschalter, und er gehört genau hierher: er entscheidet, ob
|
|
// die drei Zeilen darüber überhaupt benutzt werden. Bis hierher stand
|
|
// er als MAIL_MAILER in der .env — die Bereitschaftsseite meldete ihn
|
|
// als blockierend und konnte weder sagen, worauf man ihn stellt, noch
|
|
// dorthin verlinken, wo man es täte.
|
|
MailDelivery::set($this->deliver);
|
|
|
|
if ($serverChanged) {
|
|
Mailbox::invalidateAllVerifications();
|
|
}
|
|
|
|
$this->dispatch('notify', message: __('mail_settings.server_saved'));
|
|
}
|
|
|
|
/**
|
|
* Prüft und schreibt die Zwecke — ohne Meldung.
|
|
*
|
|
* Getrennt vom öffentlichen savePurposes(), weil die Seite die Zwecke und
|
|
* die Wegwahl jetzt in EINEM Block zeigt und mit EINEM Knopf speichert:
|
|
* eine Zuordnung, die man an zwei Stellen bestätigen muss, ist der Grund,
|
|
* warum unter der alten Seite zwei gleich aussehende Knöpfe standen. Die
|
|
* beiden öffentlichen Methoden bleiben, was sie waren — sie sind der
|
|
* Einstieg, den die Tests und ein direkter Aufruf kennen.
|
|
*/
|
|
private function writePurposes(): 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] ?? '');
|
|
}
|
|
}
|
|
|
|
public function savePurposes(): void
|
|
{
|
|
$this->writePurposes();
|
|
|
|
$this->dispatch('notify', message: __('mail_settings.purposes_saved'));
|
|
}
|
|
|
|
/**
|
|
* Die Wegwahl je Mailart. Nach dem Muster von savePurposes() — dieselbe
|
|
* Berechtigung, dieselbe Meldung — aber ohne dessen Regelwerk: ein
|
|
* Eintrag, der auf kein oder ein abgeschaltetes Postfach zeigt, ist hier
|
|
* kein Fehler, weil MailRoute::purposeOrMailbox() genau diesen Fall schon
|
|
* auf den Zweck zurückfallen lässt. Eine zweite Prüfung derselben
|
|
* Sicherung wäre doppelte Arbeit ohne eigenen Wert.
|
|
*/
|
|
private function writeRoutes(): void
|
|
{
|
|
$this->authorize('mail.manage');
|
|
|
|
foreach (MailCatalogue::all() as $key => $entry) {
|
|
Settings::set(MailRoute::settingKey($key), $this->routes[$key] ?? '');
|
|
}
|
|
}
|
|
|
|
public function saveRoutes(): void
|
|
{
|
|
$this->writeRoutes();
|
|
|
|
$this->dispatch('notify', message: __('mail_settings.purposes_saved'));
|
|
}
|
|
|
|
/**
|
|
* Der eine Knopf unter dem Reiter „Zuordnung".
|
|
*
|
|
* Zwecke zuerst: writePurposes() prüft und wirft, bevor irgendetwas
|
|
* geschrieben ist — eine abgelehnte Eingabe darf nicht die halbe Zuordnung
|
|
* hinterlassen. Eine Meldung für beides, weil es für den Betreiber ein
|
|
* Vorgang ist.
|
|
*/
|
|
public function saveAssignments(): void
|
|
{
|
|
$this->writePurposes();
|
|
$this->writeRoutes();
|
|
|
|
$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();
|
|
|
|
// Ein Reitername aus der Adresszeile ist eine Zeichenkette, die ein
|
|
// Fremder getippt hat.
|
|
if (! in_array($this->tab, self::TABS, true)) {
|
|
$this->tab = self::TABS[0];
|
|
}
|
|
|
|
$gruppen = $this->catalogueByPurpose();
|
|
|
|
return view('livewire.admin.mail', [
|
|
'mailboxes' => Mailbox::query()->orderBy('key')->get(),
|
|
'catalogueByPurpose' => $gruppen,
|
|
'inheritedAddress' => $this->inheritedAddresses(array_keys($gruppen)),
|
|
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Die Adresse, die eine Mailart ohne eigene Wahl WIRKLICH benutzt.
|
|
*
|
|
* Nicht das zugeordnete Postfach. MailboxResolver::for() lässt ein
|
|
* abgeschaltetes Postfach auf „System" zurückfallen, und writePurposes()
|
|
* erlaubt genau das — aktiv sein muss nur „System" selbst. Ein Etikett,
|
|
* das dann die abgeschaltete Adresse nennt, sagt dem Betreiber etwas
|
|
* Falsches über seinen eigenen Versand, und zwar an der Stelle, an der er
|
|
* nachsieht, um es richtig zu machen.
|
|
*
|
|
* Also dieselbe Funktion, die beim Senden entscheidet, statt einer zweiten
|
|
* Meinung darüber. Sie liest den GESPEICHERTEN Stand: was im Formular noch
|
|
* nicht gespeichert ist, gilt auf dieser Seite nirgends.
|
|
*
|
|
* @param array<int, string> $purposes
|
|
* @return array<string, ?string>
|
|
*/
|
|
private function inheritedAddresses(array $purposes): array
|
|
{
|
|
$adressen = [];
|
|
|
|
foreach ($purposes as $purpose) {
|
|
// catalogueByPurpose() lässt eine Mailart mit unbekanntem Zweck
|
|
// stehen, statt sie lautlos fallen zu lassen; for() würde für die
|
|
// werfen. Kein Zweck, keine geerbte Adresse.
|
|
$adressen[$purpose] = in_array($purpose, MailPurpose::ALL, true)
|
|
? app(MailboxResolver::class)->for($purpose)?->address
|
|
: null;
|
|
}
|
|
|
|
return $adressen;
|
|
}
|
|
|
|
/**
|
|
* Die Mailarten unter ihrem Zweck, in der Reihenfolge der Zwecke.
|
|
*
|
|
* Die Wegwahl je Mailart fällt auf den Zweck zurück, wenn sie leer ist —
|
|
* das stand bisher nur als Text in der Auswahlbeschriftung („wie der
|
|
* Zweck") und war damit siebzehnmal derselbe Satz neben siebzehn Zeilen,
|
|
* die alle gleich aussahen. Unter ihrem Zweck einsortiert zeigt die Liste
|
|
* dieselbe Regel als Form: das Postfach des Zwecks steht oben, was davon
|
|
* abweicht, steht darunter.
|
|
*
|
|
* Ein Zweck ohne eigene Mailart bleibt trotzdem stehen — sein Postfach ist
|
|
* die Rückfalllinie und muss auch dann einstellbar sein. Eine Mailart mit
|
|
* einem Zweck außerhalb von MailPurpose::ALL bekommt einen eigenen Block
|
|
* am Ende, statt lautlos aus der Seite zu fallen.
|
|
*
|
|
* @return array<string, array<string, array{label: string, purpose: string}>>
|
|
*/
|
|
private function catalogueByPurpose(): array
|
|
{
|
|
$gruppen = array_fill_keys(MailPurpose::ALL, []);
|
|
|
|
foreach (MailCatalogue::all() as $key => $entry) {
|
|
$gruppen[$entry['purpose']][$key] = $entry;
|
|
}
|
|
|
|
return $gruppen;
|
|
}
|
|
}
|