318 lines
12 KiB
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 ?? 0,
|
|
'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');
|
|
}
|
|
}
|