mailwolt/app/Livewire/Ui/Domain/Modal/DomainCreateModal.php

318 lines
12 KiB
PHP

<?php
namespace App\Livewire\Ui\Domain\Modal;
use App\Models\DkimKey;
use App\Models\Domain;
use App\Models\Setting;
use App\Services\DkimService;
use App\Services\DnsRecordService;
use App\Services\TlsaService;
use App\Services\MailStorage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
class DomainCreateModal extends ModalComponent
{
public string $domain = '';
public ?string $description = null;
public array $tags = [];
// Limits
public int $max_aliases;
public int $max_mailboxes;
// Quotas
public int $default_quota_mb; // Standard pro Mailbox
public ?int $max_quota_per_mailbox_mb = null; // optionales Limit pro Mailbox
public int $total_quota_mb; // Domain-Gesamt (0 = unbegrenzt NICHT vorgesehen hier)
// Rate-Limit (Domain-weit)
public ?int $rate_limit_per_hour = null; // null => 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 <b>' . e($this->domain) . '</b> 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');
}
}