diff --git a/app/Console/Commands/SettingsSyncCommand.php b/app/Console/Commands/SettingsSyncCommand.php new file mode 100644 index 0000000..014fad2 --- /dev/null +++ b/app/Console/Commands/SettingsSyncCommand.php @@ -0,0 +1,34 @@ +info('Syncing settings to Redis...'); + + Setting::chunk(200, function ($settings) use (&$count) { + foreach ($settings as $setting) { + $key = "settings:{$setting->group}:{$setting->key}"; + try { + Redis::set($key, $setting->value); + $count++; + } catch (\Throwable $e) { + $this->error("Failed to write {$key}"); + } + } + }); + + $this->info("✅ Synced {$count} settings to Redis."); + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/UI/Mail/AliasController.php b/app/Http/Controllers/UI/Mail/AliasController.php new file mode 100644 index 0000000..58443b4 --- /dev/null +++ b/app/Http/Controllers/UI/Mail/AliasController.php @@ -0,0 +1,15 @@ +|string> + */ + public function rules(): array + { + return [ + 'domain' => ['required','string','max:191','unique:domains,domain'], + 'total_quota_mb' => ['required','integer','min:1'], // 0 = unlimitiert wäre riskant – ich empfehle >0 + // 'default_mailbox_quota_mb' => ['nullable','integer','min:1','lte:total_quota_mb'], + 'is_active' => ['sometimes','boolean'], + 'is_system' => ['sometimes','boolean'], + ]; + } +} diff --git a/app/Livewire/Auth/SignupForm.php b/app/Livewire/Auth/SignupForm.php index d50b752..fa4391a 100644 --- a/app/Livewire/Auth/SignupForm.php +++ b/app/Livewire/Auth/SignupForm.php @@ -6,6 +6,8 @@ use App\Enums\Role; use App\Models\Setting; use App\Models\User; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\Rules\Password; use Livewire\Component; @@ -32,29 +34,71 @@ class SignupForm extends Component public function register() { $this->validate(); - $isFirstUser = User::count() === 0; - User::create([ - 'name' => $this->name, - 'email' => $this->email, - 'password' => Hash::make($this->password), - 'role' => Setting::signupAllowed() ? Role::Admin : Role::Member, - ]); - - if ($isFirstUser) { - Setting::updateOrCreate(['key' => 'signup_enabled'], ['value' => '0']); + // Registrierung global gesperrt? + if (!Setting::signupAllowed()) { + $this->addError('email', 'Registrierung ist derzeit deaktiviert.'); + return; } - $this->reset(['name','email','password','password_confirmation', 'accept']); + // Redis-Lock verhindert Doppel-Admin bei parallelem Signup + Cache::lock('signup:first-user', 10)->block(5, function () { + DB::transaction(function () { + + // innerh. Lock & TX nochmal prüfen + $isFirstUser = !User::query()->exists(); + + User::create([ + 'name' => $this->name, + 'email' => $this->email, + 'password' => Hash::make($this->password), + 'role' => $isFirstUser ? Role::Admin : Role::Member, + ]); + + // nach erstem User: Signup deaktivieren + if ($isFirstUser) { + Setting::set('system.signup_enabled', 0); // Redis + DB + } + }); + }); + + // Reset + UI + $this->reset(['name','email','password','password_confirmation','accept']); $this->dispatch('toast', state: 'done', badge: 'Signup', domain: 'Registrierung erfolgreich', - message: 'Dein Konto wurde angelegt. Bitte melde dich jetzt mit deinen Zugangsdaten an. Zum Login', + message: 'Dein Konto wurde angelegt. Bitte melde dich jetzt mit deinen Zugangsdaten an. Zum Login', duration: -1, ); } +// public function register() +// { +// $this->validate(); +// $isFirstUser = User::count() === 0; +// +// User::create([ +// 'name' => $this->name, +// 'email' => $this->email, +// 'password' => Hash::make($this->password), +// 'role' => Setting::signupAllowed() ? Role::Admin : Role::Member, +// ]); +// +// if ($isFirstUser) { +// Setting::updateOrCreate(['key' => 'signup_enabled'], ['value' => '0']); +// } +// +// $this->reset(['name','email','password','password_confirmation', 'accept']); +// $this->dispatch('toast', +// state: 'done', +// badge: 'Signup', +// domain: 'Registrierung erfolgreich', +// message: 'Dein Konto wurde angelegt. Bitte melde dich jetzt mit deinen Zugangsdaten an. Zum Login', +// duration: -1, +// ); +// } + public function render() { return view('livewire.auth.signup-form'); diff --git a/app/Livewire/Ui/Domain/DomainDnsList.php b/app/Livewire/Ui/Domain/DomainDnsList.php index 1450d09..479a60b 100644 --- a/app/Livewire/Ui/Domain/DomainDnsList.php +++ b/app/Livewire/Ui/Domain/DomainDnsList.php @@ -3,32 +3,123 @@ namespace App\Livewire\Ui\Domain; use App\Models\Domain; -use App\Services\DnsRecordService; +use Livewire\Attributes\On; use Livewire\Component; class DomainDnsList extends Component { - public Domain $domain; - public array $records = []; + /** System-Domain (is_system = true) */ + public ?Domain $systemDomain = null; -// public function mount(int $domainId): void -// { -// $this->domain = Domain::findOrFail($domainId); -// $this->records = app(DnsRecordService::class)->buildForDomain($this->domain); -// } + /** Benutzer-Domains (is_system = false) */ + public $userDomains = []; + public $tags = []; + + #[On('domain-updated')] + #[On('domain-created')] + public function reloadDomains(): void + { + $this->loadDomains(); + } + + public function loadDomains(): void + { + $this->systemDomain = Domain::where('is_system', true)->first(); +// $this->userDomains = Domain::where('is_system', false)->orderBy('domain')->get(); + $domains = Domain::where('is_system', false) + ->withCount([ + 'mailUsers as mailboxes_count', // -> mail_users + 'mailAliases as aliases_count', // -> mail_aliases + ]) + ->orderBy('domain') + ->get(); + + + $this->userDomains = $domains->map(function (Domain $d) { + $tags = $d->tag_objects ?? []; // aus Model-Accessor (vorher gebaut) + $d->setAttribute('visible_tags', array_slice($tags, 0, 2)); + $d->setAttribute('extra_tags', max(count($tags) - 2, 0)); + $d->setAttribute('domainActive', (bool)$d->is_active); + $d->setAttribute('effective_reason', $d->is_active ? 'Aktiv' : 'Inaktiv'); + return $d; + }); + + } public function openDnsModal(int $domainId): void { - // wire-elements-modal: Modal öffnen und Parameter übergeben $this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: [ 'domainId' => $domainId, ]); } + public function openEditModal(int $id): void + { + $domain = Domain::findOrFail($id); + if ($domain->is_system) { + $this->dispatch('toast', type:'forbidden', badge:'System-Domain', title: 'Domain', text: 'Diese Domain ist als System-Domain markiert und kann daher nicht geändert oder entfernt werden.', duration: 0,); + return; + } + + // dein Modal + $this->dispatch('openModal', component: 'ui.domain.modal.domain-edit-modal', arguments: ['domainId' => $id]); + } + + + public function openLimitsModal(int $id): void + { + $domain = Domain::findOrFail($id); + if ($domain->is_system) { + $this->dispatch('toast', type:'forbidden', badge:'System-Domain', title: 'Domain', text: 'Diese Domain ist als System-Domain markiert und kann daher nicht geändert oder entfernt werden.', duration: 0,); + return; + } + + // dein Modal + $this->dispatch('openModal', component: 'ui.domain.modal.domain-limits-modal', arguments: ['domainId' => $id]); + } + + public function openDeleteModal(int $id): void + { + $this->dispatch('openModal', component: 'ui.domain.modal.domain-delete-modal', arguments: [ + 'domainId' => $id, + ]); + } + + public function deleteDomain(int $id): void + { + $domain = Domain::findOrFail($id); + + if ($domain->is_system || $id === $this->systemDomainId) { + $this->dispatch('toast', type:'forbidden', badge:'System-Domain', title: 'Domain', text: 'Diese Domain ist als System-Domain markiert und kann daher nicht geändert oder entfernt werden.', duration: 0,); + return; + } + + $domain->loadCount(['mailUsers as mailboxes_count','mailAliases as aliases_count']); + if ($domain->mailboxes_count > 0 || $domain->aliases_count > 0) { + $this->dispatch('toast', type:'forbidden', badge:'Domain', + text:'Löschen blockiert: Es sind noch Postfächer/Aliasse vorhanden. Bitte zuerst entfernen.', duration:0); + return; + } + + $domain->delete(); + + $this->reloadDomains(); + $this->dispatch('toast', + type: 'done', + badge: 'Domain', + title: 'Domain gelöscht', + text: 'Die Domain ' . e($domain->domain) . ' wurde erfolgreich entfernt.', + duration: 6000 + ); + } + public function render() { + $this->loadDomains(); + return view('livewire.ui.domain.domain-dns-list', [ - 'domains' => Domain::orderBy('domain')->get(), + 'systemDomain' => $this->systemDomain, + 'userDomains' => $this->userDomains, ]); } } diff --git a/app/Livewire/Ui/Domain/Modal/DomainCreateModal.php b/app/Livewire/Ui/Domain/Modal/DomainCreateModal.php new file mode 100644 index 0000000..9b6dadc --- /dev/null +++ b/app/Livewire/Ui/Domain/Modal/DomainCreateModal.php @@ -0,0 +1,317 @@ + kein Limit + public bool $rate_limit_override = false; // Mailbox darf Domain-Limit überschreiben + + public bool $active = true; + + // DKIM + public string $dkim_selector = 'dkim'; + public int $dkim_bits = 2048; // 1024/2048/3072/4096 + + // Anzeige + public int $available_mib = 0; + + public array $tagPalette = ['#22c55e', '#06b6d4', '#a855f7', '#f59e0b', '#ef4444', '#3b82f6']; + + public function mount(MailStorage $pool): void + { + // Defaults + $this->max_aliases = (int)config('mailpool.defaults.max_aliases', 400); + $this->max_mailboxes = (int)config('mailpool.defaults.max_mailboxes', 10); + $this->default_quota_mb = (int)config('mailpool.defaults.default_quota_mb', 3072); + $this->max_quota_per_mailbox_mb = config('mailpool.defaults.max_quota_per_mailbox_mb'); // kann null sein + $this->total_quota_mb = (int)config('mailpool.defaults.total_quota_mb', 10240); + + $this->dkim_selector = (string)config('mailpool.defaults.dkim_selector', 'dkim'); + $this->dkim_bits = (int)config('mailpool.defaults.dkim_bits', 2048); + + // Speicherpool-Grenze + $this->available_mib = (int)$pool->remainingPoolMb(); + $this->total_quota_mb = min($this->total_quota_mb, $this->available_mib); + + $this->tags = [['label' => '', 'color' => $this->tagPalette[0]]]; + } + + protected function rules(): array + { + return [ + 'domain' => [ + 'required','string','lowercase','max:255', + 'regex:/^(?!-)(?:[a-z0-9-]+\.)+[a-z]{2,}$/', + Rule::unique('domains','domain'), + + function ($attr, $value, $fail) { + $value = \Illuminate\Support\Str::lower($value); + $isReserved = \App\Models\Domain::query() + ->where('is_system', true) + ->whereRaw('LOWER(domain) = ?', [$value]) + ->exists(); + + if ($isReserved) { + $fail(__('validation.domain_reserved', ['domain' => $value])); + $fail("Die Domain {$value} ist reserviert und kann nicht verwendet werden."); + } + }, + ], + 'description' => ['nullable', 'string', 'max:500'], + + 'tags' => ['array', 'max:50'], + 'tags.*.label' => ['nullable', 'string', 'max:40'], + 'tags.*.color' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'], + + 'max_aliases' => ['required', 'integer', 'min:0', 'max:100000'], + 'max_mailboxes' => ['required', 'integer', 'min:0', 'max:100000'], + + 'default_quota_mb' => ['required', 'integer', 'min:0', 'max:2000000'], + 'max_quota_per_mailbox_mb' => ['nullable', 'integer', 'min:1', 'max:2000000'], + 'total_quota_mb' => ['required', 'integer', 'min:1', 'max:2000000'], + + 'rate_limit_per_hour' => ['nullable', 'integer', 'min:1', 'max:2000000'], + 'rate_limit_override' => ['boolean'], + 'active' => ['boolean'], + + 'dkim_selector' => ['required', 'string', 'max:50', 'regex:/^[a-z0-9\-]+$/i'], + 'dkim_bits' => ['required', Rule::in([1024, 2048, 3072, 4096])], + ]; + } + + protected function messages(): array + { + return [ + 'domain.required' => 'Bitte gib eine Domain an.', + 'domain.string' => 'Die Domain muss als Text eingegeben werden.', + 'domain.lowercase' => 'Die Domain darf keine Großbuchstaben enthalten.', + 'domain.max' => 'Die Domain darf maximal 255 Zeichen lang sein.', + 'domain.regex' => 'Die Domain ist ungültig. Beispiel: mail.example.com', + 'domain.unique' => 'Diese Domain ist bereits vorhanden.', + 'domain_reserved' => 'Die Domain :domain ist reserviert und kann nicht verwendet werden.', + + 'description.max' => 'Die Beschreibung darf maximal 500 Zeichen haben.', + + 'tags.array' => 'Tags müssen als Array übergeben werden.', + 'tags.max' => 'Es dürfen maximal 50 Tags verwendet werden.', + 'tags.*.label.max' => 'Ein Tag-Label darf maximal 40 Zeichen haben.', + 'tags.*.color.regex' => 'Die Farbe eines Tags muss als Hexwert angegeben werden.', + + 'max_aliases.required' => 'Bitte gib ein Alias-Limit an.', + 'max_aliases.integer' => 'Das Alias-Limit muss eine Zahl sein.', + 'max_aliases.max' => 'Das Alias-Limit ist zu hoch.', + + 'max_mailboxes.required' => 'Bitte gib ein Postfach-Limit an.', + 'max_mailboxes.integer' => 'Das Postfach-Limit muss eine Zahl sein.', + 'max_mailboxes.max' => 'Das Postfach-Limit ist zu hoch.', + + 'default_quota_mb.required' => 'Bitte gib eine Standard-Quota an.', + 'default_quota_mb.integer' => 'Die Standard-Quota muss eine Zahl sein.', + 'default_quota_mb.max' => 'Die Standard-Quota ist zu hoch.', + + 'max_quota_per_mailbox_mb.integer' => 'Die maximale Quota pro Postfach muss eine Zahl sein.', + 'max_quota_per_mailbox_mb.max' => 'Die maximale Quota pro Postfach ist zu hoch.', + + 'total_quota_mb.required' => 'Bitte gib die Gesamt-Quota an.', + 'total_quota_mb.integer' => 'Die Gesamt-Quota muss eine Zahl sein.', + 'total_quota_mb.max' => 'Die Gesamt-Quota ist zu hoch.', + + 'rate_limit_per_hour.integer' => 'Das Rate-Limit muss eine Zahl sein.', + 'rate_limit_per_hour.max' => 'Das Rate-Limit ist zu hoch.', + + 'dkim_selector.required' => 'Bitte gib einen DKIM-Selector an.', + 'dkim_selector.regex' => 'Der DKIM-Selector darf nur Buchstaben, Zahlen und Bindestriche enthalten.', + 'dkim_bits.required' => 'Bitte wähle eine Schlüssellänge.', + 'dkim_bits.in' => 'Ungültige DKIM-Schlüssellänge.', + ]; + } + public function addTag(): void + { + $this->tags[] = ['label' => '', 'color' => $this->tagPalette[0]]; + } + + public function removeTag(int $i): void + { + unset($this->tags[$i]); + $this->tags = array_values($this->tags); + } + + public function pickTagColor(int $i, string $hex): void + { + if (!isset($this->tags[$i])) return; + $hex = $this->normalizeHex($hex); + if ($hex) $this->tags[$i]['color'] = $hex; + } + + private function normalizeHex(?string $hex): ?string + { + $hex = trim((string)$hex); + if ($hex === '') return null; + if ($hex[0] !== '#') $hex = "#{$hex}"; + return preg_match('/^#[0-9a-fA-F]{6}$/', $hex) ? strtolower($hex) : null; + } + + + private function assertNotReserved(string $fqdn): void + { + $zone = config('mailpool.platform_zone'); // z.B. sysmail.your-saas.tld + $blocked = [ + 'system.'.$fqdn, // falls du es doch schützen willst + 'bounce.'.$fqdn, + 'mx.'.$fqdn, // nur wenn du's global blocken willst + ]; + + foreach ($blocked as $bad) { + if (Str::lower($fqdn) === Str::lower($bad)) { + throw ValidationException::withMessages([ + 'domain' => 'Diese Domain/Subdomain ist reserviert und kann nicht verwendet werden.', + ]); + } + } + + // Plattform-Zone darf generell nicht als Benutzer-Domain eingetragen werden: + if (Str::endsWith(Str::lower($fqdn), '.'.Str::lower($zone)) || Str::lower($fqdn) === Str::lower($zone)) { + throw ValidationException::withMessages([ + 'domain' => 'Domains innerhalb der System-Zone sind reserviert.', + ]); + } + } + + + #[On('domain:create')] + public function save(MailStorage $pool): void + { + $this->domain = Str::lower(trim($this->domain)); + + $this->validate(); + +// $this->assertNotReserved($this->domain); + + // Konsistenz-Checks der Eingaben (Create) + if ($this->max_quota_per_mailbox_mb !== null && + $this->default_quota_mb > $this->max_quota_per_mailbox_mb) { + throw ValidationException::withMessages([ + 'default_quota_mb' => 'Die Standard-Quota darf die maximale Mailbox-Quota nicht überschreiten.', + ]); + } + + if ($this->default_quota_mb > $this->total_quota_mb) { + throw ValidationException::withMessages([ + 'default_quota_mb' => 'Die Standard-Quota darf die Domain-Gesamtquota nicht überschreiten.', + ]); + } + + // Pool-Kapazität + $remaining = (int)$pool->remainingPoolMb(); + if ($this->total_quota_mb > $remaining) { + throw ValidationException::withMessages([ + 'total_quota_mb' => 'Nicht genügend Speicher im Pool. Maximal möglich: ' + . number_format($remaining) . ' MiB.', + ]); + } + + // Tags normalisieren + $tagsOut = []; + foreach ($this->tags as $t) { + $label = trim((string)($t['label'] ?? '')); + if ($label === '') continue; + $color = $this->normalizeHex($t['color'] ?? '') ?? $this->tagPalette[0]; + $tagsOut[] = ['label' => $label, 'color' => $color]; + } + + // Persist + $domain = Domain::create([ + 'domain' => $this->domain, + 'description' => $this->description, + 'tags' => $tagsOut, + 'is_active' => $this->active, + 'is_system' => false, + + 'max_aliases' => $this->max_aliases, + 'max_mailboxes' => $this->max_mailboxes, + + 'default_quota_mb' => $this->default_quota_mb, + 'max_quota_per_mailbox_mb' => $this->max_quota_per_mailbox_mb, + 'total_quota_mb' => $this->total_quota_mb, + + 'rate_limit_per_hour' => $this->rate_limit_per_hour, + 'rate_limit_override' => $this->rate_limit_override, + ]); + + // DKIM + DNS + $dkim = app(DkimService::class)->generateForDomain($domain, $this->dkim_bits, $this->dkim_selector); + + DkimKey::create([ + 'domain_id' => $domain->id, + 'selector' => $dkim['selector'], + 'private_key_pem' => $dkim['private_pem'], + 'public_key_txt' => preg_replace('/^v=DKIM1; k=rsa; p=/', '', $dkim['dns_txt']), + 'is_active' => true, + ]); + + app(DnsRecordService::class)->provision( + $domain, + $dkim['selector'] ?? null, + $dkim['dns_txt'] ?? null, + [ + 'spf_tail' => Setting::get('mailpool.spf_tail', '~all'), + 'spf_extra' => Setting::get('mailpool.spf_extra', []), + 'dmarc_policy' => Setting::get('mailpool.dmarc_policy', 'none'), + 'rua' => Setting::get('mailpool.rua', null), + ] + ); + + app(TlsaService::class)->createForDomain($domain); + + // UI + $this->dispatch('domain-created'); + $this->dispatch('closeModal'); + $this->dispatch('toast', + type: 'done', + badge: 'Domain', + title: 'Domain angelegt', + text: 'Die Domain ' . e($this->domain) . ' wurde erfolgreich erstellt. DKIM, SPF und DMARC sind vorbereitet.', + duration: 6000, + ); + } + + public static function modalMaxWidth(): string + { + return '3xl'; + } + + public function render() + { + return view('livewire.ui.domain.modal.domain-create-modal'); + } +} diff --git a/app/Livewire/Ui/Domain/Modal/DomainDeleteModal.php b/app/Livewire/Ui/Domain/Modal/DomainDeleteModal.php new file mode 100644 index 0000000..1eb3e8e --- /dev/null +++ b/app/Livewire/Ui/Domain/Modal/DomainDeleteModal.php @@ -0,0 +1,73 @@ +findOrFail($domainId); + + if ($d->is_system) { + $this->dispatch('toast', type:'forbidden', badge:'System-Domain', + text:'Diese Domain ist als System-Domain markiert und kann nicht gelöscht werden.', duration:6000); + $this->dispatch('closeModal'); + return; + } + + $this->domainId = $d->id; + $this->domain = $d->domain; + $this->mailboxes = (int) $d->mailboxes; + $this->aliases = (int) $d->aliases; + } + + #[On('domain:delete')] + public function delete(): void + { + $d = Domain::withCount(['mailUsers as mailboxes','mailAliases as aliases'])->findOrFail($this->domainId); + + if ($d->is_system) { + $this->dispatch('toast', type:'forbidden', badge:'System-Domain', text:'System-Domain kann nicht gelöscht werden.', duration:6000); + $this->dispatch('closeModal'); return; + } + + if ($d->mailboxes > 0 || $d->aliases > 0) { + $this->dispatch('toast', type:'forbidden', badge:'Domain', + text:'Löschen blockiert: Es sind noch Postfächer oder Aliasse vorhanden.', duration:0); + return; + } + + if (trim(strtolower($this->confirm)) !== strtolower($d->domain)) { + $this->dispatch('toast', type:'failed', badge:'Domain', text:'Bestätigung stimmt nicht.', duration:6000); + return; + } + + $name = $d->domain; + $d->delete(); + + $this->dispatch('domain-updated'); // damit die Liste neu lädt + $this->dispatch('toast', + type:'done', badge:'Domain', title:'Domain gelöscht', + text:'Die Domain '.e($name).' wurde erfolgreich entfernt.', duration:6000); + $this->dispatch('closeModal'); + } + + public static function modalMaxWidth(): string { return 'md'; } + + public function render() + { + return view('livewire.ui.domain.modal.domain-delete-modal'); + } +} diff --git a/app/Livewire/Ui/Domain/Modal/DomainDnsModal.php b/app/Livewire/Ui/Domain/Modal/DomainDnsModal.php index 3c5b518..50dc238 100644 --- a/app/Livewire/Ui/Domain/Modal/DomainDnsModal.php +++ b/app/Livewire/Ui/Domain/Modal/DomainDnsModal.php @@ -2,18 +2,16 @@ namespace App\Livewire\Ui\Domain\Modal; -use App\Models\DkimKey; use App\Models\Domain; -use App\Services\DnsRecordService; +use App\Support\NetProbe; use Illuminate\Support\Facades\DB; -use Livewire\Component; use LivewireUI\Modal\ModalComponent; class DomainDnsModal extends ModalComponent { public int $domainId; public string $domainName = ''; - public string $base = ''; + public string $zone = ''; public string $ttl = '3600'; public array $recordColors = []; @@ -40,10 +38,12 @@ class DomainDnsModal extends ModalComponent $d = Domain::findOrFail($domainId); $this->domainName = $d->domain; - $ipv4 = $this->detectIPv4(); - $ipv6 = $this->detectIPv6(); // kann null sein - $this->base = env('BASE_DOMAIN', 'example.com'); - $mta = env('MTA_SUB', 'mx').'.'.$this->base; // mx.example.com + $ips = NetProbe::resolve(); + $ipv4 = $ips['ipv4']; + $ipv6 = $ips['ipv6']; + + $this->zone = $this->extractZone($d->domain); + $mta = env('MTA_SUB', 'mx').'.'.$this->zone; // mx.example.com // --- Statische Infrastruktur (für alle Domains gleich) --- $this->static = [ @@ -78,6 +78,14 @@ class DomainDnsModal extends ModalComponent ]; } + private function extractZone(string $fqdn): string + { + $fqdn = strtolower(trim($fqdn, ".")); + $parts = explode('.', $fqdn); + $n = count($parts); + return $n >= 2 ? $parts[$n-2] . '.' . $parts[$n-1] : $fqdn; // nimmt die letzten 2 Labels + } + private function detectIPv4(): string { // robust & ohne env diff --git a/app/Livewire/Ui/Domain/Modal/DomainEditModal.php b/app/Livewire/Ui/Domain/Modal/DomainEditModal.php new file mode 100644 index 0000000..1d6cbfd --- /dev/null +++ b/app/Livewire/Ui/Domain/Modal/DomainEditModal.php @@ -0,0 +1,110 @@ +is_system) { $this->dispatch('toast', state:'error', text:'System-Domain kann nicht bearbeitet werden.'); $this->dispatch('closeModal'); return; } + + $this->domain = $d->domain; + $this->description = $d->description; + $this->is_active = $d->is_active; + + $this->tags = []; + $raw = $d->tags ?? []; + if (is_string($raw)) { + $raw = array_values(array_filter(array_map('trim', explode(',', $raw)))); + foreach ($raw as $lbl) $this->tags[] = ['label'=>$lbl, 'color'=>$this->tagPalette[0]]; + } elseif (is_array($raw)) { + foreach ($raw as $t) { + $label = is_array($t) ? trim((string)($t['label'] ?? '')) : trim((string)$t); + if ($label === '') continue; + $color = $this->normalizeHex(is_array($t) ? ($t['color'] ?? '') : '') ?? $this->tagPalette[0]; + $this->tags[] = ['label'=>$label, 'color'=>$color]; + } + } + if (empty($this->tags)) $this->tags = [['label'=>'', 'color'=>$this->tagPalette[0]]]; + } + + public function addTag(): void + { + $this->tags[] = ['label'=>'', 'color'=>$this->tagPalette[0]]; + } + public function removeTag(int $i): void + { + unset($this->tags[$i]); + $this->tags = array_values($this->tags); + } + public function pickTagColor(int $i, string $hex): void + { + if (!isset($this->tags[$i])) return; + $hex = $this->normalizeHex($hex); + if ($hex) $this->tags[$i]['color'] = $hex; + } + + private function normalizeHex(?string $hex): ?string + { + $hex = trim((string)$hex); + if ($hex === '') return null; + if ($hex[0] !== '#') $hex = "#{$hex}"; + return preg_match('/^#[0-9a-fA-F]{6}$/', $hex) ? strtolower($hex) : null; + } + + public function save() + { + $this->validate([ + 'description' => 'nullable|string|max:500', + 'is_active' => 'boolean', + 'tags' => 'array|max:50', + 'tags.*.label' => 'nullable|string|max:40', + 'tags.*.color' => ['nullable','regex:/^#[0-9a-fA-F]{6}$/'], + ]); + + $d = Domain::where('domain', $this->domain)->firstOrFail(); + if ($d->is_system) { $this->dispatch('toast', state:'error', text:'System-Domain kann nicht bearbeitet werden.'); return; } + + $out = []; + foreach ($this->tags as $t) { + $label = trim((string)($t['label'] ?? '')); + if ($label === '') continue; + $color = $this->normalizeHex($t['color'] ?? '') ?? $this->tagPalette[0]; + $out[] = ['label'=>$label, 'color'=>$color]; + } + + $d->update([ + 'description' => $this->description, + 'is_active' => $this->is_active, + 'tags' => $out, + ]); + + $this->dispatch('domain-updated'); + $this->dispatch('closeModal'); + $this->dispatch('toast', + type: 'done', + badge: 'Domain', + title: 'Domain aktualisiert', + text: 'Die Domain ' . e($d->domain) . ' wurde erfolgreich aktualisiert. Alle Änderungen sind sofort aktiv.', + duration: 6000, + ); + } + + public function render() + { + return view('livewire.ui.domain.modal.domain-edit-modal', [ + 'tagPalette' => $this->tagPalette, + ]); + } +} diff --git a/app/Livewire/Ui/Domain/Modal/DomainLimitsModal.php b/app/Livewire/Ui/Domain/Modal/DomainLimitsModal.php new file mode 100644 index 0000000..8121872 --- /dev/null +++ b/app/Livewire/Ui/Domain/Modal/DomainLimitsModal.php @@ -0,0 +1,258 @@ +is_system) { + $this->dispatch('toast', type: 'error', text: 'System-Domain Limits können hier nicht geändert werden.'); + $this->dispatch('closeModal'); + return; + } + + $this->domainId = $domainId; + $this->max_aliases = (int)$d->max_aliases; + $this->max_mailboxes = (int)$d->max_mailboxes; + $this->default_quota_mb = (int)$d->default_quota_mb; + $this->max_quota_per_mailbox_mb = $d->max_quota_per_mailbox_mb !== null ? (int)$d->max_quota_per_mailbox_mb : null; + $this->total_quota_mb = (int)$d->total_quota_mb; + $this->rate_limit_per_hour = $d->rate_limit_per_hour !== null ? (int)$d->rate_limit_per_hour : null; + $this->rate_limit_override = (bool)$d->rate_limit_override; + + // Für Pool-Delta-Check merken + $this->current_total_quota_mb = (int)$d->total_quota_mb; + } + + protected function rules(): array + { + return [ + 'max_aliases' => ['required', 'integer', 'min:0'], + 'max_mailboxes' => ['required', 'integer', 'min:0'], + 'default_quota_mb' => ['required', 'integer', 'min:0'], + 'max_quota_per_mailbox_mb' => ['nullable', 'integer', 'min:1'], + 'total_quota_mb' => ['required', 'integer', 'min:0'], // 0 = unbegrenzt + 'rate_limit_per_hour' => ['nullable', 'integer', 'min:1'], // null = kein Limit + 'rate_limit_override' => ['boolean'], + ]; + } + + public function save(MailStorage $pool): void + { + $this->validate(); + + // 1) Innere Konsistenz der neuen Limits + if ($this->max_quota_per_mailbox_mb !== null && $this->default_quota_mb > $this->max_quota_per_mailbox_mb) { + throw ValidationException::withMessages([ + 'default_quota_mb' => 'Die Standard-Quota darf die maximale Mailbox-Quota nicht überschreiten.', + ]); + } + if ($this->total_quota_mb > 0 && $this->default_quota_mb > $this->total_quota_mb) { + throw ValidationException::withMessages([ + 'default_quota_mb' => 'Die Standard-Quota darf die Domain-Gesamtquota nicht überschreiten.', + ]); + } + + // 2) Bestandsdaten laden (für Abwärts-Checks) + $d = Domain::query() + ->withCount(['mailUsers', 'mailAliases']) + ->withSum('mailUsers as sum_user_quota_mb', 'quota_mb') + ->withMax('mailUsers as max_user_quota_mb', 'quota_mb') + ->withMax('mailUsers as max_user_rate', 'rate_limit_per_hour') + ->findOrFail($this->domainId); + + $existingUsers = (int)$d->mail_users_count; + $existingAliases = (int)$d->mail_aliases_count; + $sumQuota = (int)($d->sum_user_quota_mb ?? 0); + $maxUserQuota = (int)($d->max_user_quota_mb ?? 0); + $maxUserRate = (int)($d->max_user_rate ?? 0); + + // 3) Abwärts-Checks + if ($this->max_mailboxes < $existingUsers) { + throw ValidationException::withMessages([ + 'max_mailboxes' => "Es existieren bereits {$existingUsers} Mailboxen. " + . "Der Wert darf nicht unter diese Anzahl gesenkt werden.", + ]); + } + + if ($this->max_aliases < $existingAliases) { + throw ValidationException::withMessages([ + 'max_aliases' => "Es existieren bereits {$existingAliases} Aliasse. " + . "Der Wert darf nicht unter diese Anzahl gesenkt werden.", + ]); + } + + if ($this->max_quota_per_mailbox_mb !== null && $this->max_quota_per_mailbox_mb < $maxUserQuota) { + throw ValidationException::withMessages([ + 'max_quota_per_mailbox_mb' => "Mindestens ein Postfach hat {$maxUserQuota} MiB. " + . "Das Limit darf nicht darunter liegen.", + ]); + } + + if ($this->total_quota_mb > 0 && $this->total_quota_mb < $sumQuota) { + throw ValidationException::withMessages([ + 'total_quota_mb' => "Bestehende Postfächer summieren sich auf {$sumQuota} MiB. " + . "Das Domain-Gesamtlimit darf nicht darunter liegen.", + ]); + } + + if (!$this->rate_limit_override && $this->rate_limit_per_hour !== null && $maxUserRate > 0 + && $this->rate_limit_per_hour < $maxUserRate) { + throw ValidationException::withMessages([ + 'rate_limit_per_hour' => "Mindestens ein Postfach hat ein höheres stündliches Limit ({$maxUserRate}). " + . "Ohne Overrides darf der Domain-Wert nicht darunter liegen.", + ]); + } + + // 4) Pool-Check (nur bei Erhöhung der Domain-Gesamtquota) + $delta = $this->total_quota_mb - $this->current_total_quota_mb; + if ($delta > 0) { + $remaining = (int)$pool->remainingPoolMb(); + if ($delta > $remaining) { + throw ValidationException::withMessages([ + 'total_quota_mb' => 'Nicht genügend Speicher im Pool. Erhöhung um ' + . number_format($delta) . ' MiB nicht möglich. Frei: ' . number_format($remaining) . ' MiB.', + ]); + } + } + + // 5) Persist + $d->max_aliases = $this->max_aliases; + $d->max_mailboxes = $this->max_mailboxes; + $d->default_quota_mb = $this->default_quota_mb; + $d->max_quota_per_mailbox_mb = $this->max_quota_per_mailbox_mb; + $d->total_quota_mb = $this->total_quota_mb; + $d->rate_limit_per_hour = $this->rate_limit_per_hour; + $d->rate_limit_override = $this->rate_limit_override; + $d->save(); + + // 6) UI + $this->dispatch('domain-updated'); + $this->dispatch('closeModal'); + $this->dispatch('toast', + type: 'done', + badge: 'Domain-Limits', + title: 'Domain-Limits aktualisiert', + text: 'Die Domain-Limits wurden erfolgreich aktualisiert.', + duration: 6000, + ); + } + + public static function modalMaxWidth(): string + { + return '3xl'; + } + + public function render() + { + return view('livewire.ui.domain.modal.domain-limits-modal'); + } +} +// +//namespace App\Livewire\Ui\Domain\Modal; +// +//use App\Models\Domain; +//use App\Services\MailStorage; +//use Illuminate\Validation\ValidationException; +//use LivewireUI\Modal\ModalComponent; +// +// +//class DomainLimitsModal extends ModalComponent +//{ +// public int $domainId; +// public int $max_aliases; +// public int $max_mailboxes; +// public int $default_quota_mb; +// public ?int $max_quota_per_mailbox_mb = null; +// public int $total_quota_mb; +// public ?int $rate_limit_per_hour = null; +// public bool $rate_limit_override = false; +// +// public function mount(int $domainId, MailStorage $pool) +// { +// $d = Domain::findOrFail($domainId); +// if ($d->is_system) { $this->dispatch('toast', type:'error', text:'System-Domain Limits können hier nicht geändert werden.'); $this->dispatch('closeModal'); return; } +// +// $this->domainId = $domainId; +// $this->max_aliases = (int)$d->max_aliases; +// $this->max_mailboxes = (int)$d->max_mailboxes; +// $this->default_quota_mb = (int)$d->default_quota_mb; +// $this->max_quota_per_mailbox_mb = $d->max_quota_per_mailbox_mb; +// $this->total_quota_mb = (int)$d->total_quota_mb; +// $this->rate_limit_per_hour = $d->rate_limit_per_hour; +// $this->rate_limit_override = (bool)$d->rate_limit_override; +// } +// +// public function save(MailStorage $pool) +// { +// $this->validate([ +// 'max_aliases' => 'required|integer|min:0', +// 'max_mailboxes' => 'required|integer|min:0', +// 'default_quota_mb' => 'required|integer|min:0', +// 'max_quota_per_mailbox_mb' => 'nullable|integer|min:0', +// 'total_quota_mb' => 'required|integer|min:0', +// 'rate_limit_per_hour' => 'nullable|integer|min:0', +// 'rate_limit_override' => 'boolean', +// ]); +// +// if ($this->max_quota_per_mailbox_mb !== null && +// $this->default_quota_mb > $this->max_quota_per_mailbox_mb) { +// throw ValidationException::withMessages([ +// 'default_quota_mb' => 'Die Standard-Quota darf die maximale Mailbox-Quota nicht überschreiten.', +// ]); +// } +// +// $remaining = $pool->remainingPoolMb(); +// if ($this->total_quota_mb > $remaining) { +// throw ValidationException::withMessages([ +// 'total_quota_mb' => 'Nicht genügend Speicher verfügbar. Maximal möglich: '.number_format($remaining).' MiB.', +// ]); +// } +// +// Domain::whereKey($this->domainId)->update([ +// 'max_aliases' => $this->max_aliases, +// 'max_mailboxes' => $this->max_mailboxes, +// 'default_quota_mb' => $this->default_quota_mb, +// 'max_quota_per_mailbox_mb' => $this->max_quota_per_mailbox_mb, +// 'total_quota_mb' => $this->total_quota_mb, +// 'rate_limit_per_hour' => $this->rate_limit_per_hour, +// 'rate_limit_override' => $this->rate_limit_override, +// ]); +// +// $this->dispatch('domain-updated'); +// $this->dispatch('closeModal'); +// $this->dispatch('toast', +// type: 'done', +// badge: 'Domain-Limits', +// title: 'Domain-Limits aktualisiert', +// text: 'Die Domain-Limits wurde erfolgreich aktualisiert. Alle Änderungen sind sofort aktiv.', +// duration: 6000, +// ); +// } +// +// public function render() +// { +// return view('livewire.ui.domain.modal.domain-limits-modal'); +// } +//} diff --git a/app/Livewire/Ui/Mail/AliasList.php b/app/Livewire/Ui/Mail/AliasList.php new file mode 100644 index 0000000..2674a27 --- /dev/null +++ b/app/Livewire/Ui/Mail/AliasList.php @@ -0,0 +1,130 @@ +dispatch('$refresh'); + } + + public function openAliasCreate(int $domainId): void + { + $this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [ + 'domainId' => $domainId, + ]); + } + + public function openAliasEdit(int $aliasId): void + { + // nur Wert übergeben (LivewireUI Modal nimmt Positionsargumente) + $this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [ + $aliasId, + ]); + } + + public function openAliasDelete(int $aliasId): void + { + $this->dispatch('openModal', component: 'ui.mail.modal.alias-delete-modal', arguments: [ + $aliasId, + ]); + } + + public function render() + { + $system = Domain::query()->where('is_system', true)->first(); + $term = trim($this->search); + + $domains = Domain::query() + ->where('is_system', false) + ->withCount(['mailAliases']) + ->with([ + 'mailAliases' => fn ($q) => $q + ->withCount('recipients') + ->with(['recipients' => fn($r) => $r->with('mailUser')]) + ->orderBy('local'), + ]) + ->when($term !== '', function ($q) use ($term) { + $q->where(function ($w) use ($term) { + $w->where('domain', 'like', "%{$term}%") + ->orWhereHas('mailAliases', function ($a) use ($term) { + $a->where('local', 'like', "%{$term}%") + ->orWhereHas('recipients', function ($r) use ($term) { + $r->where('email', 'like', "%{$term}%") + ->orWhereHas('mailUser', fn($mu) => + $mu->where('localpart', 'like', "%{$term}%")); + }); + }); + }); + }) + ->orderBy('domain') + ->get(); + + // Vorbereiten der Alias-Daten (Logik aus dem Blade hierher) + foreach ($domains as $domain) { + $domainActive = (bool) ($domain->is_active ?? true); + + foreach ($domain->mailAliases as $alias) { + + $alias->effective_active = $domainActive && (bool) ($alias->is_active ?? true); + + $alias->inactive_reason = null; + if (!$domainActive) { + $alias->inactive_reason = 'Domain inaktiv'; + } elseif (!($alias->is_active ?? true)) { + $alias->inactive_reason = 'Alias inaktiv'; + } + + $alias->isGroup = $alias->type === 'group'; + $alias->isSingle = !$alias->isGroup; + $alias->recipientCount = $alias->recipients_count ?? $alias->recipients->count(); + $alias->maxChips = $alias->isGroup ? 2 : 1; + $alias->extraRecipients= max(0, $alias->recipientCount - $alias->maxChips); + $alias->shownRecipients= $alias->relationLoaded('recipients') + ? $alias->recipients->take($alias->maxChips) + : collect(); + + // Quelle (immer) + $alias->sourceEmail = "{$alias->local}@{$domain->domain}"; + + // Ziele (Text für Pfeil-Zeile) + if ($alias->isSingle) { + // 1. Ziel auflösen + $first = $alias->relationLoaded('recipients') ? $alias->recipients->first() : null; + if ($first) { + if ($first->mail_user_id && $first->relationLoaded('mailUser') && $first->mailUser) { + $alias->arrowTarget = "{$first->mailUser->localpart}@{$domain->domain}"; + } else { + $alias->arrowTarget = $first->email ?: '—'; + } + } else { + $alias->arrowTarget = '—'; + } + } else { + // Gruppenlabel + $label = trim((string)$alias->group_name) !== '' ? $alias->group_name : 'Gruppe'; + $alias->arrowTarget = "{$label} ({$alias->recipientCount})"; + } + + // Kompletter Pfeil-Text + $alias->arrowLine = "{$alias->sourceEmail} ⇒ {$alias->arrowTarget}"; + } + } + + return view('livewire.ui.mail.alias-list', [ + 'domains' => $domains, + 'system' => $this->showSystemCard ? $system : null, + ]); + } +} diff --git a/app/Livewire/Ui/Mail/MailboxList.php b/app/Livewire/Ui/Mail/MailboxList.php new file mode 100644 index 0000000..a0193ef --- /dev/null +++ b/app/Livewire/Ui/Mail/MailboxList.php @@ -0,0 +1,341 @@ +dispatch('$refresh'); + } + + #[On('focus:domain')] + public function focusDomain(int $id): void + { + // z. B. Domain nach oben holen / scrollen / highlighten + // oder direkt den "+ Postfach" Dialog: + // $this->openMailboxCreate($id); + } + + #[On('focus:user')] + public function focusUser(int $id): void + { + // später: Benutzerseite / Filter setzen ... + } + + public function openMailboxCreate(int $domainId): void + { + $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [ + 'domainId' => $domainId, + ]); + } + + public function openMailboxEdit(int $domainId): void + { + // $domainId == mailbox_id + $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-edit-modal', arguments: [ + $domainId, // <— nur der Wert, kein Key! + ]); + } + + public function openMailboxDelete(int $domainId): void + { + $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-delete-modal', arguments: [ + $domainId, // <— nur der Wert, kein Key! + ]); + } + + + public function render() + { + $system = Domain::query()->where('is_system', true)->first(); + $term = trim($this->search); + $hasTerm = $term !== ''; + $needle = '%'.str_replace(['%','_'], ['\%','\_'], $term).'%'; // LIKE-sicher + + $domains = Domain::query() + ->when($system, fn ($q) => $q->whereKeyNot($system->id)) + + // Domain selbst ODER MailUser/ Aliasse müssen matchen + ->when($hasTerm, function ($q) use ($needle) { + $q->where(function ($w) use ($needle) { + $w->where('domain', 'like', $needle) + ->orWhereHas('mailUsers', fn($u) => $u->where('localpart', 'like', $needle)); + }); + }) + + ->withCount(['mailUsers']) + + // Beziehungen zunächst gefiltert laden (damit "test" nur passende Mailboxen zeigt) + ->with([ + 'mailUsers' => function ($q) use ($hasTerm, $needle) { + if ($hasTerm) $q->where('localpart', 'like', $needle); + $q->orderBy('localpart'); + }, + ]) + + ->orderBy('domain') + ->get(); + + // Domains, deren NAME den Suchbegriff trifft → ALLE Mailboxen/Aliasse zeigen + if ($hasTerm) { + $lower = Str::lower($term); + foreach ($domains as $d) { + if (Str::contains(Str::lower($d->domain), $lower)) { + // volle Relationen nachladen (überschreibt die gefilterten) + $d->setRelation('mailUsers', $d->mailUsers()->orderBy('localpart')->get()); + $d->setRelation('mailAliases', $d->mailAliases()->orderBy('local')->get()); + } + } + } + + // Vorbereitung für Blade (unverändert, arbeitet auf den ggf. gefilterten Relationen) + foreach ($domains as $d) { + $prepared = []; + $domainActive = (bool)($d->is_active ?? true); + + foreach ($d->mailUsers as $u) { + $quota = (int)($u->quota_mb ?? 0); + $used = (int)($u->used_mb ?? 0); + $usage = $quota > 0 ? min(100, (int)round($used / max(1, $quota) * 100)) : 0; + + $mailboxActive = (bool)($u->is_active ?? true); + $effective = $domainActive && $mailboxActive; + + $reason = null; + if (!$effective) { + $reason = !$domainActive ? 'Domain inaktiv' + : (!$mailboxActive ? 'Postfach inaktiv' : null); + } + + $prepared[] = [ + 'id' => $u->id, + 'localpart' => (string)$u->localpart, + 'quota_mb' => $quota, + 'used_mb' => $used, + 'usage_percent' => $usage, + 'message_count' => (int)($u->message_count ?? $u->mails_count ?? 0), + 'is_active' => $mailboxActive, + 'is_effective_active' => $effective, + 'inactive_reason' => $reason, + ]; + } + + // für Blade + $d->prepared_mailboxes = $prepared; + } + + return view('livewire.ui.mail.mailbox-list', [ + 'domains' => $domains, + 'system' => $this->showSystemCard ? $system : null, + ]); + } + +// public function render() +// { +// $system = Domain::query()->where('is_system', true)->first(); +// $term = trim($this->search); +// +// $domains = Domain::query() +// ->when($system, fn ($q) => $q->where('id', '!=', $system->id)) +// ->withCount(['mailUsers','mailAliases']) +// ->with([ +// 'mailUsers' => fn ($q) => $q->orderBy('localpart'), +// 'mailAliases' => fn ($q) => $q->orderBy('local'), +// ]) +// ->when($term !== '', function ($q) use ($term) { +// $q->where(function ($w) use ($term) { +// $w->where('domain', 'like', "%{$term}%") +// ->orWhereHas('mailUsers', fn($u) => +// $u->where('localpart', 'like', "%{$term}%") +// ); +// }); +// }) +// ->orderBy('domain') +// ->get(); +// +// // Vorbereitung für Blade (unverändert) +// foreach ($domains as $d) { +// $prepared = []; +// $domainActive = (bool)($d->is_active ?? true); +// +// foreach ($d->mailUsers as $u) { +// $quota = (int) ($u->quota_mb ?? 0); +// $used = (int) ($u->used_mb ?? 0); +// $usage = $quota > 0 ? min(100, (int) round($used / max(1,$quota) * 100)) : 0; +// +// $mailboxActive = (bool)($u->is_active ?? true); +// $effective = $domainActive && $mailboxActive; +// +// $reason = null; +// if (!$effective) { +// $reason = !$domainActive ? 'Domain inaktiv' +// : (!$mailboxActive ? 'Postfach inaktiv' : null); +// } +// +// $prepared[] = [ +// 'id' => $u->id, +// 'localpart' => (string) $u->localpart, +// 'quota_mb' => $quota, +// 'used_mb' => $used, +// 'usage_percent' => $usage, +// 'message_count' => (int) ($u->message_count ?? $u->mails_count ?? 0), +// 'is_active' => $mailboxActive, +// 'is_effective_active' => $effective, +// 'inactive_reason' => $reason, +// ]; +// } +// +// $d->prepared_mailboxes = $prepared; +// } +// +// return view('livewire.ui.mail.mailbox-list', [ +// 'domains' => $domains, +// 'system' => $this->showSystemCard ? $system : null, +// ]); +// } + +// public function render() +// { +// $system = Domain::query()->where('is_system', true)->first(); +// +// $term = trim($this->search); +// +// $domains = Domain::query() +// ->when($system, fn ($q) => $q->where('id', '!=', $system->id)) +// ->withCount(['mailUsers','mailAliases']) +// ->with([ +// 'mailUsers' => fn ($q) => $q->orderBy('localpart'), +// 'mailAliases' => fn ($q) => $q->orderBy('source'), +// ]) +// ->when($term !== '', function ($q) use ($term) { +// $q->where(function ($w) use ($term) { +// $w->where('domain', 'like', "%{$term}%") +// ->orWhereHas('mailUsers', fn($u) => +// $u->where('localpart', 'like', "%{$term}%") +// ); +// }); +// }) +// ->orderBy('domain') +// ->get(); +// +// // Für das Blade vorbereiten (ohne Relations zu mutieren) +// foreach ($domains as $d) { +// $prepared = []; +// $domainActive = (bool)($d->is_active ?? true); +// +// foreach ($d->mailUsers as $u) { +// $quota = (int) ($u->quota_mb ?? 0); +// $used = (int) ($u->used_mb ?? 0); +// $usage = $quota > 0 ? min(100, (int) round($used / max(1,$quota) * 100)) : 0; +// +// $mailboxActive = (bool)($u->is_active ?? true); +// $effective = $domainActive && $mailboxActive; +// +// $reason = null; +// if (!$effective) { +// $reason = !$domainActive ? 'Domain inaktiv' +// : (!$mailboxActive ? 'Postfach inaktiv' : null); +// } +// +// $prepared[] = [ +// 'id' => $u->id, +// 'localpart' => (string) $u->localpart, +// 'quota_mb' => $quota, +// 'used_mb' => $used, +// 'usage_percent' => $usage, +// 'message_count' => (int) ($u->message_count ?? $u->mails_count ?? 0), +// 'is_active' => $mailboxActive, // ursprünglicher Flag (falls du ihn brauchst) +// 'is_effective_active' => $effective, // ← NEU: Domain & Mailbox aktiv? +// 'inactive_reason' => $reason, // ← NEU: warum gesperrt +// ]; +// } +// +// $d->prepared_mailboxes = $prepared; +// } +// +// return view('livewire.ui.mail.mailbox-list', [ +// 'domains' => $domains, +// 'system' => $this->showSystemCard ? $system : null, +// ]); +// } +} + + + +//namespace App\Livewire\Ui\Mail; +// +//use App\Models\Domain; +//use Livewire\Component; +// +//class MailboxList extends Component +//{ +// public string $search = ''; +// public bool $showSystemCard = false; // optional: Info-Karte anzeigen +// +// public function openMailboxCreate(int $domainId): void +// { +// $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [ +// 'domainId' => $domainId, +// ]); +// } +// +// public function render() +// { +// // System-Domain direkt aus der DB (bool Spalte: is_system) +// $system = Domain::query() +// ->where('is_system', true) // <-- deine DB-Flag-Spalte +// ->first(); +// +// // Benutzer-Domains (ohne System-Domain) +// $domains = Domain::query() +// ->when($system, fn($q) => $q->where('id', '!=', $system->id)) +// ->withCount(['mailUsers', 'mailAliases']) +// ->with([ +// 'mailUsers' => fn($q) => $q->orderBy('localpart'), +// 'mailAliases' => fn($q) => $q->orderBy('source'), +// ]) +// ->when($this->search !== '', function ($q) { +// $q->where('domain', 'like', "%{$this->search}%") +// ->orWhereHas('mailUsers', fn($qq) => $qq->where('localpart', 'like', "%{$this->search}%")); +// }) +// ->orderBy('domain') // falls bei dir anders: exakt die vorhandene Spalte eintragen +// ->get(); +// +// // Alle Mailboxen vorbereiten (keine Logik im Blade) +// $domains->each(function ($domain) { +// $domain->mailUsers->transform(function ($u) use ($domain) { +// $quota = (int)($u->quota_mb ?? 0); +// $used = (int)($u->used_mb ?? 0); +// $usage = $quota > 0 ? min(100, round(($used / max(1, $quota)) * 100)) : 0; +// +// // neue Properties für das Blade +// $u->email = $u->localpart ? "{$u->localpart}@{$domain->domain}" : '—'; +// $u->quota_mb = $quota; +// $u->used_mb = $used; +// $u->usage_percent = $usage; +// $u->message_count = (int)($u->message_count ?? $u->mails_count ?? 0); +// +// return $u; +// }); +// }); +// +// +// return view('livewire.ui.mail.mailbox-list', [ +// 'domains' => $domains, +// 'system' => $this->showSystemCard ? $system : null, // read-only Karte optional +// ]); +// } +//} diff --git a/app/Livewire/Ui/Mail/Modal/AliasDeleteModal.php b/app/Livewire/Ui/Mail/Modal/AliasDeleteModal.php new file mode 100644 index 0000000..9125763 --- /dev/null +++ b/app/Livewire/Ui/Mail/Modal/AliasDeleteModal.php @@ -0,0 +1,94 @@ +alias = MailAlias::with(['domain', 'recipients.mailUser'])->findOrFail($aliasId); + + $this->aliasEmail = $this->alias->local . '@' . $this->alias->domain->domain; + $this->recipientCount = $this->alias->recipients->count(); + $this->isSingle = ($this->alias->type ?? 'single') === 'single'; + + if ($this->isSingle && $this->recipientCount === 1) { + $r = $this->alias->recipients->first(); + $this->targetLabel = $r->mailUser + ? $r->mailUser->localpart . '@' . $this->alias->domain->domain + : (string) $r->email; + } else { + $group = trim((string) ($this->alias->group_name ?? 'Gruppe')); + $this->targetLabel = $group . ' (' . $this->recipientCount . ')'; + } + + $this->extraRecipients = max(0, $this->recipientCount - 3); + + // initialer Vergleich (falls Autocomplete o.ä.) + $this->recomputeConfirmMatch(); + } + + public function updatedConfirm(): void + { + $this->resetErrorBag('confirm'); + $this->recomputeConfirmMatch(); + } + + private function recomputeConfirmMatch(): void + { + $this->confirmMatches = + mb_strtolower(trim($this->confirm)) === mb_strtolower($this->aliasEmail); + } + + #[On('alias:delete')] + public function delete(): void + { + if (! $this->confirmMatches) { + $this->dispatch('toast', [ + 'type' => 'warn', + 'badge' => 'Alias', + 'title' => 'Eingabe erforderlich', + 'text' => 'Bitte gib die Alias-Adresse korrekt ein, um den Löschvorgang zu bestätigen.', + 'duration' => 6000, + ]); + return; + } + + $email = $this->aliasEmail; + $this->alias->delete(); + + $this->dispatch('alias:deleted'); + $this->dispatch('closeModal'); + $this->dispatch('toast', + type: 'done', + badge: 'Alias', + title: 'Alias gelöscht', + text: 'Der Alias ' . e($email) . ' wurde entfernt.', + duration: 6000 + ); + } + + public function render() + { + return view('livewire.ui.mail.modal.alias-delete-modal'); + } +} diff --git a/app/Livewire/Ui/Mail/Modal/AliasFormModal.php b/app/Livewire/Ui/Mail/Modal/AliasFormModal.php new file mode 100644 index 0000000..94c7192 --- /dev/null +++ b/app/Livewire/Ui/Mail/Modal/AliasFormModal.php @@ -0,0 +1,2199 @@ + */ + public $domainMailUsers; + + // -------------------- Lifecycle -------------------- + + public function mount(?int $aliasId = null, ?int $domainId = null): void + { + $this->aliasId = $aliasId; + $this->domainId = $domainId; + + if ($this->aliasId) { + $alias = MailAlias::with(['domain', 'recipients.mailUser'])->findOrFail($this->aliasId); + if ($alias->domain->is_system) abort(403, 'System-Domains sind für Aliasse gesperrt.'); + + $this->domain = $alias->domain; + $this->domainId = $alias->domain_id; + + $this->fill([ + 'local' => $alias->local, + 'type' => $alias->type, + 'group_name' => $alias->group_name, + 'is_active' => $alias->is_active, + 'notes' => $alias->notes, + ]); + + // vorhandene Empfänger -> mit stabiler id versehen + $this->recipients = $alias->recipients + ->map(fn($r) => [ + 'id' => (string)Str::uuid(), + 'mail_user_id' => $r->mail_user_id, + 'email' => $r->email, + ]) + ->all(); + } else { + $this->domain = $this->domainId + ? Domain::where('is_system', false)->findOrFail($this->domainId) + : Domain::where('is_system', false)->orderBy('domain')->first(); + + $this->domainId = $this->domain->id ?? null; + + $this->recipients = [[ + 'id' => (string)Str::uuid(), + 'mail_user_id' => null, + 'email' => null, + ]]; + } + + $this->domainMailUsers = $this->domain + ? MailUser::where('domain_id', $this->domain->id)->orderBy('localpart')->get() + : collect(); + + $this->ensureAtLeastOneRow(); + $this->recomputeUi(); + $this->validateRecipientDuplicates(); + } + + // -------------------- UI-Reaktionen -------------------- + + public function updated($name, $value): void + { + // XOR je Zeile + if (preg_match('/^recipients\.(\d+)\.(mail_user_id|email)$/', $name, $m)) { + $i = (int)$m[1]; + if ($m[2] === 'mail_user_id' && !empty($value)) { + $this->recipients[$i]['email'] = null; + } elseif ($m[2] === 'email') { + $this->recipients[$i]['mail_user_id'] = null; + $this->recipients[$i]['email'] = Str::lower(trim((string)$this->recipients[$i]['email'])); + } + } + + // Self-Loop Live-Feedback (extern == alias) + if (preg_match('/^recipients\.(\d+)\.email$/', $name, $m)) { + $i = (int)$m[1]; + $self = $this->currentAliasAddress(); + $this->resetErrorBag("recipients.$i.email"); + if ($self && Str::lower(trim((string)$this->recipients[$i]['email'])) === $self) { + $this->addError("recipients.$i.email", 'Alias darf nicht an sich selbst weiterleiten.'); + } + } + + if ($name === 'type' && $this->type === 'single') { + $first = $this->recipients[0] ?? ['id' => (string)Str::uuid(), 'mail_user_id' => null, 'email' => null]; + $first['id'] = $first['id'] ?? (string)Str::uuid(); + $this->recipients = [$first]; + } + + $this->ensureAtLeastOneRow(); + $this->recomputeUi(); + $this->validateRecipientDuplicates(); + } + + public function updatedType(string $value): void + { + if ($value === 'single') { + $first = $this->recipients[0] ?? ['id' => (string)Str::uuid(), 'mail_user_id' => null, 'email' => null]; + $first['id'] = $first['id'] ?? (string)Str::uuid(); + $this->recipients = [$first]; + $this->recomputeUi(); + $this->validateRecipientDuplicates(); + } + } + + public function updatedDomainId(): void + { + $this->domain = $this->domainId + ? Domain::where('is_system', false)->find($this->domainId) + : null; + + $this->domainMailUsers = $this->domain + ? MailUser::where('domain_id', $this->domain->id)->orderBy('localpart')->get() + : collect(); + + // Zeilen resetten (neue Domain) + $this->recipients = [[ + 'id' => (string)Str::uuid(), + 'mail_user_id' => null, + 'email' => null, + ]]; + + $this->ensureAtLeastOneRow(); + $this->recomputeUi(); + $this->validateRecipientDuplicates(); + } + + public function addRecipientRow(): void + { + if ($this->type === 'single') return; + if (count($this->recipients) >= $this->maxGroupRecipients) return; + + $this->recipients[] = ['id' => (string)Str::uuid(), 'mail_user_id' => null, 'email' => null]; + $this->recomputeUi(); + } + + public function removeRecipientRow(int $index): void + { + unset($this->recipients[$index]); + $this->recipients = array_values($this->recipients); + $this->ensureAtLeastOneRow(); + $this->recomputeUi(); + $this->validateRecipientDuplicates(); + } + + // -------------------- Helpers -------------------- + + private function currentAliasAddress(): ?string + { + if (!$this->domainId || $this->local === '') return null; + + $domainName = $this->domain?->domain ?? Domain::find($this->domainId)?->domain; + if (!$domainName) return null; + + return Str::lower(trim($this->local)) . '@' . Str::lower($domainName); + } + + private function ensureAtLeastOneRow(): void + { + if (empty($this->recipients)) { + $this->recipients = [[ + 'id' => (string)Str::uuid(), + 'mail_user_id' => null, + 'email' => null, + ]]; + } + } + + private function recomputeUi(): void + { + $count = count($this->recipients); + $this->canAddRecipient = $this->type === 'group' && $count < $this->maxGroupRecipients; + + $usedMailboxIds = []; + $usedExternal = []; + + foreach ($this->recipients as $r) { + if (!empty($r['mail_user_id'])) $usedMailboxIds[] = (int)$r['mail_user_id']; + if (!empty($r['email'])) $usedExternal[] = Str::lower(trim((string)$r['email'])); + } + + $this->disabledMailboxIdsByRow = []; + $this->rowState = []; + $this->rowDuplicateError = []; + + $aliasAddr = $this->currentAliasAddress(); + + for ($i = 0; $i < $count; $i++) { + $myId = (int)($this->recipients[$i]['mail_user_id'] ?? 0); + $hasInternal = $myId > 0; + $hasExternal = !empty($this->recipients[$i]['email']); + $myExternal = Str::lower(trim((string)($this->recipients[$i]['email'] ?? ''))); + + // andere interne IDs sperren + $disabled = array_values(array_unique(array_filter( + $usedMailboxIds, fn($id) => $id && $id !== $myId + ))); + + // interne IDs sperren, deren Adresse in externen Feldern vorkommt + if ($this->domain) { + foreach ($this->domainMailUsers as $mu) { + $addr = Str::lower($mu->localpart . '@' . $this->domain->domain); + if (in_array($addr, $usedExternal, true) && $mu->id !== $myId) { + $disabled[] = (int)$mu->id; + } + } + } + + $this->disabledMailboxIdsByRow[$i] = array_values(array_unique($disabled)); + + $this->rowState[$i] = [ + 'disable_internal' => $hasExternal || ($aliasAddr && $aliasAddr === $myExternal), + 'disable_external' => $hasInternal, + 'can_remove' => !($this->type === 'single' && $count === 1), + ]; + } + } + + private function validateRecipientDuplicates(): bool + { + $this->resetErrorBag(); + + $values = []; + $byAddr = []; + + foreach ($this->recipients as $i => $r) { + $addr = null; + + if (!empty($r['mail_user_id']) && $this->domain) { + $u = MailUser::find((int)$r['mail_user_id']); + if ($u) $addr = Str::lower($u->localpart . '@' . $this->domain->domain); + } elseif (!empty($r['email'])) { + $addr = Str::lower(trim((string)$r['email'])); + } + + if ($addr) { + $values[$i] = $addr; + $byAddr[$addr] = $byAddr[$addr] ?? []; + $byAddr[$addr][] = $i; + } + } + + $hasDupes = false; + foreach ($byAddr as $addr => $idxs) { + if (count($idxs) <= 1) continue; + $hasDupes = true; + foreach ($idxs as $i) { + if (!empty($this->recipients[$i]['mail_user_id'])) { + $this->addError("recipients.$i.mail_user_id", 'Dieser Empfänger ist bereits hinzugefügt.'); + } else { + $this->addError("recipients.$i.email", 'Dieser Empfänger ist bereits hinzugefügt.'); + } + } + } + + return $hasDupes; + } + + private function aliasConflictsWithMailbox(): bool + { + if (!$this->domainId || $this->local === '') return false; + + $local = Str::lower(trim($this->local)); + return MailUser::query() + ->where('domain_id', $this->domainId) + ->whereRaw('LOWER(localpart) = ?', [$local]) + ->exists(); + } + + // -------------------- Save -------------------- + + public function save(): void + { + $this->validate($this->rules(), $this->messages()); + + foreach ($this->recipients as $i => $r) { + if (empty($r['mail_user_id']) && empty($r['email'])) { + $this->addError("recipients.$i.email", 'Wähle internes Postfach oder externe E-Mail.'); + return; + } + } + + if ($this->validateRecipientDuplicates()) return; + + if ($aliasAddr = $this->currentAliasAddress()) { + foreach ($this->recipients as $i => $r) { + if (!empty($r['email']) && Str::lower(trim((string)$r['email'])) === $aliasAddr) { + $this->addError("recipients.$i.email", 'Alias darf nicht an sich selbst weiterleiten.'); + return; + } + if (!empty($r['mail_user_id'])) { + $u = MailUser::find((int)$r['mail_user_id']); + if ($u && $this->domain && Str::lower($u->localpart . '@' . $this->domain->domain) === $aliasAddr) { + $this->addError("recipients.$i.mail_user_id", 'Alias darf nicht an sich selbst weiterleiten.'); + return; + } + } + } + } + + if ($this->aliasConflictsWithMailbox()) { + $this->addError('local', 'Diese Adresse ist bereits als Postfach vergeben.'); + return; + } + + $rows = collect($this->recipients) + ->map(fn($r) => [ + 'mail_user_id' => $r['mail_user_id'] ?: null, + 'email' => isset($r['email']) && $r['email'] !== '' ? Str::lower(trim($r['email'])) : null, + ]) + ->filter(fn($r) => $r['mail_user_id'] || $r['email']) + ->values(); + + $isUpdate = (bool) $this->aliasId; + + DB::transaction(function () use ($rows) { + /** @var MailAlias $alias */ + $alias = MailAlias::updateOrCreate( + ['id' => $this->aliasId], + [ + 'domain_id' => $this->domainId, + 'local' => $this->local, + 'type' => $this->type, + 'group_name' => $this->type === 'group' ? ($this->group_name ?: null) : null, + 'is_active' => $this->is_active, + 'notes' => $this->notes, + ] + ); + + $alias->recipients()->delete(); + foreach ($rows as $i => $r) { + $alias->recipients()->create([ + 'mail_user_id' => $r['mail_user_id'], + 'email' => $r['email'], + 'position' => $i, + ]); + } + + $this->aliasId = $alias->id; + }); + + $email = $this->currentAliasAddress(); + if (!$email) { + $domainName = $this->domain?->domain ?? optional(\App\Models\Domain::find($this->domainId))->domain; + $email = trim(strtolower($this->local)) . '@' . trim(strtolower((string) $domainName)); + } + + $this->dispatch('alias:' . ($this->aliasId ? 'updated' : 'created')); + $this->dispatch('closeModal'); + $typeLabel = $this->type === 'single' ? 'Single-Alias' : 'Gruppen-Alias'; + $action = $this->aliasId ? 'aktualisiert' : 'erstellt'; + + $this->dispatch('toast', + type: 'done', + badge: 'Alias', + title: "$typeLabel $action", + text: "Der $typeLabel " . e($email) . " wurde erfolgreich $action.", + duration: 6000 + ); + } + + // -------------------- Validation -------------------- + + protected function rules(): array + { + $occupied = $this->domainId + ? MailUser::query() + ->where('domain_id', $this->domainId) + ->pluck('localpart') + ->map(fn($l) => Str::lower($l)) + ->all() + : []; + + return [ + 'domainId' => ['required', 'exists:domains,id'], + 'local' => [ + 'required', + 'regex:/^[A-Za-z0-9._%+-]+$/', + Rule::unique('mail_aliases', 'local') + ->ignore($this->aliasId) + ->where(fn($q) => $q->where('domain_id', $this->domainId)), + Rule::notIn($occupied), + ], + 'type' => ['required', 'in:single,group'], + 'group_name' => ['nullable','string','max:80','required_if:type,group'], + 'recipients' => ['array', 'min:1', 'max:' . ($this->type === 'single' ? 1 : $this->maxGroupRecipients)], + 'recipients.*.mail_user_id' => ['nullable', 'exists:mail_users,id'], + 'recipients.*.email' => ['nullable', 'email:rfc'], + ]; + } + + protected function messages(): array + { + return [ + 'local.required' => 'Alias-Adresse ist erforderlich.', + 'local.regex' => 'Erlaubt sind Buchstaben, Zahlen und ._%+-', + 'local.unique' => 'Dieser Alias existiert in dieser Domain bereits.', + 'local.not_in' => 'Diese Adresse ist bereits als Postfach vergeben.', + 'recipients.min' => 'Mindestens ein Empfänger ist erforderlich.', + 'recipients.max' => $this->type === 'single' + ? 'Bei „Single“ ist nur ein Empfänger zulässig.' + : 'Maximal ' . $this->maxGroupRecipients . ' Empfänger erlaubt.', + ]; + } + + public function render() + { + return view('livewire.ui.mail.modal.alias-form-modal', [ + 'domains' => Domain::where('is_system', false)->orderBy('domain')->get(['id', 'domain']), + ]); + } +} + +//namespace App\Livewire\Ui\Mail\Modal; +// +//use App\Models\Domain; +//use App\Models\MailAlias; +//use App\Models\MailUser; +//use Illuminate\Support\Facades\DB; +//use Illuminate\Support\Str; +//use Illuminate\Validation\Rule; +//use LivewireUI\Modal\ModalComponent; +// +//class AliasFormModal extends ModalComponent +//{ +// // Eingabe / State +// public ?int $aliasId = null; +// public ?int $domainId = null; +// public string $local = ''; +// public string $type = 'single'; // single|group +// public bool $is_active = true; +// public ?string $notes = null; +// +// // Empfänger-Zeilen (each: ['mail_user_id'=>?int, 'email'=>?string]) +// public array $recipients = []; +// +// // Stabile Keys je Zeile (für wire:key im Blade) +// public array $rowKeys = []; +// +// // UI-Steuerung (nur Ausgabe im Blade) +// public int $maxGroupRecipients = 20; +// public bool $canAddRecipient = false; // „+ Empfänger“ erlauben +// public array $rowState = []; // [i => ['disable_internal'=>bool,'disable_external'=>bool,'can_remove'=>bool]] +// public array $disabledMailboxIdsByRow = []; // [i => [ids...]] +// +// // Lookup +// public ?Domain $domain = null; +// /** @var \Illuminate\Support\Collection */ +// public $domainMailUsers; +// +// // -------------------- Lifecycle -------------------- +// +// public function mount(?int $aliasId = null, ?int $domainId = null): void +// { +// $this->aliasId = $aliasId; +// $this->domainId = $domainId; +// +// if ($this->aliasId) { +// $alias = MailAlias::with(['domain', 'recipients.mailUser'])->findOrFail($this->aliasId); +// if ($alias->domain->is_system) { +// abort(403, 'System-Domains sind für Aliasse gesperrt.'); +// } +// $this->domain = $alias->domain; +// $this->domainId = $alias->domain_id; +// +// $this->fill([ +// 'local' => $alias->local, +// 'type' => $alias->type, +// 'is_active' => $alias->is_active, +// 'notes' => $alias->notes, +// ]); +// +// $this->recipients = $alias->recipients +// ->map(fn($r) => ['mail_user_id' => $r->mail_user_id, 'email' => $r->email]) +// ->all(); +// } else { +// // erste Nicht-System-Domain +// $this->domain = $this->domainId +// ? Domain::where('is_system', false)->findOrFail($this->domainId) +// : Domain::where('is_system', false)->orderBy('domain')->first(); +// +// $this->domainId = $this->domain->id ?? null; +// $this->recipients = [['mail_user_id' => null, 'email' => null]]; +// } +// +// $this->domainMailUsers = $this->domain +// ? MailUser::where('domain_id', $this->domain->id)->orderBy('localpart')->get() +// : collect(); +// +// $this->ensureAtLeastOneRow(); +// $this->syncRowKeysToRecipients(); +// +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); // sofortige Feldfehler bei vorbefüllten Daten +// } +// +// // -------------------- UI-Reaktionen -------------------- +// +// public function updated($name, $value): void +// { +// // XOR: wenn intern gesetzt -> extern leeren, und umgekehrt +// if (preg_match('/^recipients\.(\d+)\.(mail_user_id|email)$/', $name, $m)) { +// $i = (int)$m[1]; +// if ($m[2] === 'mail_user_id' && !empty($value)) { +// $this->recipients[$i]['email'] = null; +// } elseif ($m[2] === 'email') { +// $this->recipients[$i]['mail_user_id'] = null; +// $this->recipients[$i]['email'] = Str::lower(trim((string)$this->recipients[$i]['email'])); +// } +// } +// +// // Self-Loop Live-Feedback: extern == alias +// if (preg_match('/^recipients\.(\d+)\.email$/', $name, $m)) { +// $i = (int)$m[1]; +// $self = $this->currentAliasAddress(); +// $this->resetErrorBag("recipients.$i.email"); +// if ($self && Str::lower(trim((string)$this->recipients[$i]['email'])) === $self) { +// $this->addError("recipients.$i.email", 'Alias darf nicht an sich selbst weiterleiten.'); +// } +// } +// +// if ($name === 'type' && $this->type === 'single') { +// $this->recipients = [$this->recipients[0] ?? ['mail_user_id' => null, 'email' => null]]; +// } +// +// $this->ensureAtLeastOneRow(); +// $this->syncRowKeysToRecipients(); +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); // feldgenaues Feedback live +// } +// +// public function updatedType(string $value): void +// { +// if ($value === 'single') { +// $first = $this->recipients[0] ?? ['mail_user_id' => null, 'email' => null]; +// $this->recipients = [$first]; +// $this->syncRowKeysToRecipients(); +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); +// } +// } +// +// public function updatedDomainId(): void +// { +// $this->domain = $this->domainId +// ? Domain::where('is_system', false)->find($this->domainId) +// : null; +// +// $this->domainMailUsers = $this->domain +// ? MailUser::where('domain_id', $this->domain->id)->orderBy('localpart')->get() +// : collect(); +// +// // Zeilen resetten, weil Empfänger domaingebunden sind +// $this->recipients = [['mail_user_id' => null, 'email' => null]]; +// +// $this->ensureAtLeastOneRow(); +// $this->syncRowKeysToRecipients(); +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); +// } +// +// public function addRecipientRow(): void +// { +// if ($this->type === 'single') return; +// if (count($this->recipients) >= $this->maxGroupRecipients) return; +// +// $this->recipients[] = ['mail_user_id' => null, 'email' => null]; +// $this->rowKeys[] = (string)Str::uuid(); // stabiler Key für neue Zeile +// +// $this->recomputeUi(); +// // kein Duplicate hier, weil leer +// } +// +// public function removeRecipientRow(int $index): void +// { +// unset($this->recipients[$index], $this->rowKeys[$index]); +// $this->recipients = array_values($this->recipients); +// $this->rowKeys = array_values($this->rowKeys); +// +// $this->ensureAtLeastOneRow(); +// $this->syncRowKeysToRecipients(); +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); +// } +// +// // -------------------- Helpers -------------------- +// +// // vollständige Alias-Adresse (z.B. "info@pixio.at") oder null +// private function currentAliasAddress(): ?string +// { +// if (!$this->domainId || $this->local === '') return null; +// +// $domainName = $this->domain?->domain ?? Domain::find($this->domainId)?->domain; +// if (!$domainName) return null; +// +// return Str::lower(trim($this->local)) . '@' . Str::lower($domainName); +// } +// +// private function ensureAtLeastOneRow(): void +// { +// if (empty($this->recipients)) { +// $this->recipients = [['mail_user_id' => null, 'email' => null]]; +// } +// } +// +// // rowKeys-Liste immer an recipients-Länge anpassen (stabile wire:key Werte) +// private function syncRowKeysToRecipients(): void +// { +// $need = count($this->recipients); +// $have = count($this->rowKeys); +// +// // auffüllen +// while ($have < $need) { +// $this->rowKeys[] = (string)Str::uuid(); +// $have++; +// } +// // kürzen +// if ($have > $need) { +// $this->rowKeys = array_slice($this->rowKeys, 0, $need); +// } +// } +// +// // UI neu berechnen (inkl. Self-Loop-Schutz) +// private function recomputeUi(): void +// { +// $count = count($this->recipients); +// $this->canAddRecipient = $this->type === 'group' && $count < $this->maxGroupRecipients; +// +// // bereits gewählte interne IDs und externe Adressen einsammeln +// $usedMailboxIds = []; +// $usedExternalAddr = []; +// +// foreach ($this->recipients as $r) { +// if (!empty($r['mail_user_id'])) $usedMailboxIds[] = (int)$r['mail_user_id']; +// if (!empty($r['email'])) $usedExternalAddr[] = Str::lower(trim((string)$r['email'])); +// } +// +// $this->disabledMailboxIdsByRow = []; +// $this->rowState = []; +// +// // Aktuelle Alias-Adresse für Self-Loop-Checks +// $aliasAddr = $this->currentAliasAddress(); +// +// for ($i = 0; $i < $count; $i++) { +// $myId = (int)($this->recipients[$i]['mail_user_id'] ?? 0); +// $hasInternal = $myId > 0; +// $hasExternal = !empty($this->recipients[$i]['email']); +// $myExternal = Str::lower(trim((string)($this->recipients[$i]['email'] ?? ''))); +// +// // andere bereits gewählte interne IDs sperren +// $disabled = array_values(array_unique(array_filter( +// $usedMailboxIds, fn($id) => $id && $id !== $myId +// ))); +// +// // interne IDs zusätzlich sperren, deren Adresse in irgendeinem externen Feld steht +// if ($this->domain) { +// foreach ($this->domainMailUsers as $mu) { +// $addr = Str::lower($mu->localpart . '@' . $this->domain->domain); +// if (in_array($addr, $usedExternalAddr, true) && $mu->id !== $myId) { +// $disabled[] = (int)$mu->id; +// } +// } +// } +// +// $this->disabledMailboxIdsByRow[$i] = array_values(array_unique($disabled)); +// +// // Self-Loop: wenn extern == alias, internen Picker sperren +// $disableInternal = $hasExternal || ($aliasAddr && $aliasAddr === $myExternal); +// +// $this->rowState[$i] = [ +// 'disable_internal' => $disableInternal, // XOR + Self-Loop +// 'disable_external' => $hasInternal, // XOR +// 'can_remove' => !($this->type === 'single' && $count === 1), +// ]; +// } +// } +// +// // Duplikate feldgenau markieren (keine Exceptions) +// private function validateRecipientDuplicates(): bool +// { +// // komplette Empfänger-Fehler zurücksetzen +// $this->resetErrorBag(); +// +// // Map Zeile -> normalisierte Zieladresse (intern => local@domain, extern => email) +// $values = []; // [i => 'addr'] +// $byAddrIndices = []; // 'addr' => [i1,i2,...] +// +// foreach ($this->recipients as $i => $r) { +// $addr = null; +// +// if (!empty($r['mail_user_id'])) { +// if ($this->domain) { +// $u = MailUser::find((int)$r['mail_user_id']); +// if ($u) $addr = Str::lower($u->localpart . '@' . $this->domain->domain); +// } +// } elseif (!empty($r['email'])) { +// $addr = Str::lower(trim((string)$r['email'])); +// } +// +// if ($addr) { +// $values[$i] = $addr; +// $byAddrIndices[$addr] = $byAddrIndices[$addr] ?? []; +// $byAddrIndices[$addr][] = $i; +// } +// } +// +// $hasDupes = false; +// +// foreach ($byAddrIndices as $addr => $indices) { +// if (count($indices) <= 1) continue; +// $hasDupes = true; +// // alle betroffenen Felder markieren (feldgenau) +// foreach ($indices as $i) { +// if (!empty($this->recipients[$i]['mail_user_id'])) { +// $this->addError("recipients.$i.mail_user_id", 'Dieser Empfänger ist bereits hinzugefügt.'); +// } else { +// $this->addError("recipients.$i.email", 'Dieser Empfänger ist bereits hinzugefügt.'); +// } +// } +// } +// +// return $hasDupes; +// } +// +// private function aliasConflictsWithMailbox(): bool +// { +// if (!$this->domainId || $this->local === '') return false; +// +// $local = Str::lower(trim($this->local)); +// return MailUser::query() +// ->where('domain_id', $this->domainId) +// ->whereRaw('LOWER(localpart) = ?', [$local]) +// ->exists(); +// } +// +// // -------------------- Save -------------------- +// +// public function save(): void +// { +// // 1) Basisregeln +// $this->validate($this->rules(), $this->messages()); +// +// // 2) Zeilen dürfen nicht leer sein (weder intern noch extern) +// foreach ($this->recipients as $i => $r) { +// if (empty($r['mail_user_id']) && empty($r['email'])) { +// $this->addError("recipients.$i.email", 'Wähle internes Postfach oder externe E-Mail.'); +// return; +// } +// } +// +// // 3) Duplikate feldgenau prüfen +// if ($this->validateRecipientDuplicates()) { +// return; +// } +// +// // 3b) Self-Loop final abfangen +// if ($aliasAddr = $this->currentAliasAddress()) { +// foreach ($this->recipients as $i => $r) { +// // extern +// if (!empty($r['email']) && Str::lower(trim((string)$r['email'])) === $aliasAddr) { +// $this->addError("recipients.$i.email", 'Alias darf nicht an sich selbst weiterleiten.'); +// return; +// } +// // intern (falls je relevant) +// if (!empty($r['mail_user_id'])) { +// $u = MailUser::find((int)$r['mail_user_id']); +// if ($u && $this->domain && Str::lower($u->localpart . '@' . $this->domain->domain) === $aliasAddr) { +// $this->addError("recipients.$i.mail_user_id", 'Alias darf nicht an sich selbst weiterleiten.'); +// return; +// } +// } +// } +// } +// +// // 4) Alias-Adresse darf kein bestehendes Postfach sein +// if ($this->aliasConflictsWithMailbox()) { +// $this->addError('local', 'Diese Adresse ist bereits als Postfach vergeben.'); +// return; +// } +// +// // 5) Persistieren +// $rows = collect($this->recipients) +// ->map(fn($r) => [ +// 'mail_user_id' => $r['mail_user_id'] ?: null, +// 'email' => isset($r['email']) && $r['email'] !== '' ? Str::lower(trim($r['email'])) : null, +// ]) +// ->filter(fn($r) => $r['mail_user_id'] || $r['email']) +// ->values(); +// +// DB::transaction(function () use ($rows) { +// /** @var MailAlias $alias */ +// $alias = MailAlias::updateOrCreate( +// ['id' => $this->aliasId], +// [ +// 'domain_id' => $this->domainId, +// 'local' => $this->local, +// 'type' => $this->type, +// 'is_active' => $this->is_active, +// 'notes' => $this->notes, +// ] +// ); +// +// $alias->recipients()->delete(); +// foreach ($rows as $i => $r) { +// $alias->recipients()->create([ +// 'mail_user_id' => $r['mail_user_id'], +// 'email' => $r['email'], +// 'position' => $i, +// ]); +// } +// +// $this->aliasId = $alias->id; +// }); +// +// $this->dispatch('alias:' . ($this->aliasId ? 'updated' : 'created')); +// $this->dispatch('closeModal'); +// } +// +// // -------------------- Validation -------------------- +// +// protected function rules(): array +// { +// // blocke offensichtliche Kollisionen mit Mailboxen (gleiche Domain) +// $occupied = $this->domainId +// ? MailUser::query() +// ->where('domain_id', $this->domainId) +// ->pluck('localpart') +// ->map(fn($l) => Str::lower($l)) +// ->all() +// : []; +// +// return [ +// 'domainId' => ['required', 'exists:domains,id'], +// 'local' => [ +// 'required', +// 'regex:/^[A-Za-z0-9._%+-]+$/', +// Rule::unique('mail_aliases', 'local') +// ->ignore($this->aliasId) +// ->where(fn($q) => $q->where('domain_id', $this->domainId)), +// Rule::notIn($occupied), +// ], +// 'type' => ['required', 'in:single,group'], +// +// 'recipients' => ['array', 'min:1', 'max:' . ($this->type === 'single' ? 1 : $this->maxGroupRecipients)], +// 'recipients.*.mail_user_id' => ['nullable', 'exists:mail_users,id'], +// 'recipients.*.email' => ['nullable', 'email:rfc'], +// ]; +// } +// +// protected function messages(): array +// { +// return [ +// 'local.required' => 'Alias-Adresse ist erforderlich.', +// 'local.regex' => 'Erlaubt sind Buchstaben, Zahlen und ._%+-', +// 'local.unique' => 'Dieser Alias existiert in dieser Domain bereits.', +// 'local.not_in' => 'Diese Adresse ist bereits als Postfach vergeben.', +// 'recipients.min' => 'Mindestens ein Empfänger ist erforderlich.', +// 'recipients.max' => $this->type === 'single' +// ? 'Bei „Single“ ist nur ein Empfänger zulässig.' +// : 'Maximal ' . $this->maxGroupRecipients . ' Empfänger erlaubt.', +// ]; +// } +// +// public function render() +// { +// return view('livewire.ui.mail.modal.alias-form-modal', [ +// 'domains' => Domain::where('is_system', false) +// ->orderBy('domain') +// ->get(['id', 'domain']), +// ]); +// } +//} + + +//namespace App\Livewire\Ui\Mail\Modal; +// +//use App\Models\Domain; +//use App\Models\MailAlias; +//use App\Models\MailUser; +//use Illuminate\Support\Facades\DB; +//use Illuminate\Support\Str; +//use Illuminate\Validation\Rule; +//use LivewireUI\Modal\ModalComponent; +// +//class AliasFormModal extends ModalComponent +//{ +// // Eingabe / State +// public ?int $aliasId = null; +// public ?int $domainId = null; +// public string $local = ''; +// public string $type = 'single'; // single|group +// public bool $is_active = true; +// public ?string $notes = null; +// +// // Empfänger-Zeilen (each: ['mail_user_id'=>?int, 'email'=>?string]) +// public array $recipients = []; +// +// // UI-Steuerung (nur Ausgabe im Blade) +// public int $maxGroupRecipients = 20; +// public bool $canAddRecipient = false; // „+ Empfänger“ erlauben +// public array $rowState = []; // [i => ['disable_internal'=>bool,'disable_external'=>bool,'can_remove'=>bool]] +// public array $disabledMailboxIdsByRow = []; // [i => [ids...]] +// +// // Lookup +// public ?Domain $domain = null; +// /** @var \Illuminate\Support\Collection */ +// public $domainMailUsers; +// +// // -------------------- Lifecycle -------------------- +// +// public function mount(?int $aliasId = null, ?int $domainId = null): void +// { +// $this->aliasId = $aliasId; +// $this->domainId = $domainId; +// +// if ($this->aliasId) { +// $alias = MailAlias::with(['domain', 'recipients.mailUser'])->findOrFail($this->aliasId); +// if ($alias->domain->is_system) { +// abort(403, 'System-Domains sind für Aliasse gesperrt.'); +// } +// $this->domain = $alias->domain; +// $this->domainId = $alias->domain_id; +// +// $this->fill([ +// 'local' => $alias->local, +// 'type' => $alias->type, +// 'is_active' => $alias->is_active, +// 'notes' => $alias->notes, +// ]); +// +// $this->recipients = $alias->recipients +// ->map(fn($r) => ['mail_user_id' => $r->mail_user_id, 'email' => $r->email]) +// ->all(); +// } else { +// // erste Nicht-System-Domain +// $this->domain = $this->domainId +// ? Domain::where('is_system', false)->findOrFail($this->domainId) +// : Domain::where('is_system', false)->orderBy('domain')->first(); +// +// $this->domainId = $this->domain->id ?? null; +// $this->recipients = [['mail_user_id' => null, 'email' => null]]; +// } +// +// $this->domainMailUsers = $this->domain +// ? MailUser::where('domain_id', $this->domain->id)->orderBy('localpart')->get() +// : collect(); +// +// $this->ensureAtLeastOneRow(); +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); // sofortige Feldfehler bei vorbefüllten Daten +// } +// +// // -------------------- UI-Reaktionen -------------------- +// +// public function updated($name, $value): void +// { +// // XOR: wenn intern gesetzt -> extern leeren, und umgekehrt +// if (preg_match('/^recipients\.(\d+)\.(mail_user_id|email)$/', $name, $m)) { +// $i = (int)$m[1]; +// if ($m[2] === 'mail_user_id' && !empty($value)) { +// $this->recipients[$i]['email'] = null; +// } elseif ($m[2] === 'email') { +// $this->recipients[$i]['mail_user_id'] = null; +// $this->recipients[$i]['email'] = Str::lower(trim((string)$this->recipients[$i]['email'])); +// } +// } +// +// // Self-Loop Live-Feedback: extern == alias +// if (preg_match('/^recipients\.(\d+)\.email$/', $name, $m)) { +// $i = (int)$m[1]; +// $self = $this->currentAliasAddress(); +// $this->resetErrorBag("recipients.$i.email"); +// if ($self && Str::lower(trim((string)$this->recipients[$i]['email'])) === $self) { +// $this->addError("recipients.$i.email", 'Alias darf nicht an sich selbst weiterleiten.'); +// } +// } +// +// if ($name === 'type' && $this->type === 'single') { +// $this->recipients = [$this->recipients[0] ?? ['mail_user_id' => null, 'email' => null]]; +// } +// +// $this->ensureAtLeastOneRow(); +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); // feldgenaues Feedback live +// } +// +// public function updatedType(string $value): void +// { +// if ($value === 'single') { +// $first = $this->recipients[0] ?? ['mail_user_id' => null, 'email' => null]; +// $this->recipients = [$first]; +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); +// } +// } +// +// public function updatedDomainId(): void +// { +// $this->domain = $this->domainId +// ? Domain::where('is_system', false)->find($this->domainId) +// : null; +// +// $this->domainMailUsers = $this->domain +// ? MailUser::where('domain_id', $this->domain->id)->orderBy('localpart')->get() +// : collect(); +// +// // Zeilen resetten, weil Empfänger domaingebunden sind +// $this->recipients = [['mail_user_id' => null, 'email' => null]]; +// +// $this->ensureAtLeastOneRow(); +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); +// } +// +// public function addRecipientRow(): void +// { +// if ($this->type === 'single') return; +// if (count($this->recipients) >= $this->maxGroupRecipients) return; +// +// $this->recipients[] = ['mail_user_id' => null, 'email' => null]; +// $this->recomputeUi(); +// } +// +// public function removeRecipientRow(int $index): void +// { +// unset($this->recipients[$index]); +// $this->recipients = array_values($this->recipients); +// $this->ensureAtLeastOneRow(); +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); +// } +// +// // -------------------- Helpers -------------------- +// +// // vollständige Alias-Adresse (z.B. "info@pixio.at") oder null +// private function currentAliasAddress(): ?string +// { +// if (!$this->domainId || $this->local === '') return null; +// +// $domainName = $this->domain?->domain ?? Domain::find($this->domainId)?->domain; +// if (!$domainName) return null; +// +// return Str::lower(trim($this->local)) . '@' . Str::lower($domainName); +// } +// +// private function ensureAtLeastOneRow(): void +// { +// if (empty($this->recipients)) { +// $this->recipients = [['mail_user_id' => null, 'email' => null]]; +// } +// } +// +// // UI neu berechnen (inkl. Self-Loop-Schutz) +// private function recomputeUi(): void +// { +// $count = count($this->recipients); +// $this->canAddRecipient = $this->type === 'group' && $count < $this->maxGroupRecipients; +// +// // bereits gewählte interne IDs und externe Adressen einsammeln +// $usedMailboxIds = []; +// $usedExternalAddr = []; +// +// foreach ($this->recipients as $r) { +// if (!empty($r['mail_user_id'])) $usedMailboxIds[] = (int)$r['mail_user_id']; +// if (!empty($r['email'])) $usedExternalAddr[] = Str::lower(trim((string)$r['email'])); +// } +// +// $this->disabledMailboxIdsByRow = []; +// $this->rowState = []; +// +// // Aktuelle Alias-Adresse für Self-Loop-Checks +// $aliasAddr = $this->currentAliasAddress(); +// +// for ($i = 0; $i < $count; $i++) { +// $myId = (int)($this->recipients[$i]['mail_user_id'] ?? 0); +// $hasInternal = $myId > 0; +// $hasExternal = !empty($this->recipients[$i]['email']); +// $myExternal = Str::lower(trim((string)($this->recipients[$i]['email'] ?? ''))); +// +// // andere bereits gewählte internen IDs sperren +// $disabled = array_values(array_unique(array_filter( +// $usedMailboxIds, fn($id) => $id && $id !== $myId +// ))); +// +// // interne IDs zusätzlich sperren, deren Adresse in irgendeinem externen Feld steht +// if ($this->domain) { +// foreach ($this->domainMailUsers as $mu) { +// $addr = Str::lower($mu->localpart . '@' . $this->domain->domain); +// if (in_array($addr, $usedExternalAddr, true) && $mu->id !== $myId) { +// $disabled[] = (int)$mu->id; +// } +// } +// } +// +// $this->disabledMailboxIdsByRow[$i] = array_values(array_unique($disabled)); +// +// // Self-Loop verhindern: wenn extern == alias, internen Picker sperren +// $disableInternal = $hasExternal || ($aliasAddr && $aliasAddr === $myExternal); +// +// $this->rowState[$i] = [ +// 'disable_internal' => $disableInternal, // XOR + Self-Loop +// 'disable_external' => $hasInternal, // XOR +// 'can_remove' => !($this->type === 'single' && $count === 1), +// ]; +// } +// } +// +// // Duplikate feldgenau markieren (keine Exceptions) +// private function validateRecipientDuplicates(): bool +// { +// // komplette Empfänger-Fehler zurücksetzen +// $this->resetErrorBag(); +// +// // Map Zeile -> normalisierte Zieladresse (intern => local@domain, extern => email) +// $values = []; // [i => 'addr'] +// $byAddrIndices = []; // 'addr' => [i1,i2,...] +// +// foreach ($this->recipients as $i => $r) { +// $addr = null; +// +// if (!empty($r['mail_user_id'])) { +// if ($this->domain) { +// $u = MailUser::find((int)$r['mail_user_id']); +// if ($u) $addr = Str::lower($u->localpart . '@' . $this->domain->domain); +// } +// } elseif (!empty($r['email'])) { +// $addr = Str::lower(trim((string)$r['email'])); +// } +// +// if ($addr) { +// $values[$i] = $addr; +// $byAddrIndices[$addr] = $byAddrIndices[$addr] ?? []; +// $byAddrIndices[$addr][] = $i; +// } +// } +// +// $hasDupes = false; +// +// foreach ($byAddrIndices as $addr => $indices) { +// if (count($indices) <= 1) continue; +// $hasDupes = true; +// // alle betroffenen Felder markieren (feldgenau) +// foreach ($indices as $i) { +// if (!empty($this->recipients[$i]['mail_user_id'])) { +// $this->addError("recipients.$i.mail_user_id", 'Dieser Empfänger ist bereits hinzugefügt.'); +// } else { +// $this->addError("recipients.$i.email", 'Dieser Empfänger ist bereits hinzugefügt.'); +// } +// } +// } +// +// return $hasDupes; +// } +// +// private function aliasConflictsWithMailbox(): bool +// { +// if (!$this->domainId || $this->local === '') return false; +// +// $local = Str::lower(trim($this->local)); +// return MailUser::query() +// ->where('domain_id', $this->domainId) +// ->whereRaw('LOWER(localpart) = ?', [$local]) +// ->exists(); +// } +// +// // -------------------- Save -------------------- +// +// public function save(): void +// { +// // 1) Basisregeln +// $this->validate($this->rules(), $this->messages()); +// +// // 2) Zeilen dürfen nicht leer sein (weder intern noch extern) +// foreach ($this->recipients as $i => $r) { +// if (empty($r['mail_user_id']) && empty($r['email'])) { +// $this->addError("recipients.$i.email", 'Wähle internes Postfach oder externe E-Mail.'); +// return; +// } +// } +// +// // 3) Duplikate feldgenau prüfen +// if ($this->validateRecipientDuplicates()) { +// return; +// } +// +// // 3b) Self-Loop final abfangen +// if ($aliasAddr = $this->currentAliasAddress()) { +// foreach ($this->recipients as $i => $r) { +// // extern +// if (!empty($r['email']) && Str::lower(trim((string)$r['email'])) === $aliasAddr) { +// $this->addError("recipients.$i.email", 'Alias darf nicht an sich selbst weiterleiten.'); +// return; +// } +// // intern (falls je relevant) +// if (!empty($r['mail_user_id'])) { +// $u = MailUser::find((int)$r['mail_user_id']); +// if ($u && $this->domain && Str::lower($u->localpart . '@' . $this->domain->domain) === $aliasAddr) { +// $this->addError("recipients.$i.mail_user_id", 'Alias darf nicht an sich selbst weiterleiten.'); +// return; +// } +// } +// } +// } +// +// // 4) Alias-Adresse darf kein bestehendes Postfach sein +// if ($this->aliasConflictsWithMailbox()) { +// $this->addError('local', 'Diese Adresse ist bereits als Postfach vergeben.'); +// return; +// } +// +// // 5) Persistieren +// $rows = collect($this->recipients) +// ->map(fn($r) => [ +// 'mail_user_id' => $r['mail_user_id'] ?: null, +// 'email' => isset($r['email']) && $r['email'] !== '' ? Str::lower(trim($r['email'])) : null, +// ]) +// ->filter(fn($r) => $r['mail_user_id'] || $r['email']) +// ->values(); +// +// DB::transaction(function () use ($rows) { +// /** @var MailAlias $alias */ +// $alias = MailAlias::updateOrCreate( +// ['id' => $this->aliasId], +// [ +// 'domain_id' => $this->domainId, +// 'local' => $this->local, +// 'type' => $this->type, +// 'is_active' => $this->is_active, +// 'notes' => $this->notes, +// ] +// ); +// +// $alias->recipients()->delete(); +// foreach ($rows as $i => $r) { +// $alias->recipients()->create([ +// 'mail_user_id' => $r['mail_user_id'], +// 'email' => $r['email'], +// 'position' => $i, +// ]); +// } +// +// $this->aliasId = $alias->id; +// }); +// +// $this->dispatch('alias:' . ($this->aliasId ? 'updated' : 'created')); +// $this->dispatch('closeModal'); +// } +// +// // -------------------- Validation -------------------- +// +// protected function rules(): array +// { +// // blocke offensichtliche Kollisionen mit Mailboxen (gleiche Domain) +// $occupied = $this->domainId +// ? MailUser::query() +// ->where('domain_id', $this->domainId) +// ->pluck('localpart') +// ->map(fn($l) => Str::lower($l)) +// ->all() +// : []; +// +// return [ +// 'domainId' => ['required', 'exists:domains,id'], +// 'local' => [ +// 'required', +// 'regex:/^[A-Za-z0-9._%+-]+$/', +// Rule::unique('mail_aliases', 'local') +// ->ignore($this->aliasId) +// ->where(fn($q) => $q->where('domain_id', $this->domainId)), +// Rule::notIn($occupied), +// ], +// 'type' => ['required', 'in:single,group'], +// +// 'recipients' => ['array', 'min:1', 'max:' . ($this->type === 'single' ? 1 : $this->maxGroupRecipients)], +// 'recipients.*.mail_user_id' => ['nullable', 'exists:mail_users,id'], +// 'recipients.*.email' => ['nullable', 'email:rfc'], +// ]; +// } +// +// protected function messages(): array +// { +// return [ +// 'local.required' => 'Alias-Adresse ist erforderlich.', +// 'local.regex' => 'Erlaubt sind Buchstaben, Zahlen und ._%+-', +// 'local.unique' => 'Dieser Alias existiert in dieser Domain bereits.', +// 'local.not_in' => 'Diese Adresse ist bereits als Postfach vergeben.', +// 'recipients.min' => 'Mindestens ein Empfänger ist erforderlich.', +// 'recipients.max' => $this->type === 'single' +// ? 'Bei „Single“ ist nur ein Empfänger zulässig.' +// : 'Maximal ' . $this->maxGroupRecipients . ' Empfänger erlaubt.', +// ]; +// } +// +// public function render() +// { +// return view('livewire.ui.mail.modal.alias-form-modal', [ +// 'domains' => Domain::where('is_system', false) +// ->orderBy('domain') +// ->get(['id', 'domain']), +// ]); +// } +//} + + + +//namespace App\Livewire\Ui\Mail\Modal; +// +//use App\Models\Domain; +//use App\Models\MailAlias; +//use App\Models\MailUser; +//use Illuminate\Support\Facades\DB; +//use Illuminate\Support\Str; +//use Illuminate\Validation\Rule; +//use LivewireUI\Modal\ModalComponent; +// +//class AliasFormModal extends ModalComponent +//{ +// // Eingabe / State +// public ?int $aliasId = null; +// public ?int $domainId = null; +// public string $local = ''; +// public string $type = 'single'; // single|group +// public bool $is_active = true; +// public ?string $notes = null; +// +// // Empfänger-Zeilen (each: ['mail_user_id'=>?int, 'email'=>?string]) +// public array $recipients = []; +// +// // UI-Steuerung (nur Ausgabe im Blade) +// public int $maxGroupRecipients = 20; +// public bool $canAddRecipient = false; // „+ Empfänger“ erlauben +// public array $rowState = []; // [i => ['disable_internal'=>bool,'disable_external'=>bool,'can_remove'=>bool]] +// public array $disabledMailboxIdsByRow = []; // [i => [ids...]] +// +// // Lookup +// public ?Domain $domain = null; +// /** @var \Illuminate\Support\Collection */ +// public $domainMailUsers; +// +// // -------------------- Lifecycle -------------------- +// +// public function mount(?int $aliasId = null, ?int $domainId = null): void +// { +// $this->aliasId = $aliasId; +// $this->domainId = $domainId; +// +// if ($this->aliasId) { +// $alias = MailAlias::with(['domain','recipients.mailUser'])->findOrFail($this->aliasId); +// if ($alias->domain->is_system) { +// abort(403, 'System-Domains sind für Aliasse gesperrt.'); +// } +// $this->domain = $alias->domain; +// $this->domainId = $alias->domain_id; +// +// $this->fill([ +// 'local' => $alias->local, +// 'type' => $alias->type, +// 'is_active' => $alias->is_active, +// 'notes' => $alias->notes, +// ]); +// +// $this->recipients = $alias->recipients +// ->map(fn($r) => ['mail_user_id' => $r->mail_user_id, 'email' => $r->email]) +// ->all(); +// } else { +// // erste Nicht-System-Domain +// $this->domain = $this->domainId +// ? Domain::where('is_system', false)->findOrFail($this->domainId) +// : Domain::where('is_system', false)->orderBy('domain')->first(); +// +// $this->domainId = $this->domain->id ?? null; +// $this->recipients = [['mail_user_id' => null, 'email' => null]]; +// } +// +// $this->domainMailUsers = $this->domain +// ? MailUser::where('domain_id', $this->domain->id)->orderBy('localpart')->get() +// : collect(); +// +// $this->ensureAtLeastOneRow(); +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); // sofortige Feldfehler bei vorbefüllten Daten +// } +// +// // -------------------- UI-Reaktionen -------------------- +// +// public function updated($name, $value): void +// { +// // XOR: wenn intern gesetzt -> extern leeren, und umgekehrt +// if (preg_match('/^recipients\.(\d+)\.(mail_user_id|email)$/', $name, $m)) { +// $i = (int)$m[1]; +// if ($m[2] === 'mail_user_id' && !empty($value)) { +// $this->recipients[$i]['email'] = null; +// } elseif ($m[2] === 'email') { +// $this->recipients[$i]['mail_user_id'] = null; +// $this->recipients[$i]['email'] = Str::lower(trim((string)$this->recipients[$i]['email'])); +// } +// } +// +// if (preg_match('/^recipients\.(\d+)\.email$/', $name, $m)) { +// $i = (int)$m[1]; +// $self = $this->currentAliasAddress(); +// $this->resetErrorBag("recipients.$i.email"); +// if ($self && \Illuminate\Support\Str::lower(trim((string)$this->recipients[$i]['email'])) === $self) { +// $this->addError("recipients.$i.email", 'Alias darf nicht an sich selbst weiterleiten.'); +// } +// } +// +// if ($name === 'type' && $this->type === 'single') { +// $this->recipients = [ $this->recipients[0] ?? ['mail_user_id'=>null,'email'=>null] ]; +// } +// +// $this->ensureAtLeastOneRow(); +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); // feldgenaues Feedback live +// } +// +// public function updatedType(string $value): void +// { +// if ($value === 'single') { +// $first = $this->recipients[0] ?? ['mail_user_id'=>null,'email'=>null]; +// $this->recipients = [ $first ]; +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); +// } +// } +// +// public function updatedDomainId(): void +// { +// $this->domain = $this->domainId +// ? Domain::where('is_system', false)->find($this->domainId) +// : null; +// +// $this->domainMailUsers = $this->domain +// ? MailUser::where('domain_id', $this->domain->id)->orderBy('localpart')->get() +// : collect(); +// +// // Zeilen resetten, weil Empfänger domaingebunden sind +// $this->recipients = [['mail_user_id' => null, 'email' => null]]; +// +// $this->ensureAtLeastOneRow(); +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); +// } +// +// public function addRecipientRow(): void +// { +// if ($this->type === 'single') return; +// if (count($this->recipients) >= $this->maxGroupRecipients) return; +// +// $this->recipients[] = ['mail_user_id'=>null,'email'=>null]; +// $this->recomputeUi(); +// // kein Duplicate hier, weil leer +// } +// +// public function removeRecipientRow(int $index): void +// { +// unset($this->recipients[$index]); +// $this->recipients = array_values($this->recipients); +// $this->ensureAtLeastOneRow(); +// $this->recomputeUi(); +// $this->validateRecipientDuplicates(); +// } +// +// // -------------------- Helpers -------------------- +// +// private function currentAliasAddress(): ?string +// { +// if (!$this->domainId || $this->local === '' || !$this->domain) return null; +// return \Illuminate\Support\Str::lower(trim($this->local).'@'.$this->domain->domain); +// } +// +// private function ensureAtLeastOneRow(): void +// { +// if (empty($this->recipients)) { +// $this->recipients = [['mail_user_id'=>null,'email'=>null]]; +// } +// } +// +// private function recomputeUi(): void +// { +// $count = count($this->recipients); +// $this->canAddRecipient = $this->type === 'group' && $count < $this->maxGroupRecipients; +// +// // bereits gewählte interne IDs und externe Adressen einsammeln +// $usedMailboxIds = []; +// $usedExternalAddrs = []; // ['a@b.tld', ...] normalisiert +// +// foreach ($this->recipients as $r) { +// if (!empty($r['mail_user_id'])) $usedMailboxIds[] = (int)$r['mail_user_id']; +// if (!empty($r['email'])) $usedExternalAddrs[] = Str::lower(trim((string)$r['email'])); +// } +// +// $this->disabledMailboxIdsByRow = []; +// $this->rowState = []; +// +// // Helper: internes Postfach -> vollständige Adresse +// $mailboxEmail = function (?int $id): ?string { +// if (!$id || !$this->domain) return null; +// $u = MailUser::find($id); +// return $u ? Str::lower($u->localpart.'@'.$this->domain->domain) : null; +// }; +// +// for ($i = 0; $i < $count; $i++) { +// $myId = (int)($this->recipients[$i]['mail_user_id'] ?? 0); +// $hasInternal = $myId > 0; +// $hasExternal = !empty($this->recipients[$i]['email']); +// +// // Basis: andere bereits gewählte interne IDs sperren +// $disabled = array_values(array_unique(array_filter( +// $usedMailboxIds, fn($id) => $id && $id !== $myId +// ))); +// +// // Zusätzlich: interne IDs sperren, deren Adresse in irgendeinem externen Feld steht +// if ($this->domain) { +// foreach ($this->domainMailUsers as $mu) { +// $addr = Str::lower($mu->localpart.'@'.$this->domain->domain); +// if (in_array($addr, $usedExternalAddrs, true) && $mu->id !== $myId) { +// $disabled[] = (int)$mu->id; +// } +// } +// } +// +// $this->disabledMailboxIdsByRow[$i] = array_values(array_unique($disabled)); +// +// $this->rowState[$i] = [ +// 'disable_internal' => $hasExternal, // XOR +// 'disable_external' => $hasInternal, // XOR +// 'can_remove' => !($this->type === 'single' && $count === 1), +// ]; +// } +// } +// +// +// // Duplikate feldgenau markieren (keine Exceptions) +// private function validateRecipientDuplicates(): bool +// { +// // komplette Empfänger-Fehler zurücksetzen +// $this->resetErrorBag(); +// +// // Map Zeile -> normalisierte Zieladresse (intern => local@domain, extern => email) +// $values = []; // [i => 'addr'] +// $byAddrIndices = []; // 'addr' => [i1,i2,...] +// +// foreach ($this->recipients as $i => $r) { +// $addr = null; +// +// if (!empty($r['mail_user_id'])) { +// if ($this->domain) { +// $u = MailUser::find((int)$r['mail_user_id']); +// if ($u) $addr = Str::lower($u->localpart.'@'.$this->domain->domain); +// } +// } elseif (!empty($r['email'])) { +// $addr = Str::lower(trim((string)$r['email'])); +// } +// +// if ($addr) { +// $values[$i] = $addr; +// $byAddrIndices[$addr] = $byAddrIndices[$addr] ?? []; +// $byAddrIndices[$addr][] = $i; +// } +// } +// +// $hasDupes = false; +// +// foreach ($byAddrIndices as $addr => $indices) { +// if (count($indices) <= 1) continue; +// $hasDupes = true; +// // alle betroffenen Felder markieren (feldgenau) +// foreach ($indices as $i) { +// if (!empty($this->recipients[$i]['mail_user_id'])) { +// $this->addError("recipients.$i.mail_user_id", 'Dieser Empfänger ist bereits hinzugefügt.'); +// } else { +// $this->addError("recipients.$i.email", 'Dieser Empfänger ist bereits hinzugefügt.'); +// } +// } +// } +// +// return $hasDupes; +// } +// +// private function aliasConflictsWithMailbox(): bool +// { +// if (!$this->domainId || $this->local === '') return false; +// +// $local = Str::lower(trim($this->local)); +// return MailUser::query() +// ->where('domain_id', $this->domainId) +// ->whereRaw('LOWER(localpart) = ?', [$local]) +// ->exists(); +// } +// +// // -------------------- Save -------------------- +// +// public function save(): void +// { +// // 1) Basisregeln +// $this->validate($this->rules(), $this->messages()); +// +// // 2) Zeilen dürfen nicht leer sein (weder intern noch extern) +// foreach ($this->recipients as $i => $r) { +// if (empty($r['mail_user_id']) && empty($r['email'])) { +// $this->addError("recipients.$i.email", 'Wähle internes Postfach oder externe E-Mail.'); +// return; +// } +// } +// +// // 3) Duplikate feldgenau prüfen +// if ($this->validateRecipientDuplicates()) { +// return; +// } +// +// if ($aliasAddr = $this->currentAliasAddress()) { +// foreach ($this->recipients as $i => $r) { +// // extern +// if (!empty($r['email']) && \Illuminate\Support\Str::lower(trim((string)$r['email'])) === $aliasAddr) { +// $this->addError("recipients.$i.email", 'Alias darf nicht an sich selbst weiterleiten.'); +// return; +// } +// // intern (falls es später mal interne Empfänger ohne existierendes Postfach geben sollte) +// if (!empty($r['mail_user_id'])) { +// $u = \App\Models\MailUser::find((int)$r['mail_user_id']); +// if ($u && $this->domain && \Illuminate\Support\Str::lower($u->localpart.'@'.$this->domain->domain) === $aliasAddr) { +// $this->addError("recipients.$i.mail_user_id", 'Alias darf nicht an sich selbst weiterleiten.'); +// return; +// } +// } +// } +// } +// +// // 4) Alias-Adresse darf kein bestehendes Postfach sein +// if ($this->aliasConflictsWithMailbox()) { +// $this->addError('local', 'Diese Adresse ist bereits als Postfach vergeben.'); +// return; +// } +// +// // 5) Persistieren +// $rows = collect($this->recipients) +// ->map(fn($r) => [ +// 'mail_user_id' => $r['mail_user_id'] ?: null, +// 'email' => isset($r['email']) && $r['email'] !== '' ? Str::lower(trim($r['email'])) : null, +// ]) +// ->filter(fn($r) => $r['mail_user_id'] || $r['email']) +// ->values(); +// +// DB::transaction(function () use ($rows) { +// /** @var MailAlias $alias */ +// $alias = MailAlias::updateOrCreate( +// ['id' => $this->aliasId], +// [ +// 'domain_id' => $this->domainId, +// 'local' => $this->local, +// 'type' => $this->type, +// 'is_active' => $this->is_active, +// 'notes' => $this->notes, +// ] +// ); +// +// $alias->recipients()->delete(); +// foreach ($rows as $i => $r) { +// $alias->recipients()->create([ +// 'mail_user_id' => $r['mail_user_id'], +// 'email' => $r['email'], +// 'position' => $i, +// ]); +// } +// +// $this->aliasId = $alias->id; +// }); +// +// $this->dispatch('alias:' . ($this->aliasId ? 'updated' : 'created')); +// $this->dispatch('closeModal'); +// } +// +// // -------------------- Validation -------------------- +// +// protected function rules(): array +// { +// // blocke offensichtliche Kollisionen mit Mailboxen (gleiche Domain) +// $occupied = $this->domainId +// ? MailUser::query() +// ->where('domain_id', $this->domainId) +// ->pluck('localpart') +// ->map(fn($l) => Str::lower($l)) +// ->all() +// : []; +// +// return [ +// 'domainId' => ['required','exists:domains,id'], +// 'local' => [ +// 'required', +// 'regex:/^[A-Za-z0-9._%+-]+$/', +// Rule::unique('mail_aliases','local') +// ->ignore($this->aliasId) +// ->where(fn($q) => $q->where('domain_id', $this->domainId)), +// Rule::notIn($occupied), +// ], +// 'type' => ['required','in:single,group'], +// +// 'recipients' => ['array','min:1','max:'.($this->type==='single' ? 1 : $this->maxGroupRecipients)], +// 'recipients.*.mail_user_id' => ['nullable','exists:mail_users,id'], +// 'recipients.*.email' => ['nullable','email:rfc'], +// ]; +// } +// +// protected function messages(): array +// { +// return [ +// 'local.required' => 'Alias-Adresse ist erforderlich.', +// 'local.regex' => 'Erlaubt sind Buchstaben, Zahlen und ._%+-', +// 'local.unique' => 'Dieser Alias existiert in dieser Domain bereits.', +// 'local.not_in' => 'Diese Adresse ist bereits als Postfach vergeben.', +// 'recipients.min' => 'Mindestens ein Empfänger ist erforderlich.', +// 'recipients.max' => $this->type === 'single' +// ? 'Bei „Single“ ist nur ein Empfänger zulässig.' +// : 'Maximal '.$this->maxGroupRecipients.' Empfänger erlaubt.', +// ]; +// } +// +// public function render() +// { +// return view('livewire.ui.mail.modal.alias-form-modal', [ +// 'domains' => Domain::where('is_system', false) +// ->orderBy('domain') +// ->get(['id','domain']), +// ]); +// } +//} + + +// +//namespace App\Livewire\Ui\Mail\Modal; +// +//use App\Models\Domain; +//use App\Models\MailAlias; +//use App\Models\MailAliasRecipient; +//use App\Models\MailUser; +//use Illuminate\Support\Facades\DB; +//use Illuminate\Support\Str; +//use Illuminate\Validation\Rule; +//use Livewire\Attributes\On; +//use LivewireUI\Modal\ModalComponent; +// +//class AliasFormModal extends ModalComponent +//{ +// // Eingabe-Props +// public ?int $aliasId = null; // wenn gesetzt -> Edit +// public ?int $domainId = null; // Create: vorbelegen (oder Auswahl) +// public string $local = ''; +// public string $type = 'single'; // 'single' | 'group' +// public bool $is_active = true; +// public ?string $notes = null; +// public int $maxGroupRecipients = 20; +// public bool $canAddRecipient = false; +// public array $rowState = []; +// public array $disabledMailboxIdsByRow = []; +// +// // optionale UI-Fehler pro Zeile (Anzeige) +// public array $rowDuplicateError = []; +// +// public array $recipients = []; +// +// // UI-Daten +// public ?Domain $domain = null; +// /** @var \Illuminate\Support\Collection */ +// public $domainMailUsers; +// +// public function mount(?int $aliasId = null, ?int $domainId = null): void +// { +// $this->aliasId = $aliasId; +// $this->domainId = $domainId; +// +// if ($this->aliasId) { +// $alias = MailAlias::with(['domain','recipients.mailUser'])->findOrFail($this->aliasId); +// if ($alias->domain->is_system) { +// abort(403, 'System-Domains sind für Aliasse gesperrt.'); +// } +// $this->domain = $alias->domain; +// $this->domainId = $alias->domain_id; +// +// $this->fill([ +// 'local' => $alias->local, +// 'type' => $alias->type, +// 'is_active' => $alias->is_active, +// 'notes' => $alias->notes, +// ]); +// +// $this->recipients = $alias->recipients->map(fn($r) => [ +// 'mail_user_id' => $r->mail_user_id, +// 'email' => $r->email, +// ])->all(); +// } else { +// // Erst NICHT-System-Domain +// $this->domain = $this->domainId +// ? Domain::where('is_system', false)->findOrFail($this->domainId) +// : Domain::where('is_system', false)->orderBy('domain')->first(); +// +// $this->domainId = $this->domain->id ?? null; +// $this->recipients = [['mail_user_id' => null, 'email' => null]]; +// } +// +// $this->domainMailUsers = $this->domain +// ? MailUser::where('domain_id', $this->domain->id)->orderBy('localpart')->get() +// : collect(); +// +// $this->ensureAtLeastOneRow(); +// $this->recomputeUi(); +// } +// +// public function updatedType(string $value): void +// { +// if ($value === 'single') { +// // exakt eine Zeile behalten +// $first = $this->recipients[0] ?? ['mail_user_id'=>null,'email'=>null]; +// $this->recipients = [ $first ]; +// } +// } +// +// private function validateRecipientDuplicates(): bool +// { +// // vorhandene Feldfehler für Empfänger leeren +// $this->resetErrorBag('recipients'); +// +// // Map: Zielstring je Zeile -> Anzahl +// $values = []; // [i => 'office@pixio.at'] +// $counts = []; // 'office@pixio.at' => n +// +// foreach ($this->recipients as $i => $r) { +// $val = null; +// +// if (!empty($r['mail_user_id'])) { +// $u = MailUser::find((int)$r['mail_user_id']); +// if ($u && $this->domain) { +// $val = Str::lower($u->localpart.'@'.$this->domain->domain); +// } +// } elseif (!empty($r['email'])) { +// $val = Str::lower(trim((string)$r['email'])); +// } +// +// if ($val) { +// $values[$i] = $val; +// $counts[$val] = ($counts[$val] ?? 0) + 1; +// } +// } +// +// $hasDupes = false; +// +// foreach ($values as $i => $val) { +// if (($counts[$val] ?? 0) > 1) { +// // Feldgenau markieren +// if (!empty($this->recipients[$i]['mail_user_id'])) { +// $this->addError("recipients.$i.mail_user_id", 'Dieser Empfänger ist bereits hinzugefügt.'); +// } else { +// $this->addError("recipients.$i.email", 'Dieser Empfänger ist bereits hinzugefügt.'); +// } +// $hasDupes = true; +// } +// } +// +// return $hasDupes; +// } +// +// +// // Livewire v3: universeller Hook für verschachtelte Felder +// public function updated($name, $value): void +// { +// // XOR je Zeile +// if (preg_match('/^recipients\.(\d+)\.(mail_user_id|email)$/', $name, $m)) { +// $i = (int)$m[1]; +// if ($m[2] === 'mail_user_id' && !empty($value)) { +// $this->recipients[$i]['email'] = null; +// } elseif ($m[2] === 'email' && !empty($value)) { +// $this->recipients[$i]['mail_user_id'] = null; +// $this->recipients[$i]['email'] = Str::lower(trim((string)$this->recipients[$i]['email'])); +// } +// } +// +// if ($name === 'type' && $this->type === 'single') { +// $this->recipients = [ $this->recipients[0] ?? ['mail_user_id'=>null,'email'=>null] ]; +// } +// +// $this->ensureAtLeastOneRow(); +// $this->recomputeUi(); +// +// // sofortiges, feldgenaues Duplikat-Feedback +// $this->validateRecipientDuplicates(); +// } +// +// public function addRecipientRow(): void +// { +// if ($this->type === 'single') return; +// if (count($this->recipients) >= $this->maxGroupRecipients) return; +// +// $this->recipients[] = ['mail_user_id'=>null,'email'=>null]; +// $this->recomputeUi(); +// } +// +// public function updatedDomainId(): void +// { +// $this->domain = $this->domainId +// ? Domain::where('is_system', false)->find($this->domainId) +// : null; +// +// $this->domainMailUsers = $this->domain +// ? MailUser::where('domain_id', $this->domain->id)->orderBy('localpart')->get() +// : collect(); +// +// $this->recipients = [['mail_user_id' => null, 'email' => null]]; +// +// $this->ensureAtLeastOneRow(); +// $this->recomputeUi(); +// } +// +// +// public function removeRecipientRow(int $index): void +// { +// unset($this->recipients[$index]); +// $this->recipients = array_values($this->recipients); +// $this->ensureAtLeastOneRow(); +// $this->recomputeUi(); +// } +// +// private function ensureAtLeastOneRow(): void +// { +// if (empty($this->recipients)) { +// $this->recipients = [['mail_user_id'=>null,'email'=>null]]; +// } +// } +// +// private function recomputeUi(): void +// { +// $count = count($this->recipients); +// +// // Button „+ Empfänger“ +// $this->canAddRecipient = $this->type === 'group' && $count < $this->maxGroupRecipients; +// +// // Sammle bereits gewählte interne Mailbox-IDs und externe E-Mails (normalisiert) +// $usedMailboxIds = []; +// $usedExternal = []; +// +// foreach ($this->recipients as $i => $r) { +// if (!empty($r['mail_user_id'])) $usedMailboxIds[] = (int)$r['mail_user_id']; +// if (!empty($r['email'])) $usedExternal[] = Str::lower(trim((string)$r['email'])); +// } +// +// // pro Zeile: welche Mailbox-IDs sollen disabled sein (alle used außer die eigene) +// $this->disabledMailboxIdsByRow = []; +// $this->rowState = []; +// $this->rowDuplicateError = []; +// +// for ($i=0; $i<$count; $i++) { +// $myId = (int)($this->recipients[$i]['mail_user_id'] ?? 0); +// $myEmail = Str::lower(trim((string)($this->recipients[$i]['email'] ?? ''))); +// +// $disabled = array_values(array_unique(array_filter($usedMailboxIds, fn($id) => $id && $id !== $myId))); +// $this->disabledMailboxIdsByRow[$i] = $disabled; +// +// $hasInternal = $myId > 0; +// $hasExternal = $myEmail !== ''; +// +// $this->rowState[$i] = [ +// 'disable_internal' => $hasExternal, // XOR +// 'disable_external' => $hasInternal, // XOR +// 'can_remove' => !($this->type === 'single' && $count === 1), +// ]; +// +// // Duplikats-UI: wenn meine Zieladresse mehrfach vorkommt -> Fehlermeldung für diese Zeile +// if ($hasInternal && count(array_filter($usedMailboxIds, fn($id) => $id === $myId)) > 1) { +// $this->rowDuplicateError[$i] = 'Dieses Postfach ist bereits Empfänger.'; +// } +// if ($hasExternal && count(array_filter($usedExternal, fn($e) => $e === $myEmail)) > 1) { +// $this->rowDuplicateError[$i] = 'Diese E-Mail ist bereits Empfänger.'; +// } +// } +// +// $this->hasDuplicatesAndMarkUi(); +// } +// +//// private function assertNoDuplicates(): void +//// { +//// // Ziel-Strings bilden: intern -> localpart@domain, extern -> E-Mail +//// $targets = []; +//// foreach ($this->recipients as $r) { +//// if (!empty($r['mail_user_id'])) { +//// $u = MailUser::find((int)$r['mail_user_id']); +//// if ($u && $this->domain) { +//// $targets[] = Str::lower($u->localpart.'@'.$this->domain->domain); +//// } +//// } elseif (!empty($r['email'])) { +//// $targets[] = Str::lower(trim((string)$r['email'])); +//// } +//// } +//// $dupes = array_unique(array_diff_key($targets, array_unique($targets))); +//// if (!empty($dupes)) { +//// $this->addError('recipients', 'Doppelte Empfänger nicht erlaubt.'); +//// throw new \RuntimeException('duplicates'); // brich Save clean ab +//// } +//// } +// +// private function hasDuplicatesAndMarkUi(): bool +// { +// // Ziel-String pro Zeile: intern => local@domain, extern => email +// $targets = []; // [i => 'office@pixio.at'] +// $byValueCount = []; // 'office@pixio.at' => n +// +// $this->rowDuplicateError = []; // UI reset +// +// foreach ($this->recipients as $i => $r) { +// $val = null; +// +// if (!empty($r['mail_user_id'])) { +// $u = MailUser::find((int)$r['mail_user_id']); +// if ($u && $this->domain) { +// $val = mb_strtolower($u->localpart.'@'.$this->domain->domain); +// } +// } elseif (!empty($r['email'])) { +// $val = mb_strtolower(trim((string)$r['email'])); +// } +// +// if ($val) { +// $targets[$i] = $val; +// $byValueCount[$val] = ($byValueCount[$val] ?? 0) + 1; +// } +// } +// +// $hasDupes = false; +// foreach ($targets as $i => $val) { +// if (($byValueCount[$val] ?? 0) > 1) { +// $this->rowDuplicateError[$i] = 'Dieser Empfänger ist bereits hinzugefügt.'; +// $hasDupes = true; +// } +// } +// +// return $hasDupes; +// } +// +// +// +// public function save(): void +// { +// $this->validate($this->rules(), $this->messages()); +// +// // XOR-Check Empfänger + Single/Group-Logik +// $rows = collect($this->recipients) +// ->map(fn($r) => [ +// 'mail_user_id' => $r['mail_user_id'] ?: null, +// 'email' => isset($r['email']) && $r['email'] !== '' ? mb_strtolower(trim($r['email'])) : null, +// ]) +// ->filter(fn($r) => $r['mail_user_id'] || $r['email']) +// ->values(); +// +// if ($rows->isEmpty()) { +// $this->addError('recipients', 'Mindestens ein Empfänger erforderlich.'); +// return; +// } +// if ($this->type === 'single' && $rows->count() !== 1) { +// $this->addError('recipients', 'Bei Single-Alias ist genau ein Empfänger erlaubt.'); +// return; +// } +// +// foreach ($this->recipients as $i => $r) { +// if (empty($r['mail_user_id']) && empty($r['email'])) { +// $this->addError("recipients.$i", 'Wähle internes Postfach oder externe E-Mail.'); +// return; +// } +// } +// +// if ($this->validateRecipientDuplicates()) { +// return; // freundlich abbrechen, keine Exception +// } +// +// // 4) Alias-Adresse vs. bestehende Mailboxen (siehe Teil B) +// if ($this->aliasConflictsWithMailbox()) { +// $this->addError('local', 'Diese Adresse ist bereits als Postfach vergeben.'); +// return; +// } +// +// +// DB::transaction(function () use ($rows) { +// /** @var MailAlias $alias */ +// $alias = MailAlias::updateOrCreate( +// ['id' => $this->aliasId], +// [ +// 'domain_id' => $this->domainId, +// 'local' => $this->local, +// 'type' => $this->type, +// 'is_active' => $this->is_active, +// 'notes' => $this->notes, +// ] +// ); +// +// // Empfänger neu schreiben (einfach & robust) +// $alias->recipients()->delete(); +// foreach ($rows as $i => $r) { +// $alias->recipients()->create([ +// 'mail_user_id' => $r['mail_user_id'], +// 'email' => $r['email'], +// 'position' => $i, +// ]); +// } +// +// $this->aliasId = $alias->id; +// }); +// +// $this->dispatch('alias:' . ($this->aliasId ? 'updated' : 'created')); +// $this->dispatch('closeModal'); +// } +// +// private function aliasConflictsWithMailbox(): bool +// { +// if (!$this->domainId || $this->local === '') return false; +// +// $local = Str::lower(trim($this->local)); +// +// return MailUser::query() +// ->where('domain_id', $this->domainId) +// ->whereRaw('LOWER(localpart) = ?', [$local]) +// ->exists(); +// } +// +// protected function rules(): array +// { +// $occupied = MailUser::query() +// ->where('domain_id', $this->domainId) +// ->pluck('localpart') +// ->map(fn($l) => Str::lower($l)) +// ->all(); +// +// return [ +// 'domainId' => ['required','exists:domains,id'], +// 'local' => [ +// 'required', +// 'regex:/^[A-Za-z0-9._%+-]+$/', +// Rule::unique('mail_aliases','local') +// ->ignore($this->aliasId) +// ->where(fn($q) => $q->where('domain_id', $this->domainId)), +// Rule::notIn($occupied), // verhindert offensichtliche Kollisionen +// ], +// 'type' => ['required','in:single,group'], +// 'recipients' => ['array','min:1','max:'.($this->type==='single'?1:$this->maxGroupRecipients)], +// 'recipients.*.mail_user_id' => ['nullable','exists:mail_users,id'], +// 'recipients.*.email' => ['nullable','email:rfc'], +// ]; +// } +// +// +// protected function messages(): array +// { +// return [ +// 'local.not_in' => 'Diese Adresse ist bereits als Postfach vergeben.', +// 'local.unique' => 'Dieser Alias existiert in dieser Domain bereits.', +// 'local.regex' => 'Erlaubt sind Buchstaben, Zahlen und ._%+-', +// 'recipients.min' => 'Mindestens ein Empfänger ist erforderlich.', +// 'recipients.max' => $this->type === 'single' +// ? 'Bei „Single“ ist nur ein Empfänger zulässig.' +// : 'Maximal '.$this->maxGroupRecipients.' Empfänger erlaubt.', +// ]; +// } +// +// +// public function render() +// { +// return view('livewire.ui.mail.modal.alias-form-modal', [ +// 'domains' => Domain::where('is_system', false)->orderBy('domain')->get(['id','domain']), +// ]); +// } +//} diff --git a/app/Livewire/Ui/Mail/Modal/MailboxCreateModal.php b/app/Livewire/Ui/Mail/Modal/MailboxCreateModal.php new file mode 100644 index 0000000..0bf167c --- /dev/null +++ b/app/Livewire/Ui/Mail/Modal/MailboxCreateModal.php @@ -0,0 +1,544 @@ + */ + 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; + public bool $must_change_pw = 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'], + 'must_change_pw' => ['boolean'], + ]; + } + + /* ---------- Lifecycle ---------- */ + public function mount(?int $domainId = null): void + { + // alle Nicht-System-Domains in Select + $this->domains = Domain::query() + ->where('is_system', 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->must_change_pw = (bool)$data['must_change_pw']; + $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 ' . e($email) . ' wurde erfolgreich angelegt.', + duration: 6000 + ); + } + + public static function modalMaxWidth(): string + { + return '3xl'; + } + + public function render() + { + return view('livewire.ui.mail.modal.mailbox-create-modal'); + } +} + +//namespace App\Livewire\Ui\Mail\Modal; +// +//use App\Models\Domain; +//use App\Models\MailUser; +//use Illuminate\Database\QueryException; +//use Illuminate\Support\Facades\Hash; +//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 */ +// public array $domains = []; +// public string $email_preview = ''; +// +// 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; +// public bool $must_change_pw = 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); +// +// return [ +// 'domain_id' => ['required', Rule::exists('domains', 'id')], +// 'localpart' => [ +// 'required', 'max:191', 'regex:/^[A-Za-z0-9._%+-]+$/', +// 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'], +// 'must_change_pw' => ['boolean'], +// ]; +// } +// +// /* ---------- Lifecycle ---------- */ +// public function mount(?int $domainId = null): void +// { +// // alle Nicht-System-Domains in Select +// $this->domains = Domain::query() +// ->where('is_system', 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(); +// } +// +// public function updatedLocalpart(): void +// { +// $this->localpart = strtolower(trim($this->localpart)); +// $this->rebuildEmailPreview(); +// } +// +// 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); +// } +// +// /* ---------- 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; +// } +// +// $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->must_change_pw = (bool)$data['must_change_pw']; +// $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 ' . e($email) . ' wurde erfolgreich angelegt.', +// duration: 6000 +// ); +// +// } +// +// public static function modalMaxWidth(): string +// { +// return '3xl'; +// } +// +// public function render() +// { +// return view('livewire.ui.mail.modal.mailbox-create-modal'); +// } +//} diff --git a/app/Livewire/Ui/Mail/Modal/MailboxDeleteModal.php b/app/Livewire/Ui/Mail/Modal/MailboxDeleteModal.php new file mode 100644 index 0000000..d17329d --- /dev/null +++ b/app/Livewire/Ui/Mail/Modal/MailboxDeleteModal.php @@ -0,0 +1,217 @@ +mailboxId = $mailboxId; + $this->mailbox = MailUser::with('domain')->findOrFail($mailboxId); + + $this->email = $this->mailbox->localpart . '@' . $this->mailbox->domain->domain; + } + + #[On('mailbox:delete')] + public function delete(): void + { + // Optional: Export/Keep behandeln (hier nur Platzhalter-Events, damit UI fertig ist) + if ($this->export_zip) { + $this->dispatch('mailbox:export-zip', id: $this->mailboxId); + } + if ($this->keep_server_mail) { + $this->dispatch('mailbox:keep-server-mail', id: $this->mailboxId); + } + + $this->mailbox->delete(); + + $this->dispatch('mailbox:deleted'); + $this->dispatch('closeModal'); + $this->dispatch('toast', + type: 'done', + badge: 'Postfach', + title: 'Postfach entfernt', + text: 'Das Postfach ' . e($this->email) . ' wurde erfolgreich entfernt.', + duration: 6000 + ); + } + + public function render() + { + return view('livewire.ui.mail.modal.mailbox-delete-modal'); + } +} + +//namespace App\Livewire\Ui\Mail\Modal; +// +//use App\Models\MailUser; +//use LivewireUI\Modal\ModalComponent; +// +//class MailboxDeleteModal extends ModalComponent +//{ +// public int $domainId; // = mailbox_id +// public string $email_display = ''; +// +// // Info-Badges +// public int $quota_mb = 0; +// public int $message_count = 0; +// +// // Auswahl +// public string $mode = 'purge'; // purge | keep | export_then_delete +// public string $confirm = ''; +// +// protected MailUser $mailbox; +// +// public function mount(int $domainId): void +// { +// $this->domainId = $domainId; +// +// $this->mailbox = MailUser::with('domain:id,domain')->findOrFail($this->domainId); +// +// $this->email_display = $this->mailbox->localpart . '@' . $this->mailbox->domain->domain; +// $this->quota_mb = (int)$this->mailbox->quota_mb; +// $this->message_count = (int)($this->mailbox->message_count ?? 0); +// } +// +// public function delete(): void +// { +// if (trim(strtolower($this->confirm)) !== strtolower($this->email_display)) { +// $this->addError('confirm', 'Bitte tippe die E-Mail exakt zur Bestätigung ein.'); +// return; +// } +// +// // Hier nur Platzhalter – deine eigentliche Lösch-/Exportlogik einsetzen +// switch ($this->mode) { +// case 'purge': +// $this->mailbox->delete(); +// break; +// case 'keep': +// // Account deaktivieren/löschen, Maildir auf dem Server behalten +// $this->mailbox->delete(); +// break; +// case 'export_then_delete': +// // Job anstoßen, ZIP exportieren, danach löschen +// // dispatch(new \App\Jobs\ExportMailboxZipAndDelete($this->mailbox->id)); +// $this->mailbox->delete(); +// break; +// } +// +// $this->dispatch('mailbox:deleted'); +// $this->dispatch('closeModal'); +// } +// +// public static function modalMaxWidth(): string +// { +// return '3xl'; +// } +// +// public function render() +// { +// return view('livewire.ui.mail.modal.mailbox-delete-modal'); +// } +//} +// +//namespace App\Livewire\Ui\Mail\Modal; +// +//use App\Models\MailUser; +//use LivewireUI\Modal\ModalComponent; +// +//class MailboxDeleteModal extends ModalComponent +//{ +// public int $mailboxId; +// +// public string $email = ''; +// public ?int $quota_mb = null; +// public int $message_count = 0; +// +// public string $confirm = ''; +// public string $mode = 'purge'; // purge | keep_maildir | export_zip +// +// public function mount(int $mailboxId): void +// { +// $u = MailUser::findOrFail($mailboxId); +// $this->mailboxId = $u->id; +// $this->email = $u->email; +// $this->quota_mb = (int)$u->quota_mb; +// $this->message_count = (int)($u->message_count ?? 0); +// } +// +// public function delete(): void +// { +// $this->validate([ +// 'confirm' => ['required','same:email'], +// 'mode' => ['required','in:purge,keep_maildir,export_zip'], +// ]); +// +// $u = MailUser::findOrFail($this->mailboxId); +// +// // OPTION: Export anstoßen +// if ($this->mode === 'export_zip') { +// // TODO: z.B. Job dispatchen: +// // MailboxExportJob::dispatch($u->id); +// // oder Service: app(MailExportService::class)->exportAndStoreZip($u); +// $this->dispatch('toast', +// type: 'info', +// badge: 'Export', +// title: 'Mail-Export gestartet', +// text: 'Der ZIP-Export wurde angestoßen. Du erhältst einen Download-Hinweis, sobald er fertig ist.', +// duration: 6000, +// ); +// } +// +// // Account löschen: +// // - purge: Maildir + Account entfernen +// // - keep_maildir: NUR Account entfernen (Maildir bleibt – je nach MTA/Storage-Setup) +// // Implementierung hängt von deiner Umgebung ab. Hier rufen wir Events, +// // damit deine Listener/Services zielgerichtet handeln können. +// if ($this->mode === 'purge') { +// $this->dispatch('mailbox:purge', id: $u->id); +// } elseif ($this->mode === 'keep_maildir') { +// $this->dispatch('mailbox:delete-keep-storage', id: $u->id); +// } else { +// $this->dispatch('mailbox:export-and-delete', id: $u->id); +// } +// +// // In jedem Fall den Datensatz entfernen: +// $u->delete(); +// +// $this->dispatch('mailbox:deleted'); +// $this->dispatch('closeModal'); +// $this->dispatch('toast', +// type: 'done', +// badge: 'Mailbox', +// title: 'Postfach gelöscht', +// text: 'Das Postfach wurde entfernt.', +// duration: 4000, +// ); +// } +// +// public static function modalMaxWidth(): string +// { +// return '2xl'; +// } +// +// public function render() +// { +// return view('livewire.ui.mail.modal.mailbox-delete-modal'); +// } +//} diff --git a/app/Livewire/Ui/Mail/Modal/MailboxEditModal.php b/app/Livewire/Ui/Mail/Modal/MailboxEditModal.php new file mode 100644 index 0000000..6c4f257 --- /dev/null +++ b/app/Livewire/Ui/Mail/Modal/MailboxEditModal.php @@ -0,0 +1,411 @@ +mailboxId = $mailboxId; + $this->mailbox = MailUser::with('domain')->findOrFail($mailboxId); + + // Felder vorbelegen + $this->display_name = $this->mailbox->display_name; + $this->quota_mb = (int)$this->mailbox->quota_mb; + $this->rate_limit_per_hour = $this->mailbox->rate_limit_per_hour; + $this->is_active = (bool)$this->mailbox->is_active; + $this->must_change_pw = (bool)$this->mailbox->must_change_pw; + + $dom = $this->mailbox->domain; + $this->email_readonly = $this->mailbox->localpart . '@' . $dom->domain; + + // Quota-Hinweis + Rate-Limit-Readonly + $this->buildHints($dom); + } + + private function buildHints(Domain $d): void + { + // verbleibend inkl. der bisherigen Quota dieser Mailbox (damit Edit nicht sofort blockiert) + $usedWithoutThis = (int)$d->mailUsers() + ->where('id', '!=', $this->mailbox->id) + ->sum('quota_mb'); + + $remainingByTotal = is_null($d->total_quota_mb) + ? null + : max(0, (int)$d->total_quota_mb - $usedWithoutThis); + + $parts = []; + if (!is_null($d->max_quota_per_mailbox_mb)) $parts[] = "Max {$d->max_quota_per_mailbox_mb} MiB pro Postfach"; + if (!is_null($remainingByTotal)) $parts[] = "Verbleibend jetzt: {$remainingByTotal} MiB"; + if (!is_null($d->default_quota_mb)) $parts[] = "Standard: {$d->default_quota_mb} MiB"; + $this->quota_hint = implode(' · ', $parts); + + // Domain-Rate-Limit: Override? + if (!$d->rate_limit_override) { + $this->rate_limit_per_hour = $d->rate_limit_per_hour; + $this->rate_limit_readonly = true; + } else { + if (is_null($this->rate_limit_per_hour)) $this->rate_limit_per_hour = $d->rate_limit_per_hour; + $this->rate_limit_readonly = false; + } + } + + protected function rules(): array + { + // defensiv: wenn mailbox oder domain noch nicht da → keine Caps + $d = $this->mailbox?->domain; + + $maxPerMailbox = $d?->max_quota_per_mailbox_mb ?? PHP_INT_MAX; + + if ($d) { + $usedWithoutThis = (int)$d->mailUsers() + ->where('id', '!=', $this->mailbox->id) + ->sum('quota_mb'); + + $remainingByTotal = is_null($d->total_quota_mb) + ? PHP_INT_MAX + : max(0, (int)$d->total_quota_mb - $usedWithoutThis); + + $cap = min($maxPerMailbox, $remainingByTotal); + } else { + $cap = PHP_INT_MAX; + } + + return [ + 'display_name' => ['nullable', 'string', 'max:191'], + 'password' => ['nullable', 'string', 'min:8'], + 'quota_mb' => ['required', 'integer', 'min:0', 'max:' . $cap], + 'rate_limit_per_hour' => ['nullable', 'integer', 'min:1'], + 'is_active' => ['boolean'], + 'must_change_pw' => ['boolean'], + ]; + } + + #[On('mailbox:edit:save')] + public function save(): void + { + $this->validate(); + + $u = $this->mailbox; // schon geladen + $d = $u->domain; + + // Speichern + $u->display_name = $this->display_name ?: null; + if (!empty($this->password)) { + $u->password_hash = Hash::make($this->password); + $u->must_change_pw = true; + } + $u->quota_mb = (int)$this->quota_mb; + $u->rate_limit_per_hour = $this->rate_limit_readonly ? $d->rate_limit_per_hour : $this->rate_limit_per_hour; + $u->is_active = (bool)$this->is_active; + $u->must_change_pw = (bool)$this->must_change_pw; + $u->save(); + + $mailbox = $u->localpart . '@' . $d->domain; + $this->dispatch('mailbox:updated'); + $this->dispatch('closeModal'); + $this->dispatch('toast', + type: 'done', + badge: 'Postfach', + title: 'Postfach aktualisiert', + text: 'Das Postfach ' . $mailbox . ' wurde erfolgreich aktualisiert.', + duration: 6000 + ); + } + + public function render() + { + return view('livewire.ui.mail.modal.mailbox-edit-modal'); + } +} + +//namespace App\Livewire\Ui\Mail\Modal; +// +//use App\Models\MailUser; +//use LivewireUI\Modal\ModalComponent; +//use Illuminate\Support\Facades\Hash; +//use Illuminate\Validation\Rule; +// +//class MailboxEditModal extends ModalComponent +//{ +// // Eingangs-Argument (du rufst openMailboxEdit($id)) +// public int $domainId; // = mailbox_id +// +// // Anzeige +// public string $email_display = ''; // readonly im UI +// +// // Editierbare Felder +// public ?string $display_name = null; +// public ?string $password = null; // leer => unverändert +// public int $quota_mb = 0; +// public ?int $rate_limit_per_hour = null; +// public bool $is_active = true; +// +// // Intern +// protected MailUser $mailbox; +// +// // Hinweise +// public string $quota_hint = ''; +// +// public function mount(int $domainId): void +// { +// $this->domainId = $domainId; +// +// $this->mailbox = MailUser::query() +// ->with('domain:id,domain,total_quota_mb,max_quota_per_mailbox_mb,default_quota_mb') +// ->findOrFail($this->domainId); +// +// $this->email_display = $this->mailbox->localpart . '@' . $this->mailbox->domain->domain; +// $this->display_name = $this->mailbox->display_name; +// $this->quota_mb = (int)$this->mailbox->quota_mb; +// $this->rate_limit_per_hour = $this->mailbox->rate_limit_per_hour; +// $this->is_active = (bool)$this->mailbox->is_active; +// +// // Quota-Hinweis bauen +// $d = $this->mailbox->domain; +// $parts = []; +// if (!is_null($d->total_quota_mb)) { +// // verbleibend „nach Speichern“ kannst du bei Bedarf einsetzen, hier nur aktuell +// $parts[] = 'Verbleibend jetzt: ' . number_format((int)$d->total_quota_mb, 0, ',', '.') . ' MiB'; +// } +// if (!is_null($d->max_quota_per_mailbox_mb)) $parts[] = 'Max ' . $d->max_quota_per_mailbox_mb . ' MiB pro Postfach'; +// if (!is_null($d->default_quota_mb)) $parts[] = 'Standard: ' . $d->default_quota_mb . ' MiB'; +// $this->quota_hint = implode(' · ', $parts); +// } +// +// protected function rules(): array +// { +// $d = $this->mailbox->domain; +// +// $maxPerMailbox = $d->max_quota_per_mailbox_mb ?: PHP_INT_MAX; +// +// return [ +// 'display_name' => ['nullable', 'string', 'max:191'], +// 'password' => ['nullable', 'string', 'min:8'], +// 'quota_mb' => ['required', 'integer', 'min:0', 'max:' . $maxPerMailbox], +// 'rate_limit_per_hour' => ['nullable', 'integer', 'min:1'], +// 'is_active' => ['boolean'], +// ]; +// } +// +// public function save(): void +// { +// $data = $this->validate(); +// +// // speichern +// $this->mailbox->display_name = $data['display_name'] ?: null; +// if (!empty($data['password'])) { +// $this->mailbox->password_hash = Hash::make($data['password']); +// } +// $this->mailbox->quota_mb = (int)$data['quota_mb']; +// $this->mailbox->rate_limit_per_hour = $data['rate_limit_per_hour']; +// $this->mailbox->is_active = (bool)$data['is_active']; +// $this->mailbox->save(); +// +// $this->dispatch('mailbox:updated'); +// $this->dispatch('closeModal'); +// $this->dispatch('toast', [ +// 'state' => 'success', +// 'text' => 'Postfach wurde erfolgreich aktualisiert.', +// ]); +// } +// +// public static function modalMaxWidth(): string +// { +// return '4xl'; +// } +// +// public function render() +// { +// return view('livewire.ui.mail.modal.mailbox-edit-modal'); +// } +//} +// +//namespace App\Livewire\Ui\Mail\Modal; +// +//use App\Models\Domain; +//use App\Models\MailUser; +//use Illuminate\Support\Facades\Hash; +//use Illuminate\Validation\Rule; +//use LivewireUI\Modal\ModalComponent; +// +//class MailboxEditModal extends ModalComponent +//{ +// public int $mailboxId; +// +// // read-only Anzeige +// public string $email = ''; +// +// // editierbare Felder +// public ?string $display_name = null; +// public string $password = ''; +// public int $quota_mb = 0; +// public ?int $rate_limit_per_hour = null; +// public bool $is_active = true; +// +// // Domain/Limits +// public int $domain_id; +// public ?int $limit_default_quota_mb = null; +// public ?int $limit_max_quota_per_mb = null; // pro Mailbox +// public ?int $limit_total_quota_mb = null; // gesamt (0/NULL = unbegrenzt) +// public ?int $limit_domain_rate_per_hour = null; +// public bool $allow_rate_limit_override = false; +// +// // Status/Ausgabe +// public int $domain_storage_used_mb = 0; // Summe Quotas aller Mailboxen +// public int $this_orig_quota_mb = 0; // Original der aktuellen Mailbox +// public string $quota_hint = ''; +// public bool $rate_limit_readonly = false; +// +// public function mount(int $mailboxId): void +// { +// $u = MailUser::with('domain')->findOrFail($mailboxId); +// $d = $u->domain; +// +// $this->mailboxId = $u->id; +// $this->domain_id = $d->id; +// +// $this->email = $u->email; +// $this->display_name = $u->display_name; +// $this->quota_mb = (int)$u->quota_mb; +// $this->this_orig_quota_mb = (int)$u->quota_mb; +// $this->rate_limit_per_hour = $u->rate_limit_per_hour; +// $this->is_active = (bool)$u->is_active; +// +// // Limits aus Domain +// $agg = Domain::query() +// ->withSum('mailUsers as used_storage_mb', 'quota_mb') +// ->findOrFail($d->id); +// +// $this->limit_default_quota_mb = $d->default_quota_mb; +// $this->limit_max_quota_per_mb = $d->max_quota_per_mailbox_mb; +// $this->limit_total_quota_mb = $d->total_quota_mb; // 0 = unbegrenzt +// $this->limit_domain_rate_per_hour = $d->rate_limit_per_hour; +// $this->allow_rate_limit_override = (bool)$d->rate_limit_override; +// +// $this->domain_storage_used_mb = (int)($agg->used_storage_mb ?? 0); +// +// // Rate-Limit-Feld ggf. readonly machen +// if (!$this->allow_rate_limit_override) { +// $this->rate_limit_per_hour = $this->limit_domain_rate_per_hour; +// $this->rate_limit_readonly = true; +// } +// +// $this->buildQuotaHint(); +// } +// +// protected function rules(): array +// { +// // „Rest gesamt“ = total - (used - this_orig) (wir dürfen die alte Quota dieser Mailbox neu verteilen) +// $remainingByTotal = ($this->limit_total_quota_mb === null || $this->limit_total_quota_mb === 0) +// ? PHP_INT_MAX +// : max(0, (int)$this->limit_total_quota_mb - max(0, (int)$this->domain_storage_used_mb - $this->this_orig_quota_mb)); +// +// $maxPerMailbox = $this->limit_max_quota_per_mb ?? PHP_INT_MAX; +// $cap = min($maxPerMailbox, $remainingByTotal); +// +// return [ +// 'display_name' => ['nullable','string','max:191'], +// 'password' => ['nullable','string','min:8'], +// 'quota_mb' => ['required','integer','min:0','max:'.$cap], +// 'rate_limit_per_hour' => ['nullable','integer','min:1'], +// 'is_active' => ['boolean'], +// ]; +// } +// +// public function updatedQuotaMb(): void +// { +// $this->buildQuotaHint(); +// } +// +// private function buildQuotaHint(): void +// { +// $parts = []; +// if (!is_null($this->limit_total_quota_mb) && $this->limit_total_quota_mb > 0) { +// $usedMinusThis = max(0, (int)$this->domain_storage_used_mb - $this->this_orig_quota_mb); +// $nowRemaining = max(0, (int)$this->limit_total_quota_mb - $usedMinusThis); +// $after = max(0, (int)$this->limit_total_quota_mb - $usedMinusThis - max(0,(int)$this->quota_mb)); +// +// $parts[] = "Verbleibend jetzt: {$nowRemaining} MiB"; +// $parts[] = "nach Speichern: {$after} 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); +// } +// +// public function save(): void +// { +// $data = $this->validate(); +// +// // Falls Override verboten ist → Domain-Wert erzwingen +// if (!$this->allow_rate_limit_override) { +// $data['rate_limit_per_hour'] = $this->limit_domain_rate_per_hour; +// } +// +// $u = MailUser::findOrFail($this->mailboxId); +// +// // Passwort optional ändern +// if (!empty($data['password'] ?? '')) { +// $u->password_hash = Hash::make($data['password']); +// } +// +// $u->display_name = $data['display_name'] ?? null; +// $u->quota_mb = (int)$data['quota_mb']; +// $u->rate_limit_per_hour = $data['rate_limit_per_hour'] ?? null; +// $u->is_active = (bool)$data['is_active']; +// $u->save(); +// +// $this->dispatch('mailbox:updated'); +// $this->dispatch('closeModal'); +// $this->dispatch('toast', +// type: 'done', +// badge: 'Mailbox', +// title: 'Postfach aktualisiert', +// text: 'Die Änderungen wurden gespeichert.', +// duration: 4000, +// ); +// } +// +// public static function modalMaxWidth(): string +// { +// return '3xl'; +// } +// +// public function render() +// { +// return view('livewire.ui.mail.modal.mailbox-edit-modal'); +// } +//} diff --git a/app/Livewire/Ui/Search/Modal/SearchPaletteModal.php b/app/Livewire/Ui/Search/Modal/SearchPaletteModal.php new file mode 100644 index 0000000..9714d26 --- /dev/null +++ b/app/Livewire/Ui/Search/Modal/SearchPaletteModal.php @@ -0,0 +1,155 @@ + [label, limit] + 'domains' => ['Domains', 8], + 'mailboxes' => ['Postfächer',8], + 'users' => ['Benutzer', 8], + ]; + + #[Computed] + public function results(): array + { + $term = trim($this->q); + if ($term === '') { + return ['domains'=>[],'mailboxes'=>[],'users'=>[]]; + } + + $out = []; + + // Domains + if (isset($this->sections['domains'])) { + [$label, $limit] = $this->sections['domains']; + $out['domains'] = Domain::query() + ->where('is_system', false) + ->where('domain', 'like', "%{$term}%") + ->orderBy('domain') + ->limit($limit) + ->get(['id','domain','is_active']) + ->map(fn($d)=>[ + 'id' => $d->id, + 'title' => $d->domain, + 'sub' => $d->is_active ? 'aktiv' : 'inaktiv', +// 'route' => route('domains.index'), // falls Route existiert + 'route' => '#', // falls Route existiert + 'type' => 'domain', + ])->all(); + } + + // Mailboxen + if (isset($this->sections['mailboxes'])) { + [$label, $limit] = $this->sections['mailboxes']; + + $out['mailboxes'] = \App\Models\MailUser::query() + // INNER JOIN: nur Mailboxen mit gültiger Domain + ->join('domains', 'domains.id', '=', 'mail_users.domain_id') + // System-Domains ausschließen + ->where('domains.is_system', false) + // Suchterm über localpart, gespeicherte email ODER Domain + ->where(function ($w) use ($term) { + $w->where('mail_users.localpart', 'like', "%{$term}%") + ->orWhere('mail_users.email', 'like', "%{$term}%") + ->orWhere('domains.domain', 'like', "%{$term}%"); + }) + ->orderBy('domains.domain') + ->orderBy('mail_users.localpart') + ->limit($limit) + ->get([ + 'mail_users.id', + 'mail_users.localpart', + 'mail_users.email', + 'mail_users.is_active', + 'domains.domain as dom', + 'domains.is_active as dom_active', + ]) + ->map(function ($r) { + // Email bevorzugt rohe DB-Spalte; sonst sauber zusammensetzen + $rawEmail = $r->getRawOriginal('email'); + $title = $rawEmail ?: ($r->localpart && $r->dom ? "{$r->localpart}@{$r->dom}" : ''); + + // Falls aus irgendeinem Grund beides leer wäre, gar keinen Geister-„@“ anzeigen: + if ($title === '') { + return null; // überspringen + } + + $sub = ($r->dom_active ? '' : 'Domain inaktiv · ') + . ($r->is_active ? 'aktiv' : 'inaktiv'); + + return [ + 'id' => (int)$r->id, + 'title' => $title, + 'sub' => $sub, + 'route' => '#', + 'type' => 'mailbox', + ]; + }) + ->filter() // nulls (leere Titel) raus + ->values() + ->all(); + } + + // Benutzer (optional) + if (class_exists(User::class) && isset($this->sections['users'])) { + [$label, $limit] = $this->sections['users']; + $out['users'] = User::query() + ->where(function($w) use ($term){ + $w->where('name','like',"%{$term}%") + ->orWhere('email','like',"%{$term}%"); + }) + ->orderBy('name') + ->limit($limit) + ->get(['id','name','email']) + ->map(fn($u)=>[ + 'id' => $u->id, + 'title' => $u->name, + 'sub' => $u->email, + 'route' => '#', // ggf. Profilroute hinterlegen + 'type' => 'user', + ])->all(); + } + + return $out; + } + + public function go(string $type, int $id): void + { + // Schließe die Palette … + $this->dispatch('closeModal'); + + // … und navigiere / öffne Kontext: + // - Domain → scrolle/markiere Domainkarte + // - Mailbox → öffne Bearbeiten-Modal + // Passe an, was du bevorzugst: + if ($type === 'domain') { + $this->dispatch('focus:domain', id: $id); + } elseif ($type === 'mailbox') { + // direkt Edit-Modal auf + $this->dispatch('openModal', component:'ui.mail.modal.mailbox-edit-modal', arguments: [$id]); + } elseif ($type === 'user') { + $this->dispatch('focus:user', id: $id); + } + } + + public static function modalMaxWidth(): string + { + return '2xl'; + } + + public function render() + { + return view('livewire.ui.search.modal.search-palette-modal'); + } +} diff --git a/app/Livewire/Ui/Security/Modal/TotpSetupModal.php b/app/Livewire/Ui/Security/Modal/TotpSetupModal.php index 9a9c9ef..8b666d2 100644 --- a/app/Livewire/Ui/Security/Modal/TotpSetupModal.php +++ b/app/Livewire/Ui/Security/Modal/TotpSetupModal.php @@ -3,6 +3,7 @@ namespace App\Livewire\Ui\Security\Modal; use Illuminate\Support\Facades\Auth; +use Livewire\Attributes\On; use LivewireUI\Modal\ModalComponent; use Vectorface\GoogleAuthenticator; @@ -36,6 +37,7 @@ class TotpSetupModal extends ModalComponent $this->alreadyActive = (bool) ($user->two_factor_enabled ?? false); } + #[On('security:totp:enable')] public function verifyAndEnable(string $code): void { $code = preg_replace('/\D/', '', $code ?? ''); diff --git a/app/Livewire/Ui/System/GeneralForm.php b/app/Livewire/Ui/System/GeneralForm.php index 764a3b8..f4cb906 100644 --- a/app/Livewire/Ui/System/GeneralForm.php +++ b/app/Livewire/Ui/System/GeneralForm.php @@ -2,24 +2,31 @@ namespace App\Livewire\Ui\System; +use Illuminate\Validation\Rule; use Livewire\Component; class GeneralForm extends Component { + public array $locales; + public array $timezones; + public string $instance_name = 'MailWolt'; // readonly Anzeige - public string $locale = 'de'; - public string $timezone = 'Europe/Berlin'; + public string $locale; + public string $timezone; public int $session_timeout = 120; // Minuten // Beispieldaten – später aus Config/DB füllen - public array $locales = [ - ['value' => 'de', 'label' => 'Deutsch'], - ['value' => 'en', 'label' => 'English'], - ]; - public array $timezones = [ - 'Europe/Berlin','UTC','Europe/Vienna','Europe/Zurich','America/New_York','Asia/Tokyo' - ]; + public function mount(): void + { + $this->locales = config('system.locales', []); + $this->timezones = config('system.timezones', []); + + // prefill from settings + $this->locale = \App\Models\Setting::get('system.locale', 'de'); + $this->timezone = \App\Models\Setting::get('system.timezone', 'Europe/Berlin'); + $this->session_timeout= (int)\App\Models\Setting::get('system.session_timeout', 120); + } protected function rules(): array { @@ -34,13 +41,25 @@ class GeneralForm extends Component { $this->validate(); + \App\Models\Setting::setMany([ + 'system.locale' => $this->locale, + 'system.timezone' => $this->timezone, + 'system.session_timeout' => $this->session_timeout, + ]); + // TODO: persist to settings storage (DB/Config) // e.g. Settings::set('app.locale', $this->locale); // Settings::set('app.timezone', $this->timezone); // Settings::set('session.timeout', $this->session_timeout); - session()->flash('saved', true); - $this->dispatch('toast', body: 'Einstellungen gespeichert.'); + $this->dispatch('toast', + type: 'done', + badge: 'Einstellungen', + title: 'Gespeichert', + text: 'Die allgemeinen Einstellungen wurden erfolgreich gespeichert.', + duration: 6000, + ); +// $this->dispatch('toast', body: 'Einstellungen gespeichert.'); } public function render() diff --git a/app/Models/Domain.php b/app/Models/Domain.php index 6fbc419..f13143f 100644 --- a/app/Models/Domain.php +++ b/app/Models/Domain.php @@ -7,18 +7,57 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class Domain extends Model { - protected $fillable = ['domain','is_active','is_system']; - protected $casts = ['is_active' => 'bool']; + protected $fillable = [ + 'domain','description','tags', + 'is_active','is_system', + 'max_aliases','max_mailboxes', + 'default_quota_mb','max_quota_per_mailbox_mb','total_quota_mb', + 'rate_limit_per_hour','rate_limit_override', + ]; - public function aliases(): HasMany - { - return $this->hasMany(MailAlias::class); + protected $casts = [ + 'tags' => 'array', + 'is_active' => 'bool', + 'is_system' => 'bool', + 'max_aliases' => 'int', + 'max_mailboxes' => 'int', + 'default_quota_mb' => 'int', + 'max_quota_per_mailbox_mb' => 'int', + 'total_quota_mb' => 'int', + 'rate_limit_per_hour' => 'int', + 'rate_limit_override' => 'bool', + ]; + + protected $appends = ['dns_verified']; + + public function mailUsers(): HasMany { + return $this->hasMany(MailUser::class)->orderBy('localpart'); + } + public function mailAliases(): HasMany { + return $this->hasMany(MailAlias::class)->orderBy('local'); } - public function users(): HasMany + public function getDnsVerifiedAttribute(): bool { - return $this->hasMany(MailUser::class); + // Dummy-Regel: als verifiziert, wenn DKIM-Key existiert (nur Beispiel) + return $this->dkimKeys()->where('is_active', true)->exists(); } + // Praktisch fürs Listing + public function scopeWithMailStats($q) { + return $q->withCount(['mailUsers','mailAliases']) + ->with(['mailUsers','mailAliases']) + ->orderBy('domain'); + } + +// public function mailAliases(): HasMany +// { +// return $this->hasMany(MailAlias::class); +// } +// +// public function mailUsers(): HasMany +// { +// return $this->hasMany(MailUser::class); +// } public function dkimKeys(): HasMany { @@ -35,4 +74,24 @@ class Domain extends Model return $this->hasMany(DmarcRecord::class); } + public function tlsaRecords() + { + return $this->hasMany(TlsaRecord::class); + } + + public function getTagObjectsAttribute(): array + { + $raw = $this->tags ?? []; + $items = []; + foreach ($raw as $t) { + if (!is_array($t)) continue; + $label = trim((string)($t['label'] ?? '')); if ($label === '') continue; + $color = $this->normalizeHex($t['color'] ?? '') ?? '#22c55e'; + $items[] = ['label'=>$label,'color'=>$color, + 'bg'=>$this->toRgba($color,0.12),'border'=>$this->toRgba($color,0.35)]; + } + return $items; + } + private function toRgba(string $hex, float $a): string { $hex=ltrim($hex,'#'); $r=hexdec(substr($hex,0,2)); $g=hexdec(substr($hex,2,2)); $b=hexdec(substr($hex,4,2)); return "rgba($r,$g,$b,$a)"; } + private function normalizeHex(?string $hex): ?string { $hex=trim((string)$hex); if($hex==='')return null; if($hex[0]!=='#')$hex="#$hex"; return preg_match('/^#[0-9a-fA-F]{6}$/',$hex)?strtolower($hex):null; } } diff --git a/app/Models/MailAlias.php b/app/Models/MailAlias.php index 1beb04e..09e01dc 100644 --- a/app/Models/MailAlias.php +++ b/app/Models/MailAlias.php @@ -4,15 +4,40 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class MailAlias extends Model { - protected $table = 'mail_aliases'; + protected $fillable = ['domain_id','local','type','group_name','is_active','notes']; + protected $casts = ['is_active' => 'bool']; - protected $fillable = ['domain_id','source','destination','is_active']; - protected $casts = ['is_active'=>'bool']; - - public function domain(): BelongsTo { + public function domain(): BelongsTo + { return $this->belongsTo(Domain::class); } + + public function recipients(): HasMany + { + return $this->hasMany(MailAliasRecipient::class, 'alias_id'); + } + + public function getAddressAttribute(): string + { + $domain = $this->relationLoaded('domain') ? $this->domain : $this->domain()->first(); + return "{$this->local}@{$domain->name}"; + } + +// protected $table = 'mail_aliases'; +// +// protected $fillable = ['domain_id','source','destination','is_active']; +// protected $casts = ['is_active'=>'bool']; +// +// public function domain(): BelongsTo { +// return $this->belongsTo(Domain::class); +// } +// + public function getSourceAttribute(): string { + return "{$this->source_local}@{$this->domain->name}"; + } + } diff --git a/app/Models/MailAliasRecipient.php b/app/Models/MailAliasRecipient.php new file mode 100644 index 0000000..66b7799 --- /dev/null +++ b/app/Models/MailAliasRecipient.php @@ -0,0 +1,31 @@ +belongsTo(MailAlias::class, 'alias_id'); + } + + // <-- hier auf MailUser verweisen, nicht Mailbox + public function mailUser(): BelongsTo + { + return $this->belongsTo(MailUser::class, 'mail_user_id'); + } + + public function getLabelAttribute(): string + { + if ($this->relationLoaded('mailUser') && $this->mailUser) { + // falls MailUser ein Attribut 'address' hat (z.B. office@pixio.at) + return '@'.$this->mailUser->address; + } + return (string) $this->email; + } +} diff --git a/app/Models/MailUser.php b/app/Models/MailUser.php index c3b2e7e..524b941 100644 --- a/app/Models/MailUser.php +++ b/app/Models/MailUser.php @@ -10,7 +10,7 @@ class MailUser extends Model // protected $table = 'mail_users'; protected $fillable = [ - 'domain_id','localpart','email','password_hash', + 'domain_id','localpart','email','display_name','password_hash', 'is_active','must_change_pw','quota_mb','is_system' ]; @@ -32,6 +32,26 @@ class MailUser extends Model $this->attributes['password_hash'] = password_hash($plain, PASSWORD_BCRYPT); } + // Ausgabe: vollständige Adresse + public function getEmailAttribute(): string { + return "{$this->local_part}@{$this->domain->name}"; + } + + public function getAddressAttribute(): string + { + $local = (string)($this->attributes['localpart'] ?? $this->localpart ?? ''); + // bevorzugt den in Queries selektierten Alias `dom`, sonst Relation, sonst Roh-Attribut + $dom = (string)( + $this->attributes['dom'] + ?? ($this->relationLoaded('domain') ? ($this->domain->domain ?? '') : '') + ?? ($this->attributes['domain'] ?? '') + ); + + if ($local !== '' && $dom !== '') return "{$local}@{$dom}"; + if (($this->attributes['email'] ?? null) !== null) return (string)$this->attributes['email']; + return $dom !== '' ? "@{$dom}" : ''; + } + // Scopes public function scopeActive($q) { return $q->where('is_active', true); } diff --git a/app/Models/Setting.php b/app/Models/Setting.php index b2f583e..501801e 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -3,31 +3,109 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Redis; class Setting extends Model { - protected $fillable = ['key','value']; - public $incrementing = false; - protected $keyType = 'string'; + protected $fillable = ['group', 'key', 'value']; + protected $keyType = 'int'; + public $incrementing = true; - public static function get(string $key, $default=null) { - $row = static::query()->find($key); - if (!$row) return $default; - $val = $row->value; - $decoded = json_decode($val, true); - return (json_last_error() === JSON_ERROR_NONE) ? $decoded : $val; - } - - public static function set(string $key, $value): void { - $val = is_array($value) ? json_encode($value, JSON_UNESCAPED_SLASHES) : (string)$value; - static::query()->updateOrCreate(['key'=>$key], ['value'=>$val]); - } - - public static function signupAllowed() + /** + * Get a setting: Redis → DB fallback → Redis rebuild + */ + public static function get(string $name, $default = null) { - $value = self::where('key', 'signup_enabled')->value('value'); - return is_null($value) || (int) $value === 1; + [$group, $key] = self::split($name); + $redisKey = self::redisKey($group, $key); + + // 1️⃣ Try Redis + try { + $cached = Redis::get($redisKey); + if ($cached !== null) { + $decoded = json_decode($cached, true); + return json_last_error() === JSON_ERROR_NONE ? $decoded : $cached; + } + } catch (\Throwable) { + // Redis down, fallback to DB + } + + // 2️⃣ Try DB + $row = static::query()->where(compact('group', 'key'))->first(); + if (!$row) return $default; + + $decoded = json_decode($row->value, true); + $value = json_last_error() === JSON_ERROR_NONE ? $decoded : $row->value; + + // 3️⃣ Writeback to Redis + try { + Redis::setex($redisKey, 3600, is_scalar($value) ? (string)$value : json_encode($value)); + } catch (\Throwable) {} + + return $value; } + /** + * Set or update a setting: DB → Redis + */ + public static function set(string $name, $value): void + { + [$group, $key] = self::split($name); + $redisKey = self::redisKey($group, $key); + + $val = is_scalar($value) + ? (string)$value + : json_encode($value, JSON_UNESCAPED_SLASHES); + + static::query()->updateOrCreate(compact('group', 'key'), ['value' => $val]); + + try { + Redis::set($redisKey, $val); + } catch (\Throwable) {} + } + + /** + * Forget a cached setting (Redis only) + */ + public static function forget(string $name): void + { + [$group, $key] = self::split($name); + try { + Redis::del(self::redisKey($group, $key)); + } catch (\Throwable) {} + } + + /** + * Build Redis key + */ + protected static function redisKey(string $group, string $key): string + { + return "settings:{$group}:{$key}"; + } + + /** + * Split "group.key" format + */ + protected static function split(string $name): array + { + if (str_contains($name, '.')) { + [$group, $key] = explode('.', $name, 2); + } else { + $group = 'system'; + $key = $name; + } + return [$group, $key]; + } + + public static function setMany(array $pairs): void + { + foreach ($pairs as $name => $value) { + self::set($name, $value); + } + } + + public static function signupAllowed(): bool + { + return (int) self::get('system.signup_enabled', 1) === 1; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6043853..9d0b3bb 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -19,25 +19,56 @@ class AppServiceProvider extends ServiceProvider /** * Bootstrap any application services. */ - public function boot(SettingsRepository $settings): void + public function boot(\App\Support\SettingsRepository $settings): void { try { $S = app(\App\Support\SettingsRepository::class); - if ($tz = $S->get('app.timezone')) { + + // 🕒 Zeitzone + if ($tz = $S->get('system.timezone')) { config(['app.timezone' => $tz]); date_default_timezone_set($tz); } + + // 🌐 Sprache + if ($locale = $S->get('system.locale')) { + app()->setLocale($locale); + } + + // 🌍 Domain / URL if ($domain = $S->get('app.domain')) { - // Falls du APP_URL dynamisch überschreiben willst: $scheme = $S->get('app.force_https', true) ? 'https' : 'http'; - config(['app.url' => $scheme.'://'.$domain]); + config(['app.url' => "{$scheme}://{$domain}"]); URL::forceRootUrl(config('app.url')); if ($scheme === 'https') { URL::forceScheme('https'); } } + } catch (\Throwable $e) { - // Im Bootstrap/Wartungsmodus still sein + // Keine Exceptions beim Booten, z. B. wenn DB oder Redis noch nicht erreichbar sind + // \Log::warning('settings.boot_failed', ['msg' => $e->getMessage()]); } } +// public function boot(SettingsRepository $settings): void +// { +// try { +// $S = app(\App\Support\SettingsRepository::class); +// if ($tz = $S->get('app.timezone')) { +// config(['app.timezone' => $tz]); +// date_default_timezone_set($tz); +// } +// if ($domain = $S->get('app.domain')) { +// // Falls du APP_URL dynamisch überschreiben willst: +// $scheme = $S->get('app.force_https', true) ? 'https' : 'http'; +// config(['app.url' => $scheme.'://'.$domain]); +// URL::forceRootUrl(config('app.url')); +// if ($scheme === 'https') { +// URL::forceScheme('https'); +// } +// } +// } catch (\Throwable $e) { +// // Im Bootstrap/Wartungsmodus still sein +// } +// } } diff --git a/app/Services/DkimService.php b/app/Services/DkimService.php new file mode 100644 index 0000000..538374c --- /dev/null +++ b/app/Services/DkimService.php @@ -0,0 +1,140 @@ +safeKey($domainId); + $selKey = $this->safeKey($selector, 32); + + $disk = Storage::disk('local'); + $baseRel = "dkim/{$dirKey}"; + $privRel = "{$baseRel}/{$selKey}.pem"; + $pubRel = "{$baseRel}/{$selKey}.pub"; + + // 1) Ordner sicherstellen + $disk->makeDirectory($baseRel); + + // 2) Keypair via PHP-OpenSSL (kein shell_exec) + $res = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => $bits, + ]); + if ($res === false) { + throw new \RuntimeException('DKIM: openssl_pkey_new() fehlgeschlagen: ' . (openssl_error_string() ?: 'unbekannt')); + } + + $privateKey = ''; + if (!openssl_pkey_export($res, $privateKey)) { + throw new \RuntimeException('DKIM: openssl_pkey_export() fehlgeschlagen: ' . (openssl_error_string() ?: 'unbekannt')); + } + + $details = openssl_pkey_get_details($res); + if ($details === false || empty($details['key'])) { + throw new \RuntimeException('DKIM: Public Key konnte nicht gelesen werden.'); + } + $publicKeyPem = $details['key']; + Log::debug('dkim.pem.first_line', ['line' => strtok($publicKeyPem, "\n")]); + Log::debug('dkim.pem.len', ['len' => strlen($publicKeyPem)]); + + // 3) Schreiben über Storage (legt Dateien an, keine zu langen Pfade in Komponenten) + if (!$disk->put($privRel, $privateKey)) { + throw new \RuntimeException("DKIM: Private-Key schreiben fehlgeschlagen: {$privRel}"); + } + if (!$disk->put($pubRel, $publicKeyPem)) { + throw new \RuntimeException("DKIM: Public-Key schreiben fehlgeschlagen: {$pubRel}"); + } + + // 4) DNS-Record bauen +// $p = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $publicKeyPem); +// $dnsTxt = "v=DKIM1; k=rsa; p={$p}"; + + $publicKeyBase64 = self::extractPublicKeyBase64($publicKeyPem); + Log::debug('dkim.p.len', ['len' => strlen($publicKeyBase64)]); + $dnsTxt = "v=DKIM1; k=rsa; p={$publicKeyBase64}"; + + // sanity: RSA2048 liegt typ. > 300 chars + if (strlen($publicKeyBase64) < 300) { + throw new \RuntimeException('DKIM: Public Key zu kurz – vermutlich Parsing-Fehler.'); + } + return [ + 'selector' => $selKey, + 'priv_path' => storage_path("app/{$privRel}"), + 'pub_path' => storage_path("app/{$pubRel}"), + 'public_pem' => $publicKeyPem, + 'private_pem' => $privateKey, + 'dns_name' => "{$selKey}._domainkey", // vor Domain hängen + 'dns_txt' => $dnsTxt, + 'bits' => $bits, + ]; + } + +// // Pfade +// $base = "dkim/{$domain->id}"; +// Storage::disk('local')->makeDirectory($base); +// +// $privPath = storage_path("app/{$base}/{$selector}.key"); +// $pubPath = storage_path("app/{$base}/{$selector}.pub"); +// +// // openssl genrsa / rsa-privkey +// $cmd = sprintf('openssl genrsa %d > %s && openssl rsa -in %s -pubout -out %s', +// $bits, escapeshellarg($privPath), escapeshellarg($privPath), escapeshellarg($pubPath) +// ); +// shell_exec($cmd); +// +// $pub = trim(file_get_contents($pubPath)); +// // Public Key extrahieren → DKIM TXT +// $pub = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $pub); +// +// $txt = "v=DKIM1; k=rsa; p={$pub}"; +// // Domain kann hier auch den Pfad/Selector speichern: +// $domain->update([ +// 'dkim_selector' => $selector, +// 'dkim_bits' => $bits, +// 'dkim_key_path' => $privPath, +// ]); +// +// return $txt; +// } + + protected function safeKey($value, int $max = 64): string + { + if (is_object($value)) { + if (isset($value->id)) $value = $value->id; + elseif (method_exists($value, 'getKey')) $value = $value->getKey(); + else $value = json_encode($value); + } + $raw = (string) $value; + $san = preg_replace('/[^A-Za-z0-9._-]/', '_', $raw); + if ($san === '' ) $san = 'unknown'; + if (strlen($san) > $max) { + $san = substr($san, 0, $max - 13) . '_' . substr(sha1($raw), 0, 12); + } + return $san; + } + + protected static function extractPublicKeyBase64(string $pem): string + { + // Hole den Body zwischen den Headern (multiline, dotall) + if (!preg_match('/^-+BEGIN PUBLIC KEY-+\r?\n(.+?)\r?\n-+END PUBLIC KEY-+\s*$/ms', $pem, $m)) { + throw new \RuntimeException('DKIM: Ungültiges Public-Key-PEM (Header/Footers nicht gefunden).'); + } + + // Whitespace entfernen → reines Base64 + $b64 = preg_replace('/\s+/', '', $m[1]); + + if ($b64 === '' || base64_decode($b64, true) === false) { + throw new \RuntimeException('DKIM: Public Key Base64 ist leer/ungültig.'); + } + + return $b64; + } +} diff --git a/app/Services/DnsRecordService.php b/app/Services/DnsRecordService.php index f1d3c79..5362097 100644 --- a/app/Services/DnsRecordService.php +++ b/app/Services/DnsRecordService.php @@ -2,68 +2,350 @@ namespace App\Services; +use App\Models\DkimKey; use App\Models\Domain; +use Illuminate\Support\Facades\Log; class DnsRecordService { - public function buildForDomain(Domain $domain): array + /** + * High-level: DKIM (optional), SPF & DMARC in DB anlegen/aktualisieren + * und empfohlene DNS-Records (required/optional) zurückgeben. + * + * $opts: + * - ipv4, ipv6 + * - spf_tail ("~all" | "-all") + * - spf_extra (Array zusätzlicher Mechanismen, z.B. ["ip4:1.2.3.4"]) + * - dmarc_policy ("none"|"quarantine"|"reject") + * - rua ("mailto:foo@bar.tld") + */ + public function provision(Domain $domain, ?string $dkimSelector = null, ?string $dkimTxt = null, array $opts = []): array { - // Quelle der Hostnamen: ENV (du hast BASE_DOMAIN, UI_SUB, WEBMAIL_SUB, MTA_SUB) - $baseDomain = env('BASE_DOMAIN', 'example.com'); - $uiHost = ($u = env('UI_SUB', 'ui')) ? "$u.$baseDomain" : $baseDomain; - $webmail = ($w = env('WEBMAIL_SUB','webmail')) ? "$w.$baseDomain" : $baseDomain; - $mtaHost = ($m = env('MTA_SUB','mx')) ? "$m.$baseDomain" : $baseDomain; + // --- Defaults aus ENV/Config --- + $opts = array_replace([ + 'ipv4' => env('SERVER_PUBLIC_IPV4'), + 'ipv6' => env('SERVER_PUBLIC_IPV6'), + 'spf_tail' => config('mailpool.spf_tail', '~all'), + 'spf_extra' => [], + 'dmarc_policy' => config('mailpool.dmarc_policy', 'none'), + 'rua' => "mailto:dmarc@{$domain->domain}", + ], $opts); - $records = []; - - // A/AAAA – nur als Anzeigehilfe (optional) - $records[] = [ - 'type' => 'A', - 'name' => $domain->domain, - 'value' => 'DEINE.SERVER.IP', // optional: aus Installer einsetzen - 'ttl' => 3600, - ]; - - // MX - $records[] = [ - 'type' => 'MX', - 'name' => $domain->domain, - 'value' => "10 $mtaHost.", - 'ttl' => 3600, - ]; - - // SPF - $records[] = [ - 'type' => 'TXT', - 'name' => $domain->domain, - 'value' => 'v=spf1 mx a -all', - 'ttl' => 3600, - ]; - - // DKIM (nimm den neuesten aktiven Key, falls vorhanden) - /** @var DkimKey|null $dkim */ - $dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first(); - if ($dkim) { - $records[] = [ - 'type' => 'TXT', - 'name' => "{$dkim->selector}._domainkey.{$domain->domain}", - 'value' => "v=DKIM1; k=rsa; p={$dkim->public_key_txt}", - 'ttl' => 3600, - ]; + // --- DKIM aus DB ziehen falls nicht übergeben --- + if (!$dkimSelector || !$dkimTxt) { + /** @var DkimKey|null $dk */ + $dk = $domain->dkimKeys()->where('is_active', true)->latest()->first(); + if ($dk) { + $dkimSelector = $dk->selector; + $dkimTxt = "v=DKIM1; k=rsa; p={$dk->public_key_txt}"; + } } - // DMARC (Default p=none – in UI änderbar) - $records[] = [ - 'type' => 'TXT', - 'name' => "_dmarc.{$domain->domain}", - 'value' => "v=DMARC1; p=none; rua=mailto:dmarc@{$domain->domain}; pct=100", - 'ttl' => 3600, - ]; + // --- SPF/DMARC in DB persistieren (oder aktualisieren) --- + $spfTxt = $this->buildSpfTxt($domain, $opts); + $dmarcTxt = $this->buildDmarcTxt($domain, $opts['dmarc_policy'], $opts['rua']); - // Optional: Webmail/UI CNAMEs - $records[] = ['type'=>'CNAME','name'=>"webmail.{$domain->domain}",'value'=>"$webmail.",'ttl'=>3600]; - $records[] = ['type'=>'CNAME','name'=>"ui.{$domain->domain}", 'value'=>"$uiHost.", 'ttl'=>3600]; + // spf_records + if (method_exists($domain, 'spf')) { + $domain->spf()->updateOrCreate( + ['is_active' => true], + ['record_txt' => $spfTxt] + ); + } + + // dmarc_records + if (method_exists($domain, 'dmarc')) { + $domain->dmarc()->updateOrCreate( + ['policy' => $opts['dmarc_policy']], + [ + 'rua' => $opts['rua'], + 'pct' => 100, + 'record_txt' => $dmarcTxt, + 'is_active' => true, + ] + ); + } + + // --- DNS-Empfehlungen berechnen --- + $records = $this->buildForDomain($domain, array_merge($opts, [ + 'dkim_selector' => $dkimSelector, + 'dkim_txt' => $dkimTxt, + ])); + + // Optional in eine eigene dnsRecords()-Relation persistieren + $this->persist($domain, $records); return $records; } + + /** SPF-String zusammenbauen (mx a [+extra] + tail) */ + public function buildSpfTxt(Domain $domain, array $opts = []): string + { + $tail = $opts['spf_tail'] ?? '~all'; + $extra = $opts['spf_extra'] ?? []; + $parts = ['v=spf1', 'mx', 'a']; + + // Falls Server-IP explizit – praktisch fürs On-Prem + if (!empty($opts['ipv4'])) $parts[] = 'ip4:' . $opts['ipv4']; + if (!empty($opts['ipv6'])) $parts[] = 'ip6:' . $opts['ipv6']; + + foreach ($extra as $m) { + $m = trim((string)$m); + if ($m !== '') $parts[] = $m; + } + + $parts[] = $tail; // "~all" oder "-all" + return implode(' ', $parts); + } + + /** DMARC-String bauen (p=policy; rua=mailto:..; pct=100) */ + public function buildDmarcTxt(Domain $domain, string $policy = 'none', string $rua = null): string + { + $rua = $rua ?: "mailto:dmarc@{$domain->domain}"; + return "v=DMARC1; p={$policy}; rua={$rua}; pct=100"; + } + + // ----------------------------------------------------------- + // Ab hier deine vorhandenen Helfer (leicht erweitert): + // ----------------------------------------------------------- + + public function buildForDomain(Domain $domain, array $opts = []): array + { + $baseDomain = env('BASE_DOMAIN', 'example.com'); + $uiSub = env('UI_SUB', 'ui'); + $webSub = env('WEBMAIL_SUB', 'webmail'); + $mxSub = env('MTA_SUB', 'mx'); + + $uiHost = $uiSub ? "{$uiSub}.{$baseDomain}" : $baseDomain; + $webmail = $webSub ? "{$webSub}.{$baseDomain}" : $baseDomain; + $mtaHost = $mxSub ? "{$mxSub}.{$baseDomain}" : $baseDomain; + + $ipv4 = $opts['ipv4'] ?? null; + $ipv6 = $opts['ipv6'] ?? null; + + $spfTxt = $opts['spf_txt'] ?? $this->buildSpfTxt($domain, $opts); + $dmarcTxt = $opts['dmarc_txt'] ?? $this->buildDmarcTxt($domain, $opts['dmarc_policy'] ?? 'none', $opts['rua'] ?? null); + + $dkimSelector = $opts['dkim_selector'] ?? null; + $dkimTxt = $opts['dkim_txt'] ?? null; + + $R = fn($type,$name,$value,$ttl=3600) => compact('type','name','value','ttl'); + + if ($dkimSelector && is_string($dkimTxt)) { + $dkimTxt = trim($dkimTxt); + + // Falls nur Base64 geliefert: auf DKIM-Format heben + if ($dkimTxt !== '' && !str_starts_with($dkimTxt, 'v=DKIM1')) { + if (!preg_match('/^[A-Za-z0-9+\/=]+$/', $dkimTxt)) { + Log::warning('DKIM TXT invalid chars', ['len'=>strlen($dkimTxt)]); + $dkimTxt = ''; // hart ablehnen statt kaputt speichern + } else { + $dkimTxt = "v=DKIM1; k=rsa; p={$dkimTxt}"; + } + } + + if ($dkimTxt !== '') { + $required[] = $R('TXT', "{$dkimSelector}._domainkey.{$domain->domain}", $dkimTxt); + } + } + + $required = [ + $R('MX', $domain->domain, "10 {$mtaHost}."), + $R('TXT', $domain->domain, $spfTxt), + $R('TXT', "_dmarc.{$domain->domain}", $dmarcTxt), + ]; + + if ($dkimSelector && $dkimTxt) { + $required[] = $R('TXT', "{$dkimSelector}._domainkey.{$domain->domain}", $dkimTxt); + } + + $optional = [ + $R('CAA', $domain->domain, '0 issue "letsencrypt.org"'), + $R('CNAME', "webmail.{$domain->domain}", "{$webmail}."), + $R('CNAME', "ui.{$domain->domain}", "{$uiHost}."), + $R('SRV', "_submission._tcp.{$domain->domain}", "0 1 587 {$mtaHost}."), + $R('SRV', "_imaps._tcp.{$domain->domain}", "0 1 993 {$mtaHost}."), + $R('SRV', "_pop3s._tcp.{$domain->domain}", "0 1 995 {$mtaHost}."), + $R('CNAME', "autoconfig.{$domain->domain}", "{$uiHost}."), + $R('CNAME', "autodiscover.{$domain->domain}", "{$uiHost}."), + ]; + + if (method_exists($domain, 'tlsaRecords')) { + $tlsa = $domain->tlsaRecords() + ->where('service', '_25._tcp') + ->latest() + ->first(); + + if ($tlsa) { + $optional[] = $R( + 'TLSA', + "{$tlsa->service}.{$tlsa->host}", + "{$tlsa->usage} {$tlsa->selector} {$tlsa->matching} {$tlsa->hash}" + ); + } + } + + if ($ipv4) $optional[] = $R('A', $domain->domain, $ipv4); + if ($ipv6) $optional[] = $R('AAAA', $domain->domain, $ipv6); + + return ['required'=>$required,'optional'=>$optional]; + } + + public function persist(Domain $domain, array $records): void + { + if (!method_exists($domain, 'dnsRecords')) return; + + $upsert = function(array $rec) use ($domain) { + $domain->dnsRecords()->updateOrCreate( + ['type'=>$rec['type'], 'name'=>$rec['name']], + ['value'=>$rec['value'], 'ttl'=>$rec['ttl'], 'is_managed'=>true] + ); + }; + + foreach ($records['required'] ?? [] as $r) $upsert($r); + foreach ($records['optional'] ?? [] as $r) $upsert($r); + } + +// public function buildForDomain(Domain $domain, array $opts = []): array +// { +// // ---- Aus ENV lesen (deine Installer-Variablen) ---- +// $baseDomain = env('BASE_DOMAIN', 'example.com'); +// $uiSub = env('UI_SUB', 'ui'); +// $webSub = env('WEBMAIL_SUB', 'webmail'); +// $mxSub = env('MTA_SUB', 'mx'); +// +// // Ziel-Hosts (wohin die Kundendomain zeigen soll) +// $uiHost = $uiSub ? "{$uiSub}.{$baseDomain}" : $baseDomain; +// $webmail = $webSub ? "{$webSub}.{$baseDomain}" : $baseDomain; +// $mtaHost = $mxSub ? "{$mxSub}.{$baseDomain}" : $baseDomain; +// +// // Public IPs (falls gesetzt; sonst leer -> nur Anzeige) +// $ipv4 = $opts['ipv4'] ?? env('SERVER_PUBLIC_IPV4'); // z.B. vom Installer in .env geschrieben +// $ipv6 = $opts['ipv6'] ?? env('SERVER_PUBLIC_IPV6'); +// +// // Policies +// $dmarcPolicy = $opts['dmarc_policy'] ?? 'none'; // none | quarantine | reject +// $spfTail = $opts['spf_tail'] ?? '~all'; // ~all | -all (streng) +// +// // DKIM (neuester aktiver Key) +// /** @var DkimKey|null $dkim */ +// $dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first(); +// $dkimSelector = $dkim?->selector ?: 'dkim'; +// $dkimTxt = $dkim ? "v=DKIM1; k=rsa; p={$dkim->public_key_txt}" : null; +// +// // Helper +// $R = fn(string $type, string $name, string $value, int $ttl = 3600) => [ +// 'type' => $type, 'name' => $name, 'value' => $value, 'ttl' => $ttl, +// ]; +// +// // ========== REQUIRED ========== +// $required = []; +// +// // MX (zeigt auf dein globales MX-Host) +// $required[] = $R('MX', $domain->domain, "10 {$mtaHost}."); +// +// // SPF: „mx“ + optional a (du hattest vorher -all; hier konfigurierbar) +// $spf = trim("v=spf1 mx a {$spfTail}"); +// $required[] = $R('TXT', $domain->domain, $spf); +// +// // DKIM (nur wenn Key vorhanden) +// if ($dkimTxt) { +// $required[] = $R('TXT', "{$dkimSelector}._domainkey.{$domain->domain}", $dkimTxt); +// } +// +// // DMARC (Default p=$dmarcPolicy + RUA) +// $required[] = $R('TXT', "_dmarc.{$domain->domain}", "v=DMARC1; p={$dmarcPolicy}; rua=mailto:dmarc@{$domain->domain}; pct=100"); +// +// // ========== OPTIONAL (empfohlen) ========== +// $optional = []; +// +// // A/AAAA für Root – NUR falls du Root direkt terminierst (sonst weglassen) +// if ($ipv4) $optional[] = $R('A', $domain->domain, $ipv4); +// if ($ipv6) $optional[] = $R('AAAA', $domain->domain, $ipv6); +// +// // CAA für ACME/Let’s Encrypt +// // 0 issue "letsencrypt.org" | optional: 0 iodef "mailto:admin@domain" +// $optional[] = $R('CAA', $domain->domain, '0 issue "letsencrypt.org"'); +// +// // CNAMEs für UI/Webmail in der Kundenzone -> zeigen auf deine globalen Hosts +// $optional[] = $R('CNAME', "webmail.{$domain->domain}", "{$webmail}."); +// $optional[] = $R('CNAME', "ui.{$domain->domain}", "{$uiHost}."); +// +// // SRV (nutzerfreundliche Autokonfigs) +// // _submission._tcp STARTTLS Port 587 +// $optional[] = $R('SRV', "_submission._tcp.{$domain->domain}", "0 1 587 {$mtaHost}."); +// // _imaps._tcp / _pop3s._tcp (falls aktiv) +// $optional[] = $R('SRV', "_imaps._tcp.{$domain->domain}", "0 1 993 {$mtaHost}."); +// $optional[] = $R('SRV', "_pop3s._tcp.{$domain->domain}", "0 1 995 {$mtaHost}."); +// +// // Autoconfig / Autodiscover (wenn du sie anbieten willst) +// // CNAMEs auf deine UI/Webmail (oder A/AAAA, wenn du echte Subdomains je Kunde willst) +// $optional[] = $R('CNAME', "autoconfig.{$domain->domain}", "{$uiHost}."); +// $optional[] = $R('CNAME', "autodiscover.{$domain->domain}", "{$uiHost}."); +// +// return [ +// 'required' => $required, +// 'optional' => $optional, +// 'meta' => [ +// 'mx_target' => $mtaHost, +// 'ui_target' => $uiHost, +// 'webmail_target'=> $webmail, +// 'dkim_selector' => $dkimSelector, +// 'has_dkim' => (bool) $dkimTxt, +// 'tips' => [ +// 'rDNS' => 'Reverse DNS der Server-IP sollte auf den MX-Host zeigen.', +// ], +// ], +// ]; +// } +// +// /** +// * Optional: Empfohlene/benötigte Records in deiner DB speichern. +// * Nutzt $domain->dnsRecords() falls vorhanden. Andernfalls einfach nicht verwenden. +// */ +// public function persist(Domain $domain, array $records): void +// { +// if (!method_exists($domain, 'dnsRecords')) { +// return; +// } +// +// $upsert = function(array $rec) use ($domain) { +// $domain->dnsRecords()->updateOrCreate( +// ['type' => $rec['type'], 'name' => $rec['name']], +// ['value' => $rec['value'], 'ttl' => $rec['ttl'], 'is_managed' => true] +// ); +// }; +// +// foreach (($records['required'] ?? []) as $r) $upsert($r); +// foreach (($records['optional'] ?? []) as $r) $upsert($r); +// } +// +// /** +// * Erzeugt empfohlene DNS-Records für eine neue Domain +// * und speichert sie (falls Domain->dnsRecords() existiert). +// */ +// public function createRecommendedRecords( +// Domain $domain, +// ?string $dkimSelector = null, +// ?string $dkimTxt = null, +// array $opts = [] +// ): array { +// // Fallback, falls kein DKIM-Schlüssel übergeben wurde +// if (!$dkimSelector || !$dkimTxt) { +// $dkim = $domain->dkimKeys()->where('is_active', true)->latest()->first(); +// $dkimSelector ??= $dkim?->selector ?? 'dkim'; +// $dkimTxt ??= $dkim ? "v=DKIM1; k=rsa; p={$dkim->public_key_txt}" : null; +// } +// +// // DNS-Empfehlungen generieren +// $records = $this->buildForDomain($domain, array_merge($opts, [ +// 'dkim_selector' => $dkimSelector, +// 'dkim_txt' => $dkimTxt, +// ])); +// +// // Falls möglich -> in Datenbank persistieren +// $this->persist($domain, $records); +// +// return $records; +// } } diff --git a/app/Services/MailStorage.php b/app/Services/MailStorage.php new file mode 100644 index 0000000..a5c6e9d --- /dev/null +++ b/app/Services/MailStorage.php @@ -0,0 +1,81 @@ +sum('total_quota_mb'); + + // Kapazität, die du *maximal* noch als Quotas verteilen darfst + $capacityLeftMb = max(0, $totalMb - $systemReserveTotalMb - $committedMb); + + // Für Eingabefelder konservativ das Minimum verwenden + $remainingPoolMb = min($freeAfterReserveMb, $capacityLeftMb); + + return [ + 'path' => $path, + 'total_mb' => $totalMb, + 'free_mb' => $freeMb, + + 'fixed_reserve_mb' => $fixedReserveMb, + 'percent_reserve' => $percentReserve, + + 'percent_reserve_on_free_mb' => $percentReserveOnFreeMb, + 'free_after_reserve_mb' => $freeAfterReserveMb, // ≈ 70 GB bei deinen Werten + + 'percent_reserve_on_total_mb' => $percentReserveOnTotalMb, + 'system_reserve_total_mb' => $systemReserveTotalMb, + + 'committed_mb' => $committedMb, + 'capacity_left_mb' => $capacityLeftMb, // Kapazitätsbremse + + 'remaining_pool_mb' => $remainingPoolMb, // MIN(free-basiert, total-basiert) + ]; + } + + /** Für UI: konservativer, gecachter Rest-Pool (in MiB) */ + public function remainingPoolMb(): int + { + return Cache::remember('mailpool.remaining', 10, fn () => $this->stats()['remaining_pool_mb']); + } + + /** Vorschlag für max. Eingabewert */ + public function suggestMaxAllocMb(): int + { + return $this->remainingPoolMb(); + } + + /** Prüfen, ob X MiB sauber in den Pool passen */ + public function canAllocate(int $mb): bool + { + return $mb >= 0 && $mb <= $this->remainingPoolMb(); + } +} diff --git a/app/Services/TlsaService.php b/app/Services/TlsaService.php new file mode 100644 index 0000000..9b414c0 --- /dev/null +++ b/app/Services/TlsaService.php @@ -0,0 +1,47 @@ +. (z.B. mx.example.com) + */ + public function createForDomain(Domain $domain): void + { + $mtaHost = (env('MTA_SUB', 'mx') ?: 'mx') . '.' . env('BASE_DOMAIN', 'example.com'); + $service = '_25._tcp'; + $certPath = "/etc/letsencrypt/live/{$mtaHost}/fullchain.pem"; + + if (!is_file($certPath)) { + Log::warning("TLSA skipped: Zertifikat fehlt: {$certPath}"); + return; + } + + // SHA256 über SubjectPublicKeyInfo + $cmd = "openssl x509 -in ".escapeshellarg($certPath)." -noout -pubkey" + . " | openssl pkey -pubin -outform DER" + . " | openssl dgst -sha256 | awk '{print \$2}'"; + $hash = trim((string)@shell_exec($cmd)); + + if ($hash === '') { + Log::error("TLSA failed: Hash konnte nicht berechnet werden ({$mtaHost})"); + return; + } + + TlsaRecord::updateOrCreate( + ['domain_id' => $domain->id, 'service' => $service, 'host' => $mtaHost], + [ + 'usage' => 3, // DANE-EE + 'selector' => 1, // SPKI + 'matching' => 1, // SHA-256 + 'hash' => $hash, + 'cert_path'=> $certPath, + ] + ); + } +} diff --git a/app/Support/NetProbe.php b/app/Support/NetProbe.php new file mode 100644 index 0000000..f956c41 --- /dev/null +++ b/app/Support/NetProbe.php @@ -0,0 +1,85 @@ + self::getIPv4($force), + 'ipv6' => self::getIPv6($force), + ]; + } + + public static function getIPv4(bool $force = false): ?string + { + // 1) ENV zuerst + $env = trim((string) env('SERVER_PUBLIC_IPV4', '')); + if ($env !== '' && filter_var($env, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return $env; + } + + // 2) Cache + if (!$force) { + $c = Cache::get('netprobe:ipv4'); + if ($c) return $c === 'none' ? null : $c; + } + + // 3) Probe (einmalig) + $ip = self::probeIPv4(); + Cache::put('netprobe:ipv4', $ip ?: 'none', now()->addDay()); + + return $ip; + } + + public static function getIPv6(bool $force = false): ?string + { + // 1) ENV zuerst + $env = trim((string) env('SERVER_PUBLIC_IPV6', '')); + if ($env !== '' && filter_var($env, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return $env; + } + + // 2) Cache mit Sentinel „none“ (verhindert Endlossuche) + if (!$force) { + $c = Cache::get('netprobe:ipv6'); + if ($c) return $c === 'none' ? null : $c; + } + + // 3) Probe (einmalig) + $ip = self::probeIPv6(); + Cache::put('netprobe:ipv6', $ip ?: 'none', now()->addDay()); + + return $ip; + } + + // — intern — + + protected static function probeIPv4(): ?string + { + $out = @shell_exec("ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if(\$i==\"src\"){print \$(i+1); exit}}'"); + $ip = trim((string) $out); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return $ip; + + $ip = trim($_SERVER['SERVER_ADDR'] ?? ''); + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? $ip : null; + } + + protected static function probeIPv6(): ?string + { + // Variante 1: „Standard“-Weg über Routing + $out = @shell_exec("ip -6 route get 2001:4860:4860::8888 2>/dev/null | awk '{for(i=1;i<=NF;i++) if(\$i==\"src\"){print \$(i+1); exit}}'"); + $ip = trim((string) $out); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) return $ip; + + // Variante 2: irgendeine globale v6 vom Interface nehmen (keine ::1, keine link-local fe80:) + $out = @shell_exec("ip -6 addr show scope global 2>/dev/null | awk '/inet6/ && $2 !~ /::1/ && $2 !~ /^fe80:/ {print $2; exit}'"); + $ip = trim((string) $out); + if ($ip !== '') $ip = strtok($ip, '/'); // Prefix entfernen + return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? $ip : null; + } + +} diff --git a/app/Support/SettingsRepository.php b/app/Support/SettingsRepository.php index d2f8a03..f27f8d6 100644 --- a/app/Support/SettingsRepository.php +++ b/app/Support/SettingsRepository.php @@ -7,52 +7,29 @@ use Illuminate\Support\Facades\Cache; class SettingsRepository { - const CACHE_KEY = 'settings.all'; - - protected function store() + public function get(string $name, $default = null) { - // zieht z.B. 'redis' aus .env: CACHE_SETTINGS_STORE=redis - $store = env('CACHE_SETTINGS_STORE', 'redis'); - return Cache::store($store); + return Setting::get($name, $default); } - protected function loadAll(): array + public function set(string $name, $value): void { - try { - /** @var array $settings */ - $settings = $this->store()->rememberForever(self::CACHE_KEY, function (): array { - return Setting::query() - ->pluck('value', 'key') - ->toArray(); - }); - - return $settings; - } catch (\Throwable $e) { - return Setting::query()->pluck('value', 'key')->toArray(); - } + Setting::set($name, $value); } - public function get(string $key, $default = null) + public function forget(string $name): void { - $all = $this->loadAll(); - return array_key_exists($key, $all) ? $all[$key] : $default; + Setting::forget($name); } - public function set(string $key, $value): void + public function all(): array { - Setting::query()->updateOrCreate(['key' => $key], ['value' => $value]); - - // Cache refreshen - try { - $all = Setting::query()->pluck('value', 'key')->toArray(); - $this->store()->forever(self::CACHE_KEY, $all); - } catch (\Throwable $e) { - // Cache kaputt? Ignorieren, DB ist aktualisiert. - } - } - - public function forgetCache(): void - { - try { $this->store()->forget(self::CACHE_KEY); } catch (\Throwable $e) {} + return Setting::query() + ->select('group', 'key', 'value') + ->get() + ->mapWithKeys(function ($row) { + return ["{$row->group}.{$row->key}" => $row->value]; + }) + ->toArray(); } } diff --git a/config/app.php b/config/app.php index d081dcb..2df5cd0 100644 --- a/config/app.php +++ b/config/app.php @@ -65,7 +65,7 @@ return [ | */ - 'timezone' => 'UTC', + 'timezone' => env('APP_TIMEZONE', 'UTC'), /* |-------------------------------------------------------------------------- @@ -78,7 +78,7 @@ return [ | */ - 'locale' => env('APP_LOCALE', 'en'), + 'locale' => env('APP_LOCALE', 'de'), 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), @@ -124,5 +124,4 @@ return [ ], 'version' => env('APP_VERSION', '1.0.0') - ]; diff --git a/config/mailpool.php b/config/mailpool.php new file mode 100644 index 0000000..4bfbb6b --- /dev/null +++ b/config/mailpool.php @@ -0,0 +1,26 @@ + env('BASE_DOMAIN', 'example.com'), + 'platform_system_zone' => env('MAILPOOL_PLATFORM_SYSTEM_ZONE', 'sysmail'), + + 'fixed_reserve_mb' => env('MAILPOOL_FIXED_RESERVE_MB', 2048), // 2 GB + 'percent_reserve' => env('MAILPOOL_PERCENT_RESERVE', 15), // 15 % + 'mail_data_path' => env('MAILPOOL_PATH', '/var/mail'), + + 'spf_tail' => env('MAILPOOL_SPF_TAIL', '~all'), + 'spf_extra' => array_filter(explode(',', env('MAILPOOL_SPF_EXTRA', ''))), + 'dmarc_policy' => env('MAILPOOL_DMARC_POLICY', 'none'), + + 'defaults' => [ + 'max_aliases' => (int) env('MAILPOOL_DEFAULT_MAX_ALIASES', 400), + 'max_mailboxes' => (int) env('MAILPOOL_DEFAULT_MAX_MAILBOXES', 10), + + 'default_quota_mb' => (int) env('MAILPOOL_DEFAULT_MAILBOX_QUOTA_MB', 3072), + 'max_quota_per_mailbox_mb' => env('MAILPOOL_DEFAULT_MAX_MAILBOX_QUOTA_MB', null), + 'total_quota_mb' => (int) env('MAILPOOL_DEFAULT_DOMAIN_TOTAL_MB', 10240), + + 'rate_limit_per_hour' => env('MAILPOOL_DEFAULT_RATE_LIMIT_PER_HOUR', null), + 'rate_limit_override' => (bool) env('MAILPOOL_DEFAULT_RATE_OVERRIDE', false), + ], +]; diff --git a/config/nginx/site.conf.tmpl b/config/nginx/site.conf.tmpl new file mode 100644 index 0000000..c9e6bbd --- /dev/null +++ b/config/nginx/site.conf.tmpl @@ -0,0 +1,64 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + + # ACME + location ^~ /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + allow all; + } + + # Wenn SSL da: redirect auf 443, sonst direkt App + {% if ssl %} + return 301 https://$host$request_uri; + {% endif %} +} + +server { + listen 443 ssl${NGINX_HTTP2_SUFFIX}; + listen [::]:443 ssl${NGINX_HTTP2_SUFFIX}; + + ssl_certificate ${UI_CERT}; + ssl_certificate_key ${UI_KEY}; + ssl_protocols TLSv1.2 TLSv1.3; + + server_name _; + + root ${APP_DIR}/public; + index index.php index.html; + + access_log /var/log/nginx/app_ssl_access.log; + error_log /var/log/nginx/app_ssl_error.log; + + client_max_body_size 25m; + + location / { try_files $uri $uri/ /index.php?$query_string; } + location ~ \.php$ { + include snippets/fastcgi-php.conf; + # Der pass (unix vs tcp) wird vom System gesetzt; Debian snippet kümmert sich + fastcgi_pass unix:/run/php/php-fpm.sock; + try_files $uri =404; + } + location ^~ /livewire/ { try_files $uri /index.php?$query_string; } + location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)$ { expires 30d; access_log off; } + + # WebSocket: Laravel Reverb + location /ws/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + proxy_pass http://127.0.0.1:8080/; + } + + # Reverb HTTP API + location /apps/ { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + proxy_pass http://127.0.0.1:8080/apps/; + } +} diff --git a/config/system.php b/config/system.php new file mode 100644 index 0000000..c1810e7 --- /dev/null +++ b/config/system.php @@ -0,0 +1,191 @@ + [ + ['value' => 'de', 'label' => 'Deutsch'], + ['value' => 'en', 'label' => 'English'], + ], + // keep this short list or pull a full IANA list if you want + 'timezones' => [ + // 🌍 UTC / Universal + 'UTC', + + // 🇪🇺 Europa + 'Europe/Amsterdam', + 'Europe/Andorra', + 'Europe/Athens', + 'Europe/Belgrade', + 'Europe/Berlin', + 'Europe/Bratislava', + 'Europe/Brussels', + 'Europe/Bucharest', + 'Europe/Budapest', + 'Europe/Copenhagen', + 'Europe/Dublin', + 'Europe/Gibraltar', + 'Europe/Helsinki', + 'Europe/Istanbul', + 'Europe/Kiev', + 'Europe/Lisbon', + 'Europe/Ljubljana', + 'Europe/London', + 'Europe/Luxembourg', + 'Europe/Madrid', + 'Europe/Malta', + 'Europe/Monaco', + 'Europe/Moscow', + 'Europe/Oslo', + 'Europe/Paris', + 'Europe/Prague', + 'Europe/Riga', + 'Europe/Rome', + 'Europe/San_Marino', + 'Europe/Sarajevo', + 'Europe/Skopje', + 'Europe/Sofia', + 'Europe/Stockholm', + 'Europe/Tallinn', + 'Europe/Tirane', + 'Europe/Vaduz', + 'Europe/Vatican', + 'Europe/Vienna', + 'Europe/Vilnius', + 'Europe/Warsaw', + 'Europe/Zurich', + + // 🇺🇸 Nordamerika + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Phoenix', + 'America/Anchorage', + 'America/Honolulu', + 'America/Toronto', + 'America/Vancouver', + 'America/Mexico_City', + 'America/Bogota', + 'America/Lima', + 'America/Caracas', + + // 🌎 Südamerika + 'America/Argentina/Buenos_Aires', + 'America/Sao_Paulo', + 'America/Montevideo', + 'America/Asuncion', + 'America/La_Paz', + 'America/Lima', + 'America/Bogota', + 'America/Santiago', + + // 🌍 Afrika + 'Africa/Abidjan', + 'Africa/Accra', + 'Africa/Addis_Ababa', + 'Africa/Algiers', + 'Africa/Cairo', + 'Africa/Casablanca', + 'Africa/Dakar', + 'Africa/Dar_es_Salaam', + 'Africa/Johannesburg', + 'Africa/Khartoum', + 'Africa/Lagos', + 'Africa/Nairobi', + 'Africa/Tunis', + 'Africa/Windhoek', + + // 🇷🇺 Russland / Asien + 'Asia/Almaty', + 'Asia/Amman', + 'Asia/Baghdad', + 'Asia/Baku', + 'Asia/Bangkok', + 'Asia/Beirut', + 'Asia/Colombo', + 'Asia/Damascus', + 'Asia/Dhaka', + 'Asia/Dubai', + 'Asia/Hong_Kong', + 'Asia/Jakarta', + 'Asia/Jerusalem', + 'Asia/Kabul', + 'Asia/Karachi', + 'Asia/Kathmandu', + 'Asia/Kolkata', + 'Asia/Kuala_Lumpur', + 'Asia/Kuwait', + 'Asia/Macau', + 'Asia/Manila', + 'Asia/Muscat', + 'Asia/Nicosia', + 'Asia/Novosibirsk', + 'Asia/Qatar', + 'Asia/Riyadh', + 'Asia/Seoul', + 'Asia/Shanghai', + 'Asia/Singapore', + 'Asia/Taipei', + 'Asia/Tashkent', + 'Asia/Tehran', + 'Asia/Tokyo', + 'Asia/Ulaanbaatar', + 'Asia/Vientiane', + 'Asia/Yangon', + + // 🇦🇺 Australien & Ozeanien + 'Australia/Adelaide', + 'Australia/Brisbane', + 'Australia/Darwin', + 'Australia/Hobart', + 'Australia/Melbourne', + 'Australia/Perth', + 'Australia/Sydney', + 'Pacific/Auckland', + 'Pacific/Fiji', + 'Pacific/Guam', + 'Pacific/Honolulu', + 'Pacific/Noumea', + 'Pacific/Port_Moresby', + 'Pacific/Samoa', + 'Pacific/Tahiti', + + // 🇨🇳 Zentral-/Ostasien + 'Asia/Chongqing', + 'Asia/Harbin', + 'Asia/Makassar', + 'Asia/Urumqi', + + // 🌍 Mittlerer Osten + 'Asia/Bahrain', + 'Asia/Doha', + 'Asia/Dubai', + 'Asia/Kuwait', + 'Asia/Muscat', + 'Asia/Qatar', + 'Asia/Riyadh', + + // ❄️ Arktis / Antarktis + 'Antarctica/Casey', + 'Antarctica/Davis', + 'Antarctica/Mawson', + 'Antarctica/Palmer', + 'Antarctica/Rothera', + 'Antarctica/Syowa', + 'Antarctica/Troll', + 'Antarctica/Vostok', + + // 🔁 Sonstige + 'Atlantic/Azores', + 'Atlantic/Bermuda', + 'Atlantic/Canary', + 'Atlantic/Cape_Verde', + 'Atlantic/Faroe', + 'Atlantic/Madeira', + 'Atlantic/Reykjavik', + 'Atlantic/South_Georgia', + 'Indian/Maldives', + 'Indian/Mauritius', + 'Indian/Reunion', + ], + +]; diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 922788e..ecfa1b3 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -24,6 +24,8 @@ return new class extends Migration $table->boolean('two_factor_email_enabled')->default(false); $table->string('totp_secret')->nullable(); $table->string('role', 32)->default('admin')->index(); + $table->string('locale', 10)->nullable()->index(); + $table->string('timezone', 64)->nullable(); $table->rememberToken(); $table->timestamps(); diff --git a/database/migrations/2025_09_27_153255_create_domains_table.php b/database/migrations/2025_09_27_153255_create_domains_table.php index cc56f02..658a3dc 100644 --- a/database/migrations/2025_09_27_153255_create_domains_table.php +++ b/database/migrations/2025_09_27_153255_create_domains_table.php @@ -14,8 +14,17 @@ return new class extends Migration Schema::create('domains', function (Blueprint $table) { $table->id(); $table->string('domain', 191)->unique(); + $table->string('description', 500)->nullable(); + $table->string('tags', 500)->nullable(); $table->boolean('is_active')->default(true)->index(); $table->boolean('is_system')->default(false); + $table->unsignedInteger('max_aliases')->default(400); + $table->unsignedInteger('max_mailboxes')->default(10); + $table->unsignedInteger('default_quota_mb')->default(3072); + $table->unsignedInteger('max_quota_per_mailbox_mb')->default(10240); + $table->unsignedBigInteger('total_quota_mb')->default(0); + $table->unsignedInteger('rate_limit_per_hour')->nullable(); + $table->boolean('rate_limit_override')->default(false); $table->timestamps(); }); } diff --git a/database/migrations/2025_09_27_153311_create_mail_users_table.php b/database/migrations/2025_09_27_153311_create_mail_users_table.php index 3cdee5e..e2d75a0 100644 --- a/database/migrations/2025_09_27_153311_create_mail_users_table.php +++ b/database/migrations/2025_09_27_153311_create_mail_users_table.php @@ -19,6 +19,7 @@ return new class extends Migration $table->string('localpart', 191); $table->string('email', 191)->unique('mail_users_email_unique'); // genau EIN Unique-Index + $table->string('display_name', 191)->nullable(); $table->string('password_hash')->nullable(); // system-Accounts dürfen null sein $table->boolean('is_system')->default(false)->index(); // oft nach Systemkonten filtern @@ -26,6 +27,7 @@ return new class extends Migration $table->boolean('must_change_pw')->default(true)->index(); $table->unsignedInteger('quota_mb')->default(0); // 0 = unlimited + $table->unsignedInteger('rate_limit_per_hour')->nullable(); $table->timestamp('last_login_at')->nullable(); $table->timestamps(); diff --git a/database/migrations/2025_09_27_153328_create_mail_aliases_table.php b/database/migrations/2025_09_27_153328_create_mail_aliases_table.php index 1b10292..ca4da03 100644 --- a/database/migrations/2025_09_27_153328_create_mail_aliases_table.php +++ b/database/migrations/2025_09_27_153328_create_mail_aliases_table.php @@ -14,12 +14,14 @@ return new class extends Migration Schema::create('mail_aliases', function (Blueprint $table) { $table->id(); $table->foreignId('domain_id')->constrained('domains')->cascadeOnDelete(); - $table->string('source', 191)->index(); // z.B. sales@example.com - $table->string('destination'); // kommasepariert: "u1@ex.com,u2@ex.com" + $table->string('local', 191); // z.B. "info" + $table->enum('type', ['single','group'])->default('single'); + $table->string('group_name', 80)->nullable(); $table->boolean('is_active')->default(true)->index(); + $table->text('notes')->nullable(); $table->timestamps(); - $table->unique(['domain_id','source']); + $table->unique(['domain_id', 'local']); }); } diff --git a/database/migrations/2025_09_27_170356_create_settings_table.php b/database/migrations/2025_09_27_170356_create_settings_table.php index ce3b578..f5e90fc 100644 --- a/database/migrations/2025_09_27_170356_create_settings_table.php +++ b/database/migrations/2025_09_27_170356_create_settings_table.php @@ -13,9 +13,12 @@ return new class extends Migration { Schema::create('settings', function (Blueprint $table) { $table->id(); - $table->string('key')->unique(); + $table->string('group')->default('system')->index(); + $table->string('key'); $table->text('value')->nullable(); $table->timestamps(); + + $table->unique(['group', 'key']); }); } diff --git a/database/migrations/2025_10_06_185027_create_tlsa_records_table.php b/database/migrations/2025_10_06_185027_create_tlsa_records_table.php index 80d10e0..6a34ee3 100644 --- a/database/migrations/2025_10_06_185027_create_tlsa_records_table.php +++ b/database/migrations/2025_10_06_185027_create_tlsa_records_table.php @@ -12,7 +12,6 @@ return new class extends Migration public function up(): void { Schema::create('tlsa_records', function (Blueprint $table) { - $table->id(); $table->id(); $table->foreignId('domain_id')->constrained()->cascadeOnDelete(); $table->string('service')->default('_25._tcp'); diff --git a/database/migrations/2025_10_13_103122_create_mail_alias_recipients_table.php b/database/migrations/2025_10_13_103122_create_mail_alias_recipients_table.php new file mode 100644 index 0000000..ff96ca1 --- /dev/null +++ b/database/migrations/2025_10_13_103122_create_mail_alias_recipients_table.php @@ -0,0 +1,44 @@ +id(); + + $table->foreignId('alias_id') + ->constrained('mail_aliases') + ->cascadeOnDelete(); + + // interner Empfänger (MailUser) ODER externer Empfänger (E-Mail) + $table->foreignId('mail_user_id') + ->nullable() + ->constrained('mail_users') // <-- richtige Tabelle! + ->nullOnDelete(); + + $table->string('email', 320)->nullable(); // externer Empfänger + $table->unsignedSmallInteger('position')->default(0); + $table->timestamps(); + + // Duplikate vermeiden: + $table->unique(['alias_id','mail_user_id']); + $table->unique(['alias_id','email']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('mail_alias_recipients'); + } +}; diff --git a/database/seeders/SystemDomainSeeder.php b/database/seeders/SystemDomainSeeder.php index 8599b6f..fbf7134 100644 --- a/database/seeders/SystemDomainSeeder.php +++ b/database/seeders/SystemDomainSeeder.php @@ -7,6 +7,7 @@ use App\Models\DmarcRecord; use App\Models\Domain; use App\Models\MailUser; use App\Models\SpfRecord; +use App\Services\DnsRecordService; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -14,13 +15,13 @@ class SystemDomainSeeder extends Seeder { public function run(): void { - $base = config('app.base_domain', env('BASE_DOMAIN', 'example.com')); + $base = config('mailpool.platform_zone', 'example.com'); if (!$base || $base === 'example.com') { $this->command->warn("BASE_DOMAIN ist 'example.com' – Seeder überspringt produktive Einträge."); return; } - $systemSub = env('SYSTEM_SUB', 'system'); + $systemSub = config('mailpool.platform_system_zone'); $base = "{$systemSub}.{$base}"; // Domain anlegen/holen @@ -33,13 +34,13 @@ class SystemDomainSeeder extends Seeder MailUser::firstOrCreate( ['email' => "no-reply@{$base}"], [ - 'domain_id' => $domain->id, - 'localpart' => 'no-reply', - 'password_hash' => null, - 'is_active' => true, - 'is_system' => true, - 'must_change_pw' => false, - 'quota_mb' => 0, + 'domain_id' => $domain->id, + 'localpart' => 'no-reply', + 'password_hash' => null, + 'is_active' => true, + 'is_system' => true, + 'must_change_pw' => false, + 'quota_mb' => 0, ] ); @@ -59,27 +60,21 @@ class SystemDomainSeeder extends Seeder $this->command->info("DKIM angelegt: Host = {$selector}._domainkey.{$base}"); } - // SPF – einfachen Default bauen - $serverIp = env('SERVER_IP'); // optional vom Installer rein schreiben - $parts = ['v=spf1','mx','a']; - if ($serverIp) $parts[] = "ip4:{$serverIp}"; - $parts[] = '-all'; - $spf = implode(' ', $parts); + $dk = $domain->dkimKeys()->where('is_active', true)->latest()->first(); + $dkimTxt = $dk ? "v=DKIM1; k=rsa; p={$dk->public_key_txt}" : null; - SpfRecord::firstOrCreate( - ['domain_id' => $domain->id, 'record_txt' => $spf], - ['is_active' => true] + app(DnsRecordService::class)->provision( + $domain, + dkimSelector: $dk?->selector, + dkimTxt: $dkimTxt, + opts: [ + 'dmarc_policy' => 'none', + 'spf_tail' => '-all', + // optional: 'ipv4' => $serverIp, 'ipv6' => ... + ] ); - // DMARC – vorsichtig starten (p=none) - $rua = "mailto:dmarc@{$base}"; - $dmarc = DmarcRecord::firstOrCreate( - ['domain_id' => $domain->id, 'policy' => 'none'], - ['rua' => $rua, 'pct' => 100, 'record_txt' => "v=DMARC1; p=none; rua={$rua}; pct=100", 'is_active' => true] - ); - - $this->command->info("System-Domain '{$base}' fertig. SPF/DMARC/DKIM eingetragen."); - $this->command->line("DNS-Hinweise:"); + $this->command->info("System-Domain '{$base}' fertig. SPF/DMARC/DKIM & DNS-Empfehlungen eingetragen."); $this->printDnsHints($domain); } diff --git a/package-lock.json b/package-lock.json index 4ffafc9..e817b20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@phosphor-icons/web": "^2.1.2", + "@tailwindplus/elements": "^1.0.17", "jquery": "^3.7.1" }, "devDependencies": { @@ -1123,6 +1124,12 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tailwindplus/elements": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@tailwindplus/elements/-/elements-1.0.17.tgz", + "integrity": "sha512-t44plxXLWqD2Cl3IOcoVzBrmtFl6jkG2QmtrTiJHRh4tRCQwbipn93yvAqhso1+gm6y6zaiKyb3CCQ94K3qy3g==", + "license": "SEE LICENSE IN LICENSE.md" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", diff --git a/package.json b/package.json index 133f876..b716703 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@phosphor-icons/web": "^2.1.2", + "@tailwindplus/elements": "^1.0.17", "jquery": "^3.7.1" } } diff --git a/resources/css/app.css b/resources/css/app.css index c0f06c9..ddb3470 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -32,6 +32,18 @@ } +.safe-pads { + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); +} +/* Falls nötig: dvh-Fallback */ +@supports (height: 100dvh) { + .min-h-dvh { min-height: 100dvh; } + .h-dvh { height: 100dvh; } +} + @utility w-sb-col { width: var(--sidebar-collapsed); } @@ -89,6 +101,7 @@ html[data-ui="booting"] * { } /* Labels, Carets, Submenüs in der Sidebar während booting NICHT zeigen */ +html[data-ui="booting"] #main, html[data-ui="booting"] #sidebar .sidebar-label, html[data-ui="booting"] #sidebar .sidebar-caret, html[data-ui="booting"] #sidebar [data-submenu] { @@ -164,17 +177,61 @@ html[data-ui="ready"] #sidebar { display: none !important; } -/* === App Backdrop (türkis/glass) === */ -.app-backdrop { +/*html { background-color: #132835; }*/ +html { background-color: #0a0f18; } +body { background: transparent; } + +/* EIN Hintergrund für alles – fix hinter dem Inhalt */ +.app-backdrop:before{ + content: ''; position: fixed; inset: 0; - z-index: -10; + z-index: 0; /* unter dem Inhalt, aber über html */ pointer-events: none; - background: radial-gradient(1600px 900px at 85% -15%, rgba(34, 211, 238, .28), transparent 62%), - radial-gradient(1200px 800px at 10% 112%, rgba(16, 185, 129, .18), transparent 70%), - linear-gradient(180deg, #0a0f16, #0f172a 52%, #0b1220 78%, #0a0f18); + + /* iOS stabil: deckt große/kleine Viewports ab */ + height: 100lvh; /* large viewport height */ + min-height: 100svh; /* safe viewport height Fallback */ + + /* Dein gesamter Verlauf hier – durchgehend! */ + background-image: + radial-gradient(120vmax 70vmax at 80% 40%, rgba(34,211,238,.28), transparent 45%), + radial-gradient(110vmax 70vmax at 20% 60%, rgba(34,211,238,.28), transparent 45%), + /*radial-gradient(110vmax 80vmax at 20% 62%, rgba(16,185,129,.18), transparent 45%),*/ + /*linear-gradient(180deg, rgba(8,47,73,.18), rgba(2,6,23,.28)), !* Farbbelag *!*/ + linear-gradient(180deg, #0a0f16, #0f172a 52%, #0b1220 78%, #0a0f18); + + /*radial-gradient(120vmax 70vmax at 80% -10%, rgba(34,211,238,.28), transparent 62%),*/ + /*radial-gradient(110vmax 80vmax at 10% 112%, rgba(16,185,129,.18), transparent 70%),*/ + /*linear-gradient(180deg, #0a0f16, #0f172a 52%, #0b1220 78%, #0a0f18);*/ +/*radial-gradient(1600px 900px at 85% -15%, rgba(34,211,238,.28), transparent 62%),*/ +/* radial-gradient(1200px 800px at 10% 112%, rgba(16,185,129,.18), transparent 70%),*/ +/* linear-gradient(180deg, #0a0f16, #0f172a 52%, #0b1220 78%, #0a0f18);*/ } +/* Inhalt bewusst über die Backdrop heben */ +#main{ position: relative; z-index: 1; } + +/* (Optional) Safe-Area-Padding für iPhone-Notch */ +.safe-pads{ + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); +} + +/* === App Backdrop (türkis/glass) === */ +/*.app-backdrop {*/ +/* position: fixed;*/ +/* inset: 0;*/ +/* z-index: -10;*/ +/* pointer-events: none;*/ +/* height: 100vh;*/ +/* background: radial-gradient(1600px 900px at 85% -15%, rgba(34, 211, 238, .28), transparent 62%),*/ +/* radial-gradient(1200px 800px at 10% 112%, rgba(16, 185, 129, .18), transparent 70%),*/ +/* linear-gradient(180deg, #0a0f16, #0f172a 52%, #0b1220 78%, #0a0f18);*/ +/*}*/ + .primary-btn { @apply relative inline-flex items-center justify-center rounded-xl px-6 py-2.5 text-sm font-medium text-white transition-all @@ -249,15 +306,25 @@ html[data-ui="ready"] #sidebar { --sb-w: 0; } + #sidebar { + transition: width .2s ease, max-width .2s ease, transform .2s ease; + } + /* off-canvas → kein Padding */ } +/*#sidebar {*/ +/* width: var(--sbw, 16rem);*/ +/* max-width: var(--sbw, 16rem);*/ +/* transition: width .2s ease, max-width .2s ease, transform .2s ease;*/ +/*}*/ + /* Sidebar selbst nimmt die variable Breite */ -#sidebar { - width: var(--sbw, 16rem); - max-width: var(--sbw, 16rem); - transition: width .2s ease, max-width .2s ease, transform .2s ease; -} +/*#sidebar {*/ +/* width: var(--sbw, 16rem);*/ +/* max-width: var(--sbw, 16rem);*/ +/* transition: width .2s ease, max-width .2s ease, transform .2s ease;*/ +/*}*/ /* Rail: Label, Carets, Submenüs ausblenden (kein Springen) */ #sidebar.u-collapsed .sidebar-label { @@ -450,6 +517,46 @@ button { } +html, body { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE/Edge */ +} + +::-webkit-scrollbar { + display: none; +} + + + +/* Pulse für "running" */ +@keyframes tgPulse { + 0%, 100% { box-shadow: 0 0 0 rgba(0,0,0,0) } + 50% { box-shadow: 0 0 40px rgba(34,211,238,.12) } /* cyan-400/12 */ +} +.tg-pulse { animation: tgPulse 1.6s ease-in-out infinite; } + +/* leichtes Shake für Fehler/forbidden */ +@keyframes tgShake { + 0% { transform: translateX(0) } + 25% { transform: translateX(-2px) } + 50% { transform: translateX(2px) } + 75% { transform: translateX(-1px) } + 100% { transform: translateX(0) } +} +.tg-shake { animation: tgShake .35s ease-in-out 1; } + +@layer base { + input[type=number]::-webkit-inner-spin-button, + input[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + input[type=number] { + -moz-appearance: textfield; + } +} + input[type="text"], input[type="email"], input[type="number"], diff --git a/resources/js/app.js b/resources/js/app.js index 2a72da2..503b3b5 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,10 +1,15 @@ import './bootstrap'; +import './ui/command.js'; + +import '@tailwindplus/elements'; import "@phosphor-icons/web/duotone"; import "@phosphor-icons/web/light"; import "@phosphor-icons/web/regular"; import "@phosphor-icons/web/bold"; import './components/sidebar.js'; -// import '@plugins/Toastra'; -import '@plugins/GlassToastra/toastra.glass.js' + +import './plugins/GlassToastra/toastra.glass.js' +import './plugins/GlassToastra/livewire-adapter'; + // import './utils/events.js'; diff --git a/resources/js/components/sidebar.js b/resources/js/components/sidebar.js index a1d7fa0..bca336b 100644 --- a/resources/js/components/sidebar.js +++ b/resources/js/components/sidebar.js @@ -280,7 +280,7 @@ document.addEventListener('DOMContentLoaded', () => { sidebar.classList.add('translate-x-0'); sidebar.classList.remove('-translate-x-full'); } else { - sidebar.classList.add('-translate-x-full'); + // sidebar.classList.add('-translate-x-full'); sidebar.classList.remove('translate-x-0'); } } else { diff --git a/resources/js/plugins/GlassToastra/livewire-adapter.js b/resources/js/plugins/GlassToastra/livewire-adapter.js new file mode 100644 index 0000000..a85ea1c --- /dev/null +++ b/resources/js/plugins/GlassToastra/livewire-adapter.js @@ -0,0 +1,11 @@ +// resources/js/plugins/GlassToastra/livewire-adapter.js +import { showToast } from '../../ui/toast'; + +document.addEventListener('livewire:init', () => { + window.addEventListener('toast', (e) => showToast(e?.detail || {})); + window.addEventListener('toast.update', (e) => showToast(e?.detail || {})); // gleiche id => ersetzt Karte + window.addEventListener('toast.clear', (e) => window.toastraGlass?.clear(e?.detail?.position)); +}); + +// optional global +window.showToast = showToast; diff --git a/resources/js/plugins/GlassToastra/toastra.glass.js b/resources/js/plugins/GlassToastra/toastra.glass.js index 37e9d70..d1d2813 100644 --- a/resources/js/plugins/GlassToastra/toastra.glass.js +++ b/resources/js/plugins/GlassToastra/toastra.glass.js @@ -1,4 +1,5 @@ -;(() => { +// resources/js/plugins/GlassToastra/toastra.glass.js +(() => { // ---- Config -------------------------------------------------------------- const MAX_PER_POSITION = 3; // wie viele Toasts pro Ecke sichtbar const ROOT_PREFIX = 'toastra-root-'; // pro Position eigener Container @@ -82,12 +83,29 @@ return root; } + // function statusMap(state) { + // switch (state) { + // case 'done': + // return { text: 'Erledigt', pill: 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/30', icon: 'ph ph-check-circle' }; + // case 'failed': + // return { text: 'Fehlgeschlagen', pill: 'bg-rose-500/15 text-rose-300 ring-1 ring-rose-500/30', icon: 'ph ph-x-circle' }; + // case 'forbidden': + // return { text: 'Verboten', pill: 'bg-fuchsia-500/15 text-fuchsia-300 ring-1 ring-fuchsia-500/30', icon: 'ph ph-shield-slash' }; + // case 'running': + // return { text: 'Läuft…', pill: 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/30', icon: 'ph ph-arrow-clockwise animate-spin' }; + // default: + // return { text: 'Wartet…', pill: 'bg-amber-500/15 text-amber-300 ring-1 ring-amber-500/30', icon: 'ph ph-pause-circle' }; + // } + // } + function statusMap(state) { switch (state) { case 'done': return { text: 'Erledigt', pill: 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/30', icon: 'ph ph-check-circle' }; case 'failed': return { text: 'Fehlgeschlagen', pill: 'bg-rose-500/15 text-rose-300 ring-1 ring-rose-500/30', icon: 'ph ph-x-circle' }; + case 'forbidden': + return { text: 'Verboten', pill: 'bg-fuchsia-500/15 text-fuchsia-300 ring-1 ring-fuchsia-500/30', icon: 'ph ph-shield-slash' }; case 'running': return { text: 'Läuft…', pill: 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/30', icon: 'ph ph-arrow-clockwise animate-spin' }; default: @@ -95,16 +113,17 @@ } } - function buildHTML(o){ + function buildHTML(o) { const st = statusMap(o.state); const progress = (o.state === 'running' || o.state === 'queued') ? `
-
-
` : ''; +
+ ` + : ''; const closeBtn = (o.close !== false) ? `` +// : '
'; +// +// return ` +//
+//
+// +//
+// ${o.badge ? `${String(o.badge).toUpperCase()}` : ''} +// ${o.domain ? `
${o.domain}
` : ''} +//
+// +// +//
+// Status +// +// +// ${st.text} +// +// ${closeBtn} +//
+//
+// +// ${o.message ? `

${o.message}

` : ''} +// +// ${progress} +// +// ${o.finalNote ? `

${o.finalNote}

` : ''} +//
+// `; +// // return ` +// //
+// //
+// // +// // +// //
+// // ${o.badge ? `${String(o.badge).toUpperCase()}` : ''} +// // ${o.domain ? `
${o.domain}
` : ''} +// //
+// // +// // +// //
+// // Status +// // +// // +// // ${st.text} +// // +// // ${closeBtn} +// //
+// //
+// // +// // ${o.message ? `

${o.message}

` : ''} +// // +// // ${progress} +// // +// // ${o.finalNote ? `

${o.finalNote}

` : ''} +// //
`; +// } + function remove(wrapper) { if (!wrapper) return; diff --git a/resources/js/ui/command.js b/resources/js/ui/command.js new file mode 100644 index 0000000..95c2f85 --- /dev/null +++ b/resources/js/ui/command.js @@ -0,0 +1,40 @@ +(function(){ + // OS erkennen (sehr defensiv) + const ua = navigator.userAgent || ''; + const platform = navigator.platform || ''; + const isMac = /Mac|Macintosh|Mac OS X/i.test(ua) || /Mac|iPhone|iPad|iPod/i.test(platform); + + // Shortcut-Label setzen + const kbd = document.getElementById('searchShortcutKbd'); + if (kbd) kbd.textContent = isMac ? '⌘ K' : 'Ctrl + K'; + + // Fallback: Tastenkombi abfangen, falls global nicht gebunden + const open = () => { + if (window.Livewire) { + window.Livewire.dispatch('openModal', { component: 'ui.search.modal.search-palette-modal' }); + } + }; + + document.addEventListener('keydown', (e) => { + const k = (e.key || '').toLowerCase(); + const meta = isMac ? e.metaKey : e.ctrlKey; + if (meta && k === 'k' && !e.shiftKey && !e.altKey) { + e.preventDefault(); + open(); + } + }); + + // Optional: Button auch via JS öffnen können + const btn = document.getElementById('openSearchPaletteBtn'); + if (btn) btn.addEventListener('click', open); +})(); + +// document.addEventListener('keydown', (e) => { +// const isMac = navigator.platform.toUpperCase().indexOf('MAC')>=0; +// const meta = isMac ? e.metaKey : e.ctrlKey; +// if (meta && e.key.toLowerCase() === 'k') { +// e.preventDefault(); +// // LivewireUI Modal öffnen +// Livewire.dispatch('openModal', { component: 'ui.search.modal.search-palette-modal' }); +// } +// }); diff --git a/resources/js/ui/toast.js b/resources/js/ui/toast.js index b198013..003b382 100644 --- a/resources/js/ui/toast.js +++ b/resources/js/ui/toast.js @@ -1,25 +1,28 @@ -// Minimal-API mit Fallbacks: GlassToastra → toastr → eigener Mini-Toast -export function showToast({ type = 'success', text = '', title = '' } = {}) { - const t = (type || 'success').toLowerCase(); - const msg = text || ''; +// Immer die Glas-Toasts verwenden (keine Fallbacks, kein toastr) +export function showToast(payload = {}) { + const { + id, type, state, text, message, title, badge, domain, + position, duration, close + } = payload || {}; - // 1) Dein Glas-Toast - if (window.GlassToastra && typeof window.GlassToastra[t] === 'function') { - window.GlassToastra[t](msg, title); + // type/state auf deine 4 Zustände mappen + const map = { success:'done', ok:'done', forbidden:'forbidden', error:'failed', danger:'failed', info:'queued', warning:'queued' }; + const stIn = String(state ?? type ?? 'done').toLowerCase(); + const st = ['done','failed','forbidden','running','queued'].includes(stIn) ? stIn : (map[stIn] || 'queued'); + + if (!window.toastraGlass || typeof window.toastraGlass.show !== 'function') { + // Optional: console.warn('toastraGlass fehlt'); return; } - // 2) toastr - if (window.toastr && typeof window.toastr[t] === 'function') { - window.toastr.options = { timeOut: 3500, progressBar: true, closeButton: true }; - window.toastr[t](msg, title); - return; - } - // 3) Fallback - const box = document.createElement('div'); - box.className = - 'fixed top-4 right-4 z-[9999] rounded-xl bg-emerald-500/90 text-white ' + - 'px-4 py-3 backdrop-blur shadow-lg border border-white/10'; - box.textContent = msg; - document.body.appendChild(box); - setTimeout(() => box.remove(), 3500); + + window.toastraGlass.show({ + id: id || ('toast-' + Date.now()), // gleiche id => ersetzt in-place + state: st, // steuert Badge/Icon/Farben + badge: badge ?? title ?? null, // linke kleine Kapsel + domain: domain ?? null, // kleine Überschrift rechts + message: (message ?? text ?? ''), // Haupttext + position: position ?? 'bottom-right', + duration: typeof duration === 'number' ? duration : 6000, // Standard 6s + close: close !== false, + }); } diff --git a/resources/js/utils/events.js b/resources/js/utils/events.js index 1385ebf..9c0f216 100644 --- a/resources/js/utils/events.js +++ b/resources/js/utils/events.js @@ -1,11 +1,71 @@ -import { showToast } from '../ui/toast.js' +// import { showToast } from '../ui/toast.js' // — Livewire-Hooks (global) +// document.addEventListener('livewire:init', () => { +// if (window.Livewire?.on) { +// window.Livewire.on('toast', (payload = {}) => showToast(payload)) +// } +// }) + document.addEventListener('livewire:init', () => { - if (window.Livewire?.on) { - window.Livewire.on('toast', (payload = {}) => showToast(payload)) + // Neu: Livewire v3 Browser-Events + window.addEventListener('toast', (e) => { + const d = e?.detail || {}; + showToastGlass(d); + }); + + // Optional: Update/Dismiss/Clear per Event + window.addEventListener('toast.update', (e) => { + const d = e?.detail || {}; + if (d.id) window.toastraGlass?.update(d.id, d); + }); + window.addEventListener('toast.clear', (e) => { + window.toastraGlass?.clear(e?.detail?.position); + }); +}); + +// Adapter: normalisiert Payload und ruft toastraGlass +function showToastGlass({ + id, + type, state, // "success" | "warning" | "error" ODER "done" | "failed" | "running" + text, message, title, // Textquellen + badge, domain, + position = 'bottom-right', + duration = 0, // 0 = stehen lassen; bei done/failed auto auf 6000ms + } = {}) { + // Map: type -> state + const t = (type || state || 'done').toLowerCase(); + const map = { success: 'done', ok: 'done', error: 'failed', danger: 'failed', info: 'queued', warning: 'queued' }; + const st = ['done','failed','running','queued'].includes(t) ? t : (map[t] || 'queued'); + + const msg = message || text || title || ''; + const _id = id || ('toast-' + Date.now()); + + if (window.toastraGlass?.show) { + window.toastraGlass.show({ + id: _id, + state: st, // queued|running|done|failed → färbt Badge/Icon + badge, // z.B. "DNS", "Signup" + domain, // optional: kleine Überschrift rechts + message: msg, + position, + duration, // 0 = stehen lassen; sonst ms + }); + } else if (window.toastr) { + // Fallback: alte toastr API + const level = (type || (st === 'failed' ? 'error' : st === 'done' ? 'success' : 'info')); + window.toastr[level](msg, badge || domain || ''); + } else { + // Minimal-Fallback + const box = document.createElement('div'); + box.className = 'fixed top-4 right-4 z-[9999] rounded-xl bg-emerald-500/90 text-white px-4 py-3 backdrop-blur shadow-lg border border-white/10'; + box.textContent = msg || 'OK'; + document.body.appendChild(box); + setTimeout(() => box.remove(), 3500); } -}) + + return _id; +} // — Session-Flash vom Backend (einmal pro Page-Load) function bootstrapFlashFromLayout() { @@ -36,7 +96,7 @@ function setupEchoToasts() { document.addEventListener('DOMContentLoaded', setupEchoToasts) // — Optional: global machen, falls du manuell aus JS/Blade rufen willst -window.showToast = showToast +// window.showToast = showToast diff --git a/resources/views/components/button/copy-btn.blade.php b/resources/views/components/button/copy-btn.blade.php new file mode 100644 index 0000000..48caa67 --- /dev/null +++ b/resources/views/components/button/copy-btn.blade.php @@ -0,0 +1,25 @@ +@props([ + 'text' => '', + 'class' => '', + 'label' => '', +]) + diff --git a/resources/views/components/button/filter-chip.blade.php b/resources/views/components/button/filter-chip.blade.php new file mode 100644 index 0000000..fab681c --- /dev/null +++ b/resources/views/components/button/filter-chip.blade.php @@ -0,0 +1,24 @@ +{{-- Filter-Chip als Dropdown (native
) --}} +@props(['label' => 'Filter']) +
+ + {{ $label }} + + +
+ {{ $slot }} +
+
+ +{{-- Option-Button Klasse --}} +@once + +@endonce diff --git a/resources/views/components/icons/icon-logo-text.blade.php b/resources/views/components/icons/icon-logo-text.blade.php new file mode 100644 index 0000000..1bccfbc --- /dev/null +++ b/resources/views/components/icons/icon-logo-text.blade.php @@ -0,0 +1,15 @@ +merge(['class' => 'size-6']) }} xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 1997 256" version="1.1" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + + + + + + + + + + + + + + diff --git a/resources/views/components/partials/header.blade.php b/resources/views/components/partials/header.blade.php index 04bfc23..c90b704 100644 --- a/resources/views/components/partials/header.blade.php +++ b/resources/views/components/partials/header.blade.php @@ -5,23 +5,45 @@