mailwolt/app/Livewire/Ui/Mail/Modal/MailboxCreateModal.php

294 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Livewire\Ui\Mail\Modal;
use App\Models\Domain;
use App\Models\MailAlias;
use App\Models\MailUser;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
class MailboxCreateModal extends ModalComponent
{
// optional vorselektierte Domain
public ?int $domain_id = null;
// Anzeige
public string $domain_name = '';
/** @var array<int,array{id:int,domain:string}> */
public array $domains = [];
public string $email_preview = '';
// Formular
public string $localpart = '';
public ?string $display_name = null;
public ?string $password = null;
public int $quota_mb = 0;
public ?int $rate_limit_per_hour = null;
public bool $is_active = true;
// Limits / Status
public ?int $limit_max_mailboxes = null;
public ?int $limit_default_quota_mb = null;
public ?int $limit_max_quota_per_mb = null;
public ?int $limit_total_quota_mb = null; // 0 = unlimitiert
public ?int $limit_domain_rate_per_hour = null;
public bool $allow_rate_limit_override = false;
public int $mailbox_count_used = 0;
public int $domain_storage_used_mb = 0;
// Hints/Flags
public string $quota_hint = '';
public bool $rate_limit_readonly = false;
public bool $no_mailbox_slots = false;
public bool $no_storage_left = false;
public bool $can_create = true;
public string $block_reason = '';
/* ---------- Validation ---------- */
protected function rules(): array
{
$maxPerMailbox = $this->limit_max_quota_per_mb ?? PHP_INT_MAX;
$remainingByTotal = (is_null($this->limit_total_quota_mb) || (int)$this->limit_total_quota_mb === 0)
? PHP_INT_MAX
: max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
$cap = min($maxPerMailbox, $remainingByTotal);
// alle Alias-Localparts der Domain (kleingeschrieben) verhindert Kollision Mailbox vs. Alias
$aliasLocals = $this->domain_id
? MailAlias::query()
->where('domain_id', $this->domain_id)
->pluck('local')
->map(fn($l) => Str::lower($l))
->all()
: [];
return [
'domain_id' => ['required', Rule::exists('domains', 'id')],
'localpart' => [
'required', 'max:191', 'regex:/^[A-Za-z0-9._%+-]+$/',
// darf nicht als Alias existieren (gleiches Domain-Scope)
Rule::notIn($aliasLocals),
// und auch kein bestehendes Postfach mit gleichem Localpart
Rule::unique('mail_users', 'localpart')
->where(fn($q) => $q->where('domain_id', $this->domain_id)),
],
'display_name' => ['nullable', 'max:191'],
'password' => ['nullable', 'min:8'],
'quota_mb' => ['required', 'integer', 'min:0', 'max:' . $cap],
'rate_limit_per_hour' => ['nullable', 'integer', 'min:1'],
'is_active' => ['boolean'],
];
}
/* ---------- Lifecycle ---------- */
public function mount(?int $domainId = null): void
{
// alle Nicht-System-Domains in Select
$this->domains = Domain::query()
->where('is_system', false)->where('is_server', false)
->orderBy('domain')->get(['id', 'domain'])->toArray();
// vorselektieren falls mitgegeben, sonst 1. Domain (falls vorhanden)
$this->domain_id = $domainId ?: ($this->domains[0]['id'] ?? null);
// Limits + Anzeige laden
$this->syncDomainContext();
}
public function updatedDomainId(): void
{
$this->resetErrorBag(); // scoped unique etc.
$this->syncDomainContext();
// bei Domainwechsel Alias-Kollision neu prüfen
$this->checkAliasCollisionLive();
}
public function updatedLocalpart(): void
{
$this->localpart = strtolower(trim($this->localpart));
$this->rebuildEmailPreview();
$this->checkAliasCollisionLive();
}
public function updatedQuotaMb(): void
{
$this->recomputeQuotaHints();
$this->recomputeBlockers();
}
/* ---------- Helpers ---------- */
private function syncDomainContext(): void
{
if (!$this->domain_id) return;
$d = Domain::query()
->withCount('mailUsers')
->withSum('mailUsers as used_storage_mb', 'quota_mb')
->findOrFail($this->domain_id);
$this->domain_name = $d->domain;
$this->limit_max_mailboxes = (int)$d->max_mailboxes;
$this->limit_default_quota_mb = (int)$d->default_quota_mb;
$this->limit_max_quota_per_mb = $d->max_quota_per_mailbox_mb !== null ? (int)$d->max_quota_per_mailbox_mb : null;
$this->limit_total_quota_mb = (int)$d->total_quota_mb; // 0 = unlimitiert
$this->limit_domain_rate_per_hour = $d->rate_limit_per_hour !== null ? (int)$d->rate_limit_per_hour : null;
$this->allow_rate_limit_override = (bool)$d->rate_limit_override;
$this->mailbox_count_used = (int)$d->mail_users_count;
$this->domain_storage_used_mb = (int)($d->used_storage_mb ?? 0);
// Defaults
$this->quota_mb = $this->limit_default_quota_mb ?? 0;
if (!$this->allow_rate_limit_override) {
$this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
$this->rate_limit_readonly = true;
} else {
$this->rate_limit_per_hour = $this->limit_domain_rate_per_hour;
$this->rate_limit_readonly = false;
}
$this->rebuildEmailPreview();
$this->recomputeQuotaHints();
$this->recomputeBlockers();
}
private function rebuildEmailPreview(): void
{
$this->email_preview = $this->localpart && $this->domain_name
? ($this->localpart . '@' . $this->domain_name) : '';
}
private function recomputeQuotaHints(): void
{
$parts = [];
if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
$remainingNow = max(0, (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb);
$remainingAfter = max(0, $remainingNow - max(0, (int)$this->quota_mb));
$parts[] = "Verbleibend jetzt: {$remainingNow} MiB";
$parts[] = "nach Speichern: {$remainingAfter} MiB";
}
if (!is_null($this->limit_max_quota_per_mb)) $parts[] = "Max {$this->limit_max_quota_per_mb} MiB pro Postfach";
if (!is_null($this->limit_default_quota_mb)) $parts[] = "Standard: {$this->limit_default_quota_mb} MiB";
$this->quota_hint = implode(' · ', $parts);
}
private function recomputeBlockers(): void
{
// Slots
$this->no_mailbox_slots = false;
if (!is_null($this->limit_max_mailboxes)) {
$free = (int)$this->limit_max_mailboxes - (int)$this->mailbox_count_used;
if ($free <= 0) $this->no_mailbox_slots = true;
}
// Speicher
$this->no_storage_left = false;
if (!is_null($this->limit_total_quota_mb) && (int)$this->limit_total_quota_mb > 0) {
$remaining = (int)$this->limit_total_quota_mb - (int)$this->domain_storage_used_mb;
if ($remaining <= 0) $this->no_storage_left = true;
}
$reasons = [];
if ($this->no_mailbox_slots) $reasons[] = 'Keine freien Postfach-Slots in dieser Domain.';
if ($this->no_storage_left) $reasons[] = 'Kein Domain-Speicher mehr verfügbar.';
$this->block_reason = implode(' ', $reasons);
$this->can_create = !($this->no_mailbox_slots || $this->no_storage_left);
}
/** Prüft live, ob bereits ein Alias gleichen Localparts existiert, und setzt eine Feldfehlermeldung. */
private function checkAliasCollisionLive(): void
{
$this->resetErrorBag('localpart');
if ($this->aliasExistsForLocalpart($this->domain_id, $this->localpart)) {
$this->addError('localpart', 'Dieser Name ist bereits als Alias in dieser Domain vergeben.');
}
}
/** true, wenn in der Domain ein Alias mit gleichem Localpart existiert (case-insensitiv) */
private function aliasExistsForLocalpart(?int $domainId, ?string $local): bool
{
$local = Str::lower(trim((string)$local));
if (!$domainId || $local === '') return false;
return MailAlias::query()
->where('domain_id', $domainId)
->whereRaw('LOWER(local) = ?', [$local])
->exists();
}
/* ---------- Save ---------- */
#[On('mailbox:create')]
public function save(): void
{
$this->recomputeBlockers();
if (!$this->can_create) {
$this->addError('domain_id', $this->block_reason ?: 'Erstellung aktuell nicht möglich.');
return;
}
// Vorab-Hard-Check gegen Alias-Kollision (zusätzlich zur Validation)
if ($this->aliasExistsForLocalpart($this->domain_id, $this->localpart)) {
$this->addError('localpart', 'Diese Adresse ist bereits als Alias vorhanden.');
return;
}
$data = $this->validate();
$email = $data['localpart'] . '@' . $this->domain_name;
try {
$u = new MailUser();
$u->domain_id = $data['domain_id'];
$u->localpart = $data['localpart'];
$u->email = $email;
$u->display_name = $this->display_name ?: null;
$u->password_hash = $this->password ? Hash::make($this->password) : null;
$u->is_system = false;
$u->is_active = (bool)$data['is_active'];
$u->quota_mb = (int)$data['quota_mb'];
$u->rate_limit_per_hour = $data['rate_limit_per_hour'];
$u->save();
} catch (QueryException $e) {
$msg = strtolower($e->getMessage());
if (str_contains($msg, 'mail_users_domain_localpart_unique')) {
$this->addError('localpart', 'Dieses Postfach existiert in dieser Domain bereits.');
return;
}
if (str_contains($msg, 'mail_users_email_unique')) {
$this->addError('localpart', 'Diese E-Mail-Adresse ist bereits vergeben.');
return;
}
throw $e;
}
$this->dispatch('mailbox:created');
$this->dispatch('closeModal');
$this->dispatch('toast',
type: 'done',
badge: 'Postfach',
title: 'Postfach angelegt',
text: 'Das Postfach <b>' . e($email) . '</b> wurde erfolgreich angelegt.',
duration: 6000
);
}
public static function modalMaxWidth(): string
{
return '3xl';
}
public function render()
{
return view('livewire.ui.mail.modal.mailbox-create-modal');
}
}