Init Mailwolt Installer

main
boban 2025-10-16 10:33:51 +02:00
parent 94ae46d4d5
commit 8bd603733f
98 changed files with 10435 additions and 938 deletions

View File

@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Models\Setting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
class SettingsSyncCommand extends Command
{
protected $signature = 'settings:sync';
protected $description = 'Synchronize all DB settings into Redis';
public function handle(): int
{
$count = 0;
$this->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;
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\UI\Mail;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class AliasController extends Controller
{
public function index()
{
return view('ui.mail.alias-index');
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Http\Controllers\UI\Mail;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class MailboxController extends Controller
{
public function index()
{
return view('ui.mail.mailbox-index');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreDomainRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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'],
];
}
}

View File

@ -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. <i class="ph ph-arrow-right" ></i> <a href="'.route('login').'" class="underline">Zum Login</a>',
message: 'Dein Konto wurde angelegt. Bitte melde dich jetzt mit deinen Zugangsdaten an. <i class="ph ph-arrow-right"></i> <a href="'.route('login').'" class="underline">Zum Login</a>',
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. <i class="ph ph-arrow-right" ></i> <a href="'.route('login').'" class="underline">Zum Login</a>',
// duration: -1,
// );
// }
public function render()
{
return view('livewire.auth.signup-form');

View File

@ -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 <b>' . e($domain->domain) . '</b> 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,
]);
}
}

View File

@ -0,0 +1,317 @@
<?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');
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Livewire\Ui\Domain\Modal;
use App\Models\Domain;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
class DomainDeleteModal extends ModalComponent
{
public int $domainId;
public string $domain;
public int $mailboxes = 0;
public int $aliases = 0;
/** Sicherheits-Input (User muss Domain schreiben) */
public string $confirm = '';
public function mount(int $domainId): void
{
$d = Domain::withCount(['mailUsers as mailboxes','mailAliases as aliases'])->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 <b>'.e($name).'</b> 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');
}
}

View File

@ -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

View File

@ -0,0 +1,110 @@
<?php
namespace App\Livewire\Ui\Domain\Modal;
use App\Models\Domain;
use LivewireUI\Modal\ModalComponent;
class DomainEditModal extends ModalComponent
{
public $domain;
public $description;
public $is_active;
public array $tags = [];
public array $tagPalette = ['#22c55e','#06b6d4','#a855f7','#f59e0b','#ef4444','#3b82f6'];
public function mount(int $domainId)
{
$d = Domain::findOrFail($domainId);
if ($d->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 <b>' . e($d->domain) . '</b> 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,
]);
}
}

View File

@ -0,0 +1,258 @@
<?php
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;
// Eingabe-Felder
public int $max_aliases;
public int $max_mailboxes;
public int $default_quota_mb;
public ?int $max_quota_per_mailbox_mb = null; // null = kein per-Mailbox-Maximum
public int $total_quota_mb; // 0 = unbegrenzt
public ?int $rate_limit_per_hour = null; // null = kein Limit
public bool $rate_limit_override = false;
// intern für Checks
private int $current_total_quota_mb = 0;
public function mount(int $domainId, MailStorage $pool): void
{
$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 !== 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');
// }
//}

View File

@ -0,0 +1,130 @@
<?php
namespace App\Livewire\Ui\Mail;
use App\Models\Domain;
use Livewire\Attributes\On;
use Livewire\Component;
class AliasList extends Component
{
public string $search = '';
public bool $showSystemCard = false;
#[On('alias:created')]
#[On('alias:updated')]
#[On('alias:deleted')]
public function refreshAliases(): void
{
$this->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,
]);
}
}

View File

@ -0,0 +1,341 @@
<?php
namespace App\Livewire\Ui\Mail;
use App\Models\Domain;
use Illuminate\Support\Str;
use Livewire\Attributes\On;
use Livewire\Component;
class MailboxList extends Component
{
public string $search = '';
public bool $showSystemCard = false;
#[On('mailbox:updated')]
#[On('mailbox:deleted')]
#[On('mailbox:created')]
public function refreshMailboxList(): void
{
$this->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
// ]);
// }
//}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Livewire\Ui\Mail\Modal;
use App\Models\MailAlias;
use Livewire\Attributes\On;
use Livewire\Component;
use LivewireUI\Modal\ModalComponent;
class AliasDeleteModal extends ModalComponent
{
public MailAlias $alias;
// Anzeige / Bestätigung
public string $aliasEmail = '';
public string $confirm = '';
public bool $confirmMatches = false; // <-- reaktiv, kein computed
// Meta
public string $targetLabel = '';
public int $recipientCount = 0;
public int $extraRecipients = 0;
public bool $isSingle = false;
public function mount(int $aliasId): void
{
$this->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 <b>' . e($email) . '</b> wurde entfernt.',
duration: 6000
);
}
public function render()
{
return view('livewire.ui.mail.modal.alias-delete-modal');
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,544 @@
<?php
namespace App\Livewire\Ui\Mail\Modal;
use App\Models\Domain;
use App\Models\MailAlias;
use App\Models\MailUser;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
class MailboxCreateModal extends ModalComponent
{
// optional vorselektierte Domain
public ?int $domain_id = null;
// Anzeige
public string $domain_name = '';
/** @var array<int,array{id:int,domain:string}> */
public array $domains = [];
public string $email_preview = '';
// Formular
public string $localpart = '';
public ?string $display_name = null;
public ?string $password = null;
public int $quota_mb = 0;
public ?int $rate_limit_per_hour = null;
public bool $is_active = true;
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 <b>' . e($email) . '</b> wurde erfolgreich angelegt.',
duration: 6000
);
}
public static function modalMaxWidth(): string
{
return '3xl';
}
public function render()
{
return view('livewire.ui.mail.modal.mailbox-create-modal');
}
}
//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<int,array{id:int,domain:string}> */
// 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 <b>' . e($email) . '</b> wurde erfolgreich angelegt.',
// duration: 6000
// );
//
// }
//
// public static function modalMaxWidth(): string
// {
// return '3xl';
// }
//
// public function render()
// {
// return view('livewire.ui.mail.modal.mailbox-create-modal');
// }
//}

View File

@ -0,0 +1,217 @@
<?php
namespace App\Livewire\Ui\Mail\Modal;
use App\Models\MailUser;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
class MailboxDeleteModal extends ModalComponent
{
public int $mailboxId;
public ?MailUser $mailbox = null;
// Optionen
public bool $keep_server_mail = false; // Mails am Server belassen?
public bool $export_zip = false; // ZIP-Export anstoßen?
public string $email = '';
public static function modalMaxWidth(): string
{
return 'md';
}
public function mount(int $mailboxId): void
{
$this->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 <b>' . e($this->email) . '</b> 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');
// }
//}

View File

@ -0,0 +1,411 @@
<?php
namespace App\Livewire\Ui\Mail\Modal;
use App\Models\MailUser;
use App\Models\Domain;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
class MailboxEditModal extends ModalComponent
{
public int $mailboxId;
public ?MailUser $mailbox = null;
// Felder
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;
// UI
public string $email_readonly = '';
public string $quota_hint = '';
public bool $rate_limit_readonly = false;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(int $mailboxId): void
{
$this->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 <b>' . $mailbox . '</b> 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');
// }
//}

View File

@ -0,0 +1,155 @@
<?php
namespace App\Livewire\Ui\Search\Modal;
use App\Models\Domain;
use App\Models\MailUser;
use App\Models\User; // falls du App\User/Models\User hast, ggf. anpassen
use Livewire\Attributes\Computed;
use LivewireUI\Modal\ModalComponent;
class SearchPaletteModal extends ModalComponent
{
public string $q = '';
/** Konfiguration: neue Bereiche leicht ergänzbar */
private array $sections = [
// key => [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');
}
}

View File

@ -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 ?? '');

View File

@ -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()

View File

@ -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; }
}

View File

@ -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}";
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MailAliasRecipient extends Model
{
protected $fillable = ['alias_id','mail_user_id','email','position'];
public function alias(): BelongsTo
{
return $this->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;
}
}

View File

@ -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); }

View File

@ -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;
}
}

View File

@ -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
// }
// }
}

View File

@ -0,0 +1,140 @@
<?php
namespace App\Services;
use App\Models\Domain;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
class DkimService
{
/** Erzeugt Keypair & gibt den TXT-Record (ohne Host) zurück. */
public function generateForDomain(Domain $domainId, int $bits = 2048, string $selector = 'dkim'): array
{
$dirKey = $this->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;
}
}

View File

@ -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/Lets 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;
// }
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Services;
use App\Models\Domain;
use Illuminate\Support\Facades\Cache;
class MailStorage
{
/**
* Basiswerte & abgeleitete Pools in MiB.
* - percent_reserve enthält bereits die "Systemmail" (z. B. 5 % in deinen 15 %)
* - fixed_reserve_mb ist eine fixe Sicherheitsmarge (z. B. 2048 MiB)
* - committed_mb = Summe der Domain-Quotas (verplanter Speicher)
*/
public function stats(): array
{
$path = (string) config('mailpool.mail_data_path', '/var/mail');
$totalMb = (int) floor((@disk_total_space($path) ?: 0) / 1048576);
$freeMb = (int) floor((@disk_free_space($path) ?: 0) / 1048576);
$percentReserve = max(0, (int) config('mailpool.percent_reserve', 15)); // inkl. „Systemmail“
$fixedReserveMb = max(0, (int) config('mailpool.fixed_reserve_mb', 2048));
// 1) Reserve auf *freiem* Platz (dein gewünschtes Verhalten)
$percentReserveOnFreeMb = (int) round($freeMb * ($percentReserve / 100));
$systemReserveFreeBaseMb = $fixedReserveMb + $percentReserveOnFreeMb;
$freeAfterReserveMb = max(0, $freeMb - $systemReserveFreeBaseMb);
// 2) Reserve auf *Gesamt*platz (Kapazitätsbremse gegen Überbuchung)
$percentReserveOnTotalMb = (int) round($totalMb * ($percentReserve / 100));
$systemReserveTotalMb = $fixedReserveMb + $percentReserveOnTotalMb;
// bereits „verplant“: Summe der Domain-Quotas (achte auf Spaltennamen!)
$committedMb = (int) Domain::where('is_active', true)->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();
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Services;
use App\Models\Domain;
use App\Models\TlsaRecord;
use Illuminate\Support\Facades\Log;
class TlsaService
{
/**
* Erstellt/aktualisiert TLSA für SMTP (_25._tcp) am MTA-Host.
* host = <MTA_SUB>.<BASE_DOMAIN> (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,
]
);
}
}

85
app/Support/NetProbe.php Normal file
View File

@ -0,0 +1,85 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Cache;
class NetProbe
{
public static function resolve(bool $force = false): array
{
return [
'ipv4' => 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;
}
}

View File

@ -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<string,string> $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();
}
}

View File

@ -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')
];

26
config/mailpool.php Normal file
View File

@ -0,0 +1,26 @@
<?php
// config/mailpool.php
return [
'platform_zone' => 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),
],
];

View File

@ -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/;
}
}

191
config/system.php Normal file
View File

@ -0,0 +1,191 @@
<?php
return [
'locales' => [
['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',
],
];

View File

@ -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();

View File

@ -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();
});
}

View File

@ -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();

View File

@ -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']);
});
}

View File

@ -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']);
});
}

View File

@ -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');

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('mail_alias_recipients', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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);
}

7
package-lock.json generated
View File

@ -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",

View File

@ -18,6 +18,7 @@
},
"dependencies": {
"@phosphor-icons/web": "^2.1.2",
"@tailwindplus/elements": "^1.0.17",
"jquery": "^3.7.1"
}
}

View File

@ -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"],

View File

@ -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';

View File

@ -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 {

View File

@ -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;

View File

@ -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')
? `<div class="mt-3 h-1.5 w-full rounded-full bg-white/5 overflow-hidden">
<div class="h-full w-1/2 bg-cyan-400/50 tg-progress"></div>
</div>` : '';
<div class="h-full w-1/2 bg-cyan-400/50 tg-progress"></div>
</div>`
: '';
const closeBtn = (o.close !== false)
? `<button class="rounded-lg bg-white/10 hover:bg-white/15 border border-white/15 p-1.5 ml-2 flex-shrink-0"
style="box-shadow:0 0 10px rgba(0,0,0,.25)" data-tg-close>
style="box-shadow:0 0 10px rgba(0,0,0,.25)" data-tg-close>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M8 8l8 8M16 8l-8 8" stroke="rgba(229,231,235,.9)" stroke-linecap="round"/>
</svg>
@ -114,11 +133,19 @@
return `
<div class="glass-card border border-glass-border/70 rounded-2xl p-4 shadow-[0_10px_30px_rgba(0,0,0,.35)] pointer-events-auto tg-card">
<div class="flex items-center justify-between gap-4">
<!-- Linke Seite -->
<div class="flex items-center gap-3 min-w-0">
${o.badge ? `<span class="badge !bg-glass-light/50 !border-glass-border whitespace-nowrap">${String(o.badge).toUpperCase()}</span>` : ''}
${o.domain ? `<div class="text-xs font-semibold text-gray-100/95 tracking-tight truncate">${o.domain}</div>` : ''}
${o.badge
? `<span class="px-2 py-0.5 rounded-md text-[11px] uppercase tracking-wide font-semibold
bg-${o.state === 'forbidden' ? 'fuchsia' : o.state === 'failed' ? 'rose' : 'emerald'}-500/15
text-${o.state === 'forbidden' ? 'fuchsia' : o.state === 'failed' ? 'rose' : 'emerald'}-300
ring-1 ring-${o.state === 'forbidden' ? 'fuchsia' : o.state === 'failed' ? 'rose' : 'emerald'}-500/30">
${String(o.badge).toUpperCase()}
</span>`
: ''}
${o.domain
? `<div class="text-xs font-semibold text-gray-100/95 tracking-tight truncate">${o.domain}</div>`
: ''}
</div>
<!-- Rechte Seite -->
@ -140,6 +167,79 @@
</div>`;
}
// function buildHTML(o){
// const st = statusMap(o.state);
// const progress = (o.state === 'running' || o.state === 'queued')
// ? `<div class="mt-3 h-1.5 w-full rounded-full bg-white/5 overflow-hidden">
// <div class="h-full w-1/2 bg-cyan-400/50 tg-progress"></div>
// </div>` : '';
//
// const closeBtn = (o.close !== false)
// ? `<button class="rounded-lg bg-white/10 hover:bg-white/15 border border-white/15 p-1.5 ml-2 flex-shrink-0"
// style="box-shadow:0 0 10px rgba(0,0,0,.25)" data-tg-close>
// <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
// <path d="M8 8l8 8M16 8l-8 8" stroke="rgba(229,231,235,.9)" stroke-linecap="round"/>
// </svg>
// </button>`
// : '<div style="width:28px;height:28px"></div>';
//
// return `
// <div class="glass-card border border-glass-border/70 rounded-2xl p-4 shadow-[0_10px_30px_rgba(0,0,0,.35)] pointer-events-auto tg-card ${st.anim}"
// style="box-shadow: inset 6px 0 0 0 ${st.accentCss}, 0 10px 30px rgba(0,0,0,.35); background-image: linear-gradient(${st.tintCss}, ${st.tintCss});">
// <div class="flex items-center justify-between gap-4">
// <!-- Links -->
// <div class="flex items-center gap-3 min-w-0">
// ${o.badge ? `<span class="badge !bg-glass-light/50 !border-glass-border whitespace-nowrap">${String(o.badge).toUpperCase()}</span>` : ''}
// ${o.domain ? `<div class="text-xs font-semibold text-gray-100/95 tracking-tight truncate">${o.domain}</div>` : ''}
// </div>
//
// <!-- Rechts -->
// <div class="flex items-center gap-2 flex-shrink-0">
// <span class="text-[13px] text-gray-300/80">Status</span>
// <span class="px-2.5 py-1 rounded-lg text-[12px] leading-none whitespace-nowrap ${st.pill}">
// <i class="${st.icon} text-[14px] align-[-1px]"></i>
// <span class="ml-1">${st.text}</span>
// </span>
// ${closeBtn}
// </div>
// </div>
//
// ${o.message ? `<p class="mt-3 text-[14px] text-gray-200/90 leading-relaxed">${o.message}</p>` : ''}
//
// ${progress}
//
// ${o.finalNote ? `<p class="mt-3 text-[12px] text-gray-400">${o.finalNote}</p>` : ''}
// </div>
// `;
// // return `
// // <div class="glass-card border border-glass-border/70 rounded-2xl p-4 shadow-[0_10px_30px_rgba(0,0,0,.35)] pointer-events-auto tg-card">
// // <div class="flex items-center justify-between gap-4">
// //
// // <!-- Linke Seite -->
// // <div class="flex items-center gap-3 min-w-0">
// // ${o.badge ? `<span class="badge !bg-glass-light/50 !border-glass-border whitespace-nowrap">${String(o.badge).toUpperCase()}</span>` : ''}
// // ${o.domain ? `<div class="text-xs font-semibold text-gray-100/95 tracking-tight truncate">${o.domain}</div>` : ''}
// // </div>
// //
// // <!-- Rechte Seite -->
// // <div class="flex items-center gap-2 flex-shrink-0">
// // <span class="text-[13px] text-gray-300/80">Status</span>
// // <span class="px-2.5 py-1 rounded-lg text-[12px] leading-none whitespace-nowrap ${st.pill}">
// // <i class="${st.icon} text-[14px] align-[-1px]"></i>
// // <span class="ml-1">${st.text}</span>
// // </span>
// // ${closeBtn}
// // </div>
// // </div>
// //
// // ${o.message ? `<p class="mt-3 text-[14px] text-gray-200/90 leading-relaxed">${o.message}</p>` : ''}
// //
// // ${progress}
// //
// // ${o.finalNote ? `<p class="mt-3 text-[12px] text-gray-400">${o.finalNote}</p>` : ''}
// // </div>`;
// }
function remove(wrapper) {
if (!wrapper) return;

View File

@ -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' });
// }
// });

View File

@ -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,
});
}

View File

@ -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

View File

@ -0,0 +1,25 @@
@props([
'text' => '',
'class' => '',
'label' => '',
])
<button
x-data="{ copied: false }"
@click="
navigator.clipboard.writeText(@js($text));
copied = true;
setTimeout(() => copied = false, 2000);
"
class="inline-flex items-center text-base gap-1.5 rounded-lg border border-white/10 bg-white/5 p-1 #py-1 text-white/80 hover:text-white hover:border-white/20 transition {{ $class }}"
>
<template x-if="!copied">
<span class="inline-flex items-center">
<i class="ph ph-copy"></i> <small>{{ $label }}</small>
</span>
</template>
<template x-if="copied">
<span class="inline-flex items-center">
<i class="ph ph-checks text-emerald-400"></i> <small>{{ $label }}</small>
</span>
</template>
</button>

View File

@ -0,0 +1,24 @@
{{-- Filter-Chip als Dropdown (native <details>) --}}
@props(['label' => 'Filter'])
<details class="group relative">
<summary
class="list-none flex items-center gap-1 h-12 px-3 rounded-xl
bg-white/5 border border-white/10 text-white/80
hover:border-white/20 cursor-pointer select-none">
<span class="text-base">{{ $label }}</span>
<i class="ph ph-caret-down text-[16px] transition-transform group-open:rotate-180"></i>
</summary>
<div
class="absolute right-0 mt-2 z-20 rounded-2xl border border-white/10 bg-gray-900/95 backdrop-blur p-2 shadow-xl">
{{ $slot }}
</div>
</details>
{{-- Option-Button Klasse --}}
@once
<style>
.chip-opt{ @apply px-2.5 py-1.5 rounded-lg border border-white/10 bg-white/5 text-white/80 text-left hover:border-white/20; }
.scrollbar-none::-webkit-scrollbar{ display:none; }
.scrollbar-none{ -ms-overflow-style:none; scrollbar-width:none; }
</style>
@endonce

View File

@ -0,0 +1,15 @@
<svg {{ $attributes->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;">
<g id="ArtBoard1" transform="matrix(1,0,0,1,-2830.73,-3936.92)">
<rect x="2830.73" y="3936.93" width="1996.58" height="255.476" style="fill:none;"/>
<g transform="matrix(0.266228,0,0,0.163123,2454.99,3276.68)">
<path d="M1685.43,5163.02L1685.43,4727.16C1820.66,4903.71 1924.33,4957.04 2053.92,4957.04C2174.49,4957.04 2284.92,4903.71 2420.14,4727.16L2420.14,5163.02C2420.14,5210.84 2444.93,5251.3 2474.23,5251.3C2503.53,5251.3 2528.32,5210.84 2528.32,5163.02L2528.32,4537.73C2528.32,4489.91 2503.53,4449.45 2474.23,4449.45C2459.58,4449.45 2447.19,4460.49 2435.92,4475.2C2240.97,4754.74 2169.98,4769.45 2053.92,4769.45C1936.72,4767.62 1862.35,4751.06 1669.66,4475.2C1658.39,4460.49 1646,4449.45 1631.35,4449.45C1602.05,4449.45 1577.26,4489.91 1577.26,4537.73L1577.26,5163.02C1577.26,5210.84 1602.05,5251.3 1631.35,5251.3C1660.64,5251.3 1685.43,5210.84 1685.43,5163.02ZM1984.05,5163.02C1984.05,5225.55 2015.6,5277.05 2053.92,5277.05C2092.23,5277.05 2123.78,5225.55 2123.78,5163.02C2123.78,5100.49 2092.23,5049 2053.92,5049C2015.6,5049 1984.05,5100.49 1984.05,5163.02Z" style="fill-rule:nonzero;"/>
<path d="M3001.6,4644.4C3010.62,4626.01 3027.52,4594.74 3044.42,4594.74C3061.32,4594.74 3078.23,4624.17 3088.37,4644.4L3350.93,5216.36C3361.07,5238.43 3376.84,5251.3 3393.75,5251.3C3423.04,5251.3 3447.84,5210.84 3447.84,5163.02C3447.84,5139.11 3441.07,5117.05 3432.06,5100.49L3158.23,4502.79C3132.32,4445.77 3089.49,4412.67 3044.42,4412.67C2995.97,4412.67 2952.02,4451.29 2926.1,4508.3L2652.28,5109.69C2644.39,5124.4 2641.01,5142.79 2641.01,5163.02C2641.01,5210.84 2665.8,5251.3 2695.1,5251.3C2710.87,5251.3 2726.65,5238.43 2736.79,5218.19L3001.6,4644.4Z" style="fill-rule:nonzero;"/>
<path d="M3668.7,5163.02L3668.7,4537.73C3668.7,4489.91 3643.91,4449.45 3614.61,4449.45C3585.31,4449.45 3560.52,4489.91 3560.52,4537.73L3560.52,5163.02C3560.52,5210.84 3585.31,5251.3 3614.61,5251.3C3643.91,5251.3 3668.7,5210.84 3668.7,5163.02Z" style="fill-rule:nonzero;"/>
<path d="M3935.76,5251.3L4608.49,5251.3C4637.79,5251.3 4662.58,5210.84 4662.58,5163.02C4662.58,5115.21 4637.79,5074.74 4608.49,5074.74L3932.38,5074.74C3909.85,5074.74 3889.56,5043.48 3889.56,5006.7L3889.56,4537.73C3889.56,4489.91 3863.64,4449.45 3835.47,4449.45C3806.17,4449.45 3781.38,4489.91 3781.38,4537.73L3781.38,5006.7C3781.38,5142.79 3852.38,5251.3 3935.76,5251.3Z" style="fill-rule:nonzero;"/>
<path d="M4775.27,4537.73L4775.27,4850.38C4775.27,5032.45 4848.51,5251.3 4971.34,5251.3L5595.62,5251.3C5725.21,5251.3 5802.96,5036.12 5802.96,4850.38L5802.96,4537.73C5802.96,4489.91 5778.17,4449.45 5748.87,4449.45C5719.57,4449.45 5694.78,4489.91 5694.78,4537.73L5694.78,4850.38C5694.78,4938.65 5672.24,5074.74 5568.57,5074.74L5461.52,5074.74C5364.61,5074.74 5343.2,4936.81 5343.2,4850.38L5343.2,4537.73C5343.2,4489.91 5318.41,4449.45 5289.11,4449.45C5259.82,4449.45 5235.03,4489.91 5235.03,4537.73L5235.03,4850.38C5235.03,4925.78 5247.42,5006.7 5271.09,5074.74L5009.65,5074.74C4905.98,5074.74 4883.45,4938.65 4883.45,4850.38L4883.45,4537.73C4883.45,4489.91 4858.66,4449.45 4829.36,4449.45C4800.06,4449.45 4775.27,4489.91 4775.27,4537.73Z" style="fill-rule:nonzero;"/>
<path d="M6832.9,4850.38C6832.9,4668.31 6759.66,4460.49 6636.83,4449.45L6122.99,4449.45C5993.4,4449.45 5915.65,4664.63 5915.65,4850.38C5915.65,5032.45 5988.89,5240.26 6111.72,5251.3L6625.56,5251.3C6755.15,5251.3 6832.9,5036.12 6832.9,4850.38ZM6601.9,5074.74L6146.65,5074.74C6042.98,5074.74 6020.44,4938.65 6020.44,4850.38C6020.44,4762.1 6042.98,4626.01 6146.65,4626.01L6601.9,4626.01C6705.57,4626.01 6728.11,4762.1 6728.11,4850.38C6728.11,4938.65 6705.57,5074.74 6601.9,5074.74Z" style="fill-rule:nonzero;"/>
<path d="M7099.97,5251.3L7772.7,5251.3C7802,5251.3 7826.79,5210.84 7826.79,5163.02C7826.79,5115.21 7802,5074.74 7772.7,5074.74L7096.59,5074.74C7074.05,5074.74 7053.77,5043.48 7053.77,5006.7L7053.77,4537.73C7053.77,4489.91 7027.85,4449.45 6999.68,4449.45C6970.38,4449.45 6945.59,4489.91 6945.59,4537.73L6945.59,5006.7C6945.59,5142.79 7016.58,5251.3 7099.97,5251.3Z" style="fill-rule:nonzero;"/>
<path d="M8627.98,4449.45L7852.71,4449.45C7823.41,4449.45 7798.62,4489.91 7798.62,4537.73C7798.62,4583.71 7821.15,4622.33 7848.2,4626.01L8186.26,4626.01L8186.26,5163.02C8186.26,5210.84 8211.05,5251.3 8240.34,5251.3C8269.64,5251.3 8294.43,5210.84 8294.43,5163.02L8294.43,4626.01L8627.98,4626.01C8657.28,4626.01 8682.07,4585.55 8682.07,4537.73C8682.07,4489.91 8657.28,4449.45 8627.98,4449.45Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -5,23 +5,45 @@
<header id="header" class="header w-full rounded-r-2xl rounded-l-none">
<nav class="flex h-[71px] #h-[74px] items-center justify-between #p-3">
<div class="#flex-1 md:w-3/5 w-full">
<div class="flex items-center gap-5">
<button id="sidebar-toggle-btn"
class="action-button sidebar-toggle flex items-center justify-center p-1.5 shadow-2xl rounded">
<svg class="size-5 group-[.expanded]/side:rotate-180"
fill="none" stroke="currentColor" stroke-width="1.5"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 5l7 7-7 7"></path>
</svg>
<div class="relative flex items-center gap-5">
<button class="sidebar-toggle translate-0 right-5 block sm:hidden text-white/60 hover:text-white text-2xl">
<i class="ph ph-list"></i>
</button>
{{-- <button id="sidebar-toggle-btn"--}}
{{-- class="action-button sidebar-toggle flex items-center justify-center p-1.5 shadow-2xl rounded">--}}
{{-- <svg class="size-5 group-[.expanded]/side:rotate-180"--}}
{{-- fill="none" stroke="currentColor" stroke-width="1.5"--}}
{{-- viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">--}}
{{-- <path stroke-linecap="round" stroke-linejoin="round"--}}
{{-- d="M9 5l7 7-7 7"></path>--}}
{{-- </svg>--}}
{{-- </button>--}}
<div class="flex items-center gap-3">
<h1 class="font-bold text-2xl">@yield('header_title')</h1>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-2 w-full max-w-96">
<a href="#" class="btn-ghost text-xs px-2 py-1"><i class="ph ph-plus mr-1"></i><span>Domain</span></a>
<button
id="openSearchPaletteBtn"
type="button"
class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5
text-white/75 hover:text-white hover:border-white/20"
title="Suche öffnen"
aria-label="Suche öffnen"
wire:click="$dispatch('openModal',{component:'ui.search.modal.search-palette-modal'})"
>
<i class="ph ph-magnifying-glass text-[16px]"></i>
<span id="searchShortcutKbd"
class="rounded-md bg-white/5 border border-white/10 px-1.5 py-0.5 text-[11px] leading-none">
<!-- wird per JS gesetzt -->
</span>
</button>
<button onclick="Livewire.dispatch('openModal',{component:'ui.domain.modal.domain-create-modal'})">
<i class="ph-duotone ph-globe-hemisphere-east"></i>
</button>
@foreach($header as $section)
<div class="#modal-button #relative #mr-2">
<div class="#p-1.5">

View File

@ -1,121 +1,3 @@
{{--<div--}}
{{-- id="sidebar"--}}
{{-- class="group/side fixed z-50 drop-shadow-2xl--}}
{{-- -translate-x-full sm:translate-x-0--}}
{{-- sm:transition-[max-width,transform] sm:duration-300--}}
{{-- sm:block sm:max-w-sb-col lg:max-w-sb-exp--}}
{{-- group-[.t-collapsed]/side:sm:max-w-sb-col--}}
{{-- group-[.ut-expanded]/side:sm:max-w-sb-exp--}}
{{-- group-[.m-expanded]/side:translate-x-0 group-[.m-expanded]/side:block--}}
{{-- group-[.u-collapsed]/side:sm:max-w-sb-col--}}
{{-- h-full w-full">--}}
{{--<div class="#flex p-2.5 w-full max-w-7xl h-full">--}}
{{-- <div--}}
{{-- class="side-bg flex flex-col rounded-lg shadow-2xl h-full border-white/10--}}
{{-- bg-gradient-to-b from-[#0f172a]/90 via-[#1e293b]/70 to-[#0d9488]/30 backdrop-blur-xl">--}}
{{-- <div class="relative items-center justify-center h-16 gap-2 border-b hr px-2">--}}
{{-- <button id="sidebar-toggle-btn"--}}
{{-- class="absolute top-1/2 -translate-y-1/2 action-button sidebar-toggle flex items-center justify-center p-2 shadow-2xl rounded z-10 sm:hidden">--}}
{{-- <svg class="size-6 group-[.expanded]/side:rotate-180"--}}
{{-- fill="none" stroke="currentColor" stroke-width="1.5"--}}
{{-- viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">--}}
{{-- <path stroke-linecap="round" stroke-linejoin="round"--}}
{{-- d="M9 5l7 7-7 7"></path>--}}
{{-- </svg>--}}
{{-- </button>--}}
{{-- <a href="{{ route('dashboard') }}"--}}
{{-- class="sidebar-link group relative flex flex-row items-center">--}}
{{-- <button class="w-full flex items-center sm:justify-normal justify-center rounded-md">--}}
{{-- <span class="text-gray #group-[.active]:text-primary p-2">--}}
{{-- <x-icons.icon-link class="!size-7 block"/>--}}
{{-- </span>--}}
{{-- <span--}}
{{-- class="sidebar-menu-text text-xs ml-2 truncate tracking-wide group-[.collapsed]/side:sm:hidden">--}}
{{-- <x-icons.icon-logo class="!size-28 max-h-16"/>--}}
{{-- </span>--}}
{{-- </button>--}}
{{-- </a>--}}
{{-- </div>--}}
{{-- <nav class="flex-1 overflow-y-auto my-3 px-2.5 Space-y-4 no-scrollbar text-[rgba(var(--gray))]">--}}
{{-- <ul class="Space-y-3">--}}
{{-- --}}{{-- Menu Sections --}}
{{-- @foreach($menu as $section)--}}
{{-- @php--}}
{{-- $isOpen = collect($section['items'] ?? [])->contains(--}}
{{-- fn($i) => isset($i['route']) && request()->routeIs($i['route'])--}}
{{-- );--}}
{{-- $secIcon = $section['icon'] ?? 'ph-dots-three-outline';--}}
{{-- $isOpen = collect($section['items'] ?? [])->contains(--}}
{{-- fn($i) => isset($i['route']) && request()->routeIs($i['route'])--}}
{{-- );--}}
{{-- @endphp--}}
{{-- <li class="Space-y-1" x-data="{ open: {{ $isOpen ? 'true' : 'false' }} }">--}}
{{-- <button type="button" @click="open = !open"--}}
{{-- class="w-full flex items-center gap-2 px-2 py-2 rounded-lg hover:bg-white/5 text-slate-300 transition-colors">--}}
{{-- <i class="ph-duotone {{ $secIcon }} text-xl shrink-0 opacity-80"></i>--}}
{{-- <span class="sidebar-menu-text text-xs font-semibold tracking-wide group-[.collapsed]/side:sm:hidden">--}}
{{-- {{ $section['label'] ?? 'Sektion' }}--}}
{{-- </span>--}}
{{-- <span class="sidebar-menu-text text-xs font-semibold tracking-wide--}}
{{-- group-[.u-collapsed]/side:hidden">--}}
{{-- {{ $section['label'] ?? 'Sektion' }}--}}
{{-- </span>--}}
{{-- <i class="ph-duotone ph-caret-down ml-auto text-sm transition-transform group-[.collapsed]/side:sm:hidden"--}}
{{-- :class="{ '-rotate-180': open }" x-cloak></i>--}}
{{-- </button>--}}
{{-- --}}{{-- WICHTIG: x-cloak + SSR hidden, wenn nicht offen --}}
{{-- <ul x-show="open && !$root.closest('#sidebar').classList.contains('u-collapsed')"--}}
{{-- x-collapse--}}
{{-- x-cloak--}}
{{-- >--}}
{{-- @foreach(($section['items'] ?? []) as $item)--}}
{{-- @php--}}
{{-- $active = isset($item['route']) && request()->routeIs($item['route']);--}}
{{-- $itemIcon = $item['icon'] ?? 'ph-dot-outline';--}}
{{-- @endphp--}}
{{-- <li>--}}
{{-- <a href="#"--}}
{{-- class="sidebar-link group relative flex items-center gap-2 px-2 py-2 rounded-lg--}}
{{-- border border-transparent transition-colors--}}
{{-- hover:bg-gradient-to-t hover:from-white/10 hover:to-transparent--}}
{{-- {{ $active ? 'bg-gradient-to-t from-white/10 to-transparent !border !border-white/10' : '' }}">--}}
{{-- <i class="ph-duotone {{ $itemIcon }} text-base opacity-80"></i>--}}
{{-- <span class="sidebar-menu-text text-xs truncate tracking-wide group-[.collapsed]/side:sm:hidden {{ $active ? 'font-semibold' : '' }}">--}}
{{-- {{ $item['label'] ?? 'Eintrag' }}--}}
{{-- </span>--}}
{{-- </a>--}}
{{-- </li>--}}
{{-- @endforeach--}}
{{-- </ul>--}}
{{-- </li>--}}
{{-- @endforeach--}}
{{-- </ul>--}}
{{-- </nav>--}}
{{-- <div class="p-3 border-t hr flex justify-end">--}}
{{-- <button id="sidebar-toggle-btn"--}}
{{-- class="action-button sidebar-toggle flex items-center justify-center p-2 shadow-2xl rounded">--}}
{{-- <svg class="size-6 group-[.expanded]/side:rotate-180"--}}
{{-- fill="none" stroke="currentColor" stroke-width="1.5"--}}
{{-- viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">--}}
{{-- <path stroke-linecap="round" stroke-linejoin="round"--}}
{{-- d="M9 5l7 7-7 7"></path>--}}
{{-- </svg>--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- </div>--}}
{{--</div>--}}
{{-- resources/views/components/partials/sidebar.blade.php --}}
<div
@ -123,57 +5,27 @@
class="fixed top-0 left-0 z-50 #drop-shadow-2xl
-translate-x-full sm:translate-x-0
h-full w-full">
{{-- Kleine, robuste Styleschicht kein Tailwind-Plugin nötig --}}
{{-- <style>--}}
{{-- /* Basisbreite über CSS-Var smooth per transition */--}}
{{-- #sidebar { --sb-w: 16rem; width: var(--sb-w); max-width: var(--sb-w); transition: width .2s ease, max-width .2s ease, transform .2s ease; }--}}
{{-- /* Desktop: 16rem Standard, im Rail 5.2rem */--}}
{{-- @media (min-width: 1024px) {--}}
{{-- #sidebar { --sb-w: 16rem; }--}}
{{-- #sidebar.u-collapsed { --sb-w: 5.2rem; }--}}
{{-- }--}}
{{-- /* Tablet: Standard Rail, Button kann erweitern */--}}
{{-- @media (min-width: 640px) and (max-width: 1023.98px) {--}}
{{-- #sidebar { --sb-w: 5.2rem; }--}}
{{-- #sidebar.ut-expanded { --sb-w: 16rem; }--}}
{{-- }--}}
{{-- /* Mobile: offcanvas via translate; JS toggelt translate-x-0/-translate-x-full */--}}
{{-- /* Rail-Modus: Text, Carets und Submenüs sicher verstecken */--}}
{{-- #sidebar.u-collapsed .sidebar-label { display: none !important; }--}}
{{-- #sidebar.u-collapsed .sidebar-caret { display: none !important; }--}}
{{-- #sidebar.u-collapsed [data-submenu] { display: none !important; }--}}
{{-- </style>--}}
<div class="p-2.5 w-full h-full">
<div class="side-bg flex flex-col rounded-lg shadow-2xl h-full border-white/10
bg-gradient-to-b from-[#0f172a]/90 via-[#1e293b]/70 to-[#0d9488]/30
backdrop-blur-xl">
backdrop-blur-xl border border-white/10">
{{-- Header --}}
<div class="relative h-16 border-b hr px-2 flex items-center">
{{-- Mobile Toggle (zeigt/verbirgt Sidebar auf <640px) --}}
{{-- <button--}}
{{-- type="button"--}}
{{-- class="absolute left-2 top-1/2 -translate-y-1/2 action-button sidebar-toggle flex items-center justify-center p-2 shadow-2xl rounded z-10 sm:hidden"--}}
{{-- aria-label="Sidebar umschalten">--}}
{{-- <svg class="size-6" fill="none" stroke="currentColor" stroke-width="1.5"--}}
{{-- viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">--}}
{{-- <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"></path>--}}
{{-- </svg>--}}
{{-- </button>--}}
<div class="relative h-16 border-b hr px-2 flex items-center justify-between">
<a href="{{ route('ui.dashboard') }}"
class="sidebar-link group relative flex flex-row items-center #mx-auto sm:mx-0">
<span class="text-gray p-1.5">
<x-icons.icon-logo-circle class="!size-9"/>
</span>
<span class="sidebar-label text-xl ml-2 truncate tracking-wide font-space">
MailWolt
{{-- optional: „MailWolt“/Brand --}}
<span class="sidebar-label text-xl ml-2 truncate tracking-wide font-bai transition-all">
<x-icons.icon-logo-text class="!size-35 fill-white"/>
</span>
</a>
<button class="sidebar-toggle absolute translate-0 right-5 block sm:hidden text-white/60 hover:text-white text-2xl"><i
class="ph ph-x"></i>
</button>
</div>
{{-- Navigation --}}
@ -192,7 +44,7 @@
<li class="space-y-1" x-data="{ open: {{ $activeInside ? 'true' : 'false' }} }">
<button
type="button"
class="js-sec-toggle w-full flex items-center gap-4 px-3 py-2 rounded-lg hover:bg-white/5 text-slate-300 transition-colors"
class="js-sec-toggle w-full flex items-center gap-4 px-2.5 py-2 rounded-lg hover:bg-white/5 text-slate-300 transition-colors"
@click="(!$root.closest('#sidebar').classList.contains('u-collapsed')) && (open = !open)">
<i class="ph-duotone {{ $secIcon }} text-xl shrink-0 opacity-80"></i>
<span class="sidebar-label text-xs font-semibold tracking-wide">
@ -210,14 +62,15 @@
$itemIcon = $item['icon'] ?? 'ph-dot-outline';
@endphp
<li>
{{-- <a href="{{ isset($item['route']) ? route($item['route']) : '#' }}"--}}
{{-- <a href="{{ isset($item['route']) ? route($item['route']) : '#' }}"--}}
<a href="#"
class="sidebar-link group relative flex items-center gap-2 px-2 py-2 rounded-lg
border border-transparent transition-colors
hover:bg-gradient-to-t hover:from-white/10 hover:to-transparent
{{ $active ? 'bg-gradient-to-t from-white/10 to-transparent !border !border-white/10' : '' }}">
<i class="ph-duotone {{ $itemIcon }} text-base opacity-80"></i>
<span class="sidebar-label text-xs truncate tracking-wide {{ $active ? 'font-semibold' : '' }}">
<span
class="sidebar-label text-xs truncate tracking-wide {{ $active ? 'font-semibold' : '' }}">
{{ $item['label'] ?? 'Eintrag' }}
</span>
</a>
@ -236,7 +89,8 @@
MailWolt <span class="font-semibold">{{ config('app.version', 'v1.0.0') }}</span>
</div>
<button type="button" class="action-button sidebar-toggle flex items-center justify-center p-2 shadow-2xl rounded"
<button type="button"
class="action-button sidebar-toggle flex items-center justify-center p-2 shadow-2xl rounded"
aria-label="Sidebar ein/ausklappen">
<svg class="size-6" fill="none" stroke="currentColor" stroke-width="1.5"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">

View File

@ -1,25 +1,23 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_','-',app()->getLocale()) }}" class="h-dvh">
<html lang="{{ str_replace('_','-',app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{-- <meta name="reverb"--}}
{{-- data-host="{{ request()->getHost() }}"--}}
{{-- data-port="{{ request()->isSecure() ? 443 : 80 }}"--}}
{{-- data-scheme="{{ request()->isSecure() ? 'https' : 'http' }}"--}}
{{-- data-path="{{ config('reverb.servers.reverb.path') }}"--}}
{{-- data-key="{{ config('reverb.servers.reverb.key') }}">--}}
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
{{-- <meta name="theme-color" content="#132835">--}}
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>@yield('title', config('app.name'))</title>
<script>
document.documentElement.setAttribute('data-ui','booting');
</script>
<script>document.documentElement.setAttribute('data-ui','booting');</script>
@vite(['resources/css/app.css'])
@livewireStyles
</head>
<body class="min-h-dvh text-slate-100 font-bai">
<div class="app-backdrop"></div>
{{--<body class="#min-h-screen text-slate-100 font-bai #safe-pads">--}}
{{--<div class="app-backdrop fixed inset-0 -z-10 pointer-events-none"></div>--}}
<body class="text-slate-100 font-bai">
<div class="app-backdrop"></div>
<div id="main" class="main content group/side main-shell rail">
<x-partials.sidebar/>
@ -27,7 +25,6 @@
<div>
<x-partials.header />
</div>
<div class="mt-5">
@yield('content')
</div>
@ -38,90 +35,44 @@
@livewireScripts
@livewire('wire-elements-modal')
</body>
{{--<body class="min-h-dvh text-slate-100">--}}
{{--<div class="fixed inset-0 -z-10 pointer-events-none--}}
{{-- [background:radial-gradient(1200px_800px_at_85%_-10%,rgba(139,215,255,.16),transparent_60%),radial-gradient(900px_700px_at_10%_110%,rgba(121,255,163,.10),transparent_60%),linear-gradient(180deg,#0b0f14,#111827_55%,#172130)]">--}}
{{--</div>--}}
{{--<div class="fixed inset-0 -z-10 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)]"></div>--}}
{{--<div--}}
{{-- class="p-2.5 pt-0--}}
{{-- lg:ml-64--}}
{{-- filter--}}
{{-- group-[.u-collapsed]/side:sm:ml-20--}}
{{-- sm:transition-[margin-left] sm:duration-300--}}
{{-- sm:ml-20 group-[.collapsed]/side:sm:!ml-20--}}
{{-- group-[.ut-expanded]/side:sm:blur-md--}}
{{-- group-[.ut-expanded]/side:sm:pointer-events-none--}}
{{-- group-[.ut-expanded]/side:sm:brightness-100--}}
{{-- group-[.ut-expanded]/side:lg:filter-none--}}
{{-- #group-[.m-collapsed]/side:!ml-0--}}
{{-- ">--}}
{{-- <div>--}}
{{-- <header class="px-6 py-4 flex items-center justify-between opacity-80">--}}
{{-- <div class="flex items-center gap-3">--}}
{{-- <div class="h-8 w-8 rounded-xl bg-indigo-500/90 grid place-items-center font-semibold">FM</div>--}}
{{-- <span class="font-semibold tracking-wide">MailWolt</span>--}}
{{-- @env('local')--}}
{{-- <span class="ml-2 text-[11px] px-2 py-0.5 rounded bg-slate-200/10 border border-white/10">dev</span>--}}
{{-- @endenv--}}
{{-- </div>--}}
{{-- @isset($setupPhase)--}}
{{-- <div class="text-xs text-slate-300/70">Setup-Phase: {{ $setupPhase }}</div>--}}
{{-- @endisset--}}
{{-- </header>--}}
</html>
{{--<!DOCTYPE html>--}}
{{--<html lang="{{ str_replace('_','-',app()->getLocale()) }}" class="h-dvh">--}}
{{--<head>--}}
{{-- <meta charset="utf-8">--}}
{{-- <meta name="viewport" content="width=device-width, initial-scale=1">--}}
{{-- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">--}}
{{-- <meta name="theme-color" content="#0b1220" media="(prefers-color-scheme: light)">--}}
{{-- <meta name="theme-color" content="#0b1220" media="(prefers-color-scheme: dark)">--}}
{{-- <meta name="apple-mobile-web-app-capable" content="yes">--}}
{{-- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">--}}
{{-- <title>@yield('title', config('app.name'))</title>--}}
{{-- <script>--}}
{{-- document.documentElement.setAttribute('data-ui','booting');--}}
{{-- </script>--}}
{{-- @vite(['resources/css/app.css'])--}}
{{-- @livewireStyles--}}
{{--</head>--}}
{{-- --}}{{----}}{{-- Seite: immer auf volle Höhe und zentriert --}}
{{-- <main class="min-h-[calc(100dvh-64px)] grid place-items-center px-4">--}}
{{-- <div id="toastra-root" class="absolute pointer-events-none"></div>--}}
{{-- @yield('content')--}}
{{-- </main>--}}
{{--</div>--}}
{{--<body class="min-h-screen text-slate-100 font-bai safe-pads">--}}
{{--<div class="app-backdrop fixed inset-0 -z-10 pointer-events-none"></div>--}}
{{--<script>--}}
{{-- window.addEventListener('toast', (e) => {--}}
{{-- const {--}}
{{-- state = 'queued',--}}
{{-- badge = '',--}}
{{-- domain = '',--}}
{{-- message = '',--}}
{{-- pos = 'top-right',--}}
{{-- duration = 0, // <- pass it through--}}
{{-- } = e.detail || {};--}}
{{-- toastraGlass.show({--}}
{{-- state, badge, domain, message,--}}
{{-- position: pos,--}}
{{-- duration: Number(duration),--}}
{{-- });--}}
{{-- });--}}
{{--</script>--}}
{{--@vite(['resources/js/app.js'])--}}
{{--@livewireScripts--}}
{{--<div id="main" class="main content group/side main-shell rail">--}}
{{--<div id="main" class="main content group/side main-shell rail min-h-screen">--}}
{{-- <x-partials.sidebar/>--}}
{{-- <div--}}
{{-- class="p-2.5 pt-0--}}
{{-- lg:ml-64--}}
{{-- filter--}}
{{-- group-[.u-collapsed]/side:sm:ml-20--}}
{{-- sm:transition-[margin-left] sm:duration-300--}}
{{-- sm:ml-20 group-[.collapsed]/side:sm:!ml-20--}}
{{-- group-[.ut-expanded]/side:sm:blur-md--}}
{{-- group-[.ut-expanded]/side:sm:pointer-events-none--}}
{{-- group-[.ut-expanded]/side:sm:brightness-100--}}
{{-- group-[.ut-expanded]/side:lg:filter-none--}}
{{-- #group-[.m-collapsed]/side:!ml-0--}}
{{-- ">--}}
{{-- <div class="px-2.5 pb-2.5 pt-0.5">--}}
{{-- <div>--}}
{{-- <x-partials.header/>--}}
{{-- <x-partials.header />--}}
{{-- </div>--}}
{{-- <div class="mt-5">--}}
{{-- @yield("content")--}}
{{-- @yield('content')--}}
{{-- </div>--}}
{{-- </div>--}}
{{--</div>--}}
{{--@vite(['resources/js/app.js'])--}}
{{--@livewireScripts--}}
{{--@livewire('wire-elements-modal')--}}
{{--</body>--}}
</html>
{{--</html>--}}

View File

@ -1,48 +1,184 @@
<div class="bg-slate-800/40 rounded-2xl p-4 space-y-3">
<!-- Header -->
<div class="hidden md:grid grid-cols-4 text-sm text-slate-300 pb-1 border-b border-slate-700/50">
<div>Domain</div>
<div>Aktiv</div>
<div>Typ</div>
<div class="text-right">Aktionen</div>
<div class="space-y-4">
<div class="">
<div class="md:flex items-center justify-between gap-3">
<h1 class="text-5xl #mb-6">Domains</h1>
</div>
<div class="my-4 md:flex items-center justify-end gap-3">
<input
type="text"
wire:model.live.debounce.300ms="search"
class="md:max-w-96 w-full justify-end flex-1 rounded-xl bg-white/5 border border-white/10 px-4 py-2.5 text-white/90 placeholder:text-white/50"
placeholder="Suchen (Alias) …">
</div>
</div>
<!-- Einträge -->
@foreach($domains as $d)
<div
class="grid grid-cols-1 md:grid-cols-4 items-center gap-2 md:gap-4 py-3 px-3 rounded-xl bg-slate-900/40 border border-slate-700/50 hover:border-slate-600/60 transition">
{{-- Titel wie in Security --}}
{{-- ================= System-Domain ================= --}}
<div class="glass-card p-5 space-y-3">
<p class="text-sm text-white/70">
Diese Domain wird für <span class="text-white/90 font-medium">interne System-E-Mails</span>
genutzt (Ausfälle, Quota-Warnungen, Zustellfehler). Ist keine echte Domain gesetzt, verwenden
wir ein self-signed Zertifikat und lokales Zustellen. Für produktiven Versand muss die
System-Domain <span class="text-white/90 font-medium">auf diese Server-IP zeigen</span>
und per ACME/Lets Encrypt ein Zertifikat erhalten.
</p>
<!-- Domain -->
<div class="text-slate-100 text-sm font-medium">
{{ $d->domain }}
@if($systemDomain)
<div class="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-3">
<div class="grid grid-cols-1 md:grid-cols-4 items-center gap-3">
<div class=" flex items-center gap-1 text-white/90 font-medium truncate">
<span class="relative flex size-2.5 mx-1">
@if($systemDomain->is_active)
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-300 opacity-60"></span>
<span class="relative inline-flex size-2.5 rounded-full bg-emerald-300 ring-2 ring-emerald-300/30"></span>
@else
<span class="relative inline-flex size-2.5 rounded-full bg-amber-400/90 ring-2 ring-amber-200/20"></span>
@endif
</span>
{{ $systemDomain->domain }}
</div>
<div class="flex items-center gap-2">
{{-- <span class="px-2 py-0.5 rounded-full text-xs border--}}
{{-- {{ $systemDomain->is_active ? 'border-emerald-400/30 text-emerald-300 bg-emerald-500/10'--}}
{{-- : 'border-white/15 text-white/60 bg-white/5' }}">--}}
{{-- {{ $systemDomain->is_active ? 'Aktiv' : 'Inaktiv' }}--}}
{{-- </span>--}}
<span class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5">
System
</span>
</div>
<div class="md:col-span-2 md:text-right">
<button
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
wire:click="openDnsModal({{ $systemDomain->id }})">
<i class="ph ph-globe-hemisphere-east text-[12px]"></i> DNS-Assistent
</button>
</div>
</div>
</div>
@else
<div class="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-3 text-white/75">
Keine System-Domain gefunden. Bitte im Setup anlegen.
</div>
@endif
</div>
{{-- ================= Benutzer-Domains ================= --}}
<div class="glass-card p-5 space-y-3">
<div class="flex items-center justify-between">
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<i class="ph ph-globe-hemisphere-west text-white/70 text-[13px]"></i>
<span class="text-[11px] uppercase tracking-wide text-white/70">Benutzer-Domains</span>
</div>
<!-- Aktiv -->
<div>
<span class="px-2 py-1 rounded text-xs {{ $d->is_active ? 'bg-emerald-600/30 text-emerald-200' : 'bg-slate-600/30 text-slate-300' }}">
{{ $d->is_active ? 'aktiv' : 'inaktiv' }}
</span>
</div>
<!-- Typ -->
<div>
<span class="px-2 py-1 rounded text-xs {{ $d->is_system ? 'bg-indigo-600/30 text-indigo-200' : 'bg-sky-600/30 text-sky-100' }}">
{{ $d->is_system ? 'System' : 'Kunde' }}
</span>
</div>
<!-- Aktion -->
<div class="text-right">
<button
onclick="Livewire.dispatch('openModal', {
component: 'ui.domain.modal.domain-dns-modal',
arguments: { domainId: {{ $d->id }} }
})"
class="inline-flex items-center justify-center px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-emerald-600 text-white text-xs font-medium hover:opacity-90">
> DNS-Assistent
</button>
</div>
<button
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
{{-- class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-white/80 hover:text-white hover:border-white/20"--}}
onclick="Livewire.dispatch('openModal',{component:'ui.domain.modal.domain-create-modal'})">
<i class="ph ph-plus text-[14px]"></i> Domain hinzufügen
</button>
</div>
@endforeach
@if($userDomains->count())
<div class="space-y-3">
@foreach($userDomains as $d)
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-3">
<div class="grid grid-cols-1 md:grid-cols-3 col-start-1 col-end-3 items-center gap-3">
{{-- Domain + Tags --}}
<div class="text-white/90 font-medium truncate">
<span class="inline-flex items-center gap-2" aria-live="polite">
<span class="relative flex size-2.5 mx-1">
@if($d->domainActive)
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-300 opacity-60"></span>
<span
class="relative inline-flex size-2.5 rounded-full bg-emerald-300 ring-2 ring-emerald-300/30"></span>
@else
<span
class="relative inline-flex size-2.5 rounded-full bg-amber-400/90 ring-2 ring-amber-200/20"></span>
@endif
</span>
</span>
{{ $d->domain }}
<div class="mt-1 flex items-center gap-1.5">
{{-- Zähler-Badges --}}
<span
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">
<i class="ph ph-user-list text-[12px]"></i> {{ $d->mailboxes_count ?? 0 }} Postfächer
</span>
<span
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">
<i class="ph ph-at text-[12px]"></i> {{ $d->aliases_count ?? 0 }} Aliasse
</span>
</div>
</div>
{{-- Status + Zähler --}}
<div class="flex flex-wrap items-center gap-2 col-span-1">
@if(!empty($d->visible_tags))
<div class="mt-1 flex items-center gap-1.5">
@foreach($d->visible_tags as $t)
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium"
style="background: {{ $t['bg'] }}; border:1px solid {{ $t['border'] }}; color: {{ $t['color'] }};">
<i class="ph ph-tag text-[12px]"></i>{{ $t['label'] }}
</span>
@endforeach
@if($d->extra_tags > 0)
<span
class="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium border border-white/10 text-white/70 bg-white/5">
+{{ $d->extra_tags }}
</span>
@endif
</div>
@endif
</div>
<hr class="md:hidden">
{{-- Aktionen --}}
<div class="md:col-span-1 md:text-right space-y-2">
<button
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
wire:click="openDnsModal({{ $d->id }})">
<i class="ph ph-globe-hemisphere-east text-[12px]"></i> DNS
</button>
<button
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
wire:click="openEditModal({{ $d->id }})">
<i class="ph ph-pencil-simple text-[12px]"></i> Bearbeiten
</button>
<button
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
wire:click="openLimitsModal({{ $d->id }})">
<i class="ph ph-sliders-horizontal text-[12px]"></i> Limits
</button>
<button
class="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs
bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30 shrink-0"
wire:click="openDeleteModal({{ $d->id }})">
<i class="ph ph-trash-simple text-[12px]"></i> Löschen
</button>
</div>
</div>
</div>
@endforeach
</div>
@else
<div class="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-6 text-white/75 text-center">
Noch keine eigenen Domains hinzugefügt.
</div>
@endif
</div>
</div>

View File

@ -0,0 +1,273 @@
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10
backdrop-blur rounded-t-2xl relative">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-white">Domain hinzufügen</h2>
<p class="text-[13px] text-slate-300/85">
Lege Limits & Quotas fest. DKIM & empfohlene DNS-Records werden automatisch vorbereitet.
</p>
</div>
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
<i class="ph ph-x"></i>
</button>
</div>
</div>
@endpush
<div class="p-6">
<div class="space-y-4">
{{-- SECTION: Basisdaten --}}
<section class="rounded-2xl border border-slate-700/60 bg-white/[0.04] p-5 backdrop-blur">
{{-- Domain --}}
<div>
<label class="flex items-center gap-2 text-sm text-slate-200">
<i class="ph ph-at text-slate-300"></i> Domain
</label>
<input type="text" placeholder="example.com" wire:model.defer="domain" class="mt-1 nx-input">
@error('domain') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
</div>
{{-- Beschreibung --}}
<div class="mt-4">
<div class="flex items-center gap-2">
<label class="text-sm text-slate-200">Beschreibung</label>
<span
class="px-2 py-0.5 rounded-full text-[11px] border border-slate-600/60 bg-slate-800/70 text-slate-200">optional</span>
</div>
<input type="text" placeholder="optional" wire:model.defer="description" class="mt-1 nx-input">
@error('description') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
</div>
</section>
{{-- Tags --}}
<section class="rounded-2xl border border-slate-700/60 bg-white/[0.04] p-5 backdrop-blur">
<div class="#mt-4">
<div class="flex items-center gap-2">
<label class="text-sm text-slate-200">Tags</label>
<span
class="px-2 py-0.5 rounded-full text-[11px] border border-slate-600/60 bg-slate-800/70 text-slate-200">optional</span>
</div>
{{-- Liste der Tags --}}
<div class="mt-2 space-y-2">
@foreach($tags as $i => $t)
<div class="#rounded-xl #border #border-slate-700/60 #bg-white/[0.04] #p-3">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
{{-- Label --}}
<div class="md:col-span-2">
<label class="block text-xs text-slate-300/80 mb-1">Label</label>
<input
type="text"
placeholder="z. B. Kunde, Produktion"
wire:model.defer="tags.{{ $i }}.label"
class="nx-input"
>
</div>
{{-- Farbe (HEX + Picker) --}}
<div>
<label class="block text-xs text-slate-300/80 mb-1">Farbe (HEX)</label>
<div class="flex items-center gap-2">
<input
type="text"
placeholder="#22c55e"
wire:model.lazy="tags.{{ $i }}.color"
class="nx-input font-mono"
>
<input
type="color"
value="{{ $t['color'] ?? '#22c55e' }}"
wire:change="pickTagColor({{ $i }}, $event.target.value)"
class="size-8 min-w-8 rounded-md border border-white/15 bg-white/5 p-1 cursor-pointer"
>
</div>
</div>
</div>
{{-- Palette + Entfernen --}}
<div class="mt-3 flex items-center justify-between gap-2">
<div class="flex gap-2">
<span class="text-xs text-slate-400">Palette:</span>
@foreach($tagPalette as $hex)
<button
type="button"
class="h-6 w-6 rounded-md border {{ ($t['color'] ?? '') === $hex ? 'ring-2 ring-white/80' : 'border-white/15' }}"
style="background: {{ $hex }}"
wire:click="pickTagColor({{ $i }}, '{{ $hex }}')"
title="{{ $hex }}"
></button>
@endforeach
</div>
<button
type="button"
wire:click="removeTag({{ $i }})"
class="inline-flex items-center gap-1.5 rounded-lg p-2 bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30 shrink-0"
>
<i class="ph ph-trash-simple text-sm"></i>
</button>
</div>
</div>
@endforeach
</div>
{{-- Tag hinzufügen --}}
<div class="mt-8 text-end">
<button
type="button"
wire:click="addTag"
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20">
<i class="ph ph-plus text-[14px]"></i> Tag hinzufügen
</button>
</div>
@error('tags.*.label') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
@error('tags.*.color') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
</div>
</section>
{{-- SECTION: Limits (Anzahl) --}}
<section class="rounded-2xl border border-slate-700/60 bg-white/[0.04] p-5 backdrop-blur">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label class="text-sm text-slate-200">Max. mögliche Aliasse</label>
<input type="number" wire:model.defer="max_aliases" class="mt-1 nx-input">
@error('max_aliases') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
</div>
<div>
<label class="text-sm text-slate-200">Max. mögliche Mailboxen</label>
<input type="number" wire:model.defer="max_mailboxes" class="mt-1 nx-input">
@error('max_mailboxes') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
</div>
</div>
</section>
{{-- SECTION: Quotas / Speicher --}}
<section class="rounded-2xl border border-slate-700/60 bg-white/[0.04] p-5 backdrop-blur">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<div class="flex items-center gap-2">
<label class="text-sm text-slate-200">Standard-Quota je Mailbox (MiB)</label>
<span
class="px-2 py-0.5 rounded-full text-[11px] border border-indigo-500/30 bg-indigo-500/15 text-indigo-200">Voreinstellung</span>
</div>
<input type="number" wire:model.defer="default_quota_mb" class="mt-1 nx-input">
<p class="mt-2 text-xs text-slate-300/90">Startwert für neue Postfächer (pro Postfach änderbar).</p>
@error('default_quota_mb') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
</div>
<div>
<div class="flex items-center gap-2">
<label class="text-sm text-slate-200">Speicher je Mailbox (MiB)</label>
<span
class="px-2 py-0.5 rounded-full text-[11px] border border-fuchsia-500/30 bg-fuchsia-500/15 text-fuchsia-200">Obergrenze</span>
<span
class="px-2 py-0.5 rounded-full text-[11px] border border-slate-600/60 bg-slate-800/70 text-slate-200">optional</span>
</div>
<input type="number" wire:model.defer="max_quota_per_mailbox_mb" class="mt-1 nx-input">
<p class="mt-2 text-xs text-slate-300/90">
Leer lassen = keine Obergrenze; limitiert nur durch die <span
class="underline decoration-slate-600/70">Domain-Gesamtgröße</span>.
</p>
@error('max_quota_per_mailbox_mb') <p
class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
</div>
<div class="md:col-span-2">
<label class="text-sm text-slate-200">Domain-Speicherplatz gesamt (MiB)</label>
<input type="number" min="1" max="{{ $available_mib }}" wire:model.defer="total_quota_mb"
class="mt-1 nx-input">
<div class="mt-2 text-xs">
<div
class="inline-flex items-center gap-2 rounded-lg border border-slate-700/70 bg-slate-900/70 px-2 py-1 text-slate-300/90">
<i class="ph ph-info"></i>
Verfügbar (nach Systemreserve): {{ number_format($available_mib) }} MiB
</div>
<p class="mt-2 text-slate-300/80">Beachte: Summe aller Postfach-Quotas darf diese Domain-Größe
nicht überschreiten.</p>
</div>
@error('total_quota_mb') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
</div>
</div>
</section>
{{-- SECTION: Versandlimits & Status --}}
<section class="rounded-2xl border border-slate-700/60 bg-white/[0.04] p-5 backdrop-blur">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<div class="flex items-center gap-2">
<label class="text-sm text-slate-200">Mails pro Stunde</label>
<span
class="px-2 py-0.5 rounded-full text-[11px] border border-slate-600/60 bg-slate-800/70 text-slate-200">optional</span>
</div>
<input type="number" placeholder="leer = kein Limit" wire:model.defer="rate_limit_per_hour"
class="mt-1 nx-input">
@error('rate_limit_per_hour') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
</div>
<div class="grid items-center gap-3">
<label class="inline-flex items-center gap-3 select-none">
<input type="checkbox" wire:model.defer="rate_limit_override"
class="peer appearance-none w-5 h-5 rounded-md border border-emerald-500/50 bg-transparent checked:bg-emerald-500 checked:border-emerald-400 grid place-content-center transition">
<span class="text-slate-200">Postfach-Overrides erlauben</span>
</label>
<label class="inline-flex items-center gap-3 select-none">
<input type="checkbox" wire:model.defer="active"
class="peer appearance-none w-5 h-5 rounded-md border border-emerald-500/50 bg-transparent checked:bg-emerald-500 checked:border-emerald-400 grid place-content-center transition">
<span class="text-slate-200">Domain aktivieren</span>
</label>
</div>
{{-- <div class="flex items-center gap-3">--}}
{{-- <label class="inline-flex items-center gap-3 select-none">--}}
{{-- <input type="checkbox" wire:model.defer="active"--}}
{{-- class="peer appearance-none w-5 h-5 rounded-md border border-emerald-500/50 bg-transparent checked:bg-emerald-500 checked:border-emerald-400 grid place-content-center transition">--}}
{{-- <span class="text-slate-200">Domain aktivieren</span>--}}
{{-- </label>--}}
{{-- </div>--}}
</div>
</section>
{{-- SECTION: DKIM --}}
<section class="rounded-2xl border border-slate-700/60 bg-white/[0.04] p-5 backdrop-blur">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label class="text-sm text-slate-200">DKIM-Selector</label>
<input type="text" placeholder="dkim" wire:model.defer="dkim_selector" class="mt-1 nx-input">
@error('dkim_selector') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
</div>
<div>
<label class="text-sm text-slate-200">DKIM-Schlüssellänge (bits)</label>
<select wire:model.defer="dkim_bits" class="mt-1 nx-input">
<option value="1024">1024</option>
<option value="2048">2048</option>
<option value="3072">3072</option>
<option value="4096">4096</option>
</select>
<p class="mt-2 text-xs text-slate-300/90">
<i class="ph ph-warning-circle mr-1"></i>
2048 ist meist ideal. 4096 erzeugt sehr lange TXT-Records DNS-Provider prüfen.
</p>
@error('dkim_bits') <p class="mt-1 text-xs text-rose-400">{{ $message }}</p> @enderror
</div>
</div>
</section>
</div>
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex justify-end gap-2">
<button type="button" wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
Abbrechen
</button>
<button type="submit" wire:click="$dispatch('domain:create')"
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
Domain hinzufügen
</button>
</div>
</div>
@endpush

View File

@ -0,0 +1,67 @@
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10
backdrop-blur rounded-t-2xl relative">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">Domain löschen</h2>
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
<i class="ph ph-x"></i>
</button>
</div>
</div>
@endpush
<div class="p-6 space-y-5">
<div class="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3 text-rose-200">
<div class="flex items-center gap-2">
<i class="ph ph-warning text-[18px]"></i>
<span class="text-sm">Diese Aktion kann nicht rückgängig gemacht werden.</span>
</div>
</div>
<div class="space-y-2">
<div class="text-white/85">
Du bist im Begriff, die Domain <span class="font-semibold">{{ $domain }}</span> zu löschen.
</div>
<div class="flex flex-wrap gap-2 text-sm">
<span class="px-2 py-0.5 rounded-full border border-white/15 text-white/70 bg-white/5">
<i class="ph ph-user-list text-[12px]"></i> {{ $mailboxes }} Postfächer
</span>
<span class="px-2 py-0.5 rounded-full border border-white/15 text-white/70 bg-white/5">
<i class="ph ph-at text-[12px]"></i> {{ $aliases }} Aliasse
</span>
</div>
@if($mailboxes > 0 || $aliases > 0)
<div class="text-sm rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-amber-200 mt-5">
Löschen ist aktuell blockiert: Entferne zuerst alle Postfächer und Aliasse.
</div>
@endif
</div>
<div class="space-y-2">
<label class="block text-sm text-white/70">Zur Bestätigung tippe den Domain-Namen:</label>
<input type="text" wire:model.defer="confirm" placeholder="{{ $domain }}"
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
</div>
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex justify-end gap-2">
<button type="button" wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
Abbrechen
</button>
@php $blocked = ($mailboxes > 0 || $aliases > 0); @endphp
<button type="button" wire:click="$dispatch('domain:delete')"
class="px-4 py-2 rounded-lg text-sm
{{ $blocked ? 'border border-white/10 bg-white/5 text-white/40 cursor-not-allowed'
: 'bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30' }}"
@if($blocked) disabled @endif>
<i class="ph ph-trash-simple"></i> Endgültig löschen
</button>
</div>
</div>
@endpush

View File

@ -1,22 +1,22 @@
{{-- resources/views/livewire/domains/dns-assistant-modal.blade.php --}}
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10
backdrop-blur rounded-t-2xl relative">
<span class="absolute top-3 right-3 z-20 inline-flex items-center gap-2
rounded-full border border-slate-700/70 bg-slate-800/70
px-3 py-1 text-[11px] text-slate-200 shadow-sm">
<i class="ph ph-clock text-slate-300"></i> TTL: {{ $ttl }}
</span>
<h2 class="text-[18px] font-semibold text-slate-100">DNS-Einträge</h2>
<p class="text-[13px] text-slate-300/80">
Setze die folgenden Records für
<span class="text-sky-300 underline decoration-sky-500/40 underline-offset-2">{{ $zone }}</span>.
</p>
</div>
@endpush
<div class="relative p-5">
<span
class="absolute top-3 right-3 z-20 inline-flex items-center gap-2
rounded-full border border-slate-700/70 bg-slate-800/70
px-3 py-1 text-[11px] text-slate-200 shadow-sm backdrop-blur">
<i class="ph ph-clock text-slate-300"></i>
TTL: {{ $ttl }}
</span>
<div class="space-y-5">
<div>
<h2 class="text-[18px] font-semibold text-slate-100">DNS-Einträge</h2>
<p class="text-[13px] text-slate-300/80">
Setze die folgenden Records in deiner DNS-Zone für
<span class="text-sky-300 underline decoration-sky-500/40 underline-offset-2">{{ $base }}</span>.
</p>
</div>
{{-- GRID: links (Mail-Records), rechts (Infrastruktur) --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
{{-- Mail-Records --}}
@ -25,28 +25,24 @@
<span class="px-2 py-0.5 rounded bg-slate-800/80 text-slate-200">Step 1</span>
<span class="text-slate-300/80">Mail-Records</span>
<span class="ml-auto px-2 py-0.5 rounded bg-indigo-600/20 text-indigo-200 border border-indigo-500/20">
<i class="ph ph-seal-check mr-1"></i>System-Absenderdomain
<i class="ph ph-seal-check mr-1"></i>Absenderdomain
</span>
</div>
<div class="space-y-4">
@foreach ($dynamic as $r)
<div class="rounded-xl border border-slate-700/50 bg-slate-900/60">
<div class="rounded-xl border border-white/10 bg-white/[0.04] #rounded-xl #border #border-slate-700/50 #bg-slate-900/60">
<div class="flex items-center justify-between px-4 py-2 text-[12px]">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 rounded bg-slate-800/80 text-slate-200 {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>
<span class="text-slate-200">{{ $r['name'] }}</span>
</div>
<div class="flex items-center gap-2 text-slate-300/70">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-slate-800/70 hover:bg-slate-800 text-slate-100"
x-data @click="navigator.clipboard.writeText(@js($r['value']))">
<i class="ph ph-copy"></i> Kopieren
</button>
<x-button.copy-btn :text="$r['value']" />
</div>
</div>
<div class="px-4 pb-3">
<pre class="text-[12px] leading-5 whitespace-pre-wrap break-all font-mono text-slate-100 bg-slate-900/70 rounded-lg p-3 border border-slate-800/60">{{ $r['value'] }}</pre>
<pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>
@if(!empty($r['helpUrl']))
<a href="{{ $r['helpUrl'] }}" target="_blank" rel="noopener"
class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200">
@ -69,34 +65,34 @@
<div class="space-y-4">
@foreach ($static as $r)
<div class="rounded-xl border border-slate-700/50 bg-slate-900/60">
<div class="rounded-xl border border-white/10 bg-white/[0.04] #rounded-xl #border #border-slate-700/50 #bg-slate-900/60">
<div class="flex items-center justify-between px-4 py-2 text-[12px]">
<div class="flex items-center gap-2">
<span class="px-2 py-0.5 rounded {{ $recordColors[$r['type']] ?? 'bg-slate-700/50 text-slate-300' }}">{{ $r['type'] }}</span>
<span class="text-slate-200">{{ $r['name'] }}</span>
</div>
<div class="flex items-center gap-2 text-slate-300/70">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-slate-800/70 hover:bg-slate-800 text-slate-100"
x-data @click="navigator.clipboard.writeText(@js($r['value']))">
<i class="ph ph-copy"></i> Kopieren
</button>
<x-button.copy-btn :text="$r['value']" />
</div>
</div>
<div class="px-4 pb-3">
<pre class="text-[12px] leading-5 whitespace-pre-wrap break-all font-mono text-slate-100 bg-slate-900/70 rounded-lg p-3 border border-slate-800/60">{{ $r['value'] }}</pre>
<pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>
</div>
</div>
@endforeach
</div>
</section>
</div>
</div>
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex justify-end">
<button wire:click="$closeModal"
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r from-sky-700 to-emerald-700 text-white hover:opacity-95">
<button wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
<i class="ph ph-check"></i> Fertig
</button>
</div>
</div>
</div>
@endpush

View File

@ -0,0 +1,129 @@
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10
backdrop-blur rounded-t-2xl relative">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">Domain bearbeiten</h2>
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
<i class="ph ph-x"></i>
</button>
</div>
</div>
@endpush
<div class="p-6 space-y-5">
<p class="text-sm text-white/60 leading-relaxed">
Hier kannst du die grundlegenden Eigenschaften der Domain ändern.
DKIM- oder DNS-Einträge werden dabei nicht neu erzeugt.
</p>
<form wire:submit.prevent="save" id="domain-edit-form" class="space-y-4">
<div class="flex items-center gap-3">
<label class="inline-flex items-center gap-3 select-none">
<input type="checkbox" wire:model.defer="is_active"
class="peer appearance-none w-5 h-5 rounded-md border border-emerald-500/50 bg-transparent checked:bg-emerald-500 checked:border-emerald-400 grid place-content-center transition">
<span class="text-slate-200 text-sm">Domain aktiv</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-white/70 mb-1">Domain</label>
<input type="text" wire:model="domain" readonly
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 cursor-not-allowed">
</div>
<div>
<label class="block text-sm font-medium text-white/70 mb-1">Beschreibung</label>
<textarea wire:model.defer="description"
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm resize-none focus:border-white/20 focus:ring-0"
rows="2" placeholder="z. B. Hauptdomain für Kundenmails"></textarea>
@error('description')
<div class="text-rose-400 text-xs mt-1">{{ $message }}</div>
@enderror
</div>
<div>
<div class="flex items-center gap-2">
<label class="text-sm text-slate-200">Tags</label>
<span class="px-2 py-0.5 rounded-full text-[11px] border border-slate-600/60 bg-slate-800/70 text-slate-200">optional</span>
</div>
<div class="mt-2 space-y-2">
@foreach($tags as $i => $t)
<div class="rounded-xl border border-slate-700/60 bg-white/[0.04] p-3">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
{{-- Label --}}
<div class="md:col-span-2">
<label class="block text-xs text-slate-300/80 mb-1">Label</label>
<input
type="text"
placeholder="z. B. Kunde, Produktion"
wire:model.defer="tags.{{ $i }}.label"
class="nx-input"
>
</div>
<div>
<label class="block text-xs text-slate-300/80 mb-1">Farbe (HEX)</label>
<div class="flex items-center gap-2">
<input
type="text"
placeholder="#22c55e"
wire:model.lazy="tags.{{ $i }}.color"
class="nx-input font-mono"
>
<input
type="color"
value="{{ $t['color'] ?? '#22c55e' }}"
wire:change="pickTagColor({{ $i }}, $event.target.value)"
class="h-9 w-9 rounded-md border border-white/15 bg-white/5 p-1"
>
</div>
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<span class="text-xs text-slate-400">Palette:</span>
@foreach($tagPalette as $hex)
<button
type="button"
class="h-6 w-6 rounded-md border {{ ($t['color'] ?? '') === $hex ? 'ring-2 ring-white/80' : 'border-white/15' }}"
style="background: {{ $hex }}"
wire:click="pickTagColor({{ $i }}, '{{ $hex }}')"
title="{{ $hex }}"
></button>
@endforeach
<button
type="button"
wire:click="removeTag({{ $i }})"
class="ml-auto inline-flex items-center gap-1 rounded-md border border-white/10 px-2 py-1 text-xs text-white/70 hover:text-white hover:border-white/20"
>
<i class="ph ph-trash-simple text-[12px]"></i> Entfernen
</button>
</div>
</div>
@endforeach
</div>
<div class="mt-2">
<button
type="button"
wire:click="addTag"
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20">
<i class="ph ph-plus text-[14px]"></i> Tag hinzufügen
</button>
</div>
@error('tags.*.label') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
@error('tags.*.color') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
</div>
</form>
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex justify-end gap-2">
<button type="button" wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
Abbrechen
</button>
<button type="submit" form="domain-edit-form"
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
Speichern
</button>
</div>
</div>
@endpush

View File

@ -0,0 +1,84 @@
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10
backdrop-blur rounded-t-2xl relative">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">Domain-Limits</h2>
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
<i class="ph ph-x"></i>
</button>
</div>
</div>
@endpush
<div class="p-6 space-y-5">
<form wire:submit.prevent="save" id="domain-limits-form" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-white/70 mb-1">Max. mögliche Aliasse</label>
<input type="number" wire:model.defer="max_aliases"
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
@error('max_aliases') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
</div>
<div>
<label class="block text-sm font-medium text-white/70 mb-1">Max. mögliche Mailboxen</label>
<input type="number" wire:model.defer="max_mailboxes"
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
@error('max_mailboxes') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-white/70 mb-1">Standard-Quota je Mailbox (MiB)</label>
<input type="number" wire:model.defer="default_quota_mb"
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
@error('default_quota_mb') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
</div>
<div>
<label class="block text-sm font-medium text-white/70 mb-1">Speicher je Mailbox (MiB, optional)</label>
<input type="number" wire:model.defer="max_quota_per_mailbox_mb"
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
@error('max_quota_per_mailbox_mb') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
</div>
</div>
<div>
<label class="block text-sm font-medium text-white/70 mb-1">Domain-Speicherplatz gesamt (MiB)</label>
<input type="number" wire:model.defer="total_quota_mb"
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
@error('total_quota_mb') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-white/70 mb-1">Mails pro Stunde (optional)</label>
<input type="number" wire:model.defer="rate_limit_per_hour" placeholder="leer = kein Limit"
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0">
@error('rate_limit_per_hour') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
</div>
<div class="flex items-center gap-3 md:mt-7">
<label class="inline-flex items-center gap-3 select-none">
<input type="checkbox" wire:model.defer="rate_limit_override"
class="peer appearance-none w-5 h-5 rounded-md border border-emerald-500/50 bg-transparent checked:bg-emerald-500 checked:border-emerald-400 grid place-content-center transition">
<span class="text-slate-200">Postfach-Overrides erlauben</span>
</label>
</div>
</div>
</form>
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex justify-end gap-2">
<button type="button" wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
Abbrechen
</button>
<button type="submit" form="domain-limits-form"
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
Speichern
</button>
</div>
</div>
@endpush

View File

@ -0,0 +1,158 @@
<div class="space-y-4">
{{-- Suche + Aktion --}}
<div class="">
<div class="md:flex items-center justify-between gap-3">
<h1 class="text-5xl #mb-6">Aliase</h1>
</div>
<div class="my-4 md:flex items-center justify-end gap-3">
<input
type="text"
wire:model.live.debounce.300ms="search"
class="md:max-w-96 w-full justify-end flex-1 rounded-xl bg-white/5 border border-white/10 px-4 py-2.5 text-white/90 placeholder:text-white/50"
placeholder="Suchen (Alias) …">
</div>
</div>
@if($domains->count())
<div class="space-y-3">
@foreach($domains as $domain)
<section class="glass-card rounded-xl border border-white/10 p-3">
{{-- Domain-Kopf --}}
<div class="grid grid-cols-1 md:grid-cols-3 items-center gap-3">
{{-- <div--}}
{{-- class="flex items-center gap-1 w-fit px-2 py-0.5 rounded-full text-xs border border-white/15 text-white font-bold bg-white/5">--}}
{{-- <i class="ph ph-globe-hemisphere-east text-[14px]"></i> {{ $domain->domain }}--}}
{{-- </div>--}}
<div class="shrink-0 flex items-center gap-2 flex-wrap md:flex-nowrap">
<div
class="flex items-center gap-1 w-fit px-2 py-0.5 rounded-full text-xs border border-white/15 text-white font-bold bg-white/5">
<i class="ph ph-globe-hemisphere-east text-[14px]"></i> {{ $domain->domain }}
</div>
{{-- <span--}}
{{-- class="px-2 py-0.5 rounded-full text-xs border {{ ($domain->is_active ?? true) ? 'border-emerald-400/30 text-emerald-300 bg-emerald-500/10' : 'border-white/15 text-white/60 bg-white/5' }}">--}}
{{-- {{ ($domain->is_active ?? true) ? 'Aktiv' : 'Inaktiv' }}--}}
{{-- </span>--}}
<span
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">
{{ $domain->mail_aliases_count ?? $domain->mailAliases_count ?? $domain->mailAliases->count() }} Aliasse
</span>
</div>
<div class="md:col-span-2 text-right space-x-1">
<button
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
wire:click="openAliasCreate({{ $domain->id }})">
<i class="ph ph-plus text-[12px]"></i> Alias
</button>
</div>
</div>
{{-- Aliasse --}}
<div class="mt-4 overflow-hidden">
@if($domain->mailAliases->isEmpty())
<div class="px-3 py-3 text-center text-xs bg-white/[0.04] text-white/60 italic">Keine
Aliasse
</div>
@else
<ul class="divide-y divide-white/5 space-y-3">
@foreach($domain->mailAliases as $alias)
<li class="">
<div
class="rounded-xl border border-white/10 flex flex-col md:flex-row md:items-center gap-3 px-3 py-3 bg-white/[0.04]">
{{-- Alias + Badges --}}
<div class="min-w-0 flex-1">
{{-- Pfeil-Zeile --}}
<div
class="text-white/90 font-semibold truncate flex items-center gap-2">
<span
class="truncate text-xs px-2 py-0.5 rounded-md bg-white/5 text-white/80 border border-white/10">{{ $alias->sourceEmail }}</span>
@if($alias->effective_active)
<i class="ph ph-play text-emerald-400 animate-pulse text-xs"></i>
@else
<i class="ph ph-pause text-white/30 text-xs"></i>
@endif
<span
class="truncate text-xs px-2 py-0.5 rounded-md bg-white/5 text-white/80 border border-white/10">{{ $alias->arrowTarget }}</span>
</div>
{{-- Badges --}}
<div class="mt-2 flex items-center gap-2 flex-wrap md:flex-nowrap">
@if(!$alias->effective_active && $alias->inactive_reason)
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-amber-400/30 text-amber-300 bg-amber-500/10 whitespace-nowrap">
{{ $alias->inactive_reason }}
</span>
@else
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border whitespace-nowrap border-emerald-400/30 text-emerald-300 bg-emerald-500/10">
Aktiv
</span>
@endif
@if($alias->isSingle)
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border
border-sky-400/30 text-sky-200 bg-sky-500/10 whitespace-nowrap">
Single
</span>
@else
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border
border-fuchsia-400/30 text-fuchsia-200 bg-fuchsia-500/10 whitespace-nowrap">
Gruppe
</span>
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">
Empfänger: {{ $alias->recipientCount }}
</span>
@endif
@if(!empty($alias->notes))
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/60 bg-white/5 whitespace-nowrap">
<i class="ph ph-note text-[12px] mr-1"></i> Notiz
</span>
@endif
</div>
</div>
<hr class="md:hidden">
{{-- Aktionen --}}
<div class="md:ml-3 flex items-center gap-2 md:justify-end">
<button
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white/80 hover:text-white hover:border-white/20"
wire:click="openAliasEdit({{ $alias->id }})">
<i class="ph ph-pencil-simple text-[12px]"></i> Bearbeiten
</button>
<button
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30"
wire:click="openAliasDelete({{ $alias->id }})">
<i class="ph ph-trash-simple text-[12px]"></i> Löschen
</button>
</div>
</div>
</li>
@endforeach
</ul>
@endif
</div>
</section>
@endforeach
</div>
@else
<div class="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-6 text-white/75 text-center">
Noch keine Aliasse vorhanden.
</div>
@endif
</div>

View File

@ -0,0 +1,330 @@
<div class="space-y-4">
{{-- Suche + Aktion --}}
<div class="">
<div class="md:flex items-center justify-between gap-3">
<h1 class="text-5xl #mb-6">Mailboxen</h1>
</div>
<div class="my-4 md:flex items-center justify-end gap-3">
<input
type="text"
wire:model.live.debounce.300ms="search"
class="md:max-w-96 w-full justify-end flex-1 rounded-xl bg-white/5 border border-white/10 px-4 py-2.5 text-white/90 placeholder:text-white/50"
placeholder="Suchen (Domain oder Postfach) …">
</div>
@if($domains->count())
<div class="space-y-3">
@foreach($domains as $domain)
<div class="glass-card rounded-xl border border-white/10 #bg-white/[0.04] p-3">
{{-- Domain-Kopf --}}
<div class="grid grid-cols-1 md:grid-cols-3 items-center gap-3">
{{-- Badges: auf md niemals umbrechen --}}
<div class="shrink-0 flex items-center gap-2 flex-wrap md:flex-nowrap">
<div
class="flex items-center gap-1 w-fit px-2 py-0.5 rounded-full text-xs border border-white/15 text-white font-bold bg-white/5">
<i class="ph ph-globe-hemisphere-east text-[14px]"></i> {{ $domain->domain }}
</div>
{{-- <span--}}
{{-- class="px-2 py-0.5 rounded-full text-xs border {{ ($domain->is_active ?? true) ? 'border-emerald-400/30 text-emerald-300 bg-emerald-500/10' : 'border-white/15 text-white/60 bg-white/5' }}">--}}
{{-- {{ ($domain->is_active ?? true) ? 'aktiv' : 'inaktiv' }}--}}
{{-- </span>--}}
<span
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">
<i class="ph ph-user-list text-[12px]"></i> {{ $domain->mail_users_count }} Postfächer
</span>
</div>
<div class="md:col-span-2 text-right space-x-1">
<button
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-white/80 hover:text-white hover:border-white/20"
wire:click="openMailboxCreate({{ $domain->id }})">
<i class="ph ph-plus text-[12px]"></i> Postfach
</button>
</div>
</div>
{{-- Postfächer --}}
{{-- <div class="mt-4 overflow-hidden">--}}
{{-- <div class="divide-y divide-white/5 space-y-3">--}}
{{-- @forelse($domain->prepared_mailboxes as $u)--}}
{{-- <div class="rounded-xl border border-white/10 flex flex-col md:flex-row md:items-center gap-3 px-3 py-3 bg-white/[0.04]">--}}
{{-- <div class="min-w-0 flex-1">--}}
{{-- <div class="text-white/90 font-medium truncate">--}}
{{-- {{ $u['localpart'] !== '' ? ($u['localpart'].'@'.$domain->domain) : '—' }}--}}
{{-- </div>--}}
{{-- <div class="mt-1 flex items-center gap-2 flex-wrap md:flex-nowrap">--}}
{{-- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border whitespace-nowrap--}}
{{-- {{ $u['is_effective_active'] ? 'border-emerald-400/30 text-emerald-300 bg-emerald-500/10'--}}
{{-- : 'border-white/15 text-white/60 bg-white/5' }}">--}}
{{-- {{ $u['is_effective_active'] ? 'aktiv' : 'inaktiv' }}--}}
{{-- </span>--}}
{{-- @if(!$u['is_effective_active'] && !empty($u['inactive_reason']))--}}
{{-- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-amber-400/30 text-amber-300 bg-amber-500/10 whitespace-nowrap">--}}
{{-- {{ $u['inactive_reason'] }}--}}
{{-- </span>--}}
{{-- @endif--}}
{{-- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">--}}
{{-- Max {{ $u['quota_mb'] }} MiB--}}
{{-- </span>--}}
{{-- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">--}}
{{-- Verbraucht: {{ $u['used_mb'] }} MiB ({{ $u['usage_percent'] }} %)--}}
{{-- </span>--}}
{{-- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5 whitespace-nowrap">--}}
{{-- <i class="ph ph-at text-[12px] mr-1"></i> {{ $u['message_count'] }} E-Mails--}}
{{-- </span>--}}
{{-- </div>--}}
{{-- <div class="mt-2 h-1.5 rounded-full bg-white/10 overflow-hidden {{ $u['is_effective_active'] ? '' : 'opacity-40' }}">--}}
{{-- <div class="h-full rounded-full bg-emerald-400/50" style="width: {{ $u['usage_percent'] }}%"></div>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- <hr class="md:hidden">--}}
{{-- Aktionen--}}
{{-- <div class="md:ml-3 flex items-center gap-2 md:justify-end">--}}
{{-- Bearbeiten--}}
{{-- <button--}}
{{-- class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white/80 hover:text-white hover:border-white/20"--}}
{{-- wire:click="openMailboxEdit({{ $u['id'] }})">--}}
{{-- <i class="ph ph-pencil-simple text-[12px]"></i> Bearbeiten--}}
{{-- </button>--}}
{{-- Löschen--}}
{{-- <button--}}
{{-- class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30"--}}
{{-- wire:click="openMailboxDelete({{ $u['id'] }})">--}}
{{-- <i class="ph ph-trash-simple text-[12px]"></i> Löschen--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @empty--}}
{{-- <div class="px-3 py-3 text-center text-xs bg-white/[0.04] text-white/60 italic">Keine Postfächer</div>--}}
{{-- @endforelse--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- Postfächer (responsive: Cards < md, Table md) --}}
<div class="mt-4">
{{-- MOBILE: Card-View --}}
<div class="md:hidden space-y-3">
@forelse($domain->prepared_mailboxes as $u)
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="font-semibold text-white truncate">
{{ $u['localpart'] ? ($u['localpart'].'@'.$domain->domain) : '—' }}
</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<span class="px-2 py-0.5 rounded-full text-xs border
{{ $u['is_effective_active'] ? 'border-emerald-400/30 text-emerald-300 bg-emerald-500/10'
: 'border-white/15 text-white/60 bg-white/5' }}">
{{ $u['is_effective_active'] ? 'aktiv' : 'inaktiv' }}
</span>
{{-- Optional: Grund anzeigen --}}
@if(!$u['is_effective_active'] && !empty($u['inactive_reason']))
<span
class="px-2 py-0.5 rounded-full text-xs border border-amber-400/30 text-amber-300 bg-amber-500/10">
{{ $u['inactive_reason'] }}
</span>
@endif
<span
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5">
{{ $u['quota_mb'] }} MiB
</span>
<span
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5">
Verbraucht: {{ $u['used_mb'] }} MiB ({{ $u['usage_percent'] }} %)
</span>
<span
class="px-2 py-0.5 rounded-full text-xs border border-white/15 text-white/70 bg-white/5">
<i class="ph ph-at mr-1 text-[12px]"></i> {{ $u['message_count'] }} E-Mails
</span>
</div>
</div>
<div class="shrink-0 flex flex-col gap-2">
<button
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white/80 hover:text-white hover:border-white/20"
wire:click="openMailboxEdit({{ $u['id'] }})">
<i class="ph ph-pencil-simple text-[12px]"></i> Bearbeiten
</button>
<button
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30"
wire:click="openMailboxDelete({{ $u['id'] }})">
<i class="ph ph-trash-simple text-[12px]"></i> Löschen
</button>
</div>
</div>
{{-- Progress --}}
<div
class="mt-3 h-1.5 rounded-full bg-white/10 overflow-hidden {{ $u['is_effective_active'] ? '' : 'opacity-40' }}">
<div class="h-full rounded-full bg-emerald-400/50"
style="width: {{ $u['usage_percent'] }}%"></div>
</div>
</div>
@empty
<div class="px-3 py-3 text-center text-xs bg-white/5 text-white/60 italic">Keine
Postfächer
</div>
@endforelse
</div>
{{-- DESKTOP: Table-View --}}
{{-- <div class="hidden md:block overflow-x-auto">--}}
{{-- <table class="min-w-full text-sm">--}}
{{-- <thead>--}}
{{-- <tr class="text-white/60 bg-white/5">--}}
{{-- <th class="px-3 py-2 text-left font-medium">E-Mail</th>--}}
{{-- <th class="px-3 py-2 text-left font-medium">Status</th>--}}
{{-- <th class="px-3 py-2 text-left font-medium">Quota</th>--}}
{{-- <th class="px-3 py-2 text-left font-medium">Verbrauch</th>--}}
{{-- <th class="px-3 py-2 text-left font-medium"><i class="ph ph-at"></i> E-Mails</th>--}}
{{-- <th class="px-3 py-2 text-left font-medium w-64">Auslastung</th>--}}
{{-- <th class="px-3 py-2 text-right font-medium">Aktionen</th>--}}
{{-- </tr>--}}
{{-- </thead>--}}
{{-- <tbody class="divide-y divide-white/5">--}}
{{-- @forelse($domain->prepared_mailboxes as $u)--}}
{{-- <tr class="hover:bg-white/[0.04]">--}}
{{-- <td class="px-3 py-2 font-medium text-white truncate max-w-[280px]">--}}
{{-- {{ $u['localpart'] ? ($u['localpart'].'@'.$domain->domain) : '—' }}--}}
{{-- </td>--}}
{{-- <td class="px-3 py-2">--}}
{{-- <span class="px-2 py-0.5 rounded-full text-xs border--}}
{{-- {{ $u['is_effective_active'] ? 'border-emerald-400/30 text-emerald-300 bg-emerald-500/10'--}}
{{-- : 'border-white/15 text-white/60 bg-white/5' }}">--}}
{{-- {{ $u['is_effective_active'] ? 'aktiv' : 'inaktiv' }}--}}
{{-- </span>--}}
{{-- @if(!$u['is_effective_active'] && !empty($u['inactive_reason']))--}}
{{-- <span class="ml-2 px-2 py-0.5 rounded-full text-xs border border-amber-400/30 text-amber-300 bg-amber-500/10">--}}
{{-- {{ $u['inactive_reason'] }}--}}
{{-- </span>--}}
{{-- @endif--}}
{{-- </td>--}}
{{-- <td class="px-3 py-2 text-white/80">{{ $u['quota_mb'] }} MiB</td>--}}
{{-- <td class="px-3 py-2 text-white/80">{{ $u['used_mb'] }} MiB ({{ $u['usage_percent'] }} %)</td>--}}
{{-- <td class="px-3 py-2 text-white/80">{{ $u['message_count'] }}</td>--}}
{{-- <td class="px-3 py-2">--}}
{{-- <div class="h-1.5 rounded-full bg-white/10 overflow-hidden {{ $u['is_effective_active'] ? '' : 'opacity-40' }}">--}}
{{-- <div class="h-full rounded-full bg-emerald-400/50" style="width: {{ $u['usage_percent'] }}%"></div>--}}
{{-- </div>--}}
{{-- </td>--}}
{{-- <td class="px-3 py-2">--}}
{{-- <div class="flex items-center gap-2 justify-end">--}}
{{-- <button--}}
{{-- class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white/80 hover:text-white hover:border-white/20"--}}
{{-- wire:click="openMailboxEdit({{ $u['id'] }})">--}}
{{-- <i class="ph ph-pencil-simple text-[12px]"></i> Bearbeiten--}}
{{-- </button>--}}
{{-- <button--}}
{{-- class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30"--}}
{{-- wire:click="openMailboxDelete({{ $u['id'] }})">--}}
{{-- <i class="ph ph-trash-simple text-[12px]"></i> Löschen--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- </td>--}}
{{-- </tr>--}}
{{-- @empty--}}
{{-- <tr>--}}
{{-- <td colspan="7" class="px-3 py-4 text-center text-xs text-white/60 italic">--}}
{{-- Keine Postfächer--}}
{{-- </td>--}}
{{-- </tr>--}}
{{-- @endforelse--}}
{{-- </tbody>--}}
{{-- </table>--}}
{{-- </div>--}}
<div class="hidden md:block overflow-x-auto">
<table class="min-w-full text-sm border-separate border-spacing-y-2">
{{-- HEADER: komplette Kapsel, alle Ecken rund --}}
<thead>
<tr class="bg-white/[0.06] text-white/70">
<th class="px-3 py-2 text-left font-medium rounded-l-xl">E-Mail</th>
<th class="px-3 py-2 text-left font-medium">Status</th>
<th class="px-3 py-2 text-left font-medium">Quota</th>
<th class="px-3 py-2 text-left font-medium">Verbrauch</th>
<th class="px-3 py-2 text-left font-medium"><i class="ph ph-at"></i> E-Mails
</th>
<th class="px-3 py-2 text-left font-medium">Auslastung</th>
<th class="px-3 py-2 text-right font-medium rounded-r-xl">Aktionen</th>
</tr>
</thead>
<tbody>
@forelse($domain->prepared_mailboxes as $u)
{{-- ROW: Kapsel beim Hover, ohne Border/Ring --}}
<tr class="bg-white/[0.03] hover:bg-white/[0.06] transition-colors duration-200">
<td class="px-3 py-2 font-medium text-white truncate max-w-[280px] rounded-l-xl">
{{ $u['localpart'] ? ($u['localpart'].'@'.$domain->domain) : '—' }}
</td>
<td class="px-3 py-2">
@if(!$u['is_effective_active'] && !empty($u['inactive_reason']))
<span class="#ml-2 px-2 py-0.5 rounded-full text-xs border border-amber-400/30 text-amber-300 bg-amber-500/10">
{{ $u['inactive_reason'] }}
</span>
@else
<span class="px-2 py-0.5 rounded-full text-xs border border-emerald-400/30 text-emerald-300 bg-emerald-500/10">
Aktiv
</span>
@endif
</td>
<td class="px-3 py-2 text-white/80">{{ $u['quota_mb'] }} MiB</td>
<td class="px-3 py-2 text-white/80">{{ $u['used_mb'] }} MiB
({{ $u['usage_percent'] }} %)
</td>
<td class="px-3 py-2 text-white/80">{{ $u['message_count'] }}</td>
<td class="px-3 py-2">
<div
class="h-1.5 rounded-full bg-white/10 overflow-hidden {{ $u['is_effective_active'] ? '' : 'opacity-40' }}">
<div class="h-full rounded-full bg-emerald-400/50"
style="width: {{ $u['usage_percent'] }}%"></div>
</div>
</td>
<td class="px-3 py-2 rounded-r-xl">
<div class="flex items-center gap-2 justify-end">
<button wire:click="openMailboxEdit({{ $u['id'] }})"
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white/80 hover:text-white hover:border-white/20">
<i class="ph ph-pencil-simple text-[12px]"></i> Bearbeiten
</button>
<button wire:click="openMailboxDelete({{ $u['id'] }})"
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30">
<i class="ph ph-trash-simple text-[12px]"></i> Löschen
</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-3 py-4 text-center text-xs text-white/60 italic">
Keine Postfächer
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@endforeach
</div>
@else
<div class="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-6 text-white/75 text-center">
Noch keine Mailboxen vorhanden.
</div>
@endif
</div>

View File

@ -0,0 +1,99 @@
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10 backdrop-blur rounded-t-2xl">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">Alias löschen</h2>
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
<i class="ph ph-x"></i>
</button>
</div>
</div>
@endpush
<div class="p-6 space-y-5">
{{-- Warnung --}}
<div class="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3 text-rose-200">
<div class="flex items-center gap-2">
<i class="ph ph-warning text-[18px]"></i>
<span class="text-sm">Diese Aktion kann nicht rückgängig gemacht werden.</span>
</div>
</div>
{{-- Alias-Info --}}
<div class="space-y-2 text-white/85">
<div>
Du bist im Begriff, den Alias
<span class="font-semibold text-white">{{ $aliasEmail }}</span>
zu löschen.
</div>
<div class="flex items-center gap-2 text-sm">
<span class="px-2 py-0.5 rounded-full border border-white/15 text-white/70 bg-white/5">
Typ: {{ $isSingle ? 'Single' : 'Gruppe' }}
</span>
<span class="px-2 py-0.5 rounded-full border border-white/15 text-white/70 bg-white/5">
Weiterleitung: {{ $targetLabel }}
</span>
</div>
@if($extraRecipients > 0)
<div class="text-xs text-white/60">+{{ $extraRecipients }} weitere Empfänger</div>
@endif
</div>
{{-- Bestätigung --}}
<div class="space-y-2">
<label class="block text-sm text-white/70">
Zur Bestätigung tippe die Alias-Adresse ein:
</label>
<input
type="text"
wire:model.live="confirm"
placeholder="{{ $aliasEmail }}"
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0"
/>
@error('confirm') <div class="text-rose-400 text-xs mt-1">{{ $message }}</div> @enderror
</div>
</div>
@push('modal.footer')
<div x-data="{ valid: @entangle('confirmMatches') }"
class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex justify-end gap-2">
<button type="button" wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
Abbrechen
</button>
<button
type="button"
wire:click="$dispatch('alias:delete')"
:disabled="!valid"
:class="valid
? 'bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30'
: 'border border-white/10 bg-white/5 text-white/40 cursor-not-allowed'"
class="px-4 py-2 rounded-lg text-sm transition-colors">
<i class="ph ph-trash-simple"></i> Endgültig löschen
</button>
</div>
</div>
{{-- <div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">--}}
{{-- <div class="flex justify-end gap-2">--}}
{{-- <button type="button" wire:click="$dispatch('closeModal')"--}}
{{-- class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">--}}
{{-- Abbrechen--}}
{{-- </button>--}}
{{-- <button--}}
{{-- type="button"--}}
{{-- wire:click="$dispatch('alias:delete')"--}}
{{-- @disabled(! $confirmMatches)--}}
{{-- class="px-4 py-2 rounded-lg text-sm--}}
{{-- {{ $confirmMatches--}}
{{-- ? 'bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30'--}}
{{-- : 'border border-white/10 bg-white/5 text-white/40 cursor-not-allowed' }}">--}}
{{-- <i class="ph ph-trash-simple"></i> Endgültig löschen--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- </div>--}}
@endpush

View File

@ -0,0 +1,322 @@
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10 backdrop-blur rounded-t-2xl">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-white">
{{ $aliasId ? 'Alias bearbeiten' : 'Alias anlegen' }}
</h2>
<p class="text-[13px] text-white/60">
{{ $aliasId ? 'Passe den Alias und seine Empfänger an.' : 'Lege Adresse und Empfänger fest.' }}
</p>
</div>
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
<i class="ph ph-x"></i>
</button>
</div>
</div>
@endpush
<div class="p-6 space-y-5">
{{-- alles in ein Formular, wie beim Limits-Modal --}}
<form id="alias-form" wire:submit.prevent="save" class="space-y-4">
{{-- Domain + Adresse + Status --}}
<div class="flex items-end">
<label class="inline-flex items-center gap-3 select-none">
<input type="checkbox" wire:model="is_active"
class="peer appearance-none w-5 h-5 rounded-md border border-emerald-500/50 bg-transparent
checked:bg-emerald-500 checked:border-emerald-400 grid place-content-center transition">
<span class="text-slate-200">Alias aktivieren</span>
</label>
</div>
{{-- Row 1: Domain + Typ --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{{-- DOMAIN (TailwindPlus Elements) --}}
<div class="relative" wire:ignore id="domain-select-{{ $this->getId() }}">
<label class="block text-xs text-white/60 mb-1">Domain</label>
<el-select id="el-domain-{{ $this->getId() }}" name="domain_id" class="block w-full">
{{-- Trigger --}}
<button type="button"
class="grid w-full grid-cols-1 cursor-default rounded-2xl bg-white/5 border border-white/10 px-3.5 h-11 text-white/90">
<el-selectedcontent class="col-start-1 row-start-1 flex items-center gap-2 pr-6">
<i class="ph ph-globe-hemisphere-west text-white/60 text-[16px]"></i>
<span class="block truncate" data-selected-label>
{{ optional($domain)->domain ?? 'Domain wählen' }}
</span>
</el-selectedcontent>
<i class="ph ph-caret-down col-start-1 row-start-1 self-center justify-self-end text-white/50"></i>
</button>
{{-- Optionen --}}
<el-options anchor="bottom start" popover
class="max-h-60 w-(--button-width) overflow-auto rounded-2xl bg-gray-900/95 backdrop-blur
py-1 text-base outline-1 -outline-offset-1 outline-white/10 shadow-xl sm:text-sm
data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0">
@foreach($domains as $d)
<el-option
value="{{ $d->id }}"
data-value="{{ $d->id }}"
data-label="{{ $d->domain }}"
@if($domain && $domain->id === $d->id) aria-selected="true" @endif
class="group/option relative block cursor-default py-2 pr-9 pl-3 text-white/90 select-none
hover:bg-white/5 focus:bg-white/10 focus:text-white focus:outline-hidden"
onclick="(function(el){
const root = el.closest('#domain-select-{{ $this->getId() }}');
root.querySelectorAll('el-option').forEach(o => o.removeAttribute('aria-selected'));
el.setAttribute('aria-selected','true');
const labelEl = root.querySelector('[data-selected-label]');
if (labelEl) { labelEl.textContent = el.dataset.label; }
@this.set('domainId', parseInt(el.dataset.value, 10));
root.querySelector('button')?.focus();
})(this)">
<div class="flex items-center gap-2">
<i class="ph ph-globe-hemisphere-west text-white/60"></i>
<span
class="block truncate group-aria-selected/option:font-semibold">{{ $d->domain }}</span>
</div>
<span class="absolute inset-y-0 right-0 hidden items-center pr-3 text-emerald-300
group-aria-selected/option:flex in-[el-selectedcontent]:hidden">
<i class="ph ph-check text-[18px]"></i>
</span>
</el-option>
@endforeach
</el-options>
</el-select>
@error('domainId')
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div> @enderror
</div>
{{-- TYP (TailwindPlus Elements) --}}
<div class="relative" wire:ignore id="type-select-{{ $this->getId() }}">
<label class="block text-xs text-white/60 mb-1">Typ</label>
<el-select id="el-type-{{ $this->getId() }}" name="alias_type" class="block w-full">
{{-- Trigger --}}
<button type="button"
class="grid w-full grid-cols-1 cursor-default rounded-2xl bg-white/5 border border-white/10 px-3.5 h-11 text-white/90">
<el-selectedcontent class="col-start-1 row-start-1 flex items-center gap-2 pr-6">
<i class="ph {{ $type === 'group' ? 'ph-users' : 'ph-user' }} text-white/60 text-[16px]"></i>
<span class="block truncate" data-selected-label-type>
{{ $type === 'group' ? 'Gruppe' : 'Single' }}
</span>
</el-selectedcontent>
<i class="ph ph-caret-down col-start-1 row-start-1 self-center justify-self-end text-white/50"></i>
</button>
{{-- Optionen --}}
<el-options anchor="bottom start" popover
class="max-h-60 w-(--button-width) overflow-auto rounded-2xl bg-gray-900/95 backdrop-blur
py-1 text-base outline-1 -outline-offset-1 outline-white/10 shadow-xl sm:text-sm
data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0">
{{-- Single --}}
<el-option data-value="single" data-label="Single"
@if($type === 'single') aria-selected="true" @endif
class="group/option relative block cursor-default py-2 pr-9 pl-3 text-white/90 select-none
hover:bg-white/5 focus:bg-white/10 focus:text-white focus:outline-hidden"
onclick="(function(el){
const root = el.closest('#type-select-{{ $this->getId() }}');
root.querySelectorAll('el-option').forEach(o => o.removeAttribute('aria-selected'));
el.setAttribute('aria-selected','true');
const lbl = root.querySelector('[data-selected-label-type]');
if (lbl) { lbl.textContent = el.dataset.label; }
// Icon im Trigger wechseln
const icon = root.querySelector('.ph-user, .ph-users');
if (icon) { icon.className = 'ph ph-user text-white/60 text-[16px]'; }
@this.set('type', 'single');
root.querySelector('button')?.focus();
})(this)">
<div class="flex items-center gap-2">
<i class="ph ph-user text-white/60"></i>
<span class="block truncate group-aria-selected/option:font-semibold">Single</span>
</div>
<span class="absolute inset-y-0 right-0 hidden items-center pr-3 text-emerald-300
group-aria-selected/option:flex in-[el-selectedcontent]:hidden">
<i class="ph ph-check text-[18px]"></i>
</span>
</el-option>
{{-- Gruppe --}}
<el-option data-value="group" data-label="Gruppe"
@if($type === 'group') aria-selected="true" @endif
class="group/option relative block cursor-default py-2 pr-9 pl-3 text-white/90 select-none
hover:bg-white/5 focus:bg-white/10 focus:text-white focus:outline-hidden"
onclick="(function(el){
const root = el.closest('#type-select-{{ $this->getId() }}');
root.querySelectorAll('el-option').forEach(o => o.removeAttribute('aria-selected'));
el.setAttribute('aria-selected','true');
const lbl = root.querySelector('[data-selected-label-type]');
if (lbl) { lbl.textContent = el.dataset.label; }
const icon = root.querySelector('.ph-user, .ph-users');
if (icon) { icon.className = 'ph ph-users text-white/60 text-[16px]'; }
@this.set('type', 'group');
root.querySelector('button')?.focus();
})(this)">
<div class="flex items-center gap-2">
<i class="ph ph-users text-white/60"></i>
<span class="block truncate group-aria-selected/option:font-semibold">Gruppe</span>
</div>
<span class="absolute inset-y-0 right-0 hidden items-center pr-3 text-emerald-300
group-aria-selected/option:flex in-[el-selectedcontent]:hidden">
<i class="ph ph-check text-[18px]"></i>
</span>
</el-option>
</el-options>
</el-select>
@error('type')
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div> @enderror
</div>
</div>
{{-- Row 2: Adresse (volle Breite, gleiche Höhe) --}}
<div>
<label class="block text-xs text-white/60 mb-1">Adresse</label>
<div class="flex">
<input type="text" wire:model.defer="local" placeholder="info"
class="flex-1 rounded-l-2xl bg-white/5 border border-white/10 text-white px-3 h-11 text-sm focus:border-white/20 focus:ring-0">
<span
class="inline-flex items-center px-3 rounded-r-2xl border border-l-0 border-white/10 bg-white/5 text-white/70 text-sm h-11">
@ {{ optional($domain)->domain }}
</span>
</div>
@error('local')
<div class="mt-1 text-xs text-rose-300">{{ $message }}</div> @enderror
</div>
@if($type === 'group')
<div>
<label class="block text-xs text-white/60 mb-1">Gruppenname (optional)</label>
<input type="text" wire:model.defer="group_name"
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 h-11 text-sm focus:border-white/20 focus:ring-0"
placeholder="z. B. Office Gruppe">
@error('group_name') <div class="mt-1 text-xs text-rose-300">{{ $message }}</div> @enderror
</div>
@endif
{{-- Empfänger --}}
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm text-white/80">Empfänger</h3>
<div class="text-end">
@if($type === 'single')
<div class="text-[11px] text-white/50 mt-1">Bei „Single“ ist nur ein Empfänger erlaubt.</div>
@else
<button type="button"
wire:click="addRecipientRow"
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-xs
text-white/80 hover:text-white hover:border-white/20 disabled:opacity-40 disabled:cursor-not-allowed"
@disabled(! $canAddRecipient)>
<i class="ph ph-plus"></i> Empfänger
</button>
@endif
</div>
</div>
@error('recipients')
<div class="text-xs text-rose-300">{{ $message }}</div>
@enderror
{{-- WICHTIG: stabiler Container-Key um die Liste --}}
<div class="space-y-4" wire:key="recipients-container">
@foreach ($recipients as $idx => $r)
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-3"
wire:key="recipient-{{ $r['id'] }}">
{{-- Labels (dynamisches Grid 3 oder 4 Spalten je nach Typ) --}}
<div class="grid {{ $type === 'single' ? 'grid-cols-[5fr_auto_5fr]' : 'grid-cols-[5fr_auto_5fr_auto]' }} gap-3 mb-1 text-xs text-white/60">
<div>Interner Empfänger (Postfach)</div>
<div></div>
<div>Externe E-Mail</div>
@if($type === 'group')
<div></div>
@endif
</div>
{{-- Eingaben + oder + (optional) Löschen --}}
<div class="grid {{ $type === 'single' ? 'grid-cols-[5fr_auto_5fr]' : 'grid-cols-[5fr_auto_5fr_auto]' }} gap-3 items-center">
{{-- Interner Empfänger --}}
<div>
<select
wire:model.live="recipients.{{ $idx }}.mail_user_id"
class="w-full h-11 rounded-2xl bg-white/5 border border-white/10 text-white px-3 text-sm
focus:border-white/20 focus:ring-0 disabled:opacity-40 disabled:cursor-not-allowed"
@disabled($rowState[$idx]['disable_internal'] ?? false)>
<option value=""></option>
@foreach($domainMailUsers as $mu)
<option value="{{ $mu->id }}"
@disabled(in_array($mu->id, $disabledMailboxIdsByRow[$idx] ?? [], true))>
{{ $mu->localpart . '@' . optional($domain)->domain }}
</option>
@endforeach
</select>
</div>
{{-- ODER --}}
<div class="flex items-center justify-center text-white/50 select-none">oder</div>
{{-- Externe E-Mail --}}
<div>
<input type="email"
wire:model.live="recipients.{{ $idx }}.email"
placeholder="user@example.com"
class="w-full h-11 rounded-2xl bg-white/5 border border-white/10 text-white px-3 text-sm
focus:border-white/20 focus:ring-0 disabled:opacity-40 disabled:cursor-not-allowed"
@disabled($rowState[$idx]['disable_external'] ?? false)>
@error("recipients.$idx.email")
<div class="mt-1 text-xs text-rose-300">{{ $message }}</div>
@enderror
</div>
{{-- Löschen (nur bei Gruppe sichtbar) --}}
@if($type === 'group')
<div class="flex items-center justify-center">
<button type="button"
wire:click="removeRecipientRow({{ $idx }})"
class="size-7 grid place-items-center rounded-lg bg-rose-500/20 text-rose-200
border border-rose-400/40 hover:bg-rose-500/30
disabled:opacity-40 disabled:cursor-not-allowed">
<i class="ph ph-trash-simple text-base"></i>
</button>
</div>
@endif
</div>
</div>
@endforeach
</div>
@if($type === 'single' && count($recipients) > 1)
<div class="text-xs text-amber-300">
Hinweis: Bei „Single“ wird nur der erste Empfänger gespeichert.
</div>
@endif
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Notizen (optional)</label>
<textarea wire:model.defer="notes" rows="3"
class="w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm focus:border-white/20 focus:ring-0"></textarea>
@error('notes')
<div class="mt-1 text-xs text-rose-300">{{ $message }}</div> @enderror
</div>
</form>
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex justify-end gap-2">
<button type="button" wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
Abbrechen
</button>
<button type="submit" form="alias-form"
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
{{ $aliasId ? 'Speichern' : 'Anlegen' }}
</button>
</div>
</div>
@endpush

View File

@ -0,0 +1,199 @@
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10
backdrop-blur rounded-t-2xl relative">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">Neues Postfach</h2>
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
<i class="ph ph-x"></i>
</button>
</div>
</div>
@endpush
<div class="p-6">
@if(!$can_create)
<div class="mb-5">
<div class="rounded-xl border border-yellow-400/30 bg-yellow-500/10 text-yellow-200 text-sm px-3 py-2">
<i class="ph ph-warning text-[16px] mr-1.5"></i>
{{ $block_reason }}
</div>
</div>
@endif
<div class="space-y-4">
{{-- Domain + Localpart --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="relative" wire:ignore>
<label class="block text-xs text-white/60 mb-1">Domain</label>
<el-select
id="domainSelect"
name="domain_id"
value="{{ (int)$domain_id }}"
class="block w-full"
wire:model.live="domain_id"
onchange="$wire.set('domain_id', parseInt(this.value))"
>
{{-- Trigger / sichtbarer Button --}}
<button type="button"
class="grid w-full grid-cols-1 cursor-default rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90
">
<el-selectedcontent class="col-start-1 row-start-1 flex items-center gap-2 pr-6">
<i class="ph ph-globe-hemisphere-west text-white/60 text-[16px]"></i>
<span class="block truncate">Domain wählen</span>
</el-selectedcontent>
<i class="ph ph-caret-down col-start-1 row-start-1 self-center justify-self-end text-white/50"></i>
</button>
{{-- Dropdown / Options (Glas, rund) --}}
<el-options anchor="bottom start" popover
class="max-h-60 w-(--button-width) overflow-auto rounded-2xl bg-gray-900/95 backdrop-blur
py-1 text-base outline-1 -outline-offset-1 outline-white/10 shadow-xl sm:text-sm
data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0">
@foreach($domains as $d)
@php
// Optional: Status fürs Label
$label = $d['domain'];
$locked = false; // falls du einzelne Domains sperren willst
@endphp
<el-option value="{{ $d['id'] }}"
class="group/option relative block cursor-default py-2 pr-9 pl-3 text-white/90 select-none
hover:bg-white/5 focus:bg-white/10 focus:text-white focus:outline-hidden
{{ $locked ? 'opacity-50 pointer-events-none' : '' }}">
<div class="flex items-center gap-2">
<i class="ph ph-globe-hemisphere-west text-white/60"></i>
<span
class="block truncate group-aria-selected/option:font-semibold">{{ $label }}</span>
</div>
{{-- Haken beim aktiven Eintrag --}}
<span
class="absolute inset-y-0 right-0 hidden items-center pr-3 text-emerald-300
group-aria-selected/option:flex in-[el-selectedcontent]:hidden">
<svg viewBox="0 0 20 20" fill="currentColor" class="size-5">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 1 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"/>
</svg>
</span>
</el-option>
@endforeach
</el-options>
</el-select>
@error('domain_id')
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>
@enderror
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Benutzername (linker Teil der E-Mail-Adresse)</label>
<input type="text" wire:model.live="localpart" placeholder="z. B. office"
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90 placeholder:text-white/50">
<div class="mt-1 text-[11px] text-white/50">Nur Buchstaben, Zahlen und .-_+</div>
@error('localpart')
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
</div>
</div>
{{-- Anzeigename --}}
<div>
<label class="block text-xs text-white/60 mb-1">Name (Anzeigename)</label>
<input type="text" wire:model="display_name" placeholder="z. B. Max Mustermann"
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90 placeholder:text-white/50">
@error('display_name')
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
</div>
{{-- Adresse Preview --}}
<div class="flex items-center gap-2">
<span class="text-sm text-white/60">Adresse:</span>
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-sm border border-white/10 bg-white/5 text-white/85">
<i class="ph ph-at text-[14px]"></i>
{{ $email_preview ?: '' }}
</span>
</div>
<div class="border-t border-white/10"></div>
{{-- Passwort / Quota --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label class="block text-xs text-white/60 mb-1">Passwort</label>
<input type="password" wire:model="password" placeholder="mind. 8 Zeichen"
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90 placeholder:text-white/50">
@error('password')
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
<div class="mt-1 text-xs text-white/50">Leer lassen, wenn extern gesetzt wird.</div>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Quota (MiB)</label>
<input type="number" min="0" wire:model.live="quota_mb"
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90">
@error('quota_mb')
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
<div class="mt-1 text-xs text-white/50">{{ $quota_hint }}</div>
</div>
</div>
{{-- Rate / Flags --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label class="block text-xs text-white/60 mb-1">Mails pro Stunde (optional)</label>
<input type="number" min="1" wire:model="rate_limit_per_hour"
@if($rate_limit_readonly) readonly disabled @endif
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90 placeholder:text-white/50"
placeholder="z. B. 120">
@if($rate_limit_readonly)
<div class="mt-1 text-xs text-white/50">Von der Domain vorgegeben (Override deaktiviert).</div>
@endif
@error('rate_limit_per_hour')
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
</div>
<div class="pt-3.5 grid items-center justify-start md:justify-center gap-6">
<label class="inline-flex items-center gap-2 cursor-pointer select-none group">
<input type="checkbox" wire:model="is_active" class="peer sr-only">
<span class="w-5 h-5 flex items-center justify-center rounded-md border border-white/15 bg-white/5
peer-checked:bg-emerald-500/20 peer-checked:border-emerald-400/40
group-hover:border-white/25 transition">
<i class="ph ph-check text-[12px] text-emerald-300 opacity-0 peer-checked:opacity-100 transition"></i>
</span>
<span class="text-white/80 text-sm">Postfach aktivieren</span>
</label>
<label class="inline-flex items-center gap-2 cursor-pointer select-none group">
<input type="checkbox" wire:model="must_change_pw" class="peer sr-only">
<span class="w-5 h-5 flex items-center justify-center rounded-md border border-white/15 bg-white/5
peer-checked:bg-sky-500/20 peer-checked:border-sky-400/40
group-hover:border-white/25 transition">
<i class="ph ph-check text-[12px] text-sky-300 opacity-0 peer-checked:opacity-100 transition"></i>
</span>
<span class="text-white/80 text-sm">Passwort bei erstem Login ändern</span>
</label>
</div>
</div>
</div>
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex justify-end gap-2">
<button type="button" wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
Abbrechen
</button>
<button type="submit" wire:click="$dispatch('mailbox:create')"
@disabled(!$can_create)
class="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm
{{ $can_create
? 'bg-emerald-500/20 text-emerald-200 border border-emerald-400/40 hover:bg-emerald-500/30'
: 'bg-white/5 text-white/40 border border-white/10 cursor-not-allowed' }}">
<i class="ph ph-check text-[16px]"></i> Speichern
</button>
</div>
</div>
@endpush

View File

@ -0,0 +1,60 @@
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10
backdrop-blur rounded-t-2xl relative">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-white">Postfach löschen</h2>
<div class="mt-0.5 text-white/70 text-sm flex items-center gap-1.5">
<span>{{ $email }}</span>
</div>
</div>
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
<i class="ph ph-x"></i>
</button>
</div>
</div>
@endpush
<div class="p-6">
<div class="space-y-4">
<div class="rounded-lg border border-rose-400/30 bg-rose-500/10 p-3 text-rose-200 text-sm">
<b>Achtung:</b> Das Postfach wird gelöscht.
@if(!$keep_server_mail)
Alle E-Mails werden <u>entfernt</u>.
@else
E-Mails bleiben am Server erhalten.
@endif
</div>
<label class="flex items-center gap-2 cursor-pointer select-none group">
<input type="checkbox" class="peer sr-only" wire:model="keep_server_mail">
<span class="w-5 h-5 flex items-center justify-center rounded-md border border-white/15 bg-white/5
peer-checked:bg-emerald-500/20 peer-checked:border-emerald-400/40"></span>
<span class="text-white/80 text-sm">E-Mails am Server belassen</span>
</label>
<label class="flex items-center gap-2 cursor-pointer select-none group">
<input type="checkbox" class="peer sr-only" wire:model="export_zip">
<span class="w-5 h-5 flex items-center justify-center rounded-md border border-white/15 bg-white/5
peer-checked:bg-sky-500/20 peer-checked:border-sky-400/40"></span>
<span class="text-white/80 text-sm">ZIP-Export der bisherigen E-Mails erstellen</span>
</label>
</div>
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex justify-end gap-2">
<button type="button" wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
Abbrechen
</button>
<button type="button"
class="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30"
wire:click="$dispatch('mailbox:delete')">
<i class="ph ph-trash-simple text-[16px]"></i> Endgültig löschen
</button>
</div>
</div>
@endpush

View File

@ -0,0 +1,84 @@
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10
backdrop-blur rounded-t-2xl relative">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-white">Postfach bearbeiten</h2>
<div class="mt-0.5 text-white/70 text-sm flex items-center gap-1.5">
<span>{{ $email_readonly }}</span>
</div>
</div>
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
<i class="ph ph-x"></i>
</button>
</div>
</div>
@endpush
<div class="p-6">
<div class="space-y-4">
<label class="inline-flex items-center gap-2 cursor-pointer select-none group">
<input type="checkbox" wire:model="is_active" class="peer sr-only">
<span class="w-5 h-5 flex items-center justify-center rounded-md border border-white/15 bg-white/5
peer-checked:bg-emerald-500/20 peer-checked:border-emerald-400/40">
<i class="ph ph-check text-[12px] text-emerald-300 opacity-0 peer-checked:opacity-100"></i>
</span>
<span class="text-white/80 text-sm">Postfach aktivieren</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label class="block text-xs text-white/60 mb-1">Name (Anzeigename)</label>
<input type="text" wire:model.defer="display_name" placeholder="z. B. Office"
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90">
@error('display_name')
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Passwort</label>
<input type="password" wire:model.defer="password" placeholder="leer = unverändert"
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90">
@error('password')
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label class="block text-xs text-white/60 mb-1">Quota (MiB)</label>
<input type="number" min="0" wire:model.defer="quota_mb"
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90">
@error('quota_mb')
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Mails pro Stunde (optional)</label>
<input type="number" min="1" wire:model.defer="rate_limit_per_hour" placeholder="z. B. 120"
class="w-full rounded-2xl bg-white/5 border border-white/10 px-3.5 py-2.5 text-white/90">
@error('rate_limit_per_hour')
<div class="text-rose-300 text-xs mt-1">{{ $message }}</div>@enderror
</div>
</div>
<div class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm #opacity-70">
<div class="#mt-1 text-xs text-white/50">{{ $quota_hint }}</div>
</div>
</div>
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex justify-end gap-2">
<button type="button" wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
Abbrechen
</button>
<button type="submit" wire:click="$dispatch('mailbox:edit:save')"
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
Speichern
</button>
</div>
</div>
@endpush

View File

@ -0,0 +1,147 @@
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10
backdrop-blur rounded-t-2xl relative">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-white">Suche</h2>
<div class="mt-1 text-[11px] text-white/50">Tipp: ⌘K / Ctrl+K öffnet die Suche überall.</div>
</div>
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
<i class="ph ph-x"></i>
</button>
</div>
</div>
@endpush
<div class="p-6">
<div class="space-y-4">
{{-- Input --}}
<div class="">
<div class="relative">
<i class="ph ph-magnifying-glass text-white/50 absolute left-3 top-1/2 -translate-y-1/2"></i>
<input type="text" autofocus
placeholder="Suche nach Domain, Postfach oder Benutzer …"
wire:model.live.debounce.300ms="q"
class="w-full rounded-2xl bg-white/5 border border-white/10 pl-9 pr-3.5 py-2.5 text-white/90 placeholder:text-white/50">
</div>
</div>
{{-- Ergebnisse --}}
<div class="space-y-4 max-h-[60vh] overflow-auto">
@php $res = $this->results; @endphp
{{-- Domains --}}
@if(!empty($res['domains']))
<div>
<div class="text-[11px] uppercase tracking-wide text-white/60 mb-1">Domains</div>
<div class="rounded-xl border border-white/10 overflow-hidden divide-y divide-white/10">
@foreach($res['domains'] as $r)
<button type="button"
class="w-full text-left px-3.5 py-2.5 hover:bg-white/5 flex items-center justify-between"
wire:click="go('domain', {{ $r['id'] }})">
<div class="min-w-0">
<div class="text-white/90 truncate">{{ $r['title'] }}</div>
<div class="text-white/50 text-xs truncate">{{ $r['sub'] }}</div>
</div>
<i class="ph ph-arrow-up-right text-white/50"></i>
</button>
@endforeach
</div>
</div>
@endif
{{-- Postfächer --}}
@if(!empty($res['mailboxes']))
<div>
<div class="text-[11px] uppercase tracking-wide text-white/60 mb-1">Postfächer</div>
<div class="rounded-xl border border-white/10 overflow-hidden divide-y divide-white/10">
@foreach($res['mailboxes'] as $r)
<button type="button"
class="w-full text-left px-3.5 py-2.5 hover:bg-white/5 flex items-center justify-between"
wire:click="go('mailbox', {{ $r['id'] }})">
<div class="min-w-0">
<div class="text-white/90 truncate">{{ $r['title'] }}</div>
<div class="text-white/50 text-xs truncate">{{ $r['sub'] }}</div>
</div>
<i class="ph ph-pencil-simple text-white/50"></i>
</button>
@endforeach
</div>
</div>
@endif
{{-- Benutzer (optional) --}}
@if(!empty($res['users']))
<div>
<div class="text-[11px] uppercase tracking-wide text-white/60 mb-1">Benutzer</div>
<div class="rounded-xl border border-white/10 overflow-hidden divide-y divide-white/10">
@foreach($res['users'] as $r)
<button type="button"
class="w-full text-left px-3.5 py-2.5 hover:bg-white/5 flex items-center justify-between"
wire:click="go('user', {{ $r['id'] }})">
<div class="min-w-0">
<div class="text-white/90 truncate">{{ $r['title'] }}</div>
<div class="text-white/50 text-xs truncate">{{ $r['sub'] }}</div>
</div>
<i class="ph ph-user text-white/50"></i>
</button>
@endforeach
</div>
</div>
@endif
@if(empty($res['domains']) && empty($res['mailboxes']) && empty($res['users']) && trim($q) !== '')
{{-- EMPTY STATE (no results) --}}
<div class="relative rounded-2xl border border-white/10 bg-white/[0.03] p-6 overflow-hidden">
{{-- Rings background (SVG, dotted, fading outward) --}}
<div class="pointer-events-none absolute inset-0 grid place-items-center">
<svg class="w-[540px] max-w-[78vw] aspect-square opacity-70" viewBox="0 0 540 540" fill="none">
@php
$rings = [
['r'=>70, 'o'=>.32],
['r'=>120, 'o'=>.22],
['r'=>175, 'o'=>.15],
['r'=>230, 'o'=>.10],
['r'=>280, 'o'=>.07],
];
@endphp
@foreach($rings as $ring)
<circle cx="270" cy="270" r="{{ $ring['r'] }}"
stroke="rgba(255,255,255,{{ $ring['o'] }})"
stroke-width="1.5"
stroke-dasharray="2 6"
vector-effect="non-scaling-stroke"/>
@endforeach
</svg>
</div>
{{-- Foreground content (on top of rings & icons) --}}
<div class="relative z-10">
<div class="mx-auto max-w-xl text-center space-y-4">
{{-- Center magnifier --}}
<div class="mx-auto size-14 rounded-2xl grid place-items-center bg-white/[0.07] border border-white/10 shadow-[0_10px_40px_-10px_rgba(0,0,0,.5)]">
<i class="ph ph-magnifying-glass text-white/80 text-xl"></i>
</div>
<div>
<h3 class="text-white font-semibold text-xl">Keine Treffer</h3>
<p class="text-white/70 mt-1">
Wir konnten zu <span class="font-medium text-white">{{ $q }}</span> nichts finden.
</p>
</div>
{{-- Actions --}}
<div class="flex items-center justify-center gap-2 pt-2">
<button type="button"
class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3.5 py-2 text-white/85 hover:text-white hover:border-white/20"
wire:click="$set('q','')">
<i class="ph ph-broom text-[16px]"></i> Suche leeren
</button>
</div>
</div>
</div>
</div>
@endif
</div>
</div>
</div>

View File

@ -25,7 +25,6 @@
class="w-full h-11 rounded-xl border border-white/10 bg-white/[0.04] px-3 text-white/90">
<p class="mt-1 text-xs text-white/45">Wird in Übersichten und Benachrichtigungen angezeigt.</p>
</div>
{{-- Benutzername --}}
<div>
<label class="block text-white/60 text-sm mb-1">Benutzername</label>

View File

@ -1,51 +1,76 @@
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-white/95">E-Mail-2FA einrichten</h3>
<button class="text-white/60 hover:text-white" wire:click="$dispatch('closeModal')">
<i class="ph ph-x text-xl"></i>
</button>
</div>
<p class="text-white/70 mb-6">
Wir senden einen 6-stelligen Bestätigungscode an deine aktuelle Account-E-Mail.
Gib ihn unten ein, um E-Mail-2FA zu aktivieren.
</p>
{{-- Senden --}}
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-4 mb-5">
<button
type="button"
wire:click="sendMail"
@if($cooldown>0) disabled @endif
class="primary-btn w-full justify-center disabled:opacity-50"
>
Code an E-Mail senden
@if($cooldown>0)
<span class="ml-2 text-white/70">({{ $cooldown }}s)</span>
@endif
</button>
</div>
{{-- Code --}}
<div class="mb-6">
<label class="block text-white/70 text-sm mb-1">6-stelliger Code</label>
<input
type="text"
inputmode="numeric"
maxlength="6"
placeholder="••••••"
wire:model.defer="code"
class="w-full h-12 rounded-xl border border-white/10 bg-white/[0.05] px-3 text-white/90 tracking-[0.35em] text-center"
>
</div>
<div class="flex items-center justify-end gap-2">
@if($alreadyActive)
<button wire:click="disable"
class="rounded-xl border border-white/15 bg-white/[0.05] text-white/85 hover:border-rose-400/30 hover:text-rose-200 px-4 py-2">
Deaktivieren
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10
backdrop-blur rounded-t-2xl relative">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">
<i class="ph ph-shield-check text-white/70 text-lg"></i>
E-Mail-2FA einrichten</h2>
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
<i class="ph ph-x"></i>
</button>
@endif
<button wire:click="verifyAndEnable" class="primary-btn">Bestätigen & aktivieren</button>
</div>
</div>
@endpush
<div class="p-6">
<div class="space-y-4">
<p class="text-white/70 text-sm #mb-6">
Wir senden einen 6-stelligen Bestätigungscode an deine aktuelle Account-E-Mail.
Gib ihn unten ein, um E-Mail-2FA zu aktivieren.
</p>
{{-- Senden --}}
<div class="text-center">
<button
type="button"
wire:click="sendMail"
@if($cooldown>0) disabled @endif
class="px-4 py-2 rounded-lg text-sm
bg-white/5 text-white/75 border border-white/15
hover:bg-white/10 hover:text-white transition">
Code an E-Mail senden
@if($cooldown>0)
<span class="ml-2 text-white/70">({{ $cooldown }}s)</span>
@endif
</button>
</div>
{{-- Code --}}
<div class="">
<label class="block text-white/70 text-sm mb-1">6-stelliger Code</label>
<input
type="text"
inputmode="numeric"
maxlength="6"
placeholder="••••••"
wire:model.defer="code"
class="w-full h-12 rounded-xl border border-white/10 bg-white/[0.05] px-3 text-white/90 tracking-[0.35em] text-center"
>
</div>
</div>
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex items-center justify-end gap-2">
<button type="button" wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
Abbrechen
</button>
@if($alreadyActive)
<button wire:click="disable"
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm bg-rose-500/20 text-rose-200 border border-rose-400/40 hover:bg-rose-500/30">
Deaktivieren
</button>
@else
<button wire:click="$dispatch('security:totp-email:enable')"
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
Bestätigen & aktivieren
</button>
@endif
</div>
</div>
@endpush

View File

@ -1,82 +1,93 @@
<div class="p-6">
{{-- Header --}}
<div class="flex items-center justify-between mb-4">
<div class="inline-flex items-center gap-2">
<i class="ph ph-shield-check text-white/70 text-lg"></i>
<h3 class="text-base font-semibold tracking-tight text-white/95">Recovery-Codes</h3>
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10
backdrop-blur rounded-t-2xl relative">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">
<i class="ph ph-shield-check text-white/70 text-lg"></i>
Recovery-Codes</h2>
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
<i class="ph ph-x"></i>
</button>
</div>
<button class="text-white/60 hover:text-white" wire:click="$dispatch('closeModal')">
<i class="ph ph-x text-xl"></i>
</button>
</div>
@endpush
@if(empty($plainCodes))
{{-- KEINE Klartextcodes sichtbar (entweder noch nie erzeugt ODER bereits vorhanden) --}}
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-4 mb-5">
@if($hasExisting)
<div class="flex items-start gap-3">
<i class="ph ph-info text-white/60 text-lg"></i>
<div class="p-6">
<div class="space-y-4">
@if(empty($plainCodes))
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-4 #mb-5">
@if($hasExisting)
<div class="flex items-start gap-3">
<i class="ph ph-info text-white/60 text-lg"></i>
<div class="text-sm text-white/80">
Für deinen Account existieren bereits Recovery-Codes.
Aus Sicherheitsgründen können sie <span class="font-semibold">nicht erneut angezeigt</span>
werden.
Du kannst sie jedoch <span class="font-semibold">neu erzeugen (rotieren)</span>.
</div>
</div>
@else
<div class="text-sm text-white/80">
Für deinen Account existieren bereits Recovery-Codes.
Aus Sicherheitsgründen können sie <span class="font-semibold">nicht erneut angezeigt</span> werden.
Du kannst sie jedoch <span class="font-semibold">neu erzeugen (rotieren)</span>.
Recovery-Codes sind Einmal-Notfallcodes, falls du z. B. den Zugriff auf deine 2FA-App verlierst.
Sie werden <span class="font-semibold">nur direkt nach der Erstellung</span> angezeigt bitte
speichere oder drucke sie sofort.
</div>
</div>
@else
<div class="text-sm text-white/80">
Recovery-Codes sind Einmal-Notfallcodes, falls du z. B. den Zugriff auf deine 2FA-App verlierst.
Sie werden <span class="font-semibold">nur direkt nach der Erstellung</span> angezeigt bitte
speichere oder drucke sie sofort.
</div>
@endif
</div>
<div class="flex items-center justify-end gap-2">
<button wire:click="$dispatch('closeModal')"
class="rounded-xl border border-white/10 bg-white/[0.06] text-white/80 hover:border-white/20 px-4 py-2">
Abbrechen
</button>
<button wire:click="generate" class="primary-btn">
{{ $hasExisting ? 'Neu erstellen & anzeigen' : 'Erstellen & anzeigen' }}
</button>
</div>
@else
{{-- EINMALIGE ANZEIGE NACH GENERIERUNG --}}
<div class="mb-3 text-sm text-white/70">
Hier sind deine neuen Recovery-Codes. Sie werden <span class="font-semibold">nur jetzt</span> angezeigt.
Bewahre sie offline & sicher auf.
</div>
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-4 mb-4">
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
@foreach($plainCodes as $c)
<div class="rounded-lg border border-white/10 bg-white/[0.06] px-3 py-2 font-mono text-white/90 tracking-wider text-center">
{{ chunk_split($c, 5, ' ') }}
</div>
@endforeach
@endif
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
x-data
x-on:click="navigator.clipboard.writeText(@js(implode(PHP_EOL, $plainCodes)))"
class="rounded-xl border border-white/10 bg-white/[0.06] text-white/80 hover:border-white/20 px-4 py-2">
In Zwischenablage
</button>
<button wire:click="downloadTxt"
class="rounded-xl border border-white/10 bg-white/[0.06] text-white/80 hover:border-white/20 px-4 py-2">
Als .txt speichern
</button>
<button wire:click="$dispatch('closeModal')" class="primary-btn ml-auto">
Fertig
</button>
</div>
<div class="mt-4 text-xs text-white/50">
Tipp: Aktiviere TOTP als Hauptmethode, nutze E-Mail-Codes als Fallback und drucke diese Codes aus.
</div>
@endif
@else
<div class="#mb-3 text-sm text-white/70">
Hier sind deine neuen Recovery-Codes. Sie werden <span class="font-semibold">nur jetzt</span> angezeigt.
Bewahre sie offline & sicher auf.
</div>
<div class="rounded-xl border border-white/10 bg-white/[0.04] p-4">
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
@foreach($plainCodes as $c)
<div
class="rounded-lg border border-white/10 bg-white/[0.06] px-3 py-2 font-mono text-white/90 tracking-wider text-center">
{{ chunk_split($c, 5, ' ') }}
</div>
@endforeach
</div>
</div>
<div class="text-xs text-white/50 rounded-xl border border-white/10 bg-white/[0.04] p-2">
<b>Tipp</b>: Aktiviere TOTP als Hauptmethode, nutze E-Mail-Codes als Fallback und drucke diese Codes aus.
</div>
@endif
</div>
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
@if(empty($plainCodes))
<div class="flex items-center justify-end gap-2">
<button wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
Abbrechen
</button>
<button wire:click="generate"
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
{{ $hasExisting ? 'Neu erstellen & anzeigen' : 'Erstellen & anzeigen' }}
</button>
</div>
@else
<div class="flex flex-wrap items-center justify-end gap-2">
<button
x-data
x-on:click="navigator.clipboard.writeText(@js(implode(PHP_EOL, $plainCodes)))"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
In Zwischenablage
</button>
<button wire:click="downloadTxt"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
Als .txt speichern
</button>
<button wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
Fertig
</button>
</div>
@endif
</div>
@endpush

View File

@ -1,94 +1,87 @@
@push('modal.header')
<div class="px-5 pt-5 pb-3 border-b border-white/10
backdrop-blur rounded-t-2xl relative">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">TOTP einrichten</h2>
<button wire:click="$dispatch('closeModal')" class="text-white/60 hover:text-white text-lg">
<i class="ph ph-x"></i>
</button>
</div>
</div>
@endpush
<div
x-data="{
code:['','','','','',''],
focusNext(i){ if(this.code[i].length===1 && i<5) this.$refs['d'+(i+1)].focus() },
paste(e){ const t=(e.clipboardData||window.clipboardData).getData('text').replace(/\D/g,'').slice(0,6); t.split('').forEach((c,i)=>this.code[i]=c) }
}"
class="p-6 sm:p-8"
class="p-6"
>
{{-- Header --}}
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-2xl font-semibold text-white/95">TOTP einrichten</h3>
<p class="mt-2 text-sm text-white/70">
Scanne den QR-Code mit deiner Authenticator-App und gib den 6-stelligen Code zur Bestätigung ein.
</p>
<div class="space-y-4">
<div class="text-sm text-white/70">
Scanne den QR-Code mit deiner Authenticator-App und gib den 6-stelligen Code zur Bestätigung ein.
</div>
<button class="text-white/60 hover:text-white" wire:click="$dispatch('closeModal')">
<i class="ph ph-x text-xl"></i>
</button>
</div>
{{-- Step 1 --}}
<div class="mt-4 mb-2 inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-3 py-1">
<span class="text-xs font-semibold text-white/90">Step 1</span>
<span class="text-xs text-white/70">QR-Code scannen</span>
</div>
{{-- Step 1 --}}
<div class="#mt-4 mb-2 inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-3 py-1">
<span class="text-xs font-semibold text-white/90">Step 1</span>
<span class="text-xs text-white/70">QR-Code scannen</span>
</div>
<div class="mt-3 rounded-2xl border border-white/10 bg-white/[0.04] p-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-center">
{{-- QR: perfectly square --}}
<div class="flex justify-center #max-h-48">
<img src="{{ $qrPng }}" alt="TOTP QR"
class="w-48 h-fit rounded-xl border border-white/10 bg-black/20 p-2 object-contain" />
</div>
{{-- Secret + copy (stacked, readable) --}}
<div class="space-y-3">
<p class="text-sm font-medium text-white/85">Kannst du nicht scannen?</p>
<p class="text-xs text-white/60">Gib stattdessen diesen Secret-Key ein:</p>
<div class="mt-2 #flex flex-col sm:flex-row sm:items-center gap-2">
<input readonly value="{{ $secret }}"
class="nx-input"/>
<div class="#mt-3 rounded-2xl border border-white/10 bg-white/[0.04] p-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-center">
{{-- QR: perfectly square --}}
<div class="flex justify-center #max-h-48">
<img src="{{ $qrPng }}" alt="TOTP QR"
class="w-48 h-fit rounded-xl border border-white/10 bg-black/20 p-2 object-contain"/>
</div>
{{-- Secret + copy (stacked, readable) --}}
<div class="space-y-3">
<p class="text-sm font-medium text-white/85">Kannst du nicht scannen?</p>
<p class="text-xs text-white/60">Gib stattdessen diesen Secret-Key ein:</p>
<div class="mt-2 #flex flex-col sm:flex-row sm:items-center gap-2">
<input readonly value="{{ $secret }}"
class="nx-input"/>
</div>
<x-button.copy-btn :text="$secret"/>
</div>
<button x-data x-on:click="navigator.clipboard.writeText('{{ $secret }}')"
class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white/80 hover:text-white hover:border-white/20 transition">
<i class="ph ph-copy"></i> Kopieren
</button>
</div>
</div>
</div>
{{-- Step 2 --}}
<div class="mt-6 mb-2 inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-3 py-1">
<span class="text-xs font-semibold text-white/90">Step 2</span>
<span class="text-xs text-white/70">Bestätigungscode eingeben</span>
</div>
{{-- Step 2 --}}
<div
class="#mt-6 #mb-2 inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-3 py-1">
<span class="text-xs font-semibold text-white/90">Step 2</span>
<span class="text-xs text-white/70">Bestätigungscode eingeben</span>
</div>
{{-- 6 small boxes, centered --}}
<div x-on:paste.prevent="paste($event)"
class="mt-3 flex justify-center gap-2 sm:gap-3">
@for($i=0;$i<6;$i++)
<input x-model="code[{{ $i }}]" x-ref="d{{ $i }}" maxlength="1"
x-on:input="focusNext({{ $i }})"
class="nx-input w-10 h-12 sm:w-11 sm:h-12 text-center text-lg" />
@endfor
</div>
{{-- Footer: hint + CTA --}}
<div class="mt-6 flex flex-col sm:flex-row items-center justify-between gap-4">
<p class="text-xs text-white/55 text-center sm:text-left">
Achte darauf, dass die Gerätezeit korrekt ist.
</p>
{{-- <button class="--}}
{{-- px-5 py-2.5 rounded-xl font-medium--}}
{{-- text-white text-sm tracking-wide--}}
{{-- border border-white/10 shadow-md--}}
{{-- bg-[linear-gradient(90deg,rgba(34,211,238,0.5)_0%,rgba(16,185,129,0.5)_100%)]--}}
{{-- hover:bg-[linear-gradient(90deg,rgba(34,211,238,0.7)_0%,rgba(16,185,129,0.7)_100%)]--}}
{{-- backdrop-blur-md transition-all duration-200--}}
{{--">--}}
{{-- Bestätigen & aktivieren--}}
{{-- </button>--}}
<button type="button" class="primary-btn">
Bestätigen & aktivieren
</button>
{{-- <button x-on:click="$wire.verifyAndEnable(code.join(''))"--}}
{{-- class="btn-ghost" >--}}
{{-- class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white/80 hover:text-white hover:border-white/20 transition">--}}
{{-- Bestätigen & aktivieren--}}
{{-- </button>--}}
{{-- 6 small boxes, centered --}}
<div x-on:paste.prevent="paste($event)"
class="mt-3 flex justify-center gap-2 sm:gap-3">
@for($i=0;$i<6;$i++)
<input x-model="code[{{ $i }}]" x-ref="d{{ $i }}" maxlength="1"
x-on:input="focusNext({{ $i }})"
class="nx-input w-10 h-12 sm:w-11 sm:h-12 text-center text-lg"/>
@endfor
</div>
</div>
</div>
@push('modal.footer')
<div class="px-5 py-3 border-t border-white/10 backdrop-blur rounded-b-2xl">
<div class="flex justify-end gap-2">
<button type="button" wire:click="$dispatch('closeModal')"
class="px-4 py-2 rounded-lg text-sm border border-white/10 text-white/70 hover:text-white hover:border-white/20">
Abbrechen
</button>
<button type="submit" wire:click="$dispatch('security:totp:enable')"
class="px-4 py-2 rounded-lg text-sm bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 hover:bg-emerald-500/30">
Bestätigen & aktivieren
</button>
</div>
</div>
@endpush

View File

@ -2,70 +2,7 @@
@extends('layouts.app')
@section('content')
<div class="max-w-6xl mx-auto px-4 py-8">
<h1 class="text-2xl font-semibold mb-6">Domains</h1>
<div class="max-w-7xl mx-auto py-4">
<livewire:ui.domain.domain-dns-list />
</div>
@endsection
{{--@extends('layouts.app')--}}
{{--@section('content')--}}
{{-- <div class="max-w-6xl mx-auto px-4 py-8" x-data="dnsAssistant()">--}}
{{-- <h1 class="text-2xl font-semibold mb-6">Domains</h1>--}}
{{-- <div class="bg-slate-800/40 rounded-2xl p-4">--}}
{{-- <table class="w-full text-sm">--}}
{{-- <thead class="text-slate-300">--}}
{{-- <tr>--}}
{{-- <th class="text-left py-2">Domain</th>--}}
{{-- <th class="text-left py-2">Aktiv</th>--}}
{{-- <th class="text-left py-2">Typ</th>--}}
{{-- <th class="py-2"></th>--}}
{{-- </tr>--}}
{{-- </thead>--}}
{{-- <tbody class="divide-y divide-slate-700/60">--}}
{{-- @foreach(\App\Models\Domain::orderBy('domain')->get() as $d)--}}
{{-- <tr>--}}
{{-- <td class="py-3">{{ $d->domain }}</td>--}}
{{-- <td class="py-3">--}}
{{-- <span class="px-2 py-1 rounded text-xs {{ $d->is_active ? 'bg-emerald-600/30 text-emerald-200' : 'bg-slate-600/30 text-slate-300' }}">--}}
{{-- {{ $d->is_active ? 'aktiv' : 'inaktiv' }}--}}
{{-- </span>--}}
{{-- </td>--}}
{{-- <td class="py-3">--}}
{{-- <span class="px-2 py-1 rounded text-xs {{ $d->is_system ? 'bg-indigo-600/30 text-indigo-200' : 'bg-sky-600/30 text-sky-100' }}">--}}
{{-- {{ $d->is_system ? 'System' : 'Kunde' }}--}}
{{-- </span>--}}
{{-- </td>--}}
{{-- <td class="py-3 text-right">--}}
{{-- <button--}}
{{-- onclick="Livewire.dispatch('openModal', 'ui.domain.modal.domain-dns-modal', {{ json_encode(['domainId' => $d->id]) }})"--}}
{{-- class="px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-emerald-600 text-white hover:opacity-90"--}}
{{-- >--}}
{{-- DNS-Assistent--}}
{{-- </button>--}}
{{-- </td>--}}
{{-- </tr>--}}
{{-- @endforeach--}}
{{-- </tbody>--}}
{{-- </table>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- <script>--}}
{{-- function dnsAssistant() {--}}
{{-- return {--}}
{{-- visible: false,--}}
{{-- loading: false,--}}
{{-- payload: { domain: '', records: [] },--}}
{{-- async open(id) {--}}
{{-- this.visible = true; this.loading = true;--}}
{{-- const res = await fetch(`{{ route('ui.domain.dns', ['domain' => 'ID_PLACEHOLDER']) }}`.replace('ID_PLACEHOLDER', id));--}}
{{-- this.payload = await res.json();--}}
{{-- this.loading = false;--}}
{{-- }--}}
{{-- }--}}
{{-- }--}}
{{-- </script>--}}
{{--@endsection--}}

View File

@ -0,0 +1,8 @@
{{-- resources/views/domains/index.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="max-w-7xl mx-auto py-4">
<livewire:ui.mail.alias-list />
</div>
@endsection

View File

@ -0,0 +1,8 @@
{{-- resources/views/domains/index.blade.php --}}
@extends('layouts.app')
@section('content')
<div class="max-w-7xl mx-auto py-4">
<livewire:ui.mail.mailbox-list />
</div>
@endsection

View File

@ -2,104 +2,67 @@
<div>
@isset($jsPath)<script>{!! file_get_contents($jsPath) !!}</script>@endisset
@isset($cssPath)<style>{!! file_get_contents($cssPath) !!}</style>@endisset
<span class="hidden sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-7xl"></span>
<div x-data="LivewireUIModal()" x-on:close.stop="setShowPropertyTo(false)"
x-on:keydown.escape.window="show && closeModalOnEscape()" x-show="show"
class="fixed inset-0 z-50 overflow-y-auto" style="display:none;">
<span class="hidden sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-7xl"></span>
{{-- overflow hier entfernen (zieht sonst Artefakte) --}}
<div x-data="LivewireUIModal()"
x-on:close.stop="setShowPropertyTo(false)"
x-on:keydown.escape.window="show && closeModalOnEscape()"
x-show="show"
class="fixed inset-0 z-50"
style="display:none;">
<div class="flex min-h-dvh items-center justify-center px-4 py-10">
{{-- Overlay (neutral, not blue) --}}
<div x-show="show" x-on:click="closeModalOnClickAway()"
x-transition:enter="ease-out #duration-300"
{{-- Overlay in ZWEI Ebenen: Dim + Blur (keine Ränder/Scroll hier) --}}
<div x-show="show"
x-on:click="closeModalOnClickAway()"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 #transition-opacity">
<div class="absolute inset-0 backdrop-blur-sm bg-black/65"></div>
class="fixed inset-0">
{{-- Dim-Layer: NUR Farbe --}}
<div class="absolute inset-0 bg-black/60"></div>
{{-- Blur-Layer: NUR Blur (GPU hint) --}}
<div class="absolute inset-0 backdrop-blur-sm transform-gpu will-change-[backdrop-filter]"></div>
</div>
{{-- Safari center helper --}}
<span class="hidden #sm:max-w-sm #sm:max-w-md #sm:max-w-lg #sm:max-w-xl #sm:max-w-2xl"></span>
<span class="hidden"></span>
<div
x-show="show && showActiveComponent"
x-bind:class="modalWidth" {{-- kommt aus dem Livewire-Modal-Script --}}
class="inline-block w-full sm:max-w-md align-middle overflow-hidden rounded-2xl
ring-1 ring-white/10 border border-white/10 shadow-2xl shadow-black/40
bg-[radial-gradient(80%_60%_at_20%_0%,rgba(34,211,238,.12),transparent_60%),radial-gradient(70%_60%_at_100%_120%,rgba(16,185,129,.10),transparent_65%)]
bg-slate-900/70 relative z-10"
id="modal-container"
x-trap.noscroll.inert="show && showActiveComponent"
aria-modal="true"
>
<div x-show="show && showActiveComponent"
x-bind:class="modalWidth"
id="modal-container"
x-trap.noscroll.inert="show && showActiveComponent"
aria-modal="true"
class="inline-block w-full sm:max-w-md align-middle rounded-2xl
ring-1 ring-white/10 border border-white/10 shadow-2xl shadow-black/40
bg-[radial-gradient(120%_100%_at_20%_0%,rgba(34,211,238,.12),transparent_60%),radial-gradient(120%_100%_at_100%_120%,rgba(16,185,129,.10),transparent_65%)]
bg-slate-900/70 relative z-10">
@forelse($components as $id => $component)
<div x-show.immediate="activeComponent == '{{ $id }}'" x-ref="{{ $id }}" wire:key="{{ $id }}">
@livewire($component['name'], $component['arguments'], key($id))
<div class="max-h-[90vh] flex flex-col">
<div class="flex-1 order-2 overflow-y-auto" id="modal-body">
@forelse($components as $id => $component)
<div x-show.immediate="activeComponent == '{{ $id }}'"
x-ref="{{ $id }}"
wire:key="{{ $id }}">
@livewire($component['name'], $component['arguments'], key($id))
</div>
@empty @endforelse
</div>
@empty @endforelse
<div class="shrink-0 order-1" id="modal-header">
@stack('modal.header')
</div>
<div class="shrink-0 order-3" id="modal-footer">
@stack('modal.footer')
</div>
</div>
</div>
</div>
</div>
</div>
{{--<div>--}}
{{-- @isset($jsPath)--}}
{{-- <script>{!! file_get_contents($jsPath) !!}</script>--}}
{{-- @endisset--}}
{{-- @isset($cssPath)--}}
{{-- <style>{!! file_get_contents($cssPath) !!}</style>--}}
{{-- @endisset--}}
{{-- <div--}}
{{-- x-data="LivewireUIModal()"--}}
{{-- x-on:close.stop="setShowPropertyTo(false)"--}}
{{-- x-on:keydown.escape.window="show && closeModalOnEscape()"--}}
{{-- x-show="show"--}}
{{-- class="fixed inset-0 z-10 overflow-y-auto"--}}
{{-- style="display: none;"--}}
{{-- >--}}
{{-- <div class="flex items-end justify-center min-h-dvh px-4 pt-4 pb-10 text-center sm:block sm:p-0">--}}
{{-- <div--}}
{{-- x-show="show"--}}
{{-- x-on:click="closeModalOnClickAway()"--}}
{{-- x-transition:enter="ease-out duration-300"--}}
{{-- x-transition:enter-start="opacity-0"--}}
{{-- x-transition:enter-end="opacity-100"--}}
{{-- x-transition:leave="ease-in duration-200"--}}
{{-- x-transition:leave-start="opacity-100"--}}
{{-- x-transition:leave-end="opacity-0"--}}
{{-- class="fixed inset-0 transition-all transform"--}}
{{-- >--}}
{{-- <div class="absolute inset-0 bg-gray-500 opacity-75"></div>--}}
{{-- </div>--}}
{{-- <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>--}}
{{-- <div--}}
{{-- x-show="show && showActiveComponent"--}}
{{-- x-transition:enter="ease-out duration-300"--}}
{{-- x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"--}}
{{-- x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"--}}
{{-- x-transition:leave="ease-in duration-200"--}}
{{-- x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"--}}
{{-- x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"--}}
{{-- x-bind:class="modalWidth"--}}
{{-- class="inline-block w-full align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:w-full"--}}
{{-- id="modal-container"--}}
{{-- x-trap.noscroll.inert="show && showActiveComponent"--}}
{{-- aria-modal="true"--}}
{{-- >--}}
{{-- @forelse($components as $id => $component)--}}
{{-- <div x-show.immediate="activeComponent == '{{ $id }}'" x-ref="{{ $id }}" wire:key="{{ $id }}">--}}
{{-- @livewire($component['name'], $component['arguments'], key($id))--}}
{{-- </div>--}}
{{-- @empty--}}
{{-- @endforelse--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- </div>--}}
{{--</div>--}}

View File

@ -4,6 +4,8 @@ use App\Http\Controllers\Api\TaskFeedController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Setup\SetupWizard;
use App\Http\Controllers\UI\Domain\DomainDnsController;
use App\Http\Controllers\UI\Mail\AliasController;
use App\Http\Controllers\UI\Mail\MailboxController;
use App\Http\Controllers\UI\Security\RecoveryCodeDownloadController;
use App\Http\Controllers\UI\Security\SecurityController;
use App\Http\Controllers\UI\System\SettingsController;
@ -43,14 +45,16 @@ Route::middleware(['auth'])
Route::get('/audit-logs', [SecurityController::class, 'auditLogs'])->name('audit');
});
Route::middleware(['auth'])
->name('ui.domain.')
->group(function () {
Route::middleware(['auth'])->name('ui.domain.')->group(function () {
Route::get('/domains', [DomainDnsController::class, 'index'])->name('index');
// Route::get('/api/domains/{domain}/dns', [DomainDnsController::class, 'show'])
// ->name('dns'); // JSON für das Modal
});
Route::middleware(['auth'])->name('ui.mail.')->group(function () {
Route::get('/mailboxes', [MailboxController::class, 'index'])->name('mailbox.index');
Route::get('/aliases', [AliasController::class, 'index'])->name('aliases.index');
});
//Route::middleware(['auth'])
// ->get('/security/recovery-codes/download', [RecoveryCodeDownloadController::class, 'download'])
// ->name('security.recovery.download')

61
scripts/10-provision.sh Normal file
View File

@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Paketquellen aktualisieren…"
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
# MariaDB include-Workaround
mkdir -p /etc/mysql /etc/mysql/mariadb.conf.d
[[ -f /etc/mysql/mariadb.cnf ]] || echo '!include /etc/mysql/mariadb.conf.d/*.cnf' > /etc/mysql/mariadb.cnf
log "Pakete installieren… (das dauert etwas)"
apt-get -y -o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" install \
postfix postfix-mysql \
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-mysql \
mariadb-server mariadb-client \
redis-server \
rspamd \
opendkim opendkim-tools \
nginx \
php php-fpm php-cli php-mbstring php-xml php-curl php-zip php-mysql php-redis php-gd unzip curl \
composer git \
certbot python3-certbot-nginx \
fail2ban \
ca-certificates rsyslog sudo openssl netcat-openbsd monit acl
# HTTP/2 prüfen
NGINX_HTTP2_SUPPORTED=0
if nginx -V 2>&1 | grep -q http_v2; then
NGINX_HTTP2_SUPPORTED=1; log "Nginx: HTTP/2 verfügbar."
else
warn "Nginx http_v2 fehlt versuche nginx-full…"
apt-get install -y nginx-full || true; systemctl restart nginx || true
nginx -V 2>&1 | grep -q http_v2 && NGINX_HTTP2_SUPPORTED=1 || warn "HTTP/2 weiterhin nicht verfügbar."
fi
export NGINX_HTTP2_SUFFIX=$([[ "$NGINX_HTTP2_SUPPORTED" = "1" ]] && echo " http2" || echo "")
# Verzeichnisse / User
log "Verzeichnisse & Benutzer…"
mkdir -p /etc/postfix/sql /etc/dovecot/conf.d /etc/rspamd/local.d /var/mail/vhosts
id vmail >/dev/null 2>&1 || adduser --system --group --home /var/mail vmail
chown -R vmail:vmail /var/mail
id "$APP_USER" >/dev/null 2>&1 || adduser --disabled-password --gecos "" "$APP_USER"
usermod -a -G "$APP_GROUP" "$APP_USER"
# Redis absichern
log "Redis absichern…"
REDIS_CONF="/etc/redis/redis.conf"
REDIS_PASS="${REDIS_PASS:-$(openssl rand -hex 16)}"
sed -i 's/^\s*#\?\s*bind .*/bind 127.0.0.1/' "$REDIS_CONF"
sed -i 's/^\s*#\?\s*protected-mode .*/protected-mode yes/' "$REDIS_CONF"
if grep -qE '^\s*#?\s*requirepass ' "$REDIS_CONF"; then
sed -i "s/^\s*#\?\s*requirepass .*/requirepass ${REDIS_PASS}/" "$REDIS_CONF"
else
printf "\nrequirepass %s\n" "${REDIS_PASS}" >> "$REDIS_CONF"
fi
systemctl enable --now redis-server
systemctl restart redis-server

92
scripts/20-ssl.sh Normal file
View File

@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
# Stabile Pfade
MAIL_SSL_DIR="/etc/ssl/mail"
UI_SSL_DIR="/etc/ssl/ui"
WEBMAIL_SSL_DIR="/etc/ssl/webmail"
ensure_dir root root 0755 "$MAIL_SSL_DIR"
ensure_dir root root 0755 "$UI_SSL_DIR"
ensure_dir root root 0755 "$WEBMAIL_SSL_DIR"
ensure_dir root root 0755 "/var/www/letsencrypt"
# Self-signed Quick-Gen (wenn kein LE kommt)
self_signed(){
local dir="$1"
local cert="${dir}/fullchain.pem" key="${dir}/privkey.pem"
[[ -s "$cert" && -s "$key" ]] && return 0
log "Self-signed für $dir"
openssl req -x509 -newkey rsa:2048 -sha256 -days 825 -nodes \
-subj "/CN=${SERVER_PUBLIC_IPV4}/O=${APP_NAME}/C=DE" \
-keyout "$key" -out "$cert" >/dev/null 2>&1
chmod 600 "$key"; chmod 644 "$cert"
}
self_signed "$MAIL_SSL_DIR"
self_signed "$UI_SSL_DIR"
self_signed "$WEBMAIL_SSL_DIR"
issue_cert(){
local host="$1"
if resolve_ok "$host"; then
log "LE für $host"
certbot certonly --agree-tos -m "${LE_EMAIL}" \
--non-interactive --webroot -w /var/www/letsencrypt -d "$host" \
|| warn "LE fehlgeschlagen für $host Self-signed bleibt aktiv."
else
warn "DNS zeigt nicht auf diese IP: $host LE wird übersprungen."
fi
}
link_if_present(){
local host="$1" target_dir="$2"
local base="/etc/letsencrypt/live/$host"
if [[ -f "$base/fullchain.pem" && -f "$base/privkey.pem" ]]; then
ln -sf "$base/fullchain.pem" "$target_dir/fullchain.pem"
ln -sf "$base/privkey.pem" "$target_dir/privkey.pem"
log "TLS verlinkt: $target_dir -> $base"
fi
}
# Echte Domain? Dann versuchen
if [[ "$BASE_DOMAIN" != "example.com" ]]; then
issue_cert "$UI_HOST"
issue_cert "$WEBMAIL_HOST"
issue_cert "$MAIL_HOSTNAME"
link_if_present "$UI_HOST" "$UI_SSL_DIR"
link_if_present "$WEBMAIL_HOST" "$WEBMAIL_SSL_DIR"
link_if_present "$MAIL_HOSTNAME" "$MAIL_SSL_DIR"
else
warn "BASE_DOMAIN=example.com bleibe bei Self-signed."
fi
# LE-Deploy-Hook (Symlinks aktuell halten)
install -d /etc/letsencrypt/renewal-hooks/deploy
cat >/etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh <<'HOOK'
#!/usr/bin/env bash
set -euo pipefail
UI_SSL_DIR="/etc/ssl/ui"
WEBMAIL_SSL_DIR="/etc/ssl/webmail"
MAIL_SSL_DIR="/etc/ssl/mail"
UI_HOST="${UI_HOST}"
WEBMAIL_HOST="${WEBMAIL_HOST}"
MX_HOST="${MAIL_HOSTNAME}"
link_if() {
local le_base="/etc/letsencrypt/live/$1" target_dir="$2"
if [[ -f "$le_base/fullchain.pem" && -f "$le_base/privkey.pem" ]]; then
install -d -m 0755 "$target_dir"
ln -sf "$le_base/fullchain.pem" "$target_dir/fullchain.pem"
ln -sf "$le_base/privkey.pem" "$target_dir/privkey.pem"
echo "[+] Linked $target_dir -> $le_base"
fi
}
link_if "$UI_HOST" "$UI_SSL_DIR"
link_if "$WEBMAIL_HOST" "$WEBMAIL_SSL_DIR"
link_if "$MX_HOST" "$MAIL_SSL_DIR"
systemctl reload nginx || true
HOOK
chmod +x /etc/letsencrypt/renewal-hooks/deploy/50-mailwolt-symlinks.sh

23
scripts/30-db.sh Normal file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
DB_NAME="${DB_NAME:-${APP_USER}}"
DB_USER="${DB_USER:-${APP_USER}}"
DB_PASS="${DB_PASS:-$(openssl rand -hex 16)}"
log "MariaDB vorbereiten…"
systemctl enable --now mariadb
mysql -uroot <<SQL
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost';
CREATE USER IF NOT EXISTS '${DB_USER}'@'127.0.0.1';
ALTER USER '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
ALTER USER '${DB_USER}'@'127.0.0.1' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'127.0.0.1';
FLUSH PRIVILEGES;
SQL
export DB_NAME DB_USER DB_PASS

67
scripts/40-postfix.sh Normal file
View File

@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
MAIL_SSL_DIR="/etc/ssl/mail"
MAIL_CERT="${MAIL_SSL_DIR}/fullchain.pem"
MAIL_KEY="${MAIL_SSL_DIR}/privkey.pem"
log "Postfix konfigurieren…"
postconf -e "myhostname = ${MAIL_HOSTNAME}"
postconf -e "myorigin = \$myhostname"
postconf -e "mydestination = "
postconf -e "inet_interfaces = all"
postconf -e "inet_protocols = ipv4"
postconf -e "smtpd_banner = \$myhostname ESMTP"
postconf -e "smtpd_tls_cert_file=${MAIL_CERT}"
postconf -e "smtpd_tls_key_file=${MAIL_KEY}"
postconf -e "smtpd_tls_security_level = may"
postconf -e "smtp_tls_security_level = may"
postconf -e "smtpd_tls_received_header = yes"
postconf -e "smtpd_tls_protocols=!SSLv2,!SSLv3"
postconf -e "smtpd_tls_mandatory_protocols=!SSLv2,!SSLv3"
postconf -e "smtpd_tls_loglevel=1"
postconf -e "smtp_tls_loglevel=1"
postconf -e "disable_vrfy_command = yes"
postconf -e "smtpd_helo_required = yes"
postconf -e "milter_default_action = accept"
postconf -e "milter_protocol = 6"
postconf -e "smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
postconf -e "smtpd_sasl_type = dovecot"
postconf -e "smtpd_sasl_path = private/auth"
postconf -e "smtpd_sasl_auth_enable = yes"
postconf -e "smtpd_sasl_security_options = noanonymous"
postconf -e "smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination"
postconf -e "smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination"
postconf -M "smtp/inet=smtp inet n - n - - smtpd -o smtpd_peername_lookup=no -o smtpd_timeout=30s"
postconf -M "submission/inet=submission inet n - n - - smtpd -o syslog_name=postfix/submission -o smtpd_peername_lookup=no -o smtpd_tls_security_level=encrypt -o smtpd_tls_auth_only=yes -o smtpd_sasl_auth_enable=yes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
postconf -M "smtps/inet=smtps inet n - n - - smtpd -o syslog_name=postfix/smtps -o smtpd_peername_lookup=no -o smtpd_tls_wrappermode=yes -o smtpd_tls_auth_only=yes -o smtpd_sasl_auth_enable=yes -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
install -d -o root -g postfix -m 750 /etc/postfix/sql
cat > /etc/postfix/sql/mysql-virtual-mailbox-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
query = SELECT 1 FROM mail_users u JOIN domains d ON d.id = u.domain_id WHERE u.email = '%s' AND u.is_active = 1 AND d.is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-mailbox-maps.cf; chmod 640 /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
cat > /etc/postfix/sql/mysql-virtual-alias-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
query = SELECT destination FROM mail_aliases a JOIN domains d ON d.id = a.domain_id WHERE a.source = '%s' AND a.is_active = 1 AND d.is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-alias-maps.cf; chmod 640 /etc/postfix/sql/mysql-virtual-alias-maps.cf
systemctl enable postfix >/dev/null 2>&1 || true

94
scripts/50-dovecot.sh Normal file
View File

@ -0,0 +1,94 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
MAIL_SSL_DIR="/etc/ssl/mail"
MAIL_CERT="${MAIL_SSL_DIR}/fullchain.pem"
MAIL_KEY="${MAIL_SSL_DIR}/privkey.pem"
log "Dovecot konfigurieren…"
cat > /etc/dovecot/dovecot.conf <<'CONF'
!include_try /etc/dovecot/conf.d/*.conf
CONF
cat > /etc/dovecot/conf.d/10-mail.conf <<'CONF'
protocols = imap pop3 lmtp
mail_location = maildir:/var/mail/vhosts/%d/%n
namespace inbox {
inbox = yes
}
mail_privileged_group = mail
CONF
cat > /etc/dovecot/conf.d/10-auth.conf <<'CONF'
disable_plaintext_auth = yes
auth_mechanisms = plain login
!include_try auth-sql.conf.ext
CONF
cat > /etc/dovecot/dovecot-sql.conf.ext <<CONF
driver = mysql
connect = host=127.0.0.1 dbname=${DB_NAME} user=${DB_USER} password=${DB_PASS}
default_pass_scheme = BLF-CRYPT
password_query = SELECT email AS user, password_hash AS password FROM mail_users WHERE email = '%u' AND is_active = 1 LIMIT 1;
CONF
chown root:dovecot /etc/dovecot/dovecot-sql.conf.ext; chmod 640 /etc/dovecot/dovecot-sql.conf.ext
cat > /etc/dovecot/conf.d/auth-sql.conf.ext <<'CONF'
passdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
}
userdb {
driver = static
args = uid=vmail gid=vmail home=/var/mail/vhosts/%d/%n
}
CONF
chown root:dovecot /etc/dovecot/conf.d/auth-sql.conf.ext; chmod 640 /etc/dovecot/conf.d/auth-sql.conf.ext
cat > /etc/dovecot/conf.d/10-master.conf <<'CONF'
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
}
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0660
user = postfix
group = postfix
}
}
service imap-login {
inet_listener imap { port = 143 }
inet_listener imaps { port = 993; ssl = yes }
}
service pop3-login {
inet_listener pop3 { port = 110 }
inet_listener pop3s { port = 995; ssl = yes }
}
CONF
DOVECOT_SSL_CONF="/etc/dovecot/conf.d/10-ssl.conf"
grep -q '^ssl\s*=' "$DOVECOT_SSL_CONF" 2>/dev/null || echo "ssl = required" >> "$DOVECOT_SSL_CONF"
if grep -q '^\s*ssl_cert\s*=' "$DOVECOT_SSL_CONF"; then
sed -i "s|^\s*ssl_cert\s*=.*|ssl_cert = <${MAIL_CERT}|" "$DOVECOT_SSL_CONF"
else
echo "ssl_cert = <${MAIL_CERT}" >> "$DOVECOT_SSL_CONF"
fi
if grep -q '^\s*ssl_key\s*=' "$DOVECOT_SSL_CONF"; then
sed -i "s|^\s*ssl_key\s*=.*|ssl_key = <${MAIL_KEY}|" "$DOVECOT_SSL_CONF"
else
echo "ssl_key = <${MAIL_KEY}" >> "$DOVECOT_SSL_CONF"
fi
mkdir -p /var/spool/postfix/private
chown postfix:postfix /var/spool/postfix /var/spool/postfix/private
chmod 0755 /var/spool/postfix /var/spool/postfix/private
systemctl enable dovecot >/dev/null 2>&1 || true

View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Rspamd + OpenDKIM aktivieren…"
cat > /etc/rspamd/local.d/worker-controller.inc <<'CONF'
password = "admin";
bind_socket = "127.0.0.1:11334";
CONF
systemctl enable --now rspamd || true
cat > /etc/opendkim.conf <<'CONF'
Syslog yes
UMask 002
Mode sv
Socket inet:8891@127.0.0.1
Canonicalization relaxed/simple
On-BadSignature accept
On-Default accept
On-KeyNotFound accept
On-NoSignature accept
LogWhy yes
OversignHeaders From
CONF
systemctl enable --now opendkim || true

26
scripts/70-nginx.sh Normal file
View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
NGINX_SITE="/etc/nginx/sites-available/${APP_USER}.conf"
NGINX_SITE_LINK="/etc/nginx/sites-enabled/${APP_USER}.conf"
TEMPLATE="$(cd .. && pwd)/config/nginx/site.conf.tmpl"
# Platzhalter für Template
export APP_DIR
export UI_SSL_DIR="/etc/ssl/ui"
export UI_CERT="${UI_SSL_DIR}/fullchain.pem"
export UI_KEY="${UI_SSL_DIR}/privkey.pem"
export NGINX_HTTP2_SUFFIX
# Template -> Site
log "Nginx konfigurieren…"
envsubst '$APP_DIR $UI_CERT $UI_KEY $NGINX_HTTP2_SUFFIX' < "$TEMPLATE" > "$NGINX_SITE"
ln -sf "$NGINX_SITE" "$NGINX_SITE_LINK"
if nginx -t; then
systemctl enable --now nginx
systemctl reload nginx || true
else
die "Nginx-Konfiguration fehlerhaft /var/log/nginx prüfen."
fi

139
scripts/80-app.sh Normal file
View File

@ -0,0 +1,139 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Laravel bereitstellen…"
mkdir -p "$(dirname "$APP_DIR")"; chown -R "$APP_USER":"$APP_GROUP" "$(dirname "$APP_DIR")"
if [[ ! -d "${APP_DIR}/.git" ]]; then
sudo -u "$APP_USER" -H bash -lc "git clone --depth=1 -b ${GIT_BRANCH} ${GIT_REPO} ${APP_DIR}"
else
sudo -u "$APP_USER" -H bash -lc "
set -e
cd ${APP_DIR}
git fetch --depth=1 origin ${GIT_BRANCH} || true
git checkout ${GIT_BRANCH} 2>/dev/null || git checkout -B ${GIT_BRANCH}
if git merge-base --is-ancestor HEAD origin/${GIT_BRANCH}; then git pull --ff-only; else git reset --hard origin/${GIT_BRANCH}; git clean -fd; fi
"
fi
if [[ -f "${APP_DIR}/composer.json" ]]; then
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && composer install --no-interaction --prefer-dist --no-dev"
fi
# .env
ENV_FILE="${APP_DIR}/.env"
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && cp -n .env.example .env || true"
grep -q '^APP_KEY=' "${ENV_FILE}" || sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan key:generate --force || true"
upsert_env(){
local k="$1" v="$2"
local ek ev; ek="$(printf '%s' "$k" | sed -e 's/[.[\*^$(){}+?|/]/\\&/g')"; ev="$(printf '%s' "$v" | sed -e 's/[&/]/\\&/g')"
if grep -qE "^[#[:space:]]*${ek}=" "$ENV_FILE"; then
sed -Ei "s|^[#[:space:]]*${ek}=.*|${k}=${ev}|g" "$ENV_FILE"
else
printf '%s=%s\n' "$k" "$v" >> "$ENV_FILE"
fi
}
# APP_URL/Host
APP_HOST_VAL="$SERVER_PUBLIC_IPV4"
UI_LE_CERT="/etc/letsencrypt/live/${UI_HOST}/fullchain.pem"
UI_LE_KEY="/etc/letsencrypt/live/${UI_HOST}/privkey.pem"
if [[ "$BASE_DOMAIN" != "example.com" && -n "$UI_HOST" ]] && ( resolve_ok "$UI_HOST" || [[ -f "$UI_LE_CERT" && -f "$UI_LE_KEY" ]] ); then
APP_HOST_VAL="$UI_HOST"
fi
if [[ "$APP_HOST_VAL" = "$UI_HOST" && -f "$UI_LE_CERT" && -f "$UI_LE_KEY" ]]; then
APP_URL_VAL="https://${UI_HOST}"
else
if [[ -f "/etc/ssl/ui/fullchain.pem" && -f "/etc/ssl/ui/privkey.pem" ]]; then
APP_URL_VAL="https://${SERVER_PUBLIC_IPV4}"
else
APP_URL_VAL="http://${SERVER_PUBLIC_IPV4}"
fi
fi
# .env schreiben
upsert_env APP_URL "${APP_URL_VAL}"
upsert_env APP_HOST "${APP_HOST_VAL}"
upsert_env APP_NAME "${APP_NAME}"
upsert_env APP_ENV "${APP_ENV}"
upsert_env APP_DEBUG "${APP_DEBUG}"
upsert_env APP_LOCALE "de"
upsert_env APP_FALLBACK_LOCALE "en"
upsert_env SERVER_PUBLIC_IPV4 "${SERVER_PUBLIC_IPV4}"
upsert_env SERVER_PUBLIC_IPV6 "${SERVER_PUBLIC_IPV6:-}"
upsert_env BASE_DOMAIN "${BASE_DOMAIN}"
upsert_env UI_SUB "${UI_SUB}"
upsert_env WEBMAIL_SUB "${WEBMAIL_SUB}"
upsert_env SYSTEM_SUB "${SYSTEM_SUB}"
upsert_env MTA_SUB "${MTA_SUB}"
upsert_env LE_EMAIL "${LE_EMAIL}"
upsert_env DB_CONNECTION "mysql"
upsert_env DB_HOST "127.0.0.1"
upsert_env DB_PORT "3306"
upsert_env DB_DATABASE "${DB_NAME}"
upsert_env DB_USERNAME "${DB_USER}"
upsert_env DB_PASSWORD "${DB_PASS}"
upsert_env CACHE_SETTINGS_STORE "redis"
upsert_env CACHE_STORE "redis"
upsert_env CACHE_DRIVER "redis"
upsert_env CACHE_PREFIX "${APP_USER_PREFIX}_cache:"
upsert_env SESSION_DRIVER "redis"
upsert_env SESSION_SECURE_COOKIE "true"
upsert_env SESSION_SAMESITE "lax"
upsert_env REDIS_CLIENT "phpredis"
upsert_env REDIS_HOST "127.0.0.1"
upsert_env REDIS_PORT "6379"
upsert_env REDIS_PASSWORD "${REDIS_PASS}"
upsert_env REDIS_DB "0"
upsert_env REDIS_CACHE_DB "1"
upsert_env REDIS_CACHE_CONNECTION "cache"
upsert_env REDIS_CACHE_LOCK_CONNECTION "default"
# Reverb / Queue (wie bei dir)
upsert_env BROADCAST_DRIVER "reverb"
upsert_env QUEUE_CONNECTION "redis"
upsert_env LOG_CHANNEL "daily"
upsert_env REVERB_APP_ID "${APP_USER_PREFIX}"
upsert_env REVERB_APP_KEY "${APP_USER_PREFIX}_$(openssl rand -hex 16)"
upsert_env REVERB_APP_SECRET "${APP_USER_PREFIX}_$(openssl rand -hex 32)"
upsert_env REVERB_HOST "\${APP_HOST}"
upsert_env REVERB_PORT "443"
upsert_env REVERB_SCHEME "https"
upsert_env REVERB_PATH "/ws"
upsert_env REVERB_SCALING_ENABLED "true"
upsert_env REVERB_SCALING_CHANNEL "reverb"
upsert_env VITE_REVERB_APP_KEY "\${REVERB_APP_KEY}"
upsert_env VITE_REVERB_HOST "\${REVERB_HOST}"
upsert_env VITE_REVERB_PORT "\${REVERB_PORT}"
upsert_env VITE_REVERB_SCHEME "\${REVERB_SCHEME}"
upsert_env VITE_REVERB_PATH "\${REVERB_PATH}"
upsert_env REVERB_SERVER_APP_KEY "\${REVERB_APP_KEY}"
upsert_env REVERB_SERVER_HOST "127.0.0.1"
upsert_env REVERB_SERVER_PORT "8080"
upsert_env REVERB_SERVER_PATH ""
upsert_env REVERB_SERVER_SCHEME "http"
# Optimize + Migrate + Seed
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan optimize:clear && php artisan config:cache"
sudo systemctl restart php*-fpm || true
sudo -u "$APP_USER" -H bash -lc "
set -e
cd ${APP_DIR}
php artisan optimize:clear
php artisan migrate --force
php artisan config:cache
php artisan optimize:clear
"
if [[ "$BASE_DOMAIN" != "example.com" ]]; then
sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan db:seed --class=SystemDomainSeeder --no-interaction || true"
else
echo "[i] BASE_DOMAIN=example.com Seeder übersprungen."
fi

105
scripts/90-services.sh Normal file
View File

@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "systemd Units (Reverb/Schedule/Queue)…"
cat > /etc/systemd/system/${APP_USER}-ws.service <<EOF
[Unit]
Description=${APP_NAME} WebSocket Backend
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment=NODE_ENV=production WS_PORT=8080
User=${APP_USER}
Group=${APP_GROUP}
WorkingDirectory=${APP_DIR}
ExecStartPre=/usr/bin/bash -lc 'test -f .env'
ExecStartPre=/usr/bin/bash -lc 'test -d vendor'
ExecStart=/usr/bin/php artisan reverb:start --host=127.0.0.1 --port=8080 --no-interaction
Restart=always
RestartSec=2
StandardOutput=append:/var/log/${APP_USER}-ws.log
StandardError=append:/var/log/${APP_USER}-ws.log
KillSignal=SIGINT
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/${APP_USER}-schedule.service <<EOF
[Unit]
Description=${APP_NAME} Laravel Scheduler
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_GROUP}
WorkingDirectory=${APP_DIR}
ExecStartPre=/usr/bin/bash -lc 'test -f .env'
ExecStartPre=/usr/bin/bash -lc 'test -d vendor'
ExecStart=/usr/bin/php artisan schedule:work
Restart=always
RestartSec=2
StandardOutput=append:/var/log/${APP_USER}-schedule.log
StandardError=append:/var/log/${APP_USER}-schedule.log
KillSignal=SIGINT
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/${APP_USER}-queue.service <<EOF
[Unit]
Description=${APP_NAME} Queue Worker
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_GROUP}
WorkingDirectory=${APP_DIR}
ExecStartPre=/usr/bin/bash -lc 'test -f .env'
ExecStartPre=/usr/bin/bash -lc 'test -d vendor'
ExecStart=/usr/bin/php artisan queue:work --queue=default,notify --tries=1
Restart=always
RestartSec=2
StandardOutput=append:/var/log/${APP_USER}-queue.log
StandardError=append:/var/log/${APP_USER}-queue.log
KillSignal=SIGINT
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target
EOF
chown root:root /etc/systemd/system/${APP_USER}-*.service
chmod 644 /etc/systemd/system/${APP_USER}-*.service
touch /var/log/${APP_USER}-ws.log /var/log/${APP_USER}-schedule.log /var/log/${APP_USER}-queue.log
chown ${APP_USER}:${APP_GROUP} /var/log/${APP_USER}-*.log
chmod 664 /var/log/${APP_USER}-*.log
systemctl daemon-reload
# Reverb optional: nur starten, wenn Artisan-Command existiert
if sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan list --no-ansi | grep -qE '(^| )reverb:start( |$)'"; then
systemctl enable --now ${APP_USER}-ws
else
systemctl disable --now ${APP_USER}-ws >/dev/null 2>&1 || true
fi
systemctl enable --now ${APP_USER}-schedule
systemctl enable --now ${APP_USER}-queue
systemctl reload nginx || true
systemctl restart php*-fpm || true
db_ready(){
mysql -u"${DB_USER}" -p"${DB_PASS}" -h 127.0.0.1 -D "${DB_NAME}" -e "SHOW TABLES LIKE 'migrations'\G" >/dev/null 2>&1
}
if db_ready; then
systemctl reload postfix || true
systemctl reload dovecot || true
else
echo "[i] DB noch nicht migriert überspringe Postfix/Dovecot reload."
fi

62
scripts/95-monit.sh Normal file
View File

@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
log "Monit konfigurieren…"
cat > /etc/monit/monitrc <<'EOF'
set daemon 60
set logfile syslog facility log_daemon
check process postfix with pidfile /var/spool/postfix/pid/master.pid
start program = "/bin/systemctl start postfix"
stop program = "/bin/systemctl stop postfix"
if failed port 25 protocol smtp then restart
if failed port 465 type tcp ssl then restart
if failed port 587 type tcp then restart
check process dovecot with pidfile /var/run/dovecot/master.pid
start program = "/bin/systemctl start dovecot"
stop program = "/bin/systemctl stop dovecot"
if failed port 143 type tcp then restart
if failed port 993 type tcp ssl then restart
if failed port 110 type tcp then restart
if failed port 995 type tcp ssl then restart
check process mariadb with pidfile /var/run/mysqld/mysqld.pid
start program = "/bin/systemctl start mariadb"
stop program = "/bin/systemctl stop mariadb"
if failed port 3306 type tcp then restart
check process redis-server with pidfile /run/redis/redis-server.pid
start program = "/bin/systemctl start redis-server"
stop program = "/bin/systemctl stop redis-server"
if failed port 6379 type tcp then restart
check process rspamd with pidfile /run/rspamd/rspamd.pid
start program = "/bin/systemctl start rspamd"
stop program = "/bin/systemctl stop rspamd"
if failed port 11332 type tcp then restart
check process opendkim with pidfile /run/opendkim/opendkim.pid
start program = "/bin/systemctl start opendkim"
stop program = "/bin/systemctl stop opendkim"
if failed port 8891 type tcp then restart
check process nginx with pidfile /run/nginx.pid
start program = "/bin/systemctl start nginx"
stop program = "/bin/systemctl stop nginx"
if failed port 80 type tcp then restart
if failed port 443 type tcp ssl then restart
# Reverb WebSocket
check process mailwolt-ws matching "reverb:start"
start program = "/bin/systemctl start mailwolt-ws"
stop program = "/bin/systemctl stop mailwolt-ws"
if failed host 127.0.0.1 port 8080 type tcp for 2 cycles then restart
if 5 restarts within 5 cycles then timeout
EOF
chmod 600 /etc/monit/monitrc
monit -t && systemctl enable --now monit
monit reload
monit summary || true

56
scripts/98-motd.sh Normal file
View File

@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
install -d /usr/local/bin
cat >/usr/local/bin/mw-motd <<'SH'
#!/usr/bin/env bash
set -euo pipefail
NC="\033[0m"; CY="\033[1;36m"; GR="\033[1;32m"; YE="\033[1;33m"; RD="\033[1;31m"; GY="\033[0;90m"
printf "\033[1;36m"
cat <<'ASCII'
:::: :::: ::: ::::::::::: ::: ::: ::: :::::::: ::: :::::::::::
+:+:+: :+:+:+ :+: :+: :+: :+: :+: :+: :+: :+: :+: :+:
+:+ +:+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+
+#+ +:+ +#+ +#++:++#++: +#+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +#+
+#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+#+ +#+ +#+ +#+ +#+ +#+
#+# #+# #+# #+# #+# #+# #+#+# #+#+# #+# #+# #+# #+#
### ### ### ### ########### ########## ### ### ######## ########## ###
ASCII
printf "\033[0m\n"
now="$(date '+%Y-%m-%d %H:%M:%S %Z')"
fqdn="$(hostname -f 2>/dev/null || hostname)"
ip_int="$(hostname -I 2>/dev/null | awk '{print $1}')"
ip_ext=""; command -v curl >/dev/null 2>&1 && ip_ext="$(curl -s --max-time 1 https://ifconfig.me || true)"
upt="$(uptime -p 2>/dev/null || true)"
cores="$(nproc 2>/dev/null || echo -n '?')"
mhz="$(LC_ALL=C lscpu 2>/dev/null | awk -F: '/MHz/{gsub(/ /,"",$2); printf("%.0f MHz",$2); exit}')"
load="$(awk '{print $1" / "$2" / "$3}' /proc/loadavg 2>/dev/null)"
mem_total="$(awk '/MemTotal/{printf "%.2f GB",$2/1024/1024}' /proc/meminfo)"
mem_free="$(awk '/MemAvailable/{printf "%.2f GB",$2/1024/1024}' /proc/meminfo)"
svc_status(){ systemctl is-active --quiet "$1" && echo -e "${GR}OK${NC}" || echo -e "${RD}FAIL${NC}"; }
printf "${CY}Information as of:${NC} ${YE}%s${NC}\n" "$now"
printf "${GY}FQDN :${NC} %s\n" "$fqdn"
if [ -n "$ip_ext" ]; then printf "${GY}IP :${NC} %s ${GY}(external:${NC} %s${GY})${NC}\n" "${ip_int:-?}" "$ip_ext"; else printf "${GY}IP :${NC} %s\n" "${ip_int:-?}"; fi
printf "${GY}Uptime :${NC} %s\n" "${upt:-?}"
printf "${GY}Core(s) :${NC} %s core(s) at ${CY}%s${NC}\n" "$cores" "${mhz:-?}"
printf "${GY}Load :${NC} %s (1 / 5 / 15)\n" "${load:-?}"
printf "${GY}Memory :${NC} ${RD}%s${NC} ${GY}(free)${NC} / ${CY}%s${NC} ${GY}(total)${NC}\n" "${mem_free:-?}" "${mem_total:-?}"
echo
printf "${GY}Services :${NC} postfix: $(svc_status postfix) dovecot: $(svc_status dovecot) nginx: $(svc_status nginx) mariadb: $(svc_status mariadb) redis: $(svc_status redis)\n"
SH
chmod +x /usr/local/bin/mw-motd
if [[ -d /etc/update-motd.d ]]; then
cat >/etc/update-motd.d/10-mailwolt <<'SH'
#!/usr/bin/env bash
/usr/local/bin/mw-motd
SH
chmod +x /etc/update-motd.d/10-mailwolt
[[ -f /etc/update-motd.d/50-motd-news ]] && chmod -x /etc/update-motd.d/50-motd-news || true
[[ -f /etc/update-motd.d/80-livepatch ]] && chmod -x /etc/update-motd.d/80-livepatch || true
else
cat >/etc/profile.d/10-mailwolt-motd.sh <<'SH'
case "$-" in *i*) /usr/local/bin/mw-motd ;; esac
SH
fi
: > /etc/motd 2>/dev/null || true

49
scripts/99-summary.sh Normal file
View File

@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
source ./lib.sh
ok(){ echo -e " [${GREEN}OK${NC}]"; }
fail(){ echo -e " [${RED}FAIL${NC}]"; }
echo "[+] Quick-Healthchecks…"
printf " • MariaDB … " ; mysqladmin ping --silent >/dev/null 2>&1 && ok || fail
printf " • Redis … " ; if command -v redis-cli >/dev/null 2>&1; then
if [[ -n "${REDIS_PASS:-}" && "${REDIS_PASS}" != "null" ]]; then redis-cli -a "${REDIS_PASS}" ping 2>/dev/null | grep -q PONG && ok || fail
else redis-cli ping 2>/dev/null | grep -q PONG && ok || fail; fi
else fail; fi
# PHP-FPM
detect_php_fpm_sock(){
for v in 8.3 8.2 8.1 8.0 7.4; do s="/run/php/php${v}-fpm.sock"; [[ -S "$s" ]] && { echo "$s"; return; }; done
[[ -S "/run/php/php-fpm.sock" ]] && { echo "/run/php/php-fpm.sock"; return; }
echo "127.0.0.1:9000"
}
PHP_FPM_SOCK="$(detect_php_fpm_sock)"
printf " • PHP-FPM … " ; if [[ "$PHP_FPM_SOCK" == 127.0.0.1:9000 ]]; then ss -ltn | grep -q ":9000 " && ok || fail; else [[ -S "$PHP_FPM_SOCK" ]] && ok || fail; fi
# App HTTP
printf " • App … " ; if command -v curl >/dev/null 2>&1; then
if [[ -f "/etc/ssl/ui/fullchain.pem" && -f "/etc/ssl/ui/privkey.pem" ]]; then curl -skI "https://127.0.0.1" >/dev/null 2>&1 && ok || fail; else curl -sI "http://127.0.0.1" >/dev/null 2>&1 && ok || fail; fi
else echo -e " ${GREY}(curl fehlt)${NC}"; fi
check_port(){ local label="$1" cmd="$2"; printf " • %-5s … " "$label"; timeout 8s bash -lc "$cmd" >/dev/null 2>&1 && ok || fail; }
check_port "25" 'printf "QUIT\r\n" | nc -w 3 127.0.0.1 25'
check_port "465" 'printf "QUIT\r\n" | openssl s_client -connect 127.0.0.1:465 -quiet -ign_eof'
check_port "587" 'printf "EHLO x\r\nSTARTTLS\r\nQUIT\r\n" | openssl s_client -starttls smtp -connect 127.0.0.1:587 -quiet -ign_eof'
check_port "110" 'printf "QUIT\r\n" | nc -w 3 127.0.0.1 110'
check_port "995" 'printf "QUIT\r\n" | openssl s_client -connect 127.0.0.1:995 -quiet -ign_eof'
check_port "143" 'printf ". LOGOUT\r\n" | nc -w 3 127.0.0.1 143'
check_port "993" 'printf ". LOGOUT\r\n" | openssl s_client -connect 127.0.0.1:993 -quiet -ign_eof'
# Abschluss
echo -e "
${GREEN}${BAR}${NC}
${GREEN}${APP_NAME} Bootstrap abgeschlossen${NC}
${GREEN}${BAR}${NC}
Aufruf UI: ${CYAN}$( [[ -f /etc/ssl/ui/fullchain.pem ]] && echo https || echo http )://${SERVER_PUBLIC_IPV4}${NC}
UI Host bevorzugt: ${GREY}${UI_HOST}${NC}
Mail-Host: ${GREY}${MAIL_HOSTNAME}${NC}
Nginx Site: ${GREY}/etc/nginx/sites-available/${APP_USER}.conf${NC}
TLS (stabile Pfade): ${GREY}/etc/ssl/{ui,webmail,mail}/{fullchain.pem,privkey.pem}${NC}
${GREEN}${BAR}${NC}
"

63
scripts/bootstrap.sh Normal file
View File

@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
source ./lib.sh
require_root
header
# ── Pflicht: Mailserver-FQDN ──────────────────────────────────────────────
MAIL_FQDN="${MAIL_FQDN:-}"
if [[ -z "${MAIL_FQDN}" ]]; then
read -r -p "Mailserver FQDN (z.B. mx.example.com): " MAIL_FQDN
fi
[[ "$MAIL_FQDN" == *.* ]] || die "MAIL_FQDN (z.B. mx.example.com) ist Pflicht."
# Ableitungen
MTA_SUB="${MAIL_FQDN%%.*}"
BASE_DOMAIN="${MAIL_FQDN#*.}"
UI_SUB="${UI_SUB:-ui}"
WEBMAIL_SUB="${WEBMAIL_SUB:-webmail}"
SYSTEM_SUB="${SYSTEM_SUB:-system}" # kommt aus Seeder (App), hier nur Derivate
export APP_NAME="${APP_NAME:-MailWolt}"
export APP_ENV="${APP_ENV:-production}"
export APP_DEBUG="${APP_DEBUG:-false}"
export BASE_DOMAIN UI_SUB WEBMAIL_SUB MTA_SUB SYSTEM_SUB
export UI_HOST="${UI_SUB}.${BASE_DOMAIN}"
export WEBMAIL_HOST="${WEBMAIL_SUB}.${BASE_DOMAIN}"
export MAIL_HOSTNAME="${MAIL_FQDN}"
export SYSTEM_HOSTNAME="${SYSTEM_SUB}.${BASE_DOMAIN}"
export SERVER_PUBLIC_IPV4="$(detect_ip)"
export SERVER_PUBLIC_IPV6="$(detect_ipv6)"
# Zeitzone/Locale heuristisch (kannst du später per UI anpassen)
export APP_TZ="$(detect_timezone)"
export APP_LOCALE="$(guess_locale_from_tz "$APP_TZ")"
# LE-Kontakt
export LE_EMAIL="${LE_EMAIL:-admin@${BASE_DOMAIN}}"
# Repo/Branch (für 80-app.sh)
export GIT_REPO="${GIT_REPO:-https://git.nexlab.at/boban/mailwolt.git}"
export GIT_BRANCH="${GIT_BRANCH:-main}"
export APP_USER="${APP_USER:-mailwolt}"
export APP_GROUP="${APP_GROUP:-www-data}"
export APP_DIR="/var/www/${APP_USER}"
export APP_USER_PREFIX="${APP_USER_PREFIX:-mw}"
# Run modules
./10-provision.sh
./20-ssl.sh
./30-db.sh
./40-postfix.sh
./50-dovecot.sh
./60-rspamd-opendkim.sh
./70-nginx.sh
./80-app.sh
./90-services.sh
./95-monit.sh
./98-motd.sh
./99-summary.sh

77
scripts/lib.sh Normal file
View File

@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Styling ────────────────────────────────────────────────────────────────
GREEN="$(printf '\033[1;32m')"; YELLOW="$(printf '\033[1;33m')"
RED="$(printf '\033[1;31m')"; CYAN="$(printf '\033[1;36m')"
GREY="$(printf '\033[0;90m')"; NC="$(printf '\033[0m')"
BAR="──────────────────────────────────────────────────────────────────────────────"
header(){ echo -e "${CYAN}${BAR}${NC}
${CYAN} 888b d888 d8b 888 888 888 888 888 ${NC}
${CYAN} 8888b d8888 Y8P 888 888 o 888 888 888 ${NC}
${CYAN} 88888b.d88888 888 888 d8b 888 888 888 ${NC}
${CYAN} 888Y88888P888 8888b. 888 888 888 d888b 888 .d88b. 888 888888 ${NC}
${CYAN} 888 Y888P 888 '88b 888 888 888d88888b888 d88''88b 888 888 ${NC}
${CYAN} 888 Y8P 888 .d888888 888 888 88888P Y88888 888 888 888 888 ${NC}
${CYAN} 888 ' 888 888 888 888 888 8888P Y8888 Y88..88P 888 Y88b. ${NC}
${CYAN} 888 888 'Y888888 888 888 888P Y888 'Y88P' 888 'Y888 ${NC}
${CYAN}${BAR}${NC}\n"; }
log(){ echo -e "${GREEN}[+]${NC} $*"; }
warn(){ echo -e "${YELLOW}[!]${NC} $*"; }
err(){ echo -e "${RED}[x]${NC} $*"; }
die(){ err "$*"; exit 1; }
require_root(){ [[ "$(id -u)" -eq 0 ]] || die "Bitte als root ausführen."; }
detect_ip(){
local ip
ip="$(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}}')" || true
[[ -n "${ip:-}" ]] || ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
[[ -n "${ip:-}" ]] || die "Konnte Server-IP nicht ermitteln."
echo "$ip"
}
detect_ipv6(){
local ip6
ip6="$(ip -6 addr show scope global 2>/dev/null | awk '/inet6/{print $2}' | cut -d/ -f1 | head -n1)" || true
[[ -n "${ip6:-}" ]] || ip6="$(hostname -I 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i ~ /:/){print $i; exit}}')" || true
echo "${ip6:-}"
}
detect_timezone(){
if command -v timedatectl >/dev/null 2>&1; then
tz="$(timedatectl show -p Timezone --value 2>/dev/null | tr -d '[:space:]')" || true
[[ -n "${tz:-}" && "$tz" == */* ]] && { echo "$tz"; return; }
fi
if [[ -r /etc/timezone ]]; then
tz="$(sed -n '1p' /etc/timezone | tr -d '[:space:]')" || true
[[ -n "${tz:-}" && "$tz" == */* ]] && { echo "$tz"; return; }
fi
if [[ -L /etc/localtime ]]; then
target="$(readlink -f /etc/localtime 2>/dev/null || true)"
target="${target#/usr/share/zoneinfo/}"
[[ "$target" == */* ]] && { echo "$target"; return; }
fi
if command -v curl >/dev/null 2>&1; then
tz="$(curl -fsSL --max-time 3 https://ipapi.co/timezone 2>/dev/null || true)"
[[ -n "${tz:-}" && "$tz" == */* ]] && { echo "$tz"; return; }
fi
echo "UTC"
}
guess_locale_from_tz(){
local tz="${1:-UTC}"
case "$tz" in
Europe/Berlin|Europe/Vienna|Europe/Zurich|Europe/Luxembourg|Europe/Brussels|Europe/Amsterdam) echo "de";;
*) echo "en";;
esac
}
resolve_ok(){
local host="$1" ip="${SERVER_PUBLIC_IPV4:-}"
[[ -n "$ip" ]] || return 1
getent ahosts "$host" | awk '{print $1}' | sort -u | grep -q -F "$ip"
}
ensure_dir(){ install -d -m "${3:-0755}" -o "${1:-root}" -g "${2:-root}" "${4}"; }