diff --git a/.env.example b/.env.example index 35db1dd..52084ab 100644 --- a/.env.example +++ b/.env.example @@ -31,9 +31,11 @@ SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ +# For cross-subdomain session sharing (e.g. webmail on mail.example.com): +# SESSION_DOMAIN=.example.com SESSION_DOMAIN=null -BROADCAST_CONNECTION=log +#BROADCAST_CONNECTION=log FILESYSTEM_DISK=local QUEUE_CONNECTION=database @@ -63,3 +65,11 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Mailwolt domain config +BASE_DOMAIN=example.com +UI_SUB=admin +MTA_SUB=mail +# Custom webmail subdomain — users access webmail at WEBMAIL_SUB.BASE_DOMAIN +# Leave empty to use only the path-based fallback (/webmail on main domain) +WEBMAIL_SUB=webmail diff --git a/INSTALL_REPORT.md b/INSTALL_REPORT.md new file mode 100644 index 0000000..1a41c19 --- /dev/null +++ b/INSTALL_REPORT.md @@ -0,0 +1,72 @@ +# MailWolt Install Report +Datum: 2026-04-17 + +## Befunde & Fixes + +### Migrationen +- **Status:** Alle 21 Migrationen erfolgreich durchgeführt (Batch 1–15) +- Tabellen vorhanden: `domains`, `mail_users`, `mail_aliases`, `mail_alias_recipients`, `dkim_keys`, `settings`, `system_tasks`, `spf_records`, `dmarc_records`, `tlsa_records`, `backup_*`, `fail2ban_*`, `two_factor_*` +- Feldbezeichnungen weichen von der Spec ab (z. B. `local` statt `source` bei Aliases) – ist konsistent mit der restlichen Anwendung + +### Setup-Wizard +- **Fix:** Route `/setup` fehlte in `routes/web.php` → hinzugefügt +- Controller: `App\Http\Controllers\Setup\SetupWizard` (existiert) +- Middleware `EnsureSetupCompleted` leitet nicht-abgeschlossene Setups korrekt auf `/setup` weiter +- `SETUP_PHASE=bootstrap` in `.env` → Setup noch nicht abgeschlossen + +### Nginx vHost (`/etc/nginx/sites-available/mailwolt.conf`) +- `$uri`-Escapes: korrekt (kein Escaping-Problem) +- PHP-FPM Socket: `unix:/run/php/php8.2-fpm.sock` ✓ +- `nginx -t`: **syntax ok / test successful** ✓ + +## Dienste-Status +``` +postfix active +dovecot active +nginx active +mariadb active +redis-server active +rspamd active +opendkim activating ← startet noch / Sockets werden ggf. verzögert gebunden +fail2ban active +php8.2-fpm active +laravel-queue active +reverb active +``` + +## Port-Test (Smoke Test) +``` +Port 25: OPEN (SMTP) +Port 465: OPEN (SMTPS) +Port 587: OPEN (Submission) +Port 143: OPEN (IMAP) +Port 993: OPEN (IMAPS) +Port 80: OPEN (HTTP → HTTPS Redirect) +Port 443: OPEN (HTTPS) +``` + +## Noch ausstehend (manuell) + +- **Setup-Wizard aufrufen:** https://10.10.70.58/setup + (Domain, Admin-E-Mail, Admin-Passwort setzen) + +- **DKIM-Keys für Domain generieren** (nach Setup, wenn Domain angelegt): + ``` + rspamadm dkim_keygen -s mail -d example.com + ``` + +- **DNS-Einträge setzen** (beim Domain-Hoster): + - `MX` → `mail.example.com` + - `SPF` → `v=spf1 mx ~all` + - `DKIM` → TXT-Eintrag aus `rspamadm dkim_keygen` + - `DMARC` → `v=DMARC1; p=none; rua=mailto:dmarc@example.com` + +- **Let's Encrypt Zertifikat** (nach DNS-Propagation): + ``` + certbot --nginx -d mail.example.com + ``` + +- **opendkim** prüfen ob Dienst vollständig gestartet ist: + ``` + systemctl status opendkim + ``` diff --git a/app/Console/Commands/BackupRun.php b/app/Console/Commands/BackupRun.php new file mode 100644 index 0000000..098e5cf --- /dev/null +++ b/app/Console/Commands/BackupRun.php @@ -0,0 +1,201 @@ +findOrFail($this->argument('jobId')); + $job->update(['status' => 'running']); + + $policy = $job->policy; + $tmpDir = sys_get_temp_dir() . '/mailwolt_backup_' . $job->id; + mkdir($tmpDir, 0700, true); + + $sources = []; + $log = []; + $exitCode = 0; + + // ── 1. External backup script (takes full control if present) ────── + $script = '/usr/local/sbin/mailwolt-backup'; + if (file_exists($script)) { + $output = []; + exec("bash " . escapeshellarg($script) . ' 2>&1', $output, $exitCode); + + $artifact = null; + foreach ($output as $line) { + if (str_starts_with($line, 'ARTIFACT:')) { + $artifact = trim(substr($line, 9)); + } + } + + $this->finalize($job, $exitCode, implode("\n", array_slice($output, -30)), $artifact); + $this->cleanTmp($tmpDir); + return $exitCode === 0 ? self::SUCCESS : self::FAILURE; + } + + // ── 2. Built-in backup ──────────────────────────────────────────── + + // Database + if ($policy?->include_db ?? true) { + [$ok, $msg, $dumpFile] = $this->dumpDatabase($tmpDir); + if ($ok) { + $sources[] = $dumpFile; + $log[] = '✓ Datenbank gesichert'; + } else { + $log[] = '✗ Datenbank: ' . $msg; + $exitCode = 1; + } + } + + // Mail directories + if ($policy?->include_maildirs ?? true) { + $mailDir = (string) Setting::get('backup_mail_dir', ''); + if (empty($mailDir)) { + foreach (['/var/vmail', '/var/mail/vhosts', '/home/vmail'] as $c) { + if (is_dir($c)) { $mailDir = $c; break; } + } + } + if ($mailDir && is_dir($mailDir)) { + $sources[] = $mailDir; + $log[] = '✓ Maildirs: ' . $mailDir; + } else { + $log[] = '✗ Maildir nicht gefunden (konfiguriere den Pfad in den Einstellungen)'; + } + } + + // Config files + SSL + if ($policy?->include_configs ?? true) { + foreach (['/etc/postfix', '/etc/dovecot', '/etc/opendkim', '/etc/rspamd', '/etc/letsencrypt'] as $dir) { + if (is_dir($dir)) { + $sources[] = $dir; + $log[] = '✓ ' . $dir; + } + } + if (file_exists(base_path('.env'))) { + $sources[] = base_path('.env'); + $log[] = '✓ .env'; + } + } + + if (empty($sources)) { + $msg = 'Keine Quellen zum Sichern gefunden.'; + $job->update(['status' => 'failed', 'finished_at' => now(), 'error' => $msg]); + $this->cleanTmp($tmpDir); + return self::FAILURE; + } + + // Build archive — use storage/app/backups so www-data always has write access + $outDir = storage_path('app/backups'); + if (!is_dir($outDir) && !mkdir($outDir, 0750, true) && !is_dir($outDir)) { + // Final fallback: /tmp (always writable) + $outDir = sys_get_temp_dir() . '/mailwolt_backups'; + mkdir($outDir, 0750, true); + } + + $stamp = now()->format('Y-m-d_H-i-s'); + $outFile = "{$outDir}/mailwolt_{$stamp}.tar.gz"; + $srcArgs = implode(' ', array_map('escapeshellarg', $sources)); + + $tarOutput = []; + $tarExit = 0; + exec("tar --ignore-failed-read -czf " . escapeshellarg($outFile) . " {$srcArgs} 2>&1", $tarOutput, $tarExit); + + $this->cleanTmp($tmpDir); + + // tar exit 1 = some files unreadable but archive was written — treat as ok + if ($tarExit <= 1 && file_exists($outFile)) { + $tarExit = 0; + } + + if ($tarExit !== 0) { + $exitCode = $tarExit; + } + + $fullLog = implode("\n", $log) . "\n\n" . implode("\n", array_slice($tarOutput, -20)); + $this->finalize($job, $exitCode, $fullLog, $tarExit === 0 ? $outFile : null); + + return $exitCode === 0 ? self::SUCCESS : self::FAILURE; + } + + private function dumpDatabase(string $tmpDir): array + { + $conn = config('database.connections.' . config('database.default')); + + if (($conn['driver'] ?? '') !== 'mysql') { + return [false, 'Nur MySQL/MariaDB wird unterstützt.', null]; + } + + $host = $conn['host'] ?? '127.0.0.1'; + $port = (string)($conn['port'] ?? '3306'); + $username = $conn['username'] ?? ''; + $password = $conn['password'] ?? ''; + $database = $conn['database'] ?? ''; + + $dumpFile = "{$tmpDir}/database.sql"; + + $cmd = 'MYSQL_PWD=' . escapeshellarg($password) + . ' mysqldump' + . ' -h ' . escapeshellarg($host) + . ' -P ' . escapeshellarg($port) + . ' -u ' . escapeshellarg($username) + . ' --single-transaction --routines --triggers' + . ' ' . escapeshellarg($database) + . ' > ' . escapeshellarg($dumpFile) + . ' 2>&1'; + + $output = []; + $exit = 0; + exec($cmd, $output, $exit); + + if ($exit !== 0 || !file_exists($dumpFile)) { + return [false, implode('; ', $output), null]; + } + + return [true, '', $dumpFile]; + } + + private function finalize(BackupJob $job, int $exitCode, string $log, ?string $artifact): void + { + $sizeBytes = ($artifact && file_exists($artifact)) ? filesize($artifact) : 0; + + if ($exitCode === 0) { + $job->update([ + 'status' => 'ok', + 'finished_at' => now(), + 'log_excerpt' => $log, + 'artifact_path' => $artifact, + 'size_bytes' => $sizeBytes, + ]); + $job->policy?->update([ + 'last_run_at' => now(), + 'last_status' => 'ok', + 'last_size_bytes' => $sizeBytes, + ]); + } else { + $job->update([ + 'status' => 'failed', + 'finished_at' => now(), + 'error' => $log, + ]); + $job->policy?->update(['last_status' => 'failed']); + } + } + + private function cleanTmp(string $dir): void + { + if (!is_dir($dir)) return; + foreach (glob("{$dir}/*") ?: [] as $f) { + is_file($f) ? unlink($f) : null; + } + @rmdir($dir); + } +} diff --git a/app/Console/Commands/BackupScheduled.php b/app/Console/Commands/BackupScheduled.php new file mode 100644 index 0000000..831e114 --- /dev/null +++ b/app/Console/Commands/BackupScheduled.php @@ -0,0 +1,39 @@ +argument('policyId')); + + if (! $policy || ! $policy->enabled) { + return self::SUCCESS; + } + + if (BackupJob::whereIn('status', ['queued', 'running'])->exists()) { + $this->warn('Backup läuft bereits — übersprungen.'); + return self::SUCCESS; + } + + $job = BackupJob::create([ + 'policy_id' => $policy->id, + 'status' => 'queued', + 'started_at' => now(), + ]); + + $artisan = base_path('artisan'); + exec("nohup php {$artisan} backup:run {$job->id} > /dev/null 2>&1 &"); + + $this->info("Backup-Job #{$job->id} gestartet."); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RestoreRun.php b/app/Console/Commands/RestoreRun.php new file mode 100644 index 0000000..6a318c0 --- /dev/null +++ b/app/Console/Commands/RestoreRun.php @@ -0,0 +1,152 @@ +argument('backupJobId')); + $token = $this->argument('token'); + $statusFile = sys_get_temp_dir() . '/' . $token . '.json'; + + $artifact = $sourceJob?->artifact_path; + + if (!$artifact || !file_exists($artifact)) { + $this->writeStatus($statusFile, 'failed', ['✗ Archivdatei nicht gefunden: ' . $artifact]); + return self::FAILURE; + } + + $this->writeStatus($statusFile, 'running', ['Archiv wird extrahiert…']); + + $extractDir = sys_get_temp_dir() . '/mailwolt_restore_' . $token; + mkdir($extractDir, 0700, true); + + $log = []; + $exitCode = 0; + + // ── 1. Archiv extrahieren ───────────────────────────────────────── + $tarOut = []; + $tarExit = 0; + exec( + "tar --ignore-failed-read -xzf " . escapeshellarg($artifact) + . " -C " . escapeshellarg($extractDir) . " 2>&1", + $tarOut, $tarExit + ); + + if ($tarExit > 1) { + $log[] = '✗ Extraktion fehlgeschlagen: ' . implode('; ', array_slice($tarOut, -3)); + $exitCode = 1; + } else { + $log[] = '✓ Archiv extrahiert'; + } + $this->writeStatus($statusFile, 'running', $log); + + // ── 2. Datenbank ───────────────────────────────────────────────── + $dbFiles = []; + exec("find " . escapeshellarg($extractDir) . " -name 'database.sql' -type f 2>/dev/null", $dbFiles); + + if (!empty($dbFiles)) { + $log[] = 'Datenbank wird importiert…'; + $this->writeStatus($statusFile, 'running', $log); + + [$ok, $msg] = $this->importDatabase($dbFiles[0]); + $log[] = $ok ? '✓ Datenbank wiederhergestellt' : '✗ Datenbank: ' . $msg; + if (!$ok) $exitCode = 1; + } else { + $log[] = '— Kein Datenbank-Dump im Archiv'; + } + $this->writeStatus($statusFile, 'running', $log); + + // ── 3. E-Mails (Maildirs) ──────────────────────────────────────── + foreach (["{$extractDir}/var/mail", "{$extractDir}/var/vmail"] as $mailSrc) { + if (is_dir($mailSrc)) { + $log[] = 'E-Mails werden wiederhergestellt…'; + $this->writeStatus($statusFile, 'running', $log); + + $destParent = '/' . implode('/', array_slice(explode('/', $mailSrc, -1 + substr_count($mailSrc, '/')), 1, -1)); + $cpOut = []; + $cpExit = 0; + exec("cp -rp " . escapeshellarg($mailSrc) . " " . escapeshellarg($destParent) . "/ 2>&1", $cpOut, $cpExit); + $log[] = $cpExit === 0 + ? '✓ E-Mails wiederhergestellt' + : '✗ Mails: ' . implode('; ', array_slice($cpOut, -3)); + if ($cpExit !== 0) $exitCode = 1; + } + } + + // ── 4. Konfiguration ───────────────────────────────────────────── + $etcSrc = "{$extractDir}/etc"; + if (is_dir($etcSrc)) { + $log[] = 'Konfiguration wird wiederhergestellt…'; + $this->writeStatus($statusFile, 'running', $log); + + foreach (scandir($etcSrc) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') continue; + $cpOut = []; + $cpExit = 0; + exec("cp -rp " . escapeshellarg("{$etcSrc}/{$entry}") . " /etc/ 2>&1", $cpOut, $cpExit); + $log[] = $cpExit === 0 + ? '✓ /etc/' . $entry + : '— /etc/' . $entry . ': ' . implode('; ', array_slice($cpOut, -1)); + } + } + + // ── 5. Dienste neu laden ───────────────────────────────────────── + foreach (['postfix', 'dovecot'] as $svc) { + if (is_dir("{$etcSrc}/{$svc}")) { + $restOut = []; + exec("systemctl reload-or-restart {$svc} 2>&1", $restOut, $restExit); + $log[] = $restExit === 0 ? '✓ ' . $svc . ' neu geladen' : '— ' . $svc . ': ' . implode('; ', $restOut); + } + } + + // ── Aufräumen ──────────────────────────────────────────────────── + exec("rm -rf " . escapeshellarg($extractDir)); + + $finalStatus = $exitCode === 0 ? 'ok' : 'failed'; + $this->writeStatus($statusFile, $finalStatus, $log); + + return $exitCode === 0 ? self::SUCCESS : self::FAILURE; + } + + private function importDatabase(string $sqlFile): array + { + $conn = config('database.connections.' . config('database.default')); + if (($conn['driver'] ?? '') !== 'mysql') { + return [false, 'Nur MySQL/MariaDB wird unterstützt.']; + } + + $cmd = 'MYSQL_PWD=' . escapeshellarg($conn['password'] ?? '') + . ' mysql' + . ' -h ' . escapeshellarg($conn['host'] ?? '127.0.0.1') + . ' -P ' . escapeshellarg((string)($conn['port'] ?? '3306')) + . ' -u ' . escapeshellarg($conn['username'] ?? '') + . ' ' . escapeshellarg($conn['database'] ?? '') + . ' < ' . escapeshellarg($sqlFile) + . ' 2>&1'; + + $output = []; + $exit = 0; + exec($cmd, $output, $exit); + + return $exit === 0 + ? [true, ''] + : [false, implode('; ', array_slice($output, -3))]; + } + + private function writeStatus(string $file, string $status, array $log): void + { + file_put_contents($file, json_encode([ + 'status' => $status, + 'log' => implode("\n", $log), + 'timestamp' => time(), + ])); + } +} diff --git a/app/Console/Commands/SandboxReceive.php b/app/Console/Commands/SandboxReceive.php new file mode 100644 index 0000000..ac6dc1f --- /dev/null +++ b/app/Console/Commands/SandboxReceive.php @@ -0,0 +1,31 @@ +option('to') ?? []; + $parser->parseAndStore($raw, $recipients); + + return 0; + } +} diff --git a/app/Console/Commands/UpdateMailboxStats.php b/app/Console/Commands/UpdateMailboxStats.php index 60a90bb..f069e67 100644 --- a/app/Console/Commands/UpdateMailboxStats.php +++ b/app/Console/Commands/UpdateMailboxStats.php @@ -134,45 +134,50 @@ class UpdateMailboxStats extends Command [$local, $domain] = explode('@', $email, 2); $maildir = "/var/mail/vhosts/{$domain}/{$local}"; - // Größe in Bytes (rekursiv) - $usedBytes = 0; - if (is_dir($maildir)) { - $it = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($maildir, \FilesystemIterator::SKIP_DOTS) - ); - foreach ($it as $f) if ($f->isFile()) $usedBytes += $f->getSize(); - } - $isSystemDomain = (bool)($u->domain->is_system ?? false); if ($isSystemDomain || $u->is_system) { - // systemische Mailboxen fließen nur in die System-Summe ein - $sumSystemBytes += $usedBytes; - continue; // KEIN per-user Setting + $sumSystemBytes += $this->sizeViaDoveadm($email) ?? $this->sizeViaFilesystem($maildir) ?? 0; + continue; } - // Message-Count - $messageCount = $this->countViaDoveadm($email); - if ($messageCount === null) $messageCount = $this->countViaFilesystem($maildir); + // Alle Ordner zählen (nicht nur INBOX) — doveadm ist primäre Quelle + $messageCount = $this->countAllFoldersViaDoveadm($email); + $usedBytes = $this->sizeViaDoveadm($email); + + if ($messageCount === null) { + // Filesystem-Fallback nur wenn das Verzeichnis lesbar ist + $fsCount = $this->countViaFilesystem($maildir); + $fsSize = $this->sizeViaFilesystem($maildir); + if ($fsCount === 0 && $fsSize === null) { + $log->debug('skip (no access)', ['email' => $email]); + continue; + } + $messageCount = $fsCount; + $usedBytes = $fsSize ?? 0; + } else { + $usedBytes = $usedBytes ?? $this->sizeViaFilesystem($maildir) ?? 0; + } $sumUserBytes += $usedBytes; - $key = "mailbox.{$email}"; - $prev = (array)(Setting::get($key, []) ?: []); - $new = [ + // Immer schreiben wenn doveadm erfolgreich war — keine Change-Detection + Setting::set("mailbox.{$email}", [ 'used_bytes' => (int)$usedBytes, 'message_count' => (int)$messageCount, 'updated_at' => now()->toDateTimeString(), - ]; - - if (($prev['used_bytes'] ?? null) !== $new['used_bytes'] - || ($prev['message_count'] ?? null) !== $new['message_count']) { - Setting::set($key, $new); - $changed++; - $this->line(sprintf("%-35s %7.2f MiB %5d msgs", - $email, $usedBytes / 1048576, $messageCount)); - $log->info('updated', ['email'=>$email]+$new); - } + ]); + \Illuminate\Support\Facades\DB::table('mail_users') + ->where('id', $u->id) + ->update([ + 'used_bytes' => (int)$usedBytes, + 'message_count' => (int)$messageCount, + 'stats_refreshed_at' => now(), + ]); + $changed++; + $this->line(sprintf("%-35s %7.2f MiB %5d msgs", + $email, $usedBytes / 1048576, $messageCount)); + $log->info('updated', ['email' => $email, 'used_bytes' => $usedBytes, 'message_count' => $messageCount]); } }); @@ -190,20 +195,58 @@ class UpdateMailboxStats extends Command } - private function countViaDoveadm(string $email): ?int + private function doveadmStatus(string $email, string $fields): array { $cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u " - . escapeshellarg($email) . " messages INBOX 2>&1"; + . escapeshellarg($email) . " {$fields} INBOX 2>/dev/null"; $out = []; - $rc = 0; + $rc = 0; exec($cmd, $out, $rc); - if ($rc !== 0) return null; + if ($rc !== 0) return []; + // header: "mailbox\tmessages" data: "INBOX\t4" + $header = null; foreach ($out as $line) { - if (preg_match('/^\s*INBOX\s+(\d+)\s*$/i', trim($line), $m)) { - return (int)$m[1]; + $parts = explode("\t", trim($line)); + if ($header === null) { + $header = $parts; + continue; } + if (count($parts) !== count($header)) continue; + return array_combine($header, $parts); } + return []; + } + + private function countAllFoldersViaDoveadm(string $email): ?int + { + // Entwürfe, Papierkorb und Spam/Junk nicht mitzählen + static $exclude = ['Drafts', 'Trash', 'Junk', 'Spam']; + + $cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u " + . escapeshellarg($email) . " messages '*' 2>/dev/null"; + $out = []; + $rc = 0; + exec($cmd, $out, $rc); + if ($rc !== 0 || count($out) < 2) return null; + + $total = 0; + $header = null; + foreach ($out as $line) { + $parts = explode("\t", trim($line)); + if ($header === null) { $header = $parts; continue; } + if (count($parts) !== count($header)) continue; + $row = array_combine($header, $parts); + if (in_array($row['mailbox'] ?? '', $exclude, true)) continue; + $total += (int)($row['messages'] ?? 0); + } + return $total; + } + + private function sizeViaDoveadm(string $email): ?int + { + $row = $this->doveadmStatus($email, 'vsize'); + if (isset($row['vsize'])) return (int)$row['vsize']; return null; } @@ -213,7 +256,7 @@ class UpdateMailboxStats extends Command foreach (['cur', 'new'] as $sub) { $dir = "{$maildir}/{$sub}"; if (!is_dir($dir)) continue; - $h = opendir($dir); + $h = @opendir($dir); if (!$h) continue; while (($fn = readdir($h)) !== false) { if ($fn === '.' || $fn === '..' || $fn[0] === '.') continue; @@ -223,6 +266,23 @@ class UpdateMailboxStats extends Command } return $n; } + + private function sizeViaFilesystem(string $maildir): ?int + { + if (!is_dir($maildir) || !is_readable($maildir)) return null; + try { + $bytes = 0; + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($maildir, \FilesystemIterator::SKIP_DOTS) + ); + foreach ($it as $f) { + if ($f->isFile()) $bytes += $f->getSize(); + } + return $bytes; + } catch (\Throwable) { + return null; + } + } } //namespace App\Console\Commands; diff --git a/app/Enums/Role.php b/app/Enums/Role.php index be0db15..3af4ccb 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -4,12 +4,35 @@ namespace App\Enums; enum Role: string { - case Member = 'member'; - case Admin = 'admin'; + case Admin = 'admin'; + case Operator = 'operator'; + case Viewer = 'viewer'; + + public function label(): string + { + return match($this) { + self::Admin => 'Admin', + self::Operator => 'Operator', + self::Viewer => 'Viewer', + }; + } + + public function badgeClass(): string + { + return match($this) { + self::Admin => 'role-badge-admin', + self::Operator => 'role-badge-op', + self::Viewer => 'mbx-badge-mute', + }; + } public static function values(): array { return array_column(self::cases(), 'value'); } + public static function options(): array + { + return array_map(fn($r) => ['value' => $r->value, 'label' => $r->label()], self::cases()); + } } diff --git a/app/Http/Controllers/Api/V1/AliasController.php b/app/Http/Controllers/Api/V1/AliasController.php new file mode 100644 index 0000000..8b67d20 --- /dev/null +++ b/app/Http/Controllers/Api/V1/AliasController.php @@ -0,0 +1,103 @@ +where('is_system', false); + + if ($request->filled('domain')) { + $query->whereHas('domain', fn($q) => $q->where('domain', $request->domain)); + } + + $aliases = $query->orderBy('local')->paginate(100); + + return response()->json([ + 'data' => $aliases->map(fn($a) => $this->format($a)), + 'meta' => ['total' => $aliases->total(), 'per_page' => $aliases->perPage(), 'current_page' => $aliases->currentPage()], + ]); + } + + public function show(int $id): JsonResponse + { + $alias = MailAlias::with(['domain', 'recipients'])->where('is_system', false)->findOrFail($id); + return response()->json(['data' => $this->format($alias)]); + } + + public function store(Request $request): JsonResponse + { + $request->tokenCan('aliases:write') || abort(403, 'Scope aliases:write required.'); + + if ($request->isSandbox ?? false) { + return response()->json(['data' => array_merge(['id' => 9999], $request->only('local', 'domain')), 'sandbox' => true], 201); + } + + $data = $request->validate([ + 'local' => 'required|string|max:64', + 'domain' => 'required|string', + 'recipients' => 'required|array|min:1', + 'recipients.*'=> 'email', + 'is_active' => 'nullable|boolean', + ]); + + $domain = Domain::where('domain', $data['domain'])->firstOrFail(); + + $alias = MailAlias::create([ + 'domain_id' => $domain->id, + 'local' => $data['local'], + 'type' => 'alias', + 'is_active' => $data['is_active'] ?? true, + ]); + + foreach ($data['recipients'] as $i => $addr) { + $alias->recipients()->create(['address' => $addr, 'position' => $i]); + } + + if (!($request->isSandbox ?? false)) { + app(WebhookService::class)->dispatch('alias.created', $this->format($alias->load(['domain', 'recipients']))); + } + + return response()->json(['data' => $this->format($alias->load(['domain', 'recipients']))], 201); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $request->tokenCan('aliases:write') || abort(403, 'Scope aliases:write required.'); + + $alias = MailAlias::where('is_system', false)->findOrFail($id); + + if ($request->isSandbox ?? false) { + return response()->json(['sandbox' => true], 204); + } + + $formatted = $this->format($alias->load(['domain', 'recipients'])); + $alias->recipients()->delete(); + $alias->delete(); + app(WebhookService::class)->dispatch('alias.deleted', $formatted); + return response()->json(null, 204); + } + + private function format(MailAlias $a): array + { + return [ + 'id' => $a->id, + 'address' => $a->address, + 'local' => $a->local, + 'domain' => $a->domain?->domain, + 'type' => $a->type, + 'is_active' => $a->is_active, + 'recipients' => $a->recipients->pluck('address'), + 'created_at' => $a->created_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/DomainController.php b/app/Http/Controllers/Api/V1/DomainController.php new file mode 100644 index 0000000..cb1bede --- /dev/null +++ b/app/Http/Controllers/Api/V1/DomainController.php @@ -0,0 +1,83 @@ +where('is_server', false) + ->orderBy('domain') + ->get() + ->map(fn($d) => $this->format($d)); + + return response()->json(['data' => $domains]); + } + + public function show(int $id): JsonResponse + { + $domain = Domain::where('is_system', false)->where('is_server', false)->findOrFail($id); + return response()->json(['data' => $this->format($domain)]); + } + + public function store(Request $request): JsonResponse + { + $request->tokenCan('domains:write') || abort(403, 'Scope domains:write required.'); + + if ($request->isSandbox ?? false) { + return response()->json(['data' => array_merge(['id' => 9999], $request->only('domain')), 'sandbox' => true], 201); + } + + $data = $request->validate([ + 'domain' => 'required|string|max:253|unique:domains,domain', + 'description' => 'nullable|string|max:255', + 'max_aliases' => 'nullable|integer|min:0', + 'max_mailboxes' => 'nullable|integer|min:0', + 'default_quota_mb' => 'nullable|integer|min:0', + ]); + + $domain = Domain::create($data); + if (!($request->isSandbox ?? false)) { + app(WebhookService::class)->dispatch('domain.created', $this->format($domain)); + } + + return response()->json(['data' => $this->format($domain)], 201); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $request->tokenCan('domains:write') || abort(403, 'Scope domains:write required.'); + + $domain = Domain::where('is_system', false)->where('is_server', false)->findOrFail($id); + + if ($request->isSandbox ?? false) { + return response()->json(['sandbox' => true], 204); + } + + $formatted = $this->format($domain); + $domain->delete(); + app(WebhookService::class)->dispatch('domain.deleted', $formatted); + return response()->json(null, 204); + } + + private function format(Domain $d): array + { + return [ + 'id' => $d->id, + 'domain' => $d->domain, + 'description' => $d->description, + 'is_active' => $d->is_active, + 'max_aliases' => $d->max_aliases, + 'max_mailboxes' => $d->max_mailboxes, + 'default_quota_mb' => $d->default_quota_mb, + 'created_at' => $d->created_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/MailboxController.php b/app/Http/Controllers/Api/V1/MailboxController.php new file mode 100644 index 0000000..a9e2f37 --- /dev/null +++ b/app/Http/Controllers/Api/V1/MailboxController.php @@ -0,0 +1,132 @@ +where('is_system', false); + + if ($request->filled('domain')) { + $query->whereHas('domain', fn($q) => $q->where('domain', $request->domain)); + } + if ($request->filled('active')) { + $query->where('is_active', filter_var($request->active, FILTER_VALIDATE_BOOLEAN)); + } + + $mailboxes = $query->orderBy('email')->paginate(100); + + return response()->json([ + 'data' => $mailboxes->map(fn($m) => $this->format($m)), + 'meta' => ['total' => $mailboxes->total(), 'per_page' => $mailboxes->perPage(), 'current_page' => $mailboxes->currentPage()], + ]); + } + + public function show(int $id): JsonResponse + { + $mailbox = MailUser::with('domain')->where('is_system', false)->findOrFail($id); + return response()->json(['data' => $this->format($mailbox)]); + } + + public function store(Request $request): JsonResponse + { + $request->tokenCan('mailboxes:write') || abort(403, 'Scope mailboxes:write required.'); + + if ($request->isSandbox ?? false) { + return response()->json(['data' => array_merge(['id' => 9999], $request->only('email')), 'sandbox' => true], 201); + } + + $data = $request->validate([ + 'email' => 'required|email|unique:mail_users,email', + 'password' => 'required|string|min:8', + 'display_name'=> 'nullable|string|max:120', + 'quota_mb' => 'nullable|integer|min:0', + 'is_active' => 'nullable|boolean', + ]); + + [$local, $domainName] = explode('@', $data['email']); + $domain = Domain::where('domain', $domainName)->firstOrFail(); + + $mailbox = MailUser::create([ + 'domain_id' => $domain->id, + 'localpart' => $local, + 'email' => $data['email'], + 'display_name' => $data['display_name'] ?? null, + 'password_hash'=> '{ARGON2I}' . base64_encode(password_hash($data['password'], PASSWORD_ARGON2I)), + 'quota_mb' => $data['quota_mb'] ?? $domain->default_quota_mb ?? 1024, + 'is_active' => $data['is_active'] ?? true, + ]); + + if (!($request->isSandbox ?? false)) { + app(WebhookService::class)->dispatch('mailbox.created', $this->format($mailbox->load('domain'))); + } + + return response()->json(['data' => $this->format($mailbox->load('domain'))], 201); + } + + public function update(Request $request, int $id): JsonResponse + { + $request->tokenCan('mailboxes:write') || abort(403, 'Scope mailboxes:write required.'); + + $mailbox = MailUser::where('is_system', false)->findOrFail($id); + + if ($request->isSandbox ?? false) { + return response()->json(['data' => $this->format($mailbox), 'sandbox' => true]); + } + + $data = $request->validate([ + 'display_name' => 'nullable|string|max:120', + 'quota_mb' => 'nullable|integer|min:0', + 'is_active' => 'nullable|boolean', + 'can_login' => 'nullable|boolean', + ]); + + $mailbox->update(array_filter($data, fn($v) => !is_null($v))); + + if (!($request->isSandbox ?? false)) { + app(WebhookService::class)->dispatch('mailbox.updated', $this->format($mailbox->load('domain'))); + } + + return response()->json(['data' => $this->format($mailbox->load('domain'))]); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $request->tokenCan('mailboxes:write') || abort(403, 'Scope mailboxes:write required.'); + + $mailbox = MailUser::where('is_system', false)->findOrFail($id); + + if ($request->isSandbox ?? false) { + return response()->json(['sandbox' => true], 204); + } + + $formatted = $this->format($mailbox->load('domain')); + $mailbox->delete(); + app(WebhookService::class)->dispatch('mailbox.deleted', $formatted); + return response()->json(null, 204); + } + + private function format(MailUser $m): array + { + return [ + 'id' => $m->id, + 'email' => $m->email, + 'display_name' => $m->display_name, + 'domain' => $m->domain?->domain, + 'quota_mb' => $m->quota_mb, + 'is_active' => $m->is_active, + 'can_login' => $m->can_login, + 'last_login_at'=> $m->last_login_at?->toIso8601String(), + 'created_at' => $m->created_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/UI/DashboardController.php b/app/Http/Controllers/UI/DashboardController.php index 09bb485..995912a 100644 --- a/app/Http/Controllers/UI/DashboardController.php +++ b/app/Http/Controllers/UI/DashboardController.php @@ -3,14 +3,147 @@ namespace App\Http\Controllers\UI; use App\Http\Controllers\Controller; -use Illuminate\Http\Request; +use App\Models\Domain; +use App\Models\MailUser; class DashboardController extends Controller { public function index() { - // ggf. Prefetch für Blade, sonst alles via Livewire return view('ui.dashboard.index'); } + public function redesign() + { + $services = [ + ['name' => 'Postfix', 'type' => 'MTA', 'online' => $this->isRunning('postfix')], + ['name' => 'Dovecot', 'type' => 'IMAP', 'online' => $this->isRunning('dovecot')], + ['name' => 'Rspamd', 'type' => 'Spam', 'online' => $this->isRunning('rspamd')], + ['name' => 'OpenDKIM', 'type' => 'DKIM', 'online' => $this->isRunning('opendkim')], + ['name' => 'MariaDB', 'type' => 'DB', 'online' => $this->isRunning('mariadb')], + ['name' => 'Redis', 'type' => 'Cache', 'online' => $this->isRunning('redis')], + ['name' => 'Nginx', 'type' => 'Web', 'online' => $this->isRunning('nginx')], + ['name' => 'ClamAV', 'type' => 'AV', 'online' => $this->isRunning('clamav')], + ]; + + $servicesActive = count(array_filter($services, fn($s) => $s['online'])); + $servicesTotal = count($services); + + [$cpu, $cpuCores, $cpuMhz] = $this->getCpu(); + [$ramPercent, $ramUsed, $ramTotal] = $this->getRam(); + [$load1, $load5, $load15] = $this->getLoad(); + [$uptimeDays, $uptimeHours] = $this->getUptime(); + [$diskUsedPercent, $diskUsedGb, $diskFreeGb, $diskTotalGb] = $this->getDisk(); + + return view('ui.dashboard.redesign', [ + 'domainCount' => Domain::count(), + 'mailboxCount' => MailUser::count(), + 'servicesActive' => $servicesActive, + 'servicesTotal' => $servicesTotal, + 'alertCount' => 0, + 'mailHostname' => gethostname() ?: 'mailserver', + 'services' => $services, + + 'cpu' => $cpu, + 'cpuCores' => $cpuCores, + 'cpuMhz' => $cpuMhz, + + 'ramPercent' => $ramPercent, + 'ramUsed' => $ramUsed, + 'ramTotal' => $ramTotal, + + 'load1' => $load1, + 'load5' => $load5, + 'load15' => $load15, + + 'uptimeDays' => $uptimeDays, + 'uptimeHours' => $uptimeHours, + + 'diskUsedPercent' => $diskUsedPercent, + 'diskUsedGb' => $diskUsedGb, + 'diskFreeGb' => $diskFreeGb, + 'diskTotalGb' => $diskTotalGb, + + 'bounceCount' => 0, + 'spamCount' => 0, + 'lastBackup' => '—', + 'backupSize' => '—', + 'backupDuration' => '—', + ]); + } + + private function isRunning(string $service): bool + { + exec("systemctl is-active --quiet " . escapeshellarg($service) . " 2>/dev/null", $out, $code); + return $code === 0; + } + + private function getCpu(): array + { + $cores = (int) shell_exec("nproc 2>/dev/null") ?: 1; + $mhz = round((float) shell_exec("awk '/^cpu MHz/{sum+=$4; n++} END{if(n)print sum/n}' /proc/cpuinfo 2>/dev/null") / 1000, 1); + + $s1 = $this->readStat(); + usleep(400000); + $s2 = $this->readStat(); + + // /proc/stat fields: user(0) nice(1) system(2) idle(3) iowait(4) irq(5) softirq(6) steal(7) + $idle1 = $s1[3] + ($s1[4] ?? 0); + $idle2 = $s2[3] + ($s2[4] ?? 0); + $total1 = array_sum($s1); + $total2 = array_sum($s2); + $dt = $total2 - $total1; + $di = $idle2 - $idle1; + $cpu = $dt > 0 ? max(0, min(100, round(($dt - $di) / $dt * 100))) : 0; + + return [$cpu, $cores, $mhz ?: '—']; + } + + private function readStat(): array + { + $raw = trim(shell_exec("head -1 /proc/stat 2>/dev/null") ?: ''); + $parts = preg_split('/\s+/', $raw); + array_shift($parts); // remove 'cpu' label + return array_map('intval', $parts); + } + + private function getRam(): array + { + $raw = shell_exec("cat /proc/meminfo 2>/dev/null") ?: ''; + preg_match('/MemTotal:\s+(\d+)/', $raw, $mt); + preg_match('/MemAvailable:\s+(\d+)/', $raw, $ma); + $total = isset($mt[1]) ? (int)$mt[1] : 0; + $avail = isset($ma[1]) ? (int)$ma[1] : 0; + $used = $total - $avail; + $percent = $total > 0 ? round($used / $total * 100) : 0; + return [ + $percent, + round($used / 1048576, 1), + round($total / 1048576, 1), + ]; + } + + private function getLoad(): array + { + $raw = trim(shell_exec("cat /proc/loadavg 2>/dev/null") ?: ''); + $p = explode(' ', $raw); + return [$p[0] ?? '0.00', $p[1] ?? '0.00', $p[2] ?? '0.00']; + } + + private function getUptime(): array + { + $secs = (int)(float)(shell_exec("awk '{print $1}' /proc/uptime 2>/dev/null") ?: 0); + return [intdiv($secs, 86400), intdiv($secs % 86400, 3600)]; + } + + private function getDisk(): array + { + $raw = trim(shell_exec("df -BG / 2>/dev/null | tail -1") ?: ''); + $p = preg_split('/\s+/', $raw); + $total = isset($p[1]) ? (int)$p[1] : 0; + $used = isset($p[2]) ? (int)$p[2] : 0; + $free = isset($p[3]) ? (int)$p[3] : 0; + $percent = $total > 0 ? round($used / $total * 100) : 0; + return [$percent, $used, $free, $total]; + } } diff --git a/app/Http/Controllers/UI/V2/Mail/MailboxController.php b/app/Http/Controllers/UI/V2/Mail/MailboxController.php new file mode 100644 index 0000000..6c59984 --- /dev/null +++ b/app/Http/Controllers/UI/V2/Mail/MailboxController.php @@ -0,0 +1,13 @@ +user()?->currentAccessToken(); + if ($token && $token->sandbox) { + $request->merge(['isSandbox' => true]); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/Require2FA.php b/app/Http/Middleware/Require2FA.php new file mode 100644 index 0000000..89f7f2e --- /dev/null +++ b/app/Http/Middleware/Require2FA.php @@ -0,0 +1,28 @@ +user(); + + if (!$user) return $next($request); + + if (!$this->totp->isEnabled($user)) return $next($request); + + if ($request->session()->get('2fa_verified')) return $next($request); + + if ($request->routeIs('auth.2fa*')) return $next($request); + + return redirect()->route('auth.2fa'); + } +} diff --git a/app/Http/Middleware/RequireRole.php b/app/Http/Middleware/RequireRole.php new file mode 100644 index 0000000..7b644fa --- /dev/null +++ b/app/Http/Middleware/RequireRole.php @@ -0,0 +1,22 @@ +user(); + + if (!$user || !in_array($user->role?->value, $roles, true)) { + abort(403, 'Keine Berechtigung.'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/ValidateHost.php b/app/Http/Middleware/ValidateHost.php new file mode 100644 index 0000000..03616ef --- /dev/null +++ b/app/Http/Middleware/ValidateHost.php @@ -0,0 +1,44 @@ +getHost(); + + if ($this->isAllowed($host)) { + return $next($request); + } + + abort(404); + } + + private function isAllowed(string $host): bool + { + // Always allow localhost and loopback (health checks, artisan, etc.) + if (in_array($host, ['localhost', '127.0.0.1', '::1'], true)) { + return true; + } + + $base = config('mailwolt.domain.base'); + $uiSub = config('mailwolt.domain.ui'); + $mtaSub = config('mailwolt.domain.mail'); + $wmHost = config('mailwolt.domain.webmail_host'); + + $allowed = array_filter([ + $wmHost, + $uiSub && $base ? "{$uiSub}.{$base}" : null, + $mtaSub && $base ? "{$mtaSub}.{$base}" : null, + // APP_HOST as fallback (e.g. during setup before domains are saved) + parse_url(config('app.url'), PHP_URL_HOST) ?: null, + ]); + + return in_array($host, $allowed, true); + } +} diff --git a/app/Jobs/RunHealthChecks.php b/app/Jobs/RunHealthChecks.php index a06139a..b3362f6 100644 --- a/app/Jobs/RunHealthChecks.php +++ b/app/Jobs/RunHealthChecks.php @@ -4,43 +4,33 @@ namespace App\Jobs; use App\Models\Setting as SettingsModel; use App\Support\CacheVer; -use App\Support\WoltGuard\Probes; +use App\Support\WoltGuard\MonitClient; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Process; -use Symfony\Component\Finder\Finder; class RunHealthChecks implements ShouldQueue { - use Queueable, Probes; + use Queueable; public int $timeout = 10; public int $tries = 1; public function handle(): void { - $cards = config('woltguard.cards', []); - $svcRows = []; - foreach ($cards as $key => $card) { - $ok = false; - foreach ($card['sources'] as $src) { - if ($this->check($src)) { - $ok = true; - break; - } - } - $svcRows[] = ['name' => $key, 'ok' => $ok]; // labels brauchst du im UI + $monit = new MonitClient(); + $svcRows = $monit->services(); + + if (empty($svcRows)) { + Log::warning('WG: Monit nicht erreichbar – kein Update'); + return; } $payload = ['ts' => time(), 'rows' => $svcRows]; Cache::put(CacheVer::k('health:services'), $payload, 300); - Log::info('WG: writing services', ['count'=>count($svcRows)]); SettingsModel::set('woltguard.services', $payload); - Cache::forget('health:services'); - + Log::info('WG: services from Monit', ['count' => count($svcRows), 'key' => CacheVer::k('health:services'), 'names' => array_column($svcRows, 'name')]); } /** Wraps a probe; logs and returns fallback on error */ diff --git a/app/Livewire/Auth/LoginForm.php b/app/Livewire/Auth/LoginForm.php index 0041f36..4cde10a 100644 --- a/app/Livewire/Auth/LoginForm.php +++ b/app/Livewire/Auth/LoginForm.php @@ -45,6 +45,7 @@ class LoginForm extends Component if (Auth::attempt([$field => $this->name, 'password' => $this->password], true)) { request()->session()->regenerate(); + Auth::user()->update(['last_login_at' => now()]); return redirect()->intended(route('ui.dashboard')); } diff --git a/app/Livewire/Auth/TwoFaChallenge.php b/app/Livewire/Auth/TwoFaChallenge.php new file mode 100644 index 0000000..11f154b --- /dev/null +++ b/app/Livewire/Auth/TwoFaChallenge.php @@ -0,0 +1,47 @@ +error = null; + $user = Auth::user(); + + if ($this->useRecovery) { + $this->validate(['code' => 'required|string']); + + if (!TwoFactorRecoveryCode::verifyAndConsume($user->id, strtoupper(trim($this->code)))) { + $this->error = 'Ungültiger Recovery-Code.'; + return null; + } + } else { + $this->validate(['code' => 'required|digits:6']); + + $secret = app(TotpService::class)->getSecret($user); + if (!$secret || !app(TotpService::class)->verify($secret, $this->code)) { + $this->error = 'Ungültiger Code. Bitte erneut versuchen.'; + return null; + } + } + + session()->put('2fa_verified', true); + return redirect()->intended(route('ui.dashboard')); + } + + public function render() + { + return view('livewire.auth.two-fa-challenge') + ->layout('layouts.blank'); + } +} diff --git a/app/Livewire/Ui/Mail/AliasList.php b/app/Livewire/Ui/Mail/AliasList.php index c931f81..de5950b 100644 --- a/app/Livewire/Ui/Mail/AliasList.php +++ b/app/Livewire/Ui/Mail/AliasList.php @@ -30,14 +30,14 @@ class AliasList extends Component { // nur Wert übergeben (LivewireUI Modal nimmt Positionsargumente) $this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [ - $aliasId, + 'aliasId' => $aliasId, ]); } public function openAliasDelete(int $aliasId): void { $this->dispatch('openModal', component: 'ui.mail.modal.alias-delete-modal', arguments: [ - $aliasId, + 'aliasId' => $aliasId, ]); } diff --git a/app/Livewire/Ui/Nx/Dashboard.php b/app/Livewire/Ui/Nx/Dashboard.php new file mode 100644 index 0000000..c197522 --- /dev/null +++ b/app/Livewire/Ui/Nx/Dashboard.php @@ -0,0 +1,211 @@ + [ + 'name' => $r['label'] ?? ucfirst($r['name']), + 'type' => $r['hint'] ?? '', + 'status' => ($r['ok'] ?? false) ? 'online' : 'offline', + ], $rows); + + [$cpu, $cpuCores, $cpuMhz] = $this->cpu(); + [$ramPercent, $ramUsed, $ramTotal] = $this->ram(); + [$load1, $load5, $load15] = $this->load(); + [$uptimeDays, $uptimeHours] = $this->uptime(); + [$diskUsedPercent, $diskUsedGb, $diskFreeGb, $diskTotalGb] = $this->disk(); + + $servicesActive = count(array_filter($services, fn($s) => $s['status'] === 'online')); + + return view('livewire.ui.nx.dashboard', [ + 'domainCount' => Domain::where('is_system', false)->where('is_server', false)->count(), + 'mailboxCount' => MailUser::where('is_system', false)->where('is_active', true)->count(), + 'servicesActive' => $servicesActive, + 'servicesTotal' => count($services), + 'alertCount' => 0, + 'backup' => $this->backupData(), + 'mailHostname' => gethostname() ?: 'mailserver', + 'services' => $services, + 'cpu' => $cpu, + 'cpuCores' => $cpuCores, + 'cpuMhz' => $cpuMhz, + 'ramPercent' => $ramPercent, + 'ramUsed' => $ramUsed, + 'ramTotal' => $ramTotal, + 'load1' => $load1, + 'load5' => $load5, + 'load15' => $load15, + 'uptimeDays' => $uptimeDays, + 'uptimeHours' => $uptimeHours, + 'diskUsedPercent' => $diskUsedPercent, + 'diskUsedGb' => $diskUsedGb, + 'diskFreeGb' => $diskFreeGb, + 'diskTotalGb' => $diskTotalGb, + ...$this->mailSecurity(), + 'ports' => $this->ports(), + ]); + } + + + private function ports(): array + { + $check = [25, 465, 587, 110, 143, 993, 995, 80, 443]; + $out = trim(@shell_exec('ss -tlnH 2>/dev/null') ?? ''); + $listening = []; + foreach (explode("\n", $out) as $line) { + if (preg_match('/:(\d+)\s/', $line, $m)) { + $listening[(int)$m[1]] = true; + } + } + $result = []; + foreach ($check as $port) { + $result[$port] = isset($listening[$port]); + } + return $result; + } + + private function mailSecurity(): array + { + // rspamd metrics from cache (populated by spamav:collect every 5 min) + $av = Cache::get('dash.spamav') ?? SettingModel::get('spamav.metrics', []); + + $spam = (int)($av['spam'] ?? 0); + $reject = (int)($av['reject'] ?? 0); + $ham = (int)($av['ham'] ?? 0); + $clamVer = $av['clamVer'] ?? '—'; + + // Postfix queue counts (active + deferred) + $queueOut = trim(@shell_exec('postqueue -p 2>/dev/null') ?? ''); + $qActive = preg_match_all('/^[A-F0-9]{9,}\*?\s+/mi', $queueOut); + $qDeferred = substr_count($queueOut, '(deferred)'); + $qTotal = $qActive + $qDeferred; + + return [ + 'spamBlocked' => $spam + $reject, + 'spamTagged' => $spam, + 'spamRejected'=> $reject, + 'hamCount' => $ham, + 'clamVer' => $clamVer, + 'queueTotal' => $qTotal, + 'queueDeferred' => $qDeferred, + ]; + } + + private function cpu(): array + { + $cores = (int)(shell_exec("nproc 2>/dev/null") ?: 1); + $mhz = round((float)shell_exec("awk '/^cpu MHz/{s+=$4;n++}END{if(n)print s/n}' /proc/cpuinfo 2>/dev/null") / 1000, 1); + $s1 = $this->stat(); usleep(400000); $s2 = $this->stat(); + $idle1 = $s1[3] + ($s1[4] ?? 0); $idle2 = $s2[3] + ($s2[4] ?? 0); + $dt = array_sum($s2) - array_sum($s1); + $cpu = $dt > 0 ? max(0, min(100, round(($dt - ($idle2 - $idle1)) / $dt * 100))) : 0; + return [$cpu, $cores, $mhz ?: '—']; + } + + private function stat(): array + { + $p = preg_split('/\s+/', trim(shell_exec("head -1 /proc/stat 2>/dev/null") ?: '')); + array_shift($p); + return array_map('intval', $p); + } + + private function ram(): array + { + preg_match('/MemTotal:\s+(\d+)/', shell_exec("cat /proc/meminfo") ?: '', $mt); + preg_match('/MemAvailable:\s+(\d+)/', shell_exec("cat /proc/meminfo") ?: '', $ma); + $total = (int)($mt[1] ?? 0); $avail = (int)($ma[1] ?? 0); $used = $total - $avail; + return [$total > 0 ? round($used / $total * 100) : 0, round($used / 1048576, 1), round($total / 1048576, 1)]; + } + + private function backupData(): array + { + $policy = BackupPolicy::first(); + if (!$policy) { + return ['status' => 'unconfigured', 'last_at' => null, 'last_at_full' => null, 'size' => null, 'duration' => null, 'next_at' => null, 'enabled' => false]; + } + + $running = BackupJob::whereIn('status', ['queued', 'running'])->exists(); + if ($running) { + $status = 'running'; + } elseif ($policy->last_run_at === null) { + $status = 'pending'; + } else { + $status = $policy->last_status ?? 'unknown'; + } + + $size = ($policy->last_size_bytes ?? 0) > 0 ? $this->fmtBytes($policy->last_size_bytes) : null; + + $duration = null; + $lastJob = BackupJob::where('status', 'ok')->latest('finished_at')->first(); + if ($lastJob && $lastJob->started_at && $lastJob->finished_at) { + $secs = $lastJob->started_at->diffInSeconds($lastJob->finished_at); + $duration = $secs >= 60 + ? round($secs / 60) . ' min' + : $secs . 's'; + } + + $next = null; + if ($policy->enabled && $policy->schedule_cron) { + try { + $cron = new \Cron\CronExpression($policy->schedule_cron); + $next = $cron->getNextRunDate()->format('d.m.Y H:i'); + } catch (\Throwable) {} + } + + return [ + 'status' => $status, + 'last_at' => $policy->last_run_at?->diffForHumans(), + 'last_at_full' => $policy->last_run_at?->format('d.m.Y H:i'), + 'size' => $size, + 'duration' => $duration, + 'next_at' => $next, + 'enabled' => (bool) $policy->enabled, + ]; + } + + private function fmtBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $i = 0; + $v = (float) $bytes; + while ($v >= 1024 && $i < 4) { $v /= 1024; $i++; } + return number_format($v, $i <= 1 ? 0 : 1) . ' ' . $units[$i]; + } + + private function load(): array + { + $p = explode(' ', trim(shell_exec("cat /proc/loadavg") ?: '')); + return [$p[0] ?? '0.00', $p[1] ?? '0.00', $p[2] ?? '0.00']; + } + + private function uptime(): array + { + $s = (int)(float)(shell_exec("awk '{print $1}' /proc/uptime") ?: 0); + return [intdiv($s, 86400), intdiv($s % 86400, 3600)]; + } + + private function disk(): array + { + $p = preg_split('/\s+/', trim(shell_exec("df -BG / 2>/dev/null | tail -1") ?: '')); + $total = (int)($p[1] ?? 0); $used = (int)($p[2] ?? 0); $free = (int)($p[3] ?? 0); + return [$total > 0 ? round($used / $total * 100) : 0, $used, $free, $total]; + } +} diff --git a/app/Livewire/Ui/Nx/Domain/DnsDkim.php b/app/Livewire/Ui/Nx/Domain/DnsDkim.php new file mode 100644 index 0000000..9f231aa --- /dev/null +++ b/app/Livewire/Ui/Nx/Domain/DnsDkim.php @@ -0,0 +1,100 @@ +dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: ['domainId' => $id]); + } + + public function openDelete(int $id): void + { + $domain = Domain::findOrFail($id); + if ($domain->is_system) { + $this->dispatch('toast', type: 'forbidden', badge: 'System-Domain', + title: 'Domain', text: 'System-Domains können nicht gelöscht werden.', duration: 4000); + return; + } + $this->dispatch('openModal', component: 'ui.domain.modal.domain-delete-modal', arguments: ['domainId' => $id]); + } + + public function regenerateDkim(int $id): void + { + $domain = Domain::findOrFail($id); + $selector = optional($domain->dkimKeys()->where('is_active', true)->latest()->first())->selector + ?: (string) config('mailpool.defaults.dkim_selector', 'mwl1'); + + try { + /** @var DkimService $svc */ + $svc = app(DkimService::class); + $res = $svc->generateForDomain($domain, 2048, $selector); + $priv = $res['priv_path'] ?? storage_path("app/private/dkim/{$domain->domain}/{$selector}.private"); + $txt = storage_path("app/private/dkim/{$domain->domain}/{$selector}.txt"); + + if (!is_readable($txt) && !empty($res['dns_txt'])) { + file_put_contents($txt, $res['dns_txt']); + } + + $proc = Process::run(['sudo', '-n', '/usr/local/sbin/mailwolt-install-dkim', + $domain->domain, $selector, $priv, $txt]); + + if (!$proc->successful()) { + throw new \RuntimeException($proc->errorOutput()); + } + + Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload', 'opendkim']); + + $this->dispatch('toast', type: 'done', badge: 'DKIM', + title: 'DKIM erneuert', text: "Schlüssel für {$domain->domain} wurde neu generiert.", duration: 5000); + } catch (\Throwable $e) { + $this->dispatch('toast', type: 'error', badge: 'DKIM', + title: 'Fehler', text: $e->getMessage(), duration: 0); + } + } + + private function dkimReady(string $domain, string $selector): bool + { + return Process::run(['sudo', '-n', '/usr/bin/test', '-s', + "/etc/opendkim/keys/{$domain}/{$selector}.private"])->successful(); + } + + public function render() + { + $defaultSelector = (string) config('mailpool.defaults.dkim_selector', 'mwl1'); + + $mapped = Domain::where('is_server', false) + ->with(['dkimKeys' => fn($q) => $q->where('is_active', true)->latest()]) + ->orderBy('domain') + ->get() + ->map(function (Domain $d) use ($defaultSelector) { + $key = $d->dkimKeys->first(); + $selector = $key?->selector ?? $defaultSelector; + $d->setAttribute('dkim_selector', $selector); + $d->setAttribute('dkim_ready', $this->dkimReady($d->domain, $selector)); + $d->setAttribute('dkim_txt', $key?->asTxtValue() ?? ''); + return $d; + }); + + $systemDomains = $mapped->where('is_system', true)->values(); + $userDomains = $mapped->where('is_system', false)->values(); + + return view('livewire.ui.nx.domain.dns-dkim', compact('systemDomains', 'userDomains')); + } +} diff --git a/app/Livewire/Ui/Nx/Domain/DomainList.php b/app/Livewire/Ui/Nx/Domain/DomainList.php new file mode 100644 index 0000000..f39b7d0 --- /dev/null +++ b/app/Livewire/Ui/Nx/Domain/DomainList.php @@ -0,0 +1,90 @@ +dispatch('openModal', component: 'ui.domain.modal.domain-create-modal'); + } + + public function openEdit(int $id): void + { + if ($this->isSystem($id)) return; + $this->dispatch('openModal', component: 'ui.domain.modal.domain-edit-modal', arguments: ['domainId' => $id]); + } + + public function openLimits(int $id): void + { + if ($this->isSystem($id)) return; + $this->dispatch('openModal', component: 'ui.domain.modal.domain-limits-modal', arguments: ['domainId' => $id]); + } + + public function openDns(int $id): void + { + $this->dispatch('openModal', component: 'ui.domain.modal.domain-dns-modal', arguments: ['domainId' => $id]); + } + + public function openDelete(int $id): void + { + if ($this->isSystem($id)) return; + $this->dispatch('openModal', component: 'ui.domain.modal.domain-delete-modal', arguments: ['domainId' => $id]); + } + + private function isSystem(int $id): bool + { + $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 nicht bearbeitet werden.', duration: 0); + return true; + } + return false; + } + + public function render() + { + $query = Domain::where('is_system', false) + ->where('is_server', false) + ->withCount(['mailUsers as mailboxes_count', 'mailAliases as aliases_count']) + ->with(['dkimKeys' => fn($q) => $q->where('is_active', true)->latest()]) + ->orderBy('domain'); + + if ($this->search !== '') { + $query->where('domain', 'like', '%' . $this->search . '%'); + } + + $domains = $query->get()->map(function (Domain $d) { + $tags = is_array($d->tags) ? $d->tags : []; + $d->setAttribute('visible_tags', array_slice($tags, 0, 2)); + $d->setAttribute('extra_tags', max(count($tags) - 2, 0)); + return $d; + }); + + $systemDomain = Domain::where('is_system', true) + ->withCount(['mailUsers as mailboxes_count', 'mailAliases as aliases_count']) + ->with(['dkimKeys' => fn($q) => $q->where('is_active', true)->latest()]) + ->first(); + $total = Domain::where('is_system', false)->where('is_server', false)->count(); + + return view('livewire.ui.nx.domain.domain-list', compact('domains', 'systemDomain', 'total')); + } +} diff --git a/app/Livewire/Ui/Nx/Mail/AliasList.php b/app/Livewire/Ui/Nx/Mail/AliasList.php new file mode 100644 index 0000000..1861366 --- /dev/null +++ b/app/Livewire/Ui/Nx/Mail/AliasList.php @@ -0,0 +1,102 @@ +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 + { + $this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [ + 'aliasId' => $aliasId, + ]); + } + + public function openAliasDelete(int $aliasId): void + { + $this->dispatch('openModal', component: 'ui.mail.modal.alias-delete-modal', arguments: [ + 'aliasId' => $aliasId, + ]); + } + + public function render() + { + $term = trim($this->search); + $hasTerm = $term !== ''; + $needle = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%'; + + $domains = Domain::query() + ->where('is_system', false) + ->where('is_server', false) + ->when($hasTerm, function ($q) use ($needle) { + $q->where(function ($w) use ($needle) { + $w->where('domain', 'like', $needle) + ->orWhereHas('mailAliases', fn($a) => $a + ->where('is_system', false) + ->where(fn($x) => $x + ->where('local', 'like', $needle) + ->orWhere('destination', 'like', $needle) + ) + ); + }); + }) + ->withCount(['mailAliases as aliases_count' => fn($a) => $a->where('is_system', false)]) + ->with(['mailAliases' => function ($q) use ($hasTerm, $needle) { + $q->where('is_system', false); + if ($hasTerm) { + $q->where(fn($x) => $x + ->where('local', 'like', $needle) + ->orWhere('destination', 'like', $needle) + ); + } + $q->orderBy('local'); + }]) + ->orderBy('domain') + ->get(); + + if ($hasTerm) { + $lower = Str::lower($term); + foreach ($domains as $d) { + if (Str::contains(Str::lower($d->domain), $lower)) { + $d->setRelation('mailAliases', $d->mailAliases() + ->where('is_system', false) + ->orderBy('local') + ->get() + ); + } + } + } + + $totalAliases = $domains->sum('aliases_count'); + + return view('livewire.ui.nx.mail.alias-list', [ + 'domains' => $domains, + 'totalAliases' => $totalAliases, + ]); + } +} diff --git a/app/Livewire/Ui/Nx/Mail/MailboxList.php b/app/Livewire/Ui/Nx/Mail/MailboxList.php new file mode 100644 index 0000000..9d5b318 --- /dev/null +++ b/app/Livewire/Ui/Nx/Mail/MailboxList.php @@ -0,0 +1,169 @@ +dispatch('$refresh'); + } + + public function openMailboxCreate(int $domainId): void + { + $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [ + 'domainId' => $domainId, + ]); + } + + public function openMailboxEdit(int $mailUserId): void + { + $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-edit-modal', arguments: [ + $mailUserId, + ]); + } + + public function openMailboxDelete(int $mailUserId): void + { + $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-delete-modal', arguments: [ + $mailUserId, + ]); + } + + public function updateMailboxStats(): void + { + Artisan::call('mail:update-stats'); + $this->dispatch('$refresh'); + $this->dispatch('toast', + type: 'done', + badge: 'Mailbox', + title: 'Statistiken aktualisiert', + text: 'Alle Mailbox-Statistiken wurden neu berechnet.', + duration: 4000, + ); + } + + public function updateSingleStat(int $mailUserId): void + { + $u = MailUser::with('domain:id,domain')->find($mailUserId); + if (! $u) return; + + $email = (string) ($u->getRawOriginal('email') ?? ''); + if (! $email) return; + + Artisan::call('mail:update-stats', ['--user' => $email]); + $this->dispatch('$refresh'); + } + + public function render() + { + $term = trim($this->search); + $hasTerm = $term !== ''; + $needle = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%'; + + $domains = Domain::query() + ->where('is_system', false) + ->where('is_server', false) + ->when($hasTerm, function ($q) use ($needle) { + $q->where(function ($w) use ($needle) { + $w->where('domain', 'like', $needle) + ->orWhereHas('mailUsers', fn($u) => $u + ->where('is_system', false) + ->where('localpart', 'like', $needle) + ); + }); + }) + ->withCount(['mailUsers as mail_users_count' => fn($u) => $u + ->where('is_system', false) + ]) + ->with(['mailUsers' => function ($q) use ($hasTerm, $needle) { + $q->where('is_system', false); + if ($hasTerm) { + $q->where('localpart', 'like', $needle); + } + $q->orderBy('localpart'); + }]) + ->orderBy('domain') + ->get(); + + if ($hasTerm) { + $lower = Str::lower($term); + foreach ($domains as $d) { + if (Str::contains(Str::lower($d->domain), $lower)) { + $d->setRelation('mailUsers', $d->mailUsers() + ->where('is_system', false) + ->orderBy('localpart') + ->get() + ); + } + } + } + + foreach ($domains as $d) { + $prepared = []; + $domainActive = (bool)($d->is_active ?? true); + + foreach ($d->mailUsers as $u) { + $usedBytes = (int)($u->used_bytes ?? 0); + $messageCount = (int)($u->message_count ?? 0); + $quotaMiB = (int)($u->quota_mb ?? 0); + $usedMiB = round($usedBytes / 1048576, 2); + $usage = $quotaMiB > 0 + ? min(100, (int)round($usedBytes / ($quotaMiB * 1048576) * 100)) + : 0; + + $mailboxActive = (bool)($u->is_active ?? true); + $effective = $domainActive && $mailboxActive; + $reason = null; + if (!$effective) { + $reason = !$domainActive ? 'Domain inaktiv' : 'Postfach inaktiv'; + } + + $barClass = $usage > 85 ? 'mbx-bar-high' : ($usage > 60 ? 'mbx-bar-mid' : 'mbx-bar-low'); + + $prepared[] = [ + 'id' => $u->id, + 'localpart' => (string)$u->localpart, + 'quota_mb' => $quotaMiB, + 'used_mb' => $usedMiB, + 'usage_percent' => $usage, + 'bar_class' => $barClass, + 'message_count' => $messageCount, + 'is_active' => $mailboxActive, + 'is_effective_active' => $effective, + 'inactive_reason' => $reason, + 'last_login' => $u->last_login_at?->diffForHumans() ?? '—', + ]; + } + + $d->prepared_mailboxes = $prepared; + } + + return view('livewire.ui.nx.mail.mailbox-list', [ + 'domains' => $domains, + 'totalMailboxes' => $domains->sum('mail_users_count'), + ]); + } +} diff --git a/app/Livewire/Ui/Nx/Mail/Modal/QuarantineMessageModal.php b/app/Livewire/Ui/Nx/Mail/Modal/QuarantineMessageModal.php new file mode 100644 index 0000000..05ab21a --- /dev/null +++ b/app/Livewire/Ui/Nx/Mail/Modal/QuarantineMessageModal.php @@ -0,0 +1,81 @@ +msgId = $msgId; + $this->message = $this->fetchMessage($msgId); + } + + public function render() + { + return view('livewire.ui.nx.mail.modal.quarantine-message-modal'); + } + + private function fetchMessage(string $id): array + { + $password = env('RSPAMD_PASSWORD', ''); + $opts = [ + 'http' => [ + 'timeout' => 3, + 'ignore_errors' => true, + 'header' => $password !== '' ? "Password: {$password}\r\n" : '', + ], + ]; + $ctx = stream_context_create($opts); + $raw = @file_get_contents("http://127.0.0.1:11334/history?rows=500", false, $ctx); + + if (!$raw) return $this->emptyMessage($id); + + $data = json_decode($raw, true); + $items = $data['rows'] ?? (isset($data[0]) ? $data : []); + + foreach ($items as $r) { + $scanId = $r['scan_id'] ?? ($r['id'] ?? ''); + if ($scanId !== $id) continue; + + $rcpt = $r['rcpt'] ?? []; + if (is_array($rcpt)) $rcpt = implode(', ', $rcpt); + + $symbols = []; + foreach ($r['symbols'] ?? [] as $name => $sym) { + $symbols[] = [ + 'name' => $name, + 'score' => round((float)($sym['score'] ?? 0), 3), + 'description' => $sym['description'] ?? '', + ]; + } + usort($symbols, fn($a, $b) => abs($b['score']) <=> abs($a['score'])); + + return [ + 'id' => $id, + 'msg_id' => $r['message-id'] ?? '—', + 'from' => $r['from'] ?? $r['sender'] ?? '—', + 'rcpt' => $rcpt ?: '—', + 'subject' => $r['subject'] ?? '(kein Betreff)', + 'score' => round((float)($r['score'] ?? 0), 2), + 'required' => round((float)($r['required_score'] ?? 15), 2), + 'action' => $r['action'] ?? 'unknown', + 'time' => (int)($r['unix_time'] ?? 0), + 'size' => (int)($r['size'] ?? 0), + 'ip' => $r['ip'] ?? '—', + 'symbols' => $symbols, + ]; + } + + return $this->emptyMessage($id); + } + + private function emptyMessage(string $id): array + { + return ['id' => $id, 'from' => '—', 'rcpt' => '—', 'subject' => '—', 'score' => 0, 'required' => 0, 'action' => '—', 'time' => 0, 'size' => 0, 'ip' => '—', 'symbols' => [], 'msg_id' => '—']; + } +} diff --git a/app/Livewire/Ui/Nx/Mail/Modal/QueueMessageModal.php b/app/Livewire/Ui/Nx/Mail/Modal/QueueMessageModal.php new file mode 100644 index 0000000..12a6c2d --- /dev/null +++ b/app/Livewire/Ui/Nx/Mail/Modal/QueueMessageModal.php @@ -0,0 +1,80 @@ +queueId = $queueId; + $this->message = $this->fetchMessage($queueId); + } + + public function delete(): void + { + @shell_exec('sudo postsuper -d ' . escapeshellarg($this->queueId) . ' 2>&1'); + $this->dispatch('toast', type: 'success', title: 'Nachricht gelöscht'); + $this->dispatch('queue:updated'); + $this->dispatch('closeModal'); + } + + public function hold(): void + { + @shell_exec('sudo postsuper -h ' . escapeshellarg($this->queueId) . ' 2>&1'); + $this->message['queue'] = 'hold'; + $this->dispatch('toast', type: 'info', title: 'Nachricht zurückgestellt'); + $this->dispatch('queue:updated'); + $this->dispatch('closeModal'); + } + + public function release(): void + { + @shell_exec('sudo postsuper -H ' . escapeshellarg($this->queueId) . ' 2>&1'); + $this->dispatch('toast', type: 'success', title: 'Nachricht freigegeben'); + $this->dispatch('queue:updated'); + $this->dispatch('closeModal'); + } + + public function render() + { + return view('livewire.ui.nx.mail.modal.queue-message-modal'); + } + + private function fetchMessage(string $id): array + { + // Get queue details from postqueue -j + $out = trim(@shell_exec('postqueue -j 2>/dev/null') ?? ''); + foreach (explode("\n", $out) as $line) { + $line = trim($line); + if ($line === '') continue; + $m = json_decode($line, true); + if (!is_array($m) || ($m['queue_id'] ?? '') !== $id) continue; + + $recipients = $m['recipients'] ?? []; + $rcptList = array_map(fn($r) => [ + 'address' => $r['address'] ?? '', + 'reason' => $r['delay_reason'] ?? '', + ], $recipients); + + // Try to get message headers + $header = trim(@shell_exec('sudo postcat -hq ' . escapeshellarg($id) . ' 2>/dev/null') ?? ''); + + return [ + 'id' => $id, + 'queue' => $m['queue_name'] ?? 'deferred', + 'sender' => $m['sender'] ?? '—', + 'recipients' => $rcptList, + 'size' => (int)($m['message_size'] ?? 0), + 'arrival' => (int)($m['arrival_time'] ?? 0), + 'header' => mb_substr($header, 0, 3000), + ]; + } + + return ['id' => $id, 'queue' => '—', 'sender' => '—', 'recipients' => [], 'size' => 0, 'arrival' => 0, 'header' => '']; + } +} diff --git a/app/Livewire/Ui/Nx/Mail/QuarantineList.php b/app/Livewire/Ui/Nx/Mail/QuarantineList.php new file mode 100644 index 0000000..424ffec --- /dev/null +++ b/app/Livewire/Ui/Nx/Mail/QuarantineList.php @@ -0,0 +1,109 @@ +dispatch('openModal', + component: 'ui.nx.mail.modal.quarantine-message-modal', + arguments: ['msgId' => $msgId] + ); + } + + public function render() + { + $all = $this->fetchHistory(); + + $suspicious = array_values(array_filter($all, fn($m) => $m['action'] !== 'no action')); + + $counts = [ + 'all' => count($all), + 'suspicious' => count($suspicious), + 'reject' => count(array_filter($all, fn($m) => $m['action'] === 'reject')), + 'add header' => count(array_filter($all, fn($m) => $m['action'] === 'add header')), + 'greylist' => count(array_filter($all, fn($m) => $m['action'] === 'greylist')), + ]; + + $messages = match($this->filter) { + 'suspicious' => $suspicious, + 'all' => $all, + default => array_values(array_filter($all, fn($m) => $m['action'] === $this->filter)), + }; + + if ($this->search !== '') { + $s = strtolower($this->search); + $messages = array_values(array_filter($messages, fn($m) => + str_contains(strtolower($m['from'] ?? ''), $s) || + str_contains(strtolower($m['rcpt'] ?? ''), $s) || + str_contains(strtolower($m['subject'] ?? ''), $s) + )); + } + + return view('livewire.ui.nx.mail.quarantine-list', compact('messages', 'counts')); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + public function fetchHistory(): array + { + $password = env('RSPAMD_PASSWORD', ''); + $opts = [ + 'http' => [ + 'timeout' => 3, + 'ignore_errors' => true, + 'header' => $password !== '' ? "Password: {$password}\r\n" : '', + ], + ]; + $ctx = stream_context_create($opts); + $raw = @file_get_contents("http://127.0.0.1:11334/history?rows={$this->rows}", false, $ctx); + + if (!$raw) return []; + + $data = json_decode($raw, true); + if (!is_array($data)) return []; + + $items = $data['rows'] ?? (isset($data[0]) ? $data : []); + + return array_map(function ($r) { + $rcpt = $r['rcpt'] ?? []; + if (is_array($rcpt)) $rcpt = implode(', ', $rcpt); + + return [ + 'id' => $r['scan_id'] ?? ($r['id'] ?? uniqid('q_')), + 'msg_id' => $r['message-id'] ?? '—', + 'from' => $r['from'] ?? $r['sender'] ?? '—', + 'rcpt' => $rcpt ?: '—', + 'subject' => $r['subject'] ?? '(kein Betreff)', + 'score' => round((float)($r['score'] ?? 0), 2), + 'required' => round((float)($r['required_score'] ?? 15), 2), + 'action' => $r['action'] ?? 'unknown', + 'time' => (int)($r['unix_time'] ?? $r['time'] ?? 0), + 'size' => (int)($r['size'] ?? 0), + 'symbols' => array_keys($r['symbols'] ?? []), + 'ip' => $r['ip'] ?? '—', + ]; + }, $items); + } +} diff --git a/app/Livewire/Ui/Nx/Mail/QueueList.php b/app/Livewire/Ui/Nx/Mail/QueueList.php new file mode 100644 index 0000000..3ec0714 --- /dev/null +++ b/app/Livewire/Ui/Nx/Mail/QueueList.php @@ -0,0 +1,165 @@ +fetchQueue(); + $this->selected = $val ? array_column($messages, 'id') : []; + } + + public function flush(): void + { + @shell_exec('sudo postqueue -f >/dev/null 2>&1 &'); + $this->dispatch('toast', type: 'info', title: 'Queue-Flush gestartet'); + } + + public function deleteSelected(): void + { + $count = count($this->selected); + foreach ($this->selected as $id) { + @shell_exec('sudo postsuper -d ' . escapeshellarg($id) . ' 2>&1'); + } + $this->selected = []; + $this->selectAll = false; + $this->dispatch('toast', type: 'success', title: "{$count} Nachricht(en) gelöscht"); + } + + public function deleteAll(): void + { + @shell_exec('sudo postsuper -d ALL 2>&1'); + $this->selected = []; + $this->selectAll = false; + $this->dispatch('toast', type: 'warning', title: 'Alle Queue-Nachrichten gelöscht'); + } + + public function openMessage(string $queueId): void + { + $this->dispatch('openModal', + component: 'ui.nx.mail.modal.queue-message-modal', + arguments: ['queueId' => $queueId] + ); + } + + public function render() + { + $all = $this->fetchQueue(); + + $counts = [ + 'all' => count($all), + 'active' => count(array_filter($all, fn($m) => $m['queue'] === 'active')), + 'deferred' => count(array_filter($all, fn($m) => $m['queue'] === 'deferred')), + 'hold' => count(array_filter($all, fn($m) => $m['queue'] === 'hold')), + ]; + + $messages = $all; + + if ($this->filter !== 'all') { + $messages = array_values(array_filter($messages, fn($m) => $m['queue'] === $this->filter)); + } + + if ($this->search !== '') { + $s = strtolower($this->search); + $messages = array_values(array_filter($messages, fn($m) => + str_contains(strtolower($m['sender'] ?? ''), $s) || + str_contains(strtolower($m['recipient'] ?? ''), $s) || + str_contains(strtolower($m['id'] ?? ''), $s) + )); + } + + return view('livewire.ui.nx.mail.queue-list', compact('messages', 'counts')); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + public function fetchQueue(): array + { + $out = trim(@shell_exec('postqueue -j 2>/dev/null') ?? ''); + if ($out !== '') { + return $this->parseJson($out); + } + return $this->parseText(trim(@shell_exec('postqueue -p 2>/dev/null') ?? '')); + } + + private function parseJson(string $out): array + { + $rows = []; + foreach (explode("\n", $out) as $line) { + $line = trim($line); + if ($line === '') continue; + $m = json_decode($line, true); + if (!is_array($m)) continue; + + $recipients = $m['recipients'] ?? []; + $rcptList = array_column($recipients, 'address'); + $reason = $recipients[0]['delay_reason'] ?? ''; + + $rows[] = [ + 'id' => $m['queue_id'] ?? '', + 'queue' => $m['queue_name'] ?? 'deferred', + 'sender' => $m['sender'] ?? '', + 'recipient' => implode(', ', $rcptList), + 'size' => (int)($m['message_size'] ?? 0), + 'arrival' => (int)($m['arrival_time'] ?? 0), + 'reason' => $this->trimReason($reason), + ]; + } + return $rows; + } + + private function parseText(string $out): array + { + $rows = []; + $current = null; + + foreach (explode("\n", $out) as $line) { + if (preg_match('/^([A-F0-9]{9,})\*?\s+(\d+)\s+\S+\s+\S+\s+\d+\s+\d{1,2}:\d{2}:\d{2}\s+(.*)/i', $line, $m)) { + if ($current) $rows[] = $current; + $current = [ + 'id' => $m[1], + 'queue' => 'active', + 'sender' => trim($m[3]), + 'recipient' => '', + 'size' => (int)$m[2], + 'arrival' => 0, + 'reason' => '', + ]; + } elseif ($current && preg_match('/^\s+([\w.+%-]+@[\w.-]+)/', $line, $m)) { + $current['recipient'] = $m[1]; + } elseif ($current && preg_match('/^\s+\((.+)\)/', $line, $m)) { + $current['reason'] = $this->trimReason($m[1]); + $current['queue'] = 'deferred'; + } + } + if ($current) $rows[] = $current; + return $rows; + } + + private function trimReason(string $r): string + { + $r = preg_replace('/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+/', '', trim($r)); + return mb_strlen($r) > 100 ? mb_substr($r, 0, 100) . '…' : $r; + } +} diff --git a/app/Livewire/Ui/Security/AuditLogsTable.php b/app/Livewire/Ui/Security/AuditLogsTable.php index 07d5d52..625db6e 100644 --- a/app/Livewire/Ui/Security/AuditLogsTable.php +++ b/app/Livewire/Ui/Security/AuditLogsTable.php @@ -2,12 +2,82 @@ namespace App\Livewire\Ui\Security; +use Illuminate\Support\Str; +use Livewire\Attributes\Layout; +use Livewire\Attributes\Title; +use Livewire\Attributes\Url; use Livewire\Component; +#[Layout('layouts.dvx')] +#[Title('Audit-Logs · Mailwolt')] class AuditLogsTable extends Component { + #[Url(as: 'q', keep: true)] + public string $search = ''; + + #[Url(as: 'lvl', keep: true)] + public string $level = ''; + + public int $limit = 200; + + public function loadMore(): void + { + $this->limit += 200; + } + + private function parseLogs(): array + { + $logFile = storage_path('logs/laravel.log'); + if (!is_readable($logFile)) return []; + + $fp = @fopen($logFile, 'r'); + if (!$fp) return []; + $size = filesize($logFile); + $chunk = 250_000; + fseek($fp, max(0, $size - $chunk)); + $raw = fread($fp, $chunk); + fclose($fp); + if (!$raw) return []; + + $lines = explode("\n", $raw); + + $lines = array_slice($lines, -2000); + + $entries = []; + $current = null; + + foreach ($lines as $line) { + if (preg_match('/^\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})[^\]]*\] \w+\.(\w+): (.+)/', $line, $m)) { + if ($current !== null) $entries[] = $current; + $current = [ + 'time' => str_replace('T', ' ', $m[1]), + 'level' => strtolower($m[2]), + 'message' => trim($m[3]), + ]; + } elseif ($current !== null) { + $current['message'] .= "\n" . trim($line); + } + } + if ($current !== null) $entries[] = $current; + + $entries = array_reverse($entries); + + $filtered = []; + foreach ($entries as $e) { + if ($this->level && $e['level'] !== $this->level) continue; + if ($this->search !== '' && !str_contains(strtolower($e['message']), strtolower($this->search))) continue; + $e['message'] = Str::limit(preg_replace('/\s+/', ' ', $e['message']), 300); + $filtered[] = $e; + if (count($filtered) >= $this->limit) break; + } + + return $filtered; + } + public function render() { - return view('livewire.ui.security.audit-logs-table'); + $logs = $this->parseLogs(); + $levels = ['', 'info', 'debug', 'warning', 'error', 'critical']; + return view('livewire.ui.security.audit-logs-table', compact('logs', 'levels')); } } diff --git a/app/Livewire/Ui/Security/Fail2banSettings.php b/app/Livewire/Ui/Security/Fail2banSettings.php index b16abcd..57aff59 100644 --- a/app/Livewire/Ui/Security/Fail2banSettings.php +++ b/app/Livewire/Ui/Security/Fail2banSettings.php @@ -3,12 +3,16 @@ namespace App\Livewire\Ui\Security; +use Livewire\Attributes\Layout; use Livewire\Attributes\On; +use Livewire\Attributes\Title; use Livewire\Component; use App\Models\Fail2banSetting; use App\Models\Fail2banIpList; use Illuminate\Validation\ValidationException; +#[Layout('layouts.dvx')] +#[Title('Fail2Ban · Mailwolt')] class Fail2banSettings extends Component { // Formfelder diff --git a/app/Livewire/Ui/Security/RspamdForm.php b/app/Livewire/Ui/Security/RspamdForm.php index ec824fd..3e9d527 100644 --- a/app/Livewire/Ui/Security/RspamdForm.php +++ b/app/Livewire/Ui/Security/RspamdForm.php @@ -2,12 +2,85 @@ namespace App\Livewire\Ui\Security; +use App\Models\Setting; +use Illuminate\Support\Facades\Process; +use Livewire\Attributes\Layout; +use Livewire\Attributes\Title; use Livewire\Component; +#[Layout('layouts.dvx')] +#[Title('Rspamd · Mailwolt')] class RspamdForm extends Component { + public float $spam_score = 5.0; + public float $greylist_score = 4.0; + public float $reject_score = 15.0; + public bool $enabled = true; + + public function mount(): void + { + $this->spam_score = (float) Setting::get('rspamd.spam_score', 5.0); + $this->greylist_score = (float) Setting::get('rspamd.greylist_score', 4.0); + $this->reject_score = (float) Setting::get('rspamd.reject_score', 15.0); + $this->enabled = (bool) Setting::get('rspamd.enabled', true); + } + + public function save(): void + { + $this->validate([ + 'spam_score' => 'required|numeric|min:1|max:50', + 'greylist_score' => 'required|numeric|min:0|max:50', + 'reject_score' => 'required|numeric|min:1|max:100', + ]); + + Setting::setMany([ + 'rspamd.spam_score' => $this->spam_score, + 'rspamd.greylist_score' => $this->greylist_score, + 'rspamd.reject_score' => $this->reject_score, + 'rspamd.enabled' => $this->enabled, + ]); + + try { + $this->writeRspamdConfig(); + Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload-or-restart', 'rspamd']); + + $this->dispatch('toast', type: 'done', badge: 'Rspamd', + title: 'Einstellungen gespeichert', + text: 'Rspamd-Konfiguration wurde übernommen und neu geladen.', duration: 5000); + } catch (\Throwable $e) { + $this->dispatch('toast', type: 'error', badge: 'Rspamd', + title: 'Fehler', text: $e->getMessage(), duration: 0); + } + } + + private function writeRspamdConfig(): void + { + $target = '/etc/rspamd/local.d/mailwolt-actions.conf'; + $content = <<reject_score}; + add_header = {$this->spam_score}; + greylist = {$this->greylist_score}; +} +CONF; + + $proc = proc_open( + sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target)), + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + if (!is_resource($proc)) throw new \RuntimeException('tee start fehlgeschlagen'); + fwrite($pipes[0], $content); + fclose($pipes[0]); + stream_get_contents($pipes[1]); + stream_get_contents($pipes[2]); + if (proc_close($proc) !== 0) throw new \RuntimeException("tee failed writing to {$target}"); + } + public function render() { - return view('livewire.ui.security.rspamd-form'); + $r = Process::run(['systemctl', 'is-active', 'rspamd']); + $running = trim($r->output()) === 'active'; + return view('livewire.ui.security.rspamd-form', compact('running')); } } diff --git a/app/Livewire/Ui/Security/SslCertificatesTable.php b/app/Livewire/Ui/Security/SslCertificatesTable.php index 994ac4b..d8a984a 100644 --- a/app/Livewire/Ui/Security/SslCertificatesTable.php +++ b/app/Livewire/Ui/Security/SslCertificatesTable.php @@ -2,10 +2,93 @@ namespace App\Livewire\Ui\Security; +use Livewire\Attributes\Layout; +use Livewire\Attributes\Title; use Livewire\Component; +#[Layout('layouts.dvx')] +#[Title('SSL/TLS · Mailwolt')] class SslCertificatesTable extends Component { + public array $certs = []; + + public function mount(): void + { + $this->certs = $this->loadCertificates(); + } + + public function refresh(): void + { + $this->certs = $this->loadCertificates(); + $this->dispatch('toast', type: 'done', badge: 'SSL', title: 'Aktualisiert', + text: 'Zertifikatsliste wurde neu geladen.', duration: 3000); + } + + public function renew(string $name): void + { + $safe = preg_replace('/[^a-z0-9._-]/i', '', $name); + if ($safe === '') return; + + $out = (string) @shell_exec( + "sudo -n /usr/bin/certbot renew --cert-name {$safe} --force-renewal 2>&1" + ); + + $this->certs = $this->loadCertificates(); + + if (str_contains($out, 'Successfully renewed') || str_contains($out, 'success')) { + $this->dispatch('toast', type: 'done', badge: 'SSL', + title: 'Zertifikat erneuert', text: "Zertifikat {$safe} wurde erfolgreich erneuert.", duration: 5000); + } else { + $this->dispatch('toast', type: 'error', badge: 'SSL', + title: 'Fehler', text: nl2br(htmlspecialchars(substr($out, 0, 300))), duration: 0); + } + } + + private function loadCertificates(): array + { + $out = (string) @shell_exec('sudo -n /usr/bin/certbot certificates 2>&1'); + if (empty(trim($out))) return [['_error' => 'unavailable']]; + if (str_contains($out, 'No certificates found')) return []; + + $certs = []; + $blocks = preg_split('/\n(?=Certificate Name:)/m', $out); + + foreach ($blocks as $block) { + if (!preg_match('/Certificate Name:\s*(.+)/i', $block, $nameM)) continue; + + preg_match('/Domains:\s*(.+)/i', $block, $domainsM); + preg_match('/Expiry Date:\s*(.+)/i', $block, $expiryM); + preg_match('/Certificate Path:\s*(.+)/i', $block, $certM); + + $expiryRaw = trim($expiryM[1] ?? ''); + $daysLeft = null; + $expired = false; + + if (preg_match('/VALID: (\d+) days/i', $expiryRaw, $dM)) { + $daysLeft = (int) $dM[1]; + } elseif (preg_match('/INVALID/i', $expiryRaw)) { + $expired = true; + $daysLeft = 0; + } + + $domainsRaw = trim($domainsM[1] ?? ''); + $domains = $domainsRaw !== '' ? array_values(array_filter(explode(' ', $domainsRaw))) : []; + + $certs[] = [ + 'name' => trim($nameM[1]), + 'domains' => $domains, + 'expiry' => $expiryRaw, + 'days_left' => $daysLeft, + 'expired' => $expired, + 'cert_path' => trim($certM[1] ?? ''), + ]; + } + + usort($certs, fn($a, $b) => ($a['days_left'] ?? 999) <=> ($b['days_left'] ?? 999)); + + return $certs; + } + public function render() { return view('livewire.ui.security.ssl-certificates-table'); diff --git a/app/Livewire/Ui/Security/TlsCiphersForm.php b/app/Livewire/Ui/Security/TlsCiphersForm.php index 747db9e..888977c 100644 --- a/app/Livewire/Ui/Security/TlsCiphersForm.php +++ b/app/Livewire/Ui/Security/TlsCiphersForm.php @@ -2,12 +2,133 @@ namespace App\Livewire\Ui\Security; +use App\Models\Setting; +use Illuminate\Support\Facades\Process; +use Livewire\Attributes\Layout; +use Livewire\Attributes\Title; use Livewire\Component; +#[Layout('layouts.dvx')] +#[Title('TLS-Ciphers · Mailwolt')] class TlsCiphersForm extends Component { + public string $preset = 'intermediate'; + + public string $postfix_protocols = '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1'; + public string $postfix_ciphers = 'medium'; + + public string $dovecot_min_proto = 'TLSv1.2'; + public string $dovecot_ciphers = 'ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+AES256:!aNULL:!MD5:!DSS'; + + private const PRESETS = [ + 'modern' => [ + 'postfix_protocols' => '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1, !TLSv1.2', + 'postfix_ciphers' => 'high', + 'dovecot_min_proto' => 'TLSv1.3', + 'dovecot_ciphers' => 'ECDH+AESGCM:ECDH+CHACHA20:!aNULL:!MD5', + ], + 'intermediate' => [ + 'postfix_protocols' => '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1', + 'postfix_ciphers' => 'medium', + 'dovecot_min_proto' => 'TLSv1.2', + 'dovecot_ciphers' => 'ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:DH+AES256:!aNULL:!MD5:!DSS', + ], + 'old' => [ + 'postfix_protocols' => '!SSLv2, !SSLv3', + 'postfix_ciphers' => 'low', + 'dovecot_min_proto' => 'TLSv1', + 'dovecot_ciphers' => 'HIGH:MEDIUM:!aNULL:!MD5', + ], + ]; + + public function mount(): void + { + $this->preset = Setting::get('tls.preset', 'intermediate'); + $this->postfix_protocols = Setting::get('tls.postfix_protocols', self::PRESETS['intermediate']['postfix_protocols']); + $this->postfix_ciphers = Setting::get('tls.postfix_ciphers', self::PRESETS['intermediate']['postfix_ciphers']); + $this->dovecot_min_proto = Setting::get('tls.dovecot_min_proto', self::PRESETS['intermediate']['dovecot_min_proto']); + $this->dovecot_ciphers = Setting::get('tls.dovecot_ciphers', self::PRESETS['intermediate']['dovecot_ciphers']); + } + + public function applyPreset(string $preset): void + { + if (!isset(self::PRESETS[$preset])) return; + $p = self::PRESETS[$preset]; + $this->preset = $preset; + $this->postfix_protocols = $p['postfix_protocols']; + $this->postfix_ciphers = $p['postfix_ciphers']; + $this->dovecot_min_proto = $p['dovecot_min_proto']; + $this->dovecot_ciphers = $p['dovecot_ciphers']; + } + + public function save(): void + { + $this->validate([ + 'postfix_protocols' => 'required|string|max:200', + 'postfix_ciphers' => 'required|string|max:500', + 'dovecot_min_proto' => 'required|string|max:50', + 'dovecot_ciphers' => 'required|string|max:500', + ]); + + Setting::setMany([ + 'tls.preset' => $this->preset, + 'tls.postfix_protocols' => $this->postfix_protocols, + 'tls.postfix_ciphers' => $this->postfix_ciphers, + 'tls.dovecot_min_proto' => $this->dovecot_min_proto, + 'tls.dovecot_ciphers' => $this->dovecot_ciphers, + ]); + + try { + $this->writePostfixConfig(); + $this->writeDovecotConfig(); + + Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload', 'postfix']); + Process::run(['sudo', '-n', '/usr/bin/systemctl', 'reload', 'dovecot']); + + $this->dispatch('toast', type: 'done', badge: 'TLS', + title: 'TLS-Konfiguration übernommen', + text: 'Postfix und Dovecot wurden neu geladen.', duration: 5000); + } catch (\Throwable $e) { + $this->dispatch('toast', type: 'error', badge: 'TLS', + title: 'Fehler', text: $e->getMessage(), duration: 0); + } + } + + private function writePostfixConfig(): void + { + $target = '/etc/postfix/mailwolt-tls.cf'; + $content = "smtpd_tls_protocols = {$this->postfix_protocols}\n" + . "smtp_tls_protocols = {$this->postfix_protocols}\n" + . "smtpd_tls_ciphers = {$this->postfix_ciphers}\n" + . "smtp_tls_ciphers = {$this->postfix_ciphers}\n"; + $this->tee($target, $content); + } + + private function writeDovecotConfig(): void + { + $target = '/etc/dovecot/conf.d/99-mailwolt-tls.conf'; + $content = "ssl_min_protocol = {$this->dovecot_min_proto}\n" + . "ssl_cipher_list = {$this->dovecot_ciphers}\n"; + $this->tee($target, $content); + } + + private function tee(string $target, string $content): void + { + $proc = proc_open( + sprintf('sudo -n /usr/bin/tee %s >/dev/null', escapeshellarg($target)), + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + if (!is_resource($proc)) throw new \RuntimeException('tee start fehlgeschlagen'); + fwrite($pipes[0], $content); + fclose($pipes[0]); + stream_get_contents($pipes[1]); + stream_get_contents($pipes[2]); + if (proc_close($proc) !== 0) throw new \RuntimeException("tee failed: {$target}"); + } + public function render() { - return view('livewire.ui.security.tls-ciphers-form'); + return view('livewire.ui.security.tls-ciphers-form', ['presets' => array_keys(self::PRESETS)]); } } diff --git a/app/Livewire/Ui/System/ApiKeyTable.php b/app/Livewire/Ui/System/ApiKeyTable.php new file mode 100644 index 0000000..c00cccb --- /dev/null +++ b/app/Livewire/Ui/System/ApiKeyTable.php @@ -0,0 +1,34 @@ +where('tokenable_type', Auth::user()::class) + ->findOrFail($id); + + $token->delete(); + $this->dispatch('notify', type: 'success', message: 'API Key gelöscht.'); + } + + public function render() + { + $tokens = Auth::user() + ->tokens() + ->latest() + ->get(); + + return view('livewire.ui.system.api-key-table', compact('tokens')); + } +} diff --git a/app/Livewire/Ui/System/BackupJobList.php b/app/Livewire/Ui/System/BackupJobList.php new file mode 100644 index 0000000..40f1cdf --- /dev/null +++ b/app/Livewire/Ui/System/BackupJobList.php @@ -0,0 +1,130 @@ +dispatch('toast', type: 'warn', badge: 'Backup', + title: 'Kein Zeitplan', text: 'Bitte zuerst einen Backup-Zeitplan konfigurieren.', duration: 4000); + return; + } + + // Reset stale jobs (stuck > 30 min with no running process) + BackupJob::whereIn('status', ['queued', 'running']) + ->where('started_at', '<', now()->subMinutes(30)) + ->update(['status' => 'failed', 'finished_at' => now(), 'error' => 'Timeout — Prozess nicht mehr aktiv.']); + + $running = BackupJob::whereIn('status', ['queued', 'running'])->exists(); + if ($running) { + $this->dispatch('toast', type: 'warn', badge: 'Backup', + title: 'Läuft bereits', text: 'Ein Backup-Job ist bereits aktiv.', duration: 3000); + return; + } + + $job = BackupJob::create([ + 'policy_id' => $policy->id, + 'status' => 'queued', + 'started_at' => now(), + ]); + + $artisan = base_path('artisan'); + exec("nohup php {$artisan} backup:run {$job->id} > /dev/null 2>&1 &"); + + $this->dispatch('openModal', + component: 'ui.system.modal.backup-progress-modal', + arguments: ['jobId' => $job->id] + ); + } + + public function openProgress(int $id): void + { + $this->dispatch('openModal', + component: 'ui.system.modal.backup-progress-modal', + arguments: ['jobId' => $id] + ); + } + + public function openDeleteConfirm(int $id): void + { + $this->dispatch('openModal', + component: 'ui.system.modal.backup-delete-modal', + arguments: ['jobId' => $id] + ); + } + + public function openRestoreConfirm(int $id): void + { + $this->dispatch('openModal', + component: 'ui.system.modal.backup-restore-confirm-modal', + arguments: ['jobId' => $id] + ); + } + + #[On('backup-list-refresh')] + public function refresh(): void {} + + #[On('backup:do-restore')] + public function onRestoreConfirmed(int $jobId): void + { + $this->restore($jobId); + } + + public function restore(int $id): void + { + $sourceJob = BackupJob::findOrFail($id); + + if (!$sourceJob->artifact_path || !file_exists($sourceJob->artifact_path)) { + $this->dispatch('toast', type: 'warn', badge: 'Backup', + title: 'Datei nicht gefunden', text: 'Das Backup-Archiv wurde nicht gefunden.', duration: 4000); + return; + } + + $token = 'mailwolt_restore_' . uniqid(); + $artisan = base_path('artisan'); + exec("nohup php {$artisan} restore:run {$sourceJob->id} {$token} > /dev/null 2>&1 &"); + + $this->dispatch('openModal', + component: 'ui.system.modal.backup-progress-modal', + arguments: ['jobId' => $sourceJob->id, 'restoreToken' => $token] + ); + } + + public function delete(int $id): void + { + $job = BackupJob::findOrFail($id); + + if ($job->artifact_path && file_exists($job->artifact_path)) { + @unlink($job->artifact_path); + } + + $job->delete(); + + $this->dispatch('toast', type: 'done', badge: 'Backup', + title: 'Gelöscht', text: 'Backup-Eintrag wurde entfernt.', duration: 3000); + } + + public function render() + { + $jobs = BackupJob::where('checksum', '!=', 'restore')->orWhereNull('checksum')->latest('started_at')->paginate(20); + $policy = BackupPolicy::first(); + + $hasRunning = BackupJob::whereIn('status', ['queued', 'running']) + ->where('started_at', '>=', now()->subMinutes(30)) + ->exists(); + + return view('livewire.ui.system.backup-job-list', compact('jobs', 'policy', 'hasRunning')); + } +} diff --git a/app/Livewire/Ui/System/DomainsSslForm.php b/app/Livewire/Ui/System/DomainsSslForm.php index f64a160..d1e9aba 100644 --- a/app/Livewire/Ui/System/DomainsSslForm.php +++ b/app/Livewire/Ui/System/DomainsSslForm.php @@ -3,6 +3,7 @@ namespace App\Livewire\Ui\System; use DateTimeImmutable; +use Illuminate\Support\Facades\Artisan; use Illuminate\Validation\Rule; use Livewire\Component; @@ -10,12 +11,14 @@ class DomainsSslForm extends Component { /* ========= Basis & Hosts ========= */ - public string $base_domain = 'example.com'; + public string $base_domain = ''; // nur Subdomain-Teile (ohne Punkte/Protokoll) - public string $ui_sub = 'mail'; - public string $webmail_sub = 'webmail'; - public string $mta_sub = 'mx'; + public string $ui_sub = ''; + public string $webmail_sub = ''; + public string $mta_sub = ''; + + public bool $domainsSaving = false; /* ========= TLS / Redirect ========= */ public bool $force_https = true; @@ -87,9 +90,9 @@ class DomainsSslForm extends Component { return [ 'base_domain' => ['required','regex:/^(?:[a-z0-9-]+\.)+[a-z]{2,}$/i'], - 'ui_sub' => ['required','regex:/^[a-z0-9-]+$/i'], - 'webmail_sub' => ['required','regex:/^[a-z0-9-]+$/i'], - 'mta_sub' => ['required','regex:/^[a-z0-9-]+$/i'], + 'ui_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'], + 'webmail_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'], + 'mta_sub' => ['nullable','regex:/^[a-z0-9-]+$/i'], 'force_https' => ['boolean'], 'hsts' => ['boolean'], @@ -124,6 +127,16 @@ class DomainsSslForm extends Component } + public function mount(): void + { + $this->base_domain = (string) config('mailwolt.domain.base', ''); + $this->ui_sub = (string) config('mailwolt.domain.ui', ''); + $this->webmail_sub = (string) config('mailwolt.domain.webmail', ''); + $this->mta_sub = (string) config('mailwolt.domain.mail', ''); + + $this->loadMtaStsFromFileIfPossible(); + } + protected function loadMtaStsFromFileIfPossible(): void { $file = public_path('.well-known/mta-sts.txt'); @@ -154,9 +167,52 @@ class DomainsSslForm extends Component public function saveDomains(): void { - $this->validate(['base_domain','ui_sub','webmail_sub','mta_sub']); - // TODO: persist - $this->dispatch('toast', body: 'Domains gespeichert.'); + $this->validate(['base_domain', 'ui_sub', 'webmail_sub', 'mta_sub']); + + $this->domainsSaving = true; + + $wmHost = $this->webmail_sub ? $this->webmail_sub.'.'.$this->base_domain : ''; + + $this->writeEnv([ + 'BASE_DOMAIN' => $this->base_domain, + 'UI_SUB' => $this->ui_sub, + 'WEBMAIL_SUB' => $this->webmail_sub, + 'MTA_SUB' => $this->mta_sub, + 'WEBMAIL_DOMAIN' => $wmHost, + ]); + + Artisan::call('config:clear'); + Artisan::call('route:clear'); + + $this->domainsSaving = false; + + $this->dispatch('toast', + type: 'done', + badge: 'Domains', + title: 'Einstellungen gespeichert', + text: 'Konfiguration wurde übernommen.', + duration: 4000, + ); + } + + private function writeEnv(array $values): void + { + $path = base_path('.env'); + $content = file_get_contents($path); + + foreach ($values as $key => $value) { + $escaped = $value === '' ? '' : (str_contains($value, ' ') ? '"' . $value . '"' : $value); + $line = $key . '=' . $escaped; + $pattern = '/^' . preg_quote($key, '/') . '=[^\r\n]*/m'; + + if (preg_match($pattern, $content)) { + $content = preg_replace($pattern, $line, $content); + } else { + $content .= "\n{$line}"; + } + } + + file_put_contents($path, $content); } public function saveTls(): void diff --git a/app/Livewire/Ui/System/InstallerPage.php b/app/Livewire/Ui/System/InstallerPage.php new file mode 100644 index 0000000..59cfc09 --- /dev/null +++ b/app/Livewire/Ui/System/InstallerPage.php @@ -0,0 +1,222 @@ + ['label' => 'Nginx', 'service' => 'nginx'], + 'postfix' => ['label' => 'Postfix', 'service' => 'postfix'], + 'dovecot' => ['label' => 'Dovecot', 'service' => 'dovecot'], + 'rspamd' => ['label' => 'Rspamd', 'service' => 'rspamd'], + 'fail2ban' => ['label' => 'Fail2ban', 'service' => 'fail2ban'], + 'certbot' => ['label' => 'Certbot', 'service' => null, 'binary' => 'certbot'], + ]; + + /* ========================================================= */ + + public function mount(): void + { + $this->refreshLowLevelState(); + $this->readLogLines(); + + if ($this->running) { + $this->state = 'running'; + } + + $this->checkComponentStatus(); + $this->recalcProgress(); + } + + public function render() + { + return view('livewire.ui.system.installer-page'); + } + + /* ================== Aktionen ================== */ + + public function openConfirmModal(string $component = 'all'): void + { + $this->component = $component; + $this->dispatch('openModal', + component: 'ui.system.modal.installer-confirm-modal', + arguments: ['component' => $component] + ); + } + + public function runInstaller(string $component = 'all'): void + { + if ($this->running || $this->state === 'running') { + $this->dispatch('toast', type: 'warn', badge: 'Installer', + title: 'Läuft bereits', + text: 'Ein Installer-Prozess ist bereits aktiv.', + duration: 3000); + return; + } + + $this->component = $component; + $this->state = 'running'; + $this->running = true; + $this->rc = null; + $this->postActionsDone = false; + $this->logLines = ['Installer gestartet …']; + $this->progressPct = 5; + + $safeComponent = preg_replace('/[^a-z0-9_-]/i', '', $component); + @shell_exec("nohup sudo -n /usr/local/sbin/mailwolt-install {$safeComponent} >/dev/null 2>&1 &"); + } + + public function pollStatus(): void + { + $this->refreshLowLevelState(); + $this->readLogLines(); + $this->recalcProgress(); + + if ($this->rc !== null) { + $this->running = false; + } + + if ($this->lowState === 'done') { + usleep(300_000); + $this->readLogLines(); + $this->progressPct = 100; + + if ($this->rc === 0 && !$this->postActionsDone) { + @shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &'); + $this->postActionsDone = true; + + $this->dispatch('toast', type: 'done', badge: 'Installer', + title: 'Installation abgeschlossen', + text: 'Die Komponente wurde erfolgreich installiert/konfiguriert.', + duration: 6000); + } elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) { + $this->postActionsDone = true; + $this->dispatch('toast', type: 'error', badge: 'Installer', + title: 'Installation fehlgeschlagen', + text: "Rückgabecode: {$this->rc}. Bitte Log prüfen.", + duration: 0); + } + + $this->state = 'idle'; + $this->checkComponentStatus(); + } + } + + public function checkComponentStatus(): void + { + $statuses = []; + + foreach (self::COMPONENTS as $key => $info) { + $installed = false; + $active = false; + + // Check if binary exists + $binary = $info['binary'] ?? $key; + $which = @trim(@shell_exec("which {$binary} 2>/dev/null") ?: ''); + $installed = $which !== ''; + + // Check service active state + if ($installed && isset($info['service']) && $info['service']) { + $svcState = @trim(@shell_exec("systemctl is-active {$info['service']} 2>/dev/null") ?: ''); + $active = ($svcState === 'active'); + } elseif ($installed && $key === 'certbot') { + $active = true; // certbot is a one-shot tool, if installed it's "OK" + } + + $statuses[$key] = [ + 'label' => $info['label'], + 'installed' => $installed, + 'active' => $active, + ]; + } + + $this->componentStatus = $statuses; + } + + public function clearLog(): void + { + @file_put_contents(self::INSTALL_LOG, ''); + $this->logLines = []; + $this->dispatch('toast', type: 'done', badge: 'Installer', + title: 'Log geleert', text: '', duration: 2500); + } + + /* ================== Helpers ================== */ + + protected function refreshLowLevelState(): void + { + $state = @trim(@file_get_contents(self::STATE_DIR . '/state') ?: ''); + $rcRaw = @trim(@file_get_contents(self::STATE_DIR . '/rc') ?: ''); + + $this->lowState = $state !== '' ? $state : null; + $this->running = ($this->lowState !== 'done'); + $this->rc = ($this->lowState === 'done' && is_numeric($rcRaw)) ? (int) $rcRaw : null; + } + + protected function readLogLines(): void + { + $p = self::INSTALL_LOG; + if (!is_readable($p)) { + $this->logLines = []; + return; + } + $lines = @file($p, FILE_IGNORE_NEW_LINES) ?: []; + $this->logLines = array_slice($lines, -100); + } + + protected function recalcProgress(): void + { + if ($this->state !== 'running' && $this->lowState !== 'running') { + if ($this->lowState === 'done') { + $this->progressPct = 100; + } + return; + } + + $text = implode("\n", $this->logLines); + $pct = 5; + foreach ([ + 'Installation gestartet' => 10, + 'Nginx' => 20, + 'Postfix' => 35, + 'Dovecot' => 50, + 'Rspamd' => 65, + 'Fail2ban' => 78, + 'SSL' => 88, + 'Installation beendet' => 100, + ] as $needle => $val) { + if (stripos($text, $needle) !== false) { + $pct = max($pct, $val); + } + } + + if ($this->lowState === 'done') { + $pct = 100; + } + + $this->progressPct = $pct; + } +} diff --git a/app/Livewire/Ui/System/Modal/ApiKeyCreateModal.php b/app/Livewire/Ui/System/Modal/ApiKeyCreateModal.php new file mode 100644 index 0000000..a8ffe0c --- /dev/null +++ b/app/Livewire/Ui/System/Modal/ApiKeyCreateModal.php @@ -0,0 +1,64 @@ + 'Mailboxen lesen', + 'mailboxes:write' => 'Mailboxen schreiben', + 'aliases:read' => 'Aliases lesen', + 'aliases:write' => 'Aliases schreiben', + 'domains:read' => 'Domains lesen', + 'domains:write' => 'Domains schreiben', + ]; + + public function create(): void + { + $this->validate([ + 'name' => 'required|string|max:80', + 'selected' => 'required|array|min:1', + 'selected.*' => 'in:' . implode(',', array_keys(self::$availableScopes)), + ], [ + 'selected.required' => 'Bitte mindestens einen Scope auswählen.', + 'selected.min' => 'Bitte mindestens einen Scope auswählen.', + ]); + + $token = Auth::user()->createToken( + $this->name, + $this->selected, + ); + + $pat = $token->accessToken; + if ($this->sandbox) { + $pat->sandbox = true; + $pat->save(); + } + + $this->dispatch('token-created', plainText: $token->plainTextToken); + $this->closeModal(); + } + + public function toggleAll(): void + { + if (count($this->selected) === count(self::$availableScopes)) { + $this->selected = []; + } else { + $this->selected = array_keys(self::$availableScopes); + } + } + + public function render() + { + return view('livewire.ui.system.modal.api-key-create-modal', [ + 'scopes' => self::$availableScopes, + ]); + } +} diff --git a/app/Livewire/Ui/System/Modal/ApiKeyShowModal.php b/app/Livewire/Ui/System/Modal/ApiKeyShowModal.php new file mode 100644 index 0000000..af5e208 --- /dev/null +++ b/app/Livewire/Ui/System/Modal/ApiKeyShowModal.php @@ -0,0 +1,25 @@ +plainText = $plainText; + } + + public static function modalMaxWidth(): string + { + return 'md'; + } + + public function render() + { + return view('livewire.ui.system.modal.api-key-show-modal'); + } +} diff --git a/app/Livewire/Ui/System/Modal/BackupDeleteModal.php b/app/Livewire/Ui/System/Modal/BackupDeleteModal.php new file mode 100644 index 0000000..093f8b6 --- /dev/null +++ b/app/Livewire/Ui/System/Modal/BackupDeleteModal.php @@ -0,0 +1,44 @@ +jobId = $job->id; + $this->filename = $job->artifact_path ? basename($job->artifact_path) : '—'; + } + + #[On('backup:confirm-delete')] + public function delete(): void + { + $job = BackupJob::find($this->jobId); + if ($job) { + if ($job->artifact_path && file_exists($job->artifact_path)) { + @unlink($job->artifact_path); + } + $job->delete(); + } + + $this->dispatch('backup-list-refresh'); + $this->dispatch('toast', type: 'done', badge: 'Backup', + title: 'Gelöscht', text: 'Backup-Eintrag wurde entfernt.', duration: 3000); + $this->closeModal(); + } + + public function render() + { + return view('livewire.ui.system.modal.backup-delete-modal'); + } +} diff --git a/app/Livewire/Ui/System/Modal/BackupProgressModal.php b/app/Livewire/Ui/System/Modal/BackupProgressModal.php new file mode 100644 index 0000000..ddbc2fc --- /dev/null +++ b/app/Livewire/Ui/System/Modal/BackupProgressModal.php @@ -0,0 +1,68 @@ +jobId = $jobId; + $this->restoreToken = $restoreToken; + } + + public function getJobProperty(): ?BackupJob + { + return BackupJob::find($this->jobId); + } + + public function getRestoreStatusProperty(): array + { + if (empty($this->restoreToken)) { + return ['status' => 'unknown', 'log' => '']; + } + + $file = sys_get_temp_dir() . '/' . $this->restoreToken . '.json'; + + if (!file_exists($file)) { + return ['status' => 'queued', 'log' => 'Wartend auf Start…']; + } + + $data = json_decode(file_get_contents($file), true); + return $data ?: ['status' => 'unknown', 'log' => '']; + } + + public function close(): void + { + // Clean up status file for restore jobs + if ($this->restoreToken) { + $file = sys_get_temp_dir() . '/' . $this->restoreToken . '.json'; + @unlink($file); + } + $this->closeModal(); + } + + public function render() + { + if (!$this->notifiedDone) { + $status = empty($this->restoreToken) + ? ($this->job?->status ?? 'queued') + : ($this->restoreStatus['status'] ?? 'queued'); + + if (in_array($status, ['ok', 'failed', 'canceled'])) { + $this->notifiedDone = true; + $this->dispatch('backup-list-refresh'); + } + } + + return view('livewire.ui.system.modal.backup-progress-modal'); + } +} diff --git a/app/Livewire/Ui/System/Modal/BackupRestoreConfirmModal.php b/app/Livewire/Ui/System/Modal/BackupRestoreConfirmModal.php new file mode 100644 index 0000000..d752f13 --- /dev/null +++ b/app/Livewire/Ui/System/Modal/BackupRestoreConfirmModal.php @@ -0,0 +1,35 @@ +jobId = $job->id; + $this->filename = $job->artifact_path ? basename($job->artifact_path) : '—'; + $this->startedAt = $job->started_at?->format('d.m.Y H:i') ?? '—'; + } + + public function confirm(): void + { + $this->closeModal(); + // Trigger restore in the parent list component via event + $this->dispatch('backup:do-restore', jobId: $this->jobId); + } + + public function render() + { + return view('livewire.ui.system.modal.backup-restore-confirm-modal'); + } +} diff --git a/app/Livewire/Ui/System/Modal/InstallerConfirmModal.php b/app/Livewire/Ui/System/Modal/InstallerConfirmModal.php new file mode 100644 index 0000000..2251616 --- /dev/null +++ b/app/Livewire/Ui/System/Modal/InstallerConfirmModal.php @@ -0,0 +1,28 @@ +component = $component; + } + + public function confirm(): void + { + $this->closeModal(); + $this->dispatch('installer:run', component: $this->component); + } + + public function render() + { + return view('livewire.ui.system.modal.installer-confirm-modal'); + } +} diff --git a/app/Livewire/Ui/System/Modal/SslProvisionModal.php b/app/Livewire/Ui/System/Modal/SslProvisionModal.php new file mode 100644 index 0000000..9a14281 --- /dev/null +++ b/app/Livewire/Ui/System/Modal/SslProvisionModal.php @@ -0,0 +1,21 @@ +closeModal(); + $this->dispatch('ssl:provision'); + } + + public function render() + { + return view('livewire.ui.system.modal.ssl-provision-modal'); + } +} diff --git a/app/Livewire/Ui/System/Modal/TotpSetupModal.php b/app/Livewire/Ui/System/Modal/TotpSetupModal.php new file mode 100644 index 0000000..baf8953 --- /dev/null +++ b/app/Livewire/Ui/System/Modal/TotpSetupModal.php @@ -0,0 +1,54 @@ +secret = $totp->generateSecret(); + $this->qrSvg = $totp->qrCodeSvg(Auth::user(), $this->secret); + } + + public function verify(): void + { + $this->validate(['code' => 'required|digits:6']); + + $totp = app(TotpService::class); + + if (!$totp->verify($this->secret, $this->code)) { + $this->addError('code', 'Ungültiger Code. Bitte erneut versuchen.'); + return; + } + + $this->recoveryCodes = $totp->enable(Auth::user(), $this->secret); + $this->step = 'codes'; + } + + public function done(): void + { + $this->dispatch('toast', type: 'done', badge: '2FA', + title: 'TOTP aktiviert', + text: 'Zwei-Faktor-Authentifizierung ist jetzt aktiv.', duration: 5000); + $this->dispatch('2fa-status-changed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.ui.system.modal.totp-setup-modal'); + } +} diff --git a/app/Livewire/Ui/System/Modal/UserCreateModal.php b/app/Livewire/Ui/System/Modal/UserCreateModal.php new file mode 100644 index 0000000..97eb456 --- /dev/null +++ b/app/Livewire/Ui/System/Modal/UserCreateModal.php @@ -0,0 +1,60 @@ +value; + public bool $is_active = true; + + protected function rules(): array + { + return [ + 'name' => 'required|string|max:100|unique:users,name', + 'email' => 'required|email|max:190|unique:users,email', + 'password' => 'required|string|min:8', + 'role' => 'required|in:' . implode(',', Role::values()), + ]; + } + + protected function messages(): array + { + return [ + 'name.unique' => 'Dieser Benutzername ist bereits vergeben.', + 'email.unique' => 'Diese E-Mail-Adresse wird bereits verwendet.', + ]; + } + + public function save(): void + { + $this->validate(); + + User::create([ + 'name' => $this->name, + 'email' => $this->email, + 'password' => Hash::make($this->password), + 'role' => $this->role, + 'is_active' => $this->is_active, + ]); + + $this->dispatch('toast', type: 'done', badge: 'Benutzer', + title: 'Erstellt', text: "Benutzer {$this->name} wurde angelegt.", duration: 4000); + + $this->dispatch('$refresh'); + $this->closeModal(); + } + + public function render() + { + $roles = Role::cases(); + return view('livewire.ui.system.modal.user-create-modal', compact('roles')); + } +} diff --git a/app/Livewire/Ui/System/Modal/UserDeleteModal.php b/app/Livewire/Ui/System/Modal/UserDeleteModal.php new file mode 100644 index 0000000..7227b49 --- /dev/null +++ b/app/Livewire/Ui/System/Modal/UserDeleteModal.php @@ -0,0 +1,42 @@ +userId = $userId; + $this->userName = $user->name; + } + + public function delete(): void + { + if ($this->userId === auth()->id()) { + $this->dispatch('toast', type: 'error', badge: 'Benutzer', + title: 'Fehler', text: 'Du kannst deinen eigenen Account nicht löschen.', duration: 5000); + $this->closeModal(); + return; + } + + User::findOrFail($this->userId)->delete(); + + $this->dispatch('toast', type: 'done', badge: 'Benutzer', + title: 'Gelöscht', text: "Benutzer {$this->userName} wurde entfernt.", duration: 4000); + + $this->dispatch('$refresh'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.ui.system.modal.user-delete-modal'); + } +} diff --git a/app/Livewire/Ui/System/Modal/UserEditModal.php b/app/Livewire/Ui/System/Modal/UserEditModal.php new file mode 100644 index 0000000..a916c84 --- /dev/null +++ b/app/Livewire/Ui/System/Modal/UserEditModal.php @@ -0,0 +1,69 @@ +userId = $userId; + $this->name = $user->name; + $this->email = $user->email; + $this->role = $user->role?->value ?? Role::Operator->value; + $this->is_active = $user->is_active; + } + + protected function rules(): array + { + return [ + 'name' => "required|string|max:100|unique:users,name,{$this->userId}", + 'email' => "required|email|max:190|unique:users,email,{$this->userId}", + 'password' => 'nullable|string|min:8', + 'role' => 'required|in:' . implode(',', Role::values()), + ]; + } + + public function save(): void + { + $this->validate(); + + $data = [ + 'name' => $this->name, + 'email' => $this->email, + 'role' => $this->role, + 'is_active' => $this->is_active, + ]; + + if ($this->password !== '') { + $data['password'] = Hash::make($this->password); + } + + User::findOrFail($this->userId)->update($data); + + $this->dispatch('toast', type: 'done', badge: 'Benutzer', + title: 'Gespeichert', text: "Benutzer {$this->name} wurde aktualisiert.", duration: 4000); + + $this->dispatch('$refresh'); + $this->closeModal(); + } + + public function render() + { + $roles = Role::cases(); + $isSelf = $this->userId === auth()->id(); + return view('livewire.ui.system.modal.user-edit-modal', compact('roles', 'isSelf')); + } +} diff --git a/app/Livewire/Ui/System/Modal/WebhookCreateModal.php b/app/Livewire/Ui/System/Modal/WebhookCreateModal.php new file mode 100644 index 0000000..7fc6756 --- /dev/null +++ b/app/Livewire/Ui/System/Modal/WebhookCreateModal.php @@ -0,0 +1,52 @@ +validate([ + 'name' => 'required|string|max:80', + 'url' => 'required|url|max:500', + 'selected' => 'required|array|min:1', + 'selected.*' => 'in:' . implode(',', array_keys(Webhook::allEvents())), + ], [ + 'selected.required' => 'Bitte mindestens ein Event auswählen.', + 'selected.min' => 'Bitte mindestens ein Event auswählen.', + ]); + + Webhook::create([ + 'name' => $this->name, + 'url' => $this->url, + 'events' => $this->selected, + 'secret' => WebhookService::generateSecret(), + 'is_active' => $this->is_active, + ]); + + $this->dispatch('webhook-saved'); + $this->closeModal(); + } + + public function toggleAll(): void + { + $all = array_keys(Webhook::allEvents()); + $this->selected = count($this->selected) === count($all) ? [] : $all; + } + + public function render() + { + return view('livewire.ui.system.modal.webhook-create-modal', [ + 'allEvents' => Webhook::allEvents(), + ]); + } +} diff --git a/app/Livewire/Ui/System/Modal/WebhookDeleteModal.php b/app/Livewire/Ui/System/Modal/WebhookDeleteModal.php new file mode 100644 index 0000000..b7c959d --- /dev/null +++ b/app/Livewire/Ui/System/Modal/WebhookDeleteModal.php @@ -0,0 +1,31 @@ +webhookId = $webhookId; + $this->webhookName = $webhook->name; + } + + public function delete(): void + { + Webhook::findOrFail($this->webhookId)->delete(); + $this->dispatch('webhook-saved'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.ui.system.modal.webhook-delete-modal'); + } +} diff --git a/app/Livewire/Ui/System/Modal/WebhookEditModal.php b/app/Livewire/Ui/System/Modal/WebhookEditModal.php new file mode 100644 index 0000000..2e462d0 --- /dev/null +++ b/app/Livewire/Ui/System/Modal/WebhookEditModal.php @@ -0,0 +1,70 @@ +webhookId = $webhookId; + $this->name = $webhook->name; + $this->url = $webhook->url; + $this->selected = $webhook->events; + $this->is_active = $webhook->is_active; + $this->secret = $webhook->secret; + } + + public function save(): void + { + $this->validate([ + 'name' => 'required|string|max:80', + 'url' => 'required|url|max:500', + 'selected' => 'required|array|min:1', + 'selected.*' => 'in:' . implode(',', array_keys(Webhook::allEvents())), + ], [ + 'selected.required' => 'Bitte mindestens ein Event auswählen.', + ]); + + Webhook::findOrFail($this->webhookId)->update([ + 'name' => $this->name, + 'url' => $this->url, + 'events' => $this->selected, + 'is_active' => $this->is_active, + ]); + + $this->dispatch('webhook-saved'); + $this->closeModal(); + } + + public function regenerateSecret(): void + { + $webhook = Webhook::findOrFail($this->webhookId); + $webhook->update(['secret' => \App\Services\WebhookService::generateSecret()]); + $this->secret = $webhook->fresh()->secret; + $this->dispatch('notify', type: 'success', message: 'Secret neu generiert.'); + } + + public function toggleAll(): void + { + $all = array_keys(Webhook::allEvents()); + $this->selected = count($this->selected) === count($all) ? [] : $all; + } + + public function render() + { + return view('livewire.ui.system.modal.webhook-edit-modal', [ + 'allEvents' => Webhook::allEvents(), + ]); + } +} diff --git a/app/Livewire/Ui/System/SandboxMailbox.php b/app/Livewire/Ui/System/SandboxMailbox.php new file mode 100644 index 0000000..43f9077 --- /dev/null +++ b/app/Livewire/Ui/System/SandboxMailbox.php @@ -0,0 +1,59 @@ +selectedId = $id; + SandboxMail::find($id)?->update(['is_read' => true]); + } + + public function deleteOne(int $id): void + { + SandboxMail::findOrFail($id)->delete(); + if ($this->selectedId === $id) { + $this->selectedId = null; + } + } + + public function clearAll(): void + { + SandboxMail::truncate(); + $this->selectedId = null; + } + + public function render() + { + $query = SandboxMail::orderByDesc('received_at'); + + if ($this->search !== '') { + $s = '%' . $this->search . '%'; + $query->where(fn($q) => $q + ->where('from_address', 'like', $s) + ->orWhere('subject', 'like', $s) + ->orWhereJsonContains('to_addresses', $this->search) + ); + } + + $mails = $query->get(); + $selected = $this->selectedId ? SandboxMail::find($this->selectedId) : null; + $unread = SandboxMail::where('is_read', false)->count(); + + return view('livewire.ui.system.sandbox-mailbox', compact('mails', 'selected', 'unread')); + } +} diff --git a/app/Livewire/Ui/System/SettingsForm.php b/app/Livewire/Ui/System/SettingsForm.php index ffc17ae..1f22eb3 100644 --- a/app/Livewire/Ui/System/SettingsForm.php +++ b/app/Livewire/Ui/System/SettingsForm.php @@ -2,91 +2,372 @@ namespace App\Livewire\Ui\System; +use App\Models\BackupPolicy; use App\Models\Setting; +use Illuminate\Support\Facades\Artisan; +use Livewire\Attributes\Layout; +use Livewire\Attributes\Title; use Livewire\Component; +#[Layout('layouts.dvx')] +#[Title('Einstellungen · Mailwolt')] class SettingsForm extends Component { - // Tab-Steuerung (optional per Alpine, hier aber auch als Prop) - public string $tab = 'general'; - // Allgemein - public string $instance_name = ''; // readonly - public string $locale = 'de'; - public string $timezone = 'Europe/Berlin'; - public ?int $session_timeout = 120; // Minuten + public string $instance_name = ''; + public string $locale = 'de'; + public string $timezone = 'Europe/Berlin'; + public int $session_timeout = 120; - // Domains & SSL + // Domains public string $ui_domain = ''; public string $mail_domain = ''; public string $webmail_domain = ''; - public bool $ssl_auto = true; // Sicherheit - public bool $twofa_enabled = false; - public ?int $rate_limit = 5; // Versuche / Minute - public ?int $password_min = 10; + public int $rate_limit = 5; + public int $password_min = 10; + + // Backup + public bool $backup_enabled = false; + public string $backup_preset = 'daily'; + public string $backup_time = '03:00'; + public string $backup_weekday = '1'; + public string $backup_monthday = '1'; + public string $backup_cron = '0 3 * * *'; + public int $backup_retention = 7; + public bool $backup_include_db = true; + public bool $backup_include_maildirs = true; + public bool $backup_include_configs = true; + public string $backup_mail_dir = ''; protected function rules(): array { return [ - 'locale' => 'required|string|max:10', - 'timezone' => 'required|string|max:64', - 'session_timeout' => 'nullable|integer|min:5|max:1440', + 'locale' => 'required|string|max:10', + 'timezone' => 'required|string|max:64', + 'session_timeout' => 'required|integer|min:5|max:1440', + 'rate_limit' => 'required|integer|min:1|max:100', + 'password_min' => 'required|integer|min:6|max:128', + 'backup_enabled' => 'boolean', + 'backup_preset' => 'required|in:hourly,daily,weekly,monthly,custom', + 'backup_time' => 'required_unless:backup_preset,hourly,custom|regex:/^\d{1,2}:\d{2}$/', + 'backup_weekday' => 'required_if:backup_preset,weekly|integer|min:0|max:6', + 'backup_monthday' => 'required_if:backup_preset,monthly|integer|min:1|max:28', + 'backup_cron' => 'required_if:backup_preset,custom|string|max:64', + 'backup_retention' => 'required|integer|min:1|max:365', + ]; + } - 'ui_domain' => 'nullable|string|max:190', - 'mail_domain' => 'nullable|string|max:190', - 'webmail_domain' => 'nullable|string|max:190', - 'ssl_auto' => 'boolean', + protected function domainRules(): array + { + $host = 'required|string|max:190|regex:/^(?!https?:\/\/)(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'; + return [ + 'ui_domain' => $host, + 'mail_domain' => $host, + 'webmail_domain'=> $host, + ]; + } - 'twofa_enabled' => 'boolean', - 'rate_limit' => 'nullable|integer|min:1|max:100', - 'password_min' => 'nullable|integer|min:6|max:128', + protected function domainMessages(): array + { + $fmt = 'Ungültige Domain — kein Schema (http://) und kein Slash am Ende erlaubt.'; + $req = 'Dieses Feld darf nicht leer sein.'; + return [ + 'ui_domain.required' => $req, + 'mail_domain.required' => $req, + 'webmail_domain.required' => $req, + 'ui_domain.regex' => $fmt, + 'mail_domain.regex' => $fmt, + 'webmail_domain.regex' => $fmt, ]; } public function mount(): void { - // Anzeige-Name der Instanz – lies was du hast (z.B. config('app.name')) - $this->instance_name = (string) (config('app.name') ?? 'MailWolt'); + $this->instance_name = (string) (config('app.name') ?? 'Mailwolt'); + $this->locale = (string) Setting::get('locale', $this->locale); + $this->timezone = (string) Setting::get('timezone', $this->timezone); + $this->session_timeout = (int) Setting::get('session_timeout', $this->session_timeout); + $base = env('BASE_DOMAIN', ''); + $persist = []; - // Laden aus unserem einfachen Store - $this->locale = Setting::get('locale', $this->locale); - $this->timezone = Setting::get('timezone', $this->timezone); - $this->session_timeout = (int) Setting::get('session_timeout', $this->session_timeout); + $uiSetting = Setting::get('ui_domain', null); + if ($uiSetting !== null) { + $this->ui_domain = (string) $uiSetting; + } else { + $this->ui_domain = (string) env('APP_HOST', ''); + $persist['ui_domain'] = $this->ui_domain; + } - $this->ui_domain = Setting::get('ui_domain', $this->ui_domain); - $this->mail_domain = Setting::get('mail_domain', $this->mail_domain); - $this->webmail_domain = Setting::get('webmail_domain', $this->webmail_domain); - $this->ssl_auto = (bool) Setting::get('ssl_auto', $this->ssl_auto); + $mailSetting = Setting::get('mail_domain', null); + if ($mailSetting !== null) { + $this->mail_domain = (string) $mailSetting; + } else { + $mtaSub = env('MTA_SUB', ''); + $this->mail_domain = $mtaSub && $base ? "{$mtaSub}.{$base}" : ''; + $persist['mail_domain'] = $this->mail_domain; + } - $this->twofa_enabled = (bool) Setting::get('twofa_enabled', $this->twofa_enabled); - $this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit); - $this->password_min = (int) Setting::get('password_min', $this->password_min); + $webmailSetting = Setting::get('webmail_domain', null); + if ($webmailSetting !== null) { + $this->webmail_domain = (string) $webmailSetting; + } else { + $webmailSub = env('WEBMAIL_SUB', ''); + $webmailDomain = env('WEBMAIL_DOMAIN', ''); + $this->webmail_domain = $webmailDomain ?: ($webmailSub && $base ? "{$webmailSub}.{$base}" : ''); + $persist['webmail_domain'] = $this->webmail_domain; + } + + if ($persist) { + Setting::setMany($persist); + } + + $this->rate_limit = (int) Setting::get('rate_limit', $this->rate_limit); + $this->password_min = (int) Setting::get('password_min', $this->password_min); + + // Backup aus BackupPolicy laden + $policy = BackupPolicy::first(); + if ($policy) { + $this->backup_enabled = $policy->enabled; + $this->backup_retention = $policy->retention_count; + $this->backup_cron = $policy->schedule_cron; + $this->backup_include_db = (bool) $policy->include_db; + $this->backup_include_maildirs = (bool) $policy->include_maildirs; + $this->backup_include_configs = (bool) $policy->include_configs; + $this->backup_mail_dir = (string) Setting::get('backup_mail_dir', ''); + $this->parseCronToPreset($policy->schedule_cron); + } } public function save(): void { $this->validate(); - Setting::put('locale', $this->locale); - Setting::put('timezone', $this->timezone); - Setting::put('session_timeout', $this->session_timeout); + Setting::setMany([ + 'locale' => $this->locale, + 'timezone' => $this->timezone, + 'session_timeout' => $this->session_timeout, + 'rate_limit' => $this->rate_limit, + 'password_min' => $this->password_min, + ]); - Setting::put('ui_domain', $this->ui_domain); - Setting::put('mail_domain', $this->mail_domain); - Setting::put('webmail_domain', $this->webmail_domain); - Setting::put('ssl_auto', $this->ssl_auto); + // Backup-Policy speichern + $cron = $this->buildCron(); + BackupPolicy::updateOrCreate([], [ + 'enabled' => $this->backup_enabled, + 'schedule_cron' => $cron, + 'retention_count' => $this->backup_retention, + 'include_db' => $this->backup_include_db, + 'include_maildirs' => $this->backup_include_maildirs, + 'include_configs' => $this->backup_include_configs, + ]); + Setting::setMany(['backup_mail_dir' => $this->backup_mail_dir]); + $this->backup_cron = $cron; - Setting::put('twofa_enabled', $this->twofa_enabled); - Setting::put('rate_limit', $this->rate_limit); - Setting::put('password_min', $this->password_min); + $this->dispatch('toast', type: 'done', badge: 'Einstellungen', + title: 'Gespeichert', + text: 'Alle Einstellungen wurden übernommen.', duration: 4000); + } - $this->dispatch('toast', type: 'success', message: 'Einstellungen gespeichert'); // optional + public function saveDomains(): void + { + $this->validate($this->domainRules(), $this->domainMessages()); + + // Normalize: strip schema + trailing slashes + $this->ui_domain = $this->cleanDomain($this->ui_domain); + $this->mail_domain = $this->cleanDomain($this->mail_domain); + $this->webmail_domain = $this->cleanDomain($this->webmail_domain); + + Setting::setMany([ + 'ui_domain' => $this->ui_domain, + 'mail_domain' => $this->mail_domain, + 'webmail_domain' => $this->webmail_domain, + ]); + + // Immer .env aktualisieren — unabhängig von DNS oder Nginx + $this->syncDomainsToEnv(); + + // Nginx-Konfiguration nur anwenden wenn alle Domains gesetzt sind + if ($this->ui_domain && $this->mail_domain && $this->webmail_domain) { + $sslAuto = app()->isProduction(); + $this->applyDomains($sslAuto); + } + + $this->dispatch('toast', type: 'done', badge: 'Domains', + title: 'Domains gespeichert', + text: 'Konfiguration wurde übernommen.', duration: 4000); + } + + private function cleanDomain(string $value): string + { + $value = trim($value); + $value = preg_replace('#^https?://#i', '', $value); + return rtrim($value, '/'); + } + + public function openSslModal(): void + { + if (! ($this->ui_domain && $this->mail_domain && $this->webmail_domain)) { + $this->dispatch('toast', type: 'warn', badge: 'SSL', + title: 'Domains fehlen', + text: 'Bitte erst alle drei Domains speichern.', duration: 5000); + return; + } + $this->dispatch('openModal', component: 'ui.system.modal.ssl-provision-modal'); + } + + #[\Livewire\Attributes\On('ssl:provision')] + public function provisionSsl(): void + { + $this->applyDomains(true); + } + + public function updatedUiDomain(): void { $this->validateOnly('ui_domain', $this->domainRules(), $this->domainMessages()); } + public function updatedMailDomain(): void { $this->validateOnly('mail_domain', $this->domainRules(), $this->domainMessages()); } + public function updatedWebmailDomain(): void { $this->validateOnly('webmail_domain', $this->domainRules(), $this->domainMessages()); } + + // Aktualisiert die Cron-Vorschau live wenn der User Felder ändert + public function updatedBackupPreset(): void { $this->backup_cron = $this->buildCron(); } + public function updatedBackupTime(): void { $this->backup_cron = $this->buildCron(); } + public function updatedBackupWeekday(): void { $this->backup_cron = $this->buildCron(); } + public function updatedBackupMonthday(): void{ $this->backup_cron = $this->buildCron(); } + + private function buildCron(): string + { + [$h, $m] = array_pad(explode(':', $this->backup_time ?? '03:00'), 2, '00'); + $h = (int)$h; $m = (int)$m; + + return match($this->backup_preset) { + 'hourly' => '0 * * * *', + 'daily' => sprintf('%d %d * * *', $m, $h), + 'weekly' => sprintf('%d %d * * %d', $m, $h, (int)$this->backup_weekday), + 'monthly' => sprintf('%d %d %d * *', $m, $h, (int)$this->backup_monthday), + default => $this->backup_cron ?: '0 3 * * *', + }; + } + + private function parseCronToPreset(string $cron): void + { + $p = preg_split('/\s+/', trim($cron)); + if (count($p) !== 5) { $this->backup_preset = 'custom'; return; } + [$min, $hour, $dom, $mon, $dow] = $p; + + if ($cron === '0 * * * *') { + $this->backup_preset = 'hourly'; + } elseif ($dom === '*' && $mon === '*' && $dow === '*' && is_numeric($hour)) { + $this->backup_preset = 'daily'; + $this->backup_time = sprintf('%02d:%02d', $hour, $min); + } elseif ($dom === '*' && $mon === '*' && is_numeric($dow) && is_numeric($hour)) { + $this->backup_preset = 'weekly'; + $this->backup_time = sprintf('%02d:%02d', $hour, $min); + $this->backup_weekday = $dow; + } elseif ($mon === '*' && $dow === '*' && is_numeric($dom) && is_numeric($hour)) { + $this->backup_preset = 'monthly'; + $this->backup_time = sprintf('%02d:%02d', $hour, $min); + $this->backup_monthday = $dom; + } else { + $this->backup_preset = 'custom'; + } + } + + private function applyDomains(bool $sslAuto = false): void + { + // DNS prüfen — Warnung anzeigen, aber Nginx trotzdem versuchen + $unreachable = []; + foreach ([$this->ui_domain, $this->webmail_domain, $this->mail_domain] as $host) { + if (! checkdnsrr($host, 'A') && ! checkdnsrr($host, 'AAAA')) { + $unreachable[] = $host; + } + } + + if ($unreachable) { + $this->dispatch('toast', type: 'warn', badge: 'DNS', + title: 'DNS noch nicht erreichbar', + text: 'Kein DNS-Eintrag für: ' . implode(', ', $unreachable) . '. Domains wurden trotzdem gespeichert — Nginx-Konfiguration bitte nach DNS-Setup erneut speichern.', + duration: 9000, + ); + return; + } + + $helper = '/usr/local/sbin/mailwolt-apply-domains'; + $cmd = sprintf( + 'sudo -n %s --ui-host %s --webmail-host %s --mail-host %s --ssl-auto %d 2>&1', + escapeshellarg($helper), + escapeshellarg($this->ui_domain), + escapeshellarg($this->webmail_domain), + escapeshellarg($this->mail_domain), + $sslAuto ? 1 : 0, + ); + + $output = shell_exec($cmd); + $ok = str_contains((string) $output, 'fertig'); + + \Illuminate\Support\Facades\Log::info('mailwolt-apply-domains', ['output' => $output]); + + if ($ok) { + $this->dispatch('toast', type: 'done', badge: 'Nginx', + title: 'Nginx aktualisiert', + text: 'Nginx-Konfiguration wurde neu geladen.', + duration: 5000, + ); + } else { + $this->dispatch('toast', type: 'warn', badge: 'Nginx', + title: 'Nginx-Konfiguration fehlgeschlagen', + text: 'Nginx konnte nicht neu geladen werden. Domains wurden trotzdem gespeichert. Siehe Laravel-Log.', + duration: 7000, + ); + } + } + + private function syncDomainsToEnv(): void + { + $base = rtrim((string) config('mailwolt.domain.base', ''), '.'); + + $sub = function (string $full) use ($base): string { + $full = strtolower(trim($full)); + if ($base && str_ends_with($full, '.' . $base)) { + return substr($full, 0, -(strlen($base) + 1)); + } + // Kein passender Base — ersten Label nehmen + $parts = explode('.', $full); + return count($parts) > 1 ? $parts[0] : ''; + }; + + $this->writeEnv([ + 'WEBMAIL_SUB' => $this->webmail_domain ? $sub($this->webmail_domain) : '', + 'WEBMAIL_DOMAIN' => $this->webmail_domain ?: '', + 'UI_SUB' => $this->ui_domain ? $sub($this->ui_domain) : '', + 'MTA_SUB' => $this->mail_domain ? $sub($this->mail_domain) : '', + ]); + + Artisan::call('config:clear'); + Artisan::call('route:clear'); + } + + private function writeEnv(array $values): void + { + $path = base_path('.env'); + $content = file_get_contents($path); + + foreach ($values as $key => $value) { + $escaped = $value === '' ? '' : (str_contains($value, ' ') ? '"' . $value . '"' : $value); + $line = $key . '=' . $escaped; + $pattern = '/^' . preg_quote($key, '/') . '=[^\r\n]*/m'; + + if (preg_match($pattern, $content)) { + $content = preg_replace($pattern, $line, $content); + } else { + $content .= "\n{$line}"; + } + } + + file_put_contents($path, $content); } public function render() { - return view('livewire.ui.system.settings-form'); + $timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL); + return view('livewire.ui.system.settings-form', compact('timezones')); } } diff --git a/app/Livewire/Ui/System/TwoFaStatus.php b/app/Livewire/Ui/System/TwoFaStatus.php new file mode 100644 index 0000000..40a2a96 --- /dev/null +++ b/app/Livewire/Ui/System/TwoFaStatus.php @@ -0,0 +1,40 @@ +enabled = app(TotpService::class)->isEnabled(Auth::user()); + } + + #[On('2fa-status-changed')] + public function refresh(): void + { + $this->enabled = app(TotpService::class)->isEnabled(Auth::user()); + } + + public function disable(): void + { + app(TotpService::class)->disable(Auth::user()); + session()->forget('2fa_verified'); + $this->enabled = false; + + $this->dispatch('toast', type: 'done', badge: '2FA', + title: 'TOTP deaktiviert', + text: '2FA wurde deaktiviert. Melde dich erneut an um es wieder zu aktivieren.', duration: 5000); + } + + public function render() + { + return view('livewire.ui.system.two-fa-status'); + } +} diff --git a/app/Livewire/Ui/System/UpdatePage.php b/app/Livewire/Ui/System/UpdatePage.php new file mode 100644 index 0000000..3504957 --- /dev/null +++ b/app/Livewire/Ui/System/UpdatePage.php @@ -0,0 +1,295 @@ +reloadVersionsAndStatus(); + $this->recompute(); + $this->readLogLines(); + + if ($this->running) { + $this->state = 'running'; + } + $this->recalcProgress(); + } + + public function render() + { + return view('livewire.ui.system.update-page'); + } + + /* ================== Aktionen ================== */ + + public function checkForUpdates(): void + { + @shell_exec('nohup php /var/www/mailwolt/artisan mailwolt:check-updates >/dev/null 2>&1 &'); + // Give the process a tiny head-start, then reload versions + usleep(500_000); + $this->reloadVersionsAndStatus(); + $this->recompute(); + + $this->dispatch('toast', type: 'done', badge: 'Updates', + title: 'Prüfung gestartet', + text: 'Update-Prüfung läuft im Hintergrund. Bitte in einigen Sekunden neu laden.', + duration: 4000); + } + + public function runUpdate(): void + { + if ($this->running || $this->state === 'running') { + $this->dispatch('toast', type: 'warn', badge: 'Updates', + title: 'Läuft bereits', + text: 'Ein Update-Prozess ist bereits aktiv.', + duration: 3000); + return; + } + + Cache::forget('mailwolt.update_available'); + Cache::put($this->cacheStartedAtKey, time(), now()->addHour()); + + @shell_exec('nohup sudo -n /usr/local/sbin/mailwolt-update >/dev/null 2>&1 &'); + + $this->latest = null; + $this->displayLatest = null; + $this->hasUpdate = false; + $this->state = 'running'; + $this->running = true; + $this->rc = null; + $this->postActionsDone = false; + $this->logLines = ['Update gestartet …']; + $this->progressPct = 5; + } + + public function pollStatus(): void + { + $this->refreshLowLevelState(); + $this->readLogLines(); + $this->recalcProgress(); + + if ($this->rc !== null) { + $this->running = false; + } + + // Failsafe + $started = (int) Cache::get($this->cacheStartedAtKey, 0); + if ($this->running && $started > 0 && (time() - $started) > $this->failsafeSeconds) { + $this->running = false; + $this->lowState = 'done'; + $this->rc ??= 0; + } + + if ($this->lowState === 'done') { + Cache::forget($this->cacheStartedAtKey); + usleep(300_000); + + $this->reloadVersionsAndStatus(); + $this->recompute(); + $this->readLogLines(); + $this->progressPct = 100; + + if ($this->rc === 0 && !$this->postActionsDone) { + @shell_exec('nohup php /var/www/mailwolt/artisan optimize:clear >/dev/null 2>&1 &'); + @shell_exec('nohup php /var/www/mailwolt/artisan health:collect >/dev/null 2>&1 &'); + @shell_exec('nohup php /var/www/mailwolt/artisan settings:sync >/dev/null 2>&1 &'); + $this->postActionsDone = true; + + $ver = $this->displayCurrent ?? 'aktuelle Version'; + $this->dispatch('toast', type: 'done', badge: 'Updates', + title: 'Update abgeschlossen', + text: "Mailwolt wurde auf {$ver} aktualisiert.", + duration: 6000); + } elseif ($this->rc !== null && $this->rc !== 0 && !$this->postActionsDone) { + $this->postActionsDone = true; + $this->dispatch('toast', type: 'error', badge: 'Updates', + title: 'Update fehlgeschlagen', + text: "Rückgabecode: {$this->rc}. Bitte Log prüfen.", + duration: 0); + } + + $this->state = 'idle'; + } + } + + public function clearLog(): void + { + // Only admins / allow via gate if you have policy; basic protection: + @file_put_contents(self::UPDATE_LOG, ''); + $this->logLines = []; + $this->dispatch('toast', type: 'done', badge: 'Updates', + title: 'Log geleert', text: '', duration: 2500); + } + + /* ================== Helpers ================== */ + + protected function reloadVersionsAndStatus(): void + { + $this->current = $this->readCurrentVersion(); + + $latNorm = Cache::get('updates:latest'); + $latRaw = Cache::get('updates:latest_raw'); + + if (!$latNorm && ($legacy = Cache::get('mailwolt.update_available'))) { + $latNorm = $this->normalizeVersion($legacy); + $latRaw = $legacy; + } + + $this->latest = $latNorm ?: null; + $this->displayLatest = $latRaw ?: ($latNorm ? 'v' . $latNorm : null); + + $this->refreshLowLevelState(); + } + + protected function recompute(): void + { + $curNorm = $this->normalizeVersion($this->current); + $latNorm = $this->normalizeVersion($this->latest); + + $this->hasUpdate = ($curNorm && $latNorm) + ? version_compare($latNorm, $curNorm, '>') + : false; + + $this->displayCurrent = $curNorm ? 'v' . $curNorm : null; + + if (!$this->displayLatest && $latNorm) { + $this->displayLatest = 'v' . $latNorm; + } + } + + protected function refreshLowLevelState(): void + { + $state = @trim(@file_get_contents(self::STATE_DIR . '/state') ?: ''); + $rcRaw = @trim(@file_get_contents(self::STATE_DIR . '/rc') ?: ''); + + $this->lowState = $state !== '' ? $state : null; + $this->running = ($this->lowState !== 'done'); + $this->rc = ($this->lowState === 'done' && is_numeric($rcRaw)) ? (int) $rcRaw : null; + } + + protected function readLogLines(): void + { + $p = self::UPDATE_LOG; + if (!is_readable($p)) { + $this->logLines = []; + return; + } + $lines = @file($p, FILE_IGNORE_NEW_LINES) ?: []; + $this->logLines = array_slice($lines, -100); + } + + protected function recalcProgress(): void + { + if ($this->state !== 'running' && $this->lowState !== 'running') { + if ($this->lowState === 'done') { + $this->progressPct = 100; + } + return; + } + + $text = implode("\n", $this->logLines); + $pct = 5; + foreach ([ + 'Update gestartet' => 10, + 'Composer' => 25, + 'npm ci' => 40, + 'npm run build' => 60, + 'migrate' => 75, + 'optimize' => 85, + 'Version aktualisiert' => 95, + 'Update beendet' => 100, + ] as $needle => $val) { + if (stripos($text, $needle) !== false) { + $pct = max($pct, $val); + } + } + + if ($this->lowState === 'done') { + $pct = 100; + } + + $this->progressPct = $pct; + } + + protected function readCurrentVersion(): ?string + { + $v = @trim(@file_get_contents(self::VERSION_FILE) ?: ''); + if ($v !== '') return $v; + + $raw = @trim(@file_get_contents(self::VERSION_FILE_RAW) ?: ''); + if ($raw !== '') return $this->normalizeVersion($raw); + + $build = @file_get_contents(self::BUILD_INFO); + if ($build) { + foreach (preg_split('/\R+/', $build) as $line) { + if (str_starts_with($line, 'version=')) { + $v = $this->normalizeVersion(trim(substr($line, 8))); + if ($v) return $v; + } + } + } + + $v = $this->normalizeVersion(config('app.version') ?: ''); + return $v ?: null; + } + + protected function normalizeVersion(?string $v): ?string + { + if ($v === null) return null; + $v = trim($v); + if ($v === '') return null; + $v = ltrim($v, "vV \t\n\r\0\x0B"); + $v = preg_replace('/-.*$/', '', $v); + return $v !== '' ? $v : null; + } + + protected function getBuildTimestamp(): ?string + { + $build = @file_get_contents(self::BUILD_INFO); + if (!$build) return null; + foreach (preg_split('/\R+/', $build) as $line) { + if (str_starts_with($line, 'built_at=') || str_starts_with($line, 'date=')) { + $parts = explode('=', $line, 2); + return trim($parts[1] ?? ''); + } + } + return null; + } +} diff --git a/app/Livewire/Ui/System/UserTable.php b/app/Livewire/Ui/System/UserTable.php new file mode 100644 index 0000000..35a3633 --- /dev/null +++ b/app/Livewire/Ui/System/UserTable.php @@ -0,0 +1,50 @@ +id === auth()->id()) return; + + $user->update(['is_active' => !$user->is_active]); + } + + public function render() + { + $query = User::query() + ->when($this->search !== '', fn($q) => + $q->where(fn($q) => + $q->where('name', 'like', "%{$this->search}%") + ->orWhere('email', 'like', "%{$this->search}%") + ) + ) + ->when($this->filterRole !== '', fn($q) => + $q->where('role', $this->filterRole) + ) + ->orderBy('name'); + + $users = $query->get(); + $roles = Role::cases(); + + return view('livewire.ui.system.user-table', compact('users', 'roles')); + } +} diff --git a/app/Livewire/Ui/System/WebhookTable.php b/app/Livewire/Ui/System/WebhookTable.php new file mode 100644 index 0000000..799c7d0 --- /dev/null +++ b/app/Livewire/Ui/System/WebhookTable.php @@ -0,0 +1,27 @@ +update(['is_active' => !$webhook->is_active]); + } + + public function render() + { + return view('livewire.ui.system.webhook-table', [ + 'webhooks' => Webhook::orderByDesc('created_at')->get(), + 'allEvents' => Webhook::allEvents(), + ]); + } +} diff --git a/app/Livewire/Ui/V2/Mail/MailboxList.php b/app/Livewire/Ui/V2/Mail/MailboxList.php new file mode 100644 index 0000000..be80b5d --- /dev/null +++ b/app/Livewire/Ui/V2/Mail/MailboxList.php @@ -0,0 +1,155 @@ +dispatch('$refresh'); + } + + public function openMailboxCreate(int $domainId): void + { + $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-create-modal', arguments: [ + 'domainId' => $domainId, + ]); + } + + public function openMailboxEdit(int $mailUserId): void + { + $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-edit-modal', arguments: [ + $mailUserId, + ]); + } + + public function openMailboxDelete(int $mailUserId): void + { + $this->dispatch('openModal', component: 'ui.mail.modal.mailbox-delete-modal', arguments: [ + $mailUserId, + ]); + } + + public function updateMailboxStats(): void + { + dispatch(fn() => Artisan::call('mail:update-stats')); + $this->dispatch('$refresh'); + $this->dispatch('toast', + type: 'done', + badge: 'Mailbox', + title: 'Statistiken aktualisiert', + text: 'Die Mailbox-Statistiken wurden aktualisiert.', + duration: 5000, + ); + } + + public function render() + { + $term = trim($this->search); + $hasTerm = $term !== ''; + $needle = '%' . str_replace(['%', '_'], ['\%', '\_'], $term) . '%'; + + $domains = Domain::query() + ->where('is_system', false) + ->where('is_server', false) + ->when($hasTerm, function ($q) use ($needle) { + $q->where(function ($w) use ($needle) { + $w->where('domain', 'like', $needle) + ->orWhereHas('mailUsers', fn($u) => $u + ->where('is_active', true) + ->where('is_system', false) + ->where('localpart', 'like', $needle) + ); + }); + }) + ->withCount(['mailUsers as mail_users_count' => fn($u) => $u + ->where('is_active', true) + ->where('is_system', false) + ]) + ->with(['mailUsers' => function ($q) use ($hasTerm, $needle) { + $q->where('is_active', true)->where('is_system', false); + if ($hasTerm) { + $q->where('localpart', 'like', $needle); + } + $q->orderBy('localpart'); + }]) + ->orderBy('domain') + ->get(); + + if ($hasTerm) { + $lower = Str::lower($term); + foreach ($domains as $d) { + if (Str::contains(Str::lower($d->domain), $lower)) { + $d->setRelation('mailUsers', $d->mailUsers() + ->where('is_active', true) + ->where('is_system', false) + ->orderBy('localpart') + ->get() + ); + } + } + } + + foreach ($domains as $d) { + $prepared = []; + $domainActive = (bool)($d->is_active ?? true); + + foreach ($d->mailUsers as $u) { + $email = $u->email ?? null; + $stats = $email ? (Setting::get("mailbox.{$email}", []) ?: []) : []; + + $usedBytes = (int)($stats['used_bytes'] ?? 0); + $messageCount = (int)($stats['message_count'] ?? 0); + $usedMiB = round($usedBytes / 1048576, 2); + $quotaMiB = (int)($u->quota_mb ?? 0); + $usage = $quotaMiB > 0 + ? min(100, (int)round($usedBytes / ($quotaMiB * 1048576) * 100)) + : 0; + + $mailboxActive = (bool)($u->is_active ?? true); + $effective = $domainActive && $mailboxActive; + $reason = null; + if (!$effective) { + $reason = !$domainActive ? 'Domain inaktiv' : 'Postfach inaktiv'; + } + + $barClass = $usage > 85 ? 'mbx-bar-high' : ($usage > 60 ? 'mbx-bar-mid' : 'mbx-bar-low'); + + $prepared[] = [ + 'id' => $u->id, + 'localpart' => (string)$u->localpart, + 'quota_mb' => $quotaMiB, + 'used_mb' => $usedMiB, + 'usage_percent' => $usage, + 'bar_class' => $barClass, + 'message_count' => $messageCount, + 'is_active' => $mailboxActive, + 'is_effective_active' => $effective, + 'inactive_reason' => $reason, + 'last_login' => $u->last_login_at?->diffForHumans() ?? '—', + ]; + } + + $d->prepared_mailboxes = $prepared; + } + + $totalMailboxes = $domains->sum('mail_users_count'); + + return view('livewire.ui.v2.mail.mailbox-list', [ + 'domains' => $domains, + 'totalMailboxes'=> $totalMailboxes, + ]); + } +} diff --git a/app/Livewire/Ui/Webmail/Compose.php b/app/Livewire/Ui/Webmail/Compose.php new file mode 100644 index 0000000..560ca06 --- /dev/null +++ b/app/Livewire/Ui/Webmail/Compose.php @@ -0,0 +1,298 @@ +redirect(route('ui.webmail.login')); + return; + } + + // Restore staged attachments from session, drop any whose files were cleaned up + $staged = session('compose_attachments', []); + $this->stagedAttachments = array_values( + array_filter($staged, fn($a) => Storage::exists($a['path'])) + ); + + $user = MailUser::where('email', session('webmail_email'))->first(); + $prefs = $user?->webmail_prefs ?? []; + $sig = (string) ($user?->signature ?? ''); + + if ($this->draftUid > 0) { + $this->loadDraft(); + return; + } + + // Signatur laden — wird NICHT in den Body injiziert, sondern separat angezeigt + if ($sig) { + $this->signatureIsHtml = (bool) preg_match('/<[a-zA-Z][^>]*>/', $sig); + $applyNew = ($prefs['signature_new'] ?? true); + $applyReply = ($prefs['signature_reply'] ?? false); + + if ($this->replyUid > 0 && $applyReply) { + $this->signatureRaw = $sig; + } elseif ($this->replyUid === 0 && $applyNew) { + $this->signatureRaw = $sig; + } + } + + if ($this->replyUid > 0) { + $this->loadReply(); + return; + } + } + + // Called by Livewire when $attachments changes (after upload completes) + public function updatedAttachments(): void + { + $dir = 'webmail-tmp/' . session()->getId(); + + foreach ($this->attachments as $file) { + $filename = uniqid('att_') . '_' . preg_replace('/[^a-zA-Z0-9._-]/', '_', $file->getClientOriginalName()); + $path = $file->storeAs($dir, $filename, 'local'); + + $this->stagedAttachments[] = [ + 'name' => $file->getClientOriginalName(), + 'size' => $file->getSize(), + 'mime' => $file->getMimeType() ?: 'application/octet-stream', + 'path' => $path, + ]; + } + + $this->attachments = []; + session(['compose_attachments' => $this->stagedAttachments]); + } + + private function loadDraft(): void + { + $this->isDraft = true; + try { + $imap = app(ImapService::class); + $client = $imap->client(session('webmail_email'), session('webmail_password')); + $orig = $imap->getMessage($client, $this->draftFolder, $this->draftUid); + $client->disconnect(); + + $this->to = $orig['to'] ?? ''; + $this->subject = $orig['subject'] ?? ''; + $this->body = $orig['text'] ?? strip_tags($orig['html'] ?? ''); + } catch (\Throwable) {} + } + + private function loadReply(): void + { + $this->isReply = true; + try { + $imap = app(ImapService::class); + $client = $imap->client(session('webmail_email'), session('webmail_password')); + $orig = $imap->getMessage($client, $this->replyFolder, $this->replyUid); + $client->disconnect(); + + $this->to = $orig['from'] ?? ''; + + $sub = $orig['subject'] ?? ''; + $this->subject = str_starts_with(strtolower($sub), 're:') ? $sub : 'Re: ' . $sub; + + $date = $orig['date'] + ? \Carbon\Carbon::parse($orig['date'])->format('d.m.Y \u\m H:i \U\h\r') + : ''; + $fromName = $orig['from_name'] ?: ($orig['from'] ?? ''); + $fromAddr = $orig['from'] ?? ''; + $this->replyMeta = "Am {$date} schrieb {$fromName} <{$fromAddr}>:"; + + $quoteText = $orig['text'] ?: strip_tags($orig['html'] ?? ''); + $quoted = implode("\n", array_map( + fn($l) => '> ' . $l, + explode("\n", rtrim($quoteText)) + )); + + $this->body = "\n\n\n" . $this->replyMeta . "\n" . $quoted; + } catch (\Throwable) {} + } + + public function back(): void + { + $hasContent = trim($this->to) + || trim($this->subject) + || trim(preg_replace('/^[\s>]+$/m', '', $this->body)); + + if ($hasContent) { + $this->saveCurrentDraft(true); + } + + $this->clearStagedAttachments(); + $this->redirect(route('ui.webmail.inbox')); + } + + public function send(): void + { + $this->validate([ + 'to' => 'required|email', + 'subject' => 'required|string|max:255', + 'body' => 'required|string', + ]); + + try { + $imap = app(ImapService::class); + if ($this->signatureIsHtml && $this->signatureRaw) { + $htmlBody = '
' + . htmlspecialchars($this->body) + . '
' + . '
' + . $this->signatureRaw + . '
'; + $imap->sendMessage( + session('webmail_email'), + session('webmail_password'), + $this->to, + $this->subject, + $htmlBody, + $this->stagedAttachments, + true, + ); + } else { + $plainBody = $this->body; + if ($this->signatureRaw) { + $plainBody .= "\n\n" . $this->normalizeSignature($this->signatureRaw); + } + $imap->sendMessage( + session('webmail_email'), + session('webmail_password'), + $this->to, + $this->subject, + $plainBody, + $this->stagedAttachments, + ); + } + + if ($this->draftUid > 0) { + try { + $client = $imap->client(session('webmail_email'), session('webmail_password')); + $imap->deleteMessage($client, $this->draftFolder, $this->draftUid); + $client->disconnect(); + } catch (\Throwable) {} + } + + $this->clearStagedAttachments(); + + $this->sent = true; + $this->to = ''; + $this->subject = ''; + $this->body = ''; + $this->isReply = false; + $this->isDraft = false; + + $this->dispatch('toast', type: 'done', badge: 'Webmail', + title: 'Gesendet', text: 'Nachricht wurde erfolgreich gesendet.', duration: 4000); + } catch (\Throwable $e) { + $this->addError('to', 'Senden fehlgeschlagen: ' . $e->getMessage()); + } + } + + public function removeAttachment(int $index): void + { + if (isset($this->stagedAttachments[$index])) { + Storage::delete($this->stagedAttachments[$index]['path']); + } + array_splice($this->stagedAttachments, $index, 1); + session(['compose_attachments' => $this->stagedAttachments]); + } + + private function normalizeSignature(string $sig): string + { + $trimmed = ltrim($sig, "\r\n"); + // Prüfen ob der User bereits "-- " als Trenner eingetragen hat + if (str_starts_with($trimmed, '-- ') || str_starts_with($trimmed, "--\n") || str_starts_with($trimmed, "--\r\n")) { + return $trimmed; + } + return "-- \n" . $trimmed; + } + + private function clearStagedAttachments(): void + { + foreach ($this->stagedAttachments as $att) { + Storage::delete($att['path']); + } + $this->stagedAttachments = []; + session()->forget('compose_attachments'); + } + + private function saveCurrentDraft(bool $notify = false): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client(session('webmail_email'), session('webmail_password')); + + if ($this->draftUid > 0) { + $imap->deleteMessage($client, $this->draftFolder, $this->draftUid); + } + + $imap->saveDraft($client, session('webmail_email'), $this->to, $this->subject, $this->body); + $client->disconnect(); + + if ($notify) { + $this->dispatch('toast', type: 'done', badge: 'Webmail', + title: 'Entwurf gespeichert', text: 'Nachricht in Entwürfe gespeichert.', duration: 3000); + } + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('Draft save failed', ['error' => $e->getMessage()]); + } + } + + public function autoSave(): void + { + if ($this->sent) return; + $hasContent = trim($this->to) + || trim($this->subject) + || trim(preg_replace('/^[\s>]+$/m', '', $this->body)); + if (! $hasContent) return; + + $this->saveCurrentDraft(false); + } + + public function render() + { + return view('livewire.ui.webmail.compose'); + } +} diff --git a/app/Livewire/Ui/Webmail/FolderSidebar.php b/app/Livewire/Ui/Webmail/FolderSidebar.php new file mode 100644 index 0000000..5c49722 --- /dev/null +++ b/app/Livewire/Ui/Webmail/FolderSidebar.php @@ -0,0 +1,39 @@ +client(session('webmail_email'), session('webmail_password')); + $this->folders = $imap->folders($client); + $client->disconnect(); + } catch (\Throwable) { + $this->folders = []; + } + } + + public function placeholder() + { + return view('livewire.ui.webmail.folder-sidebar-placeholder'); + } + + public function render() + { + return view('livewire.ui.webmail.folder-sidebar'); + } +} diff --git a/app/Livewire/Ui/Webmail/Inbox.php b/app/Livewire/Ui/Webmail/Inbox.php new file mode 100644 index 0000000..65340e6 --- /dev/null +++ b/app/Livewire/Ui/Webmail/Inbox.php @@ -0,0 +1,246 @@ +redirect(route('ui.webmail.login')); + return; + } + $this->load(); + } + + public function load(): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client( + session('webmail_email'), + session('webmail_password'), + ); + + $this->folders = $imap->folders($client); + + if ($this->folder === '_starred') { + $result = $imap->starredMessages($client, $this->page, $this->perPage); + } else { + $result = $imap->messages($client, $this->folder, $this->page, $this->perPage); + } + + $this->total = $result['total']; + $this->messages = in_array($this->folder, ['INBOX', '_starred']) + ? array_map(fn($m) => array_merge($m, ['category' => $this->getCategory($m)]), $result['messages']) + : $result['messages']; + $this->categories = []; + + $client->disconnect(); + } catch (\Throwable) { + session()->forget(['webmail_email', 'webmail_password']); + $this->redirect(route('ui.webmail.login')); + } + } + + private function getCategory(array $msg): string + { + $from = strtolower($msg['from'] ?? ''); + foreach (['facebook','twitter','instagram','linkedin','tiktok','youtube','pinterest','reddit','snapchat','xing','mastodon'] as $kw) { + if (str_contains($from, $kw)) return 'social'; + } + foreach (['newsletter','noreply','no-reply','no_reply','marketing','mailer','mailchimp','sendgrid','campaign','promo','offers','deals'] as $kw) { + if (str_contains($from, $kw)) return 'promo'; + } + return 'general'; + } + + public function updatedSearch(string $value): void + { + if (mb_strlen(trim($value)) < 2) { + $this->searchResults = []; + $this->searching = false; + return; + } + $this->searching = true; + try { + $imap = app(ImapService::class); + $client = $imap->client(session('webmail_email'), session('webmail_password')); + $searchFolder = $this->folder === '_starred' ? 'INBOX' : $this->folder; + $this->searchResults = $imap->searchMessages($client, $searchFolder, trim($value)); + $client->disconnect(); + } catch (\Throwable) { + $this->searchResults = []; + } + $this->searching = false; + } + + public function clearSearch(): void + { + $this->search = ''; + $this->searchResults = []; + } + + public function switchTab(string $tab): void + { + $allowed = ['all', 'general', 'promo', 'social']; + $this->tab = in_array($tab, $allowed, true) ? $tab : 'all'; + } + + public function switchFolder(string $folder): void + { + $this->folder = $folder; + $this->page = 1; + $this->tab = 'all'; + $this->load(); + } + + public function nextPage(): void + { + if ($this->page * $this->perPage < $this->total) { + $this->page++; + $this->load(); + } + } + + public function prevPage(): void + { + if ($this->page > 1) { + $this->page--; + $this->load(); + } + } + + public function toggleFlag(int $uid): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client(session('webmail_email'), session('webmail_password')); + $imap->toggleFlag($client, $this->folder, $uid); + $client->disconnect(); + } catch (\Throwable) {} + $this->load(); + } + + public function deleteDraft(int $uid): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client(session('webmail_email'), session('webmail_password')); + $imap->deleteMessage($client, 'Drafts', $uid); + $client->disconnect(); + } catch (\Throwable) {} + $this->load(); + } + + public function bulkMarkUnseen(array $uids): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client(session('webmail_email'), session('webmail_password')); + foreach ($uids as $uid) { + $imap->markUnseen($client, $this->folder, (int) $uid); + } + $client->disconnect(); + } catch (\Throwable) {} + $this->load(); + } + + public function bulkMarkSeen(array $uids): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client(session('webmail_email'), session('webmail_password')); + foreach ($uids as $uid) { + $imap->markSeen($client, $this->folder, (int) $uid); + } + $client->disconnect(); + } catch (\Throwable) {} + $this->load(); + } + + public function bulkMoveTo(array $uids, string $target): void + { + $allowed = ['INBOX', 'Sent', 'Archive', 'Junk', 'Trash']; + if (! in_array($target, $allowed, true) || $target === $this->folder) return; + try { + $imap = app(ImapService::class); + $client = $imap->client(session('webmail_email'), session('webmail_password')); + foreach ($uids as $uid) { + $imap->moveMessage($client, $this->folder, (int) $uid, $target); + } + $client->disconnect(); + } catch (\Throwable) {} + $this->load(); + } + + public function bulkDelete(array $uids): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client(session('webmail_email'), session('webmail_password')); + foreach ($uids as $uid) { + if ($this->folder === 'Trash') { + $imap->deleteMessage($client, $this->folder, (int) $uid); + } else { + $imap->moveMessage($client, $this->folder, (int) $uid, 'Trash'); + } + } + $client->disconnect(); + } catch (\Throwable) {} + $this->load(); + } + + public function emptyTrash(): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client(session('webmail_email'), session('webmail_password')); + $folder = $client->getFolder('Trash'); + foreach ($folder->query()->all()->get() as $msg) { + $msg->delete(false); + } + $folder->expunge(); + $client->disconnect(); + } catch (\Throwable) {} + $this->load(); + } + + public function logout(): void + { + session()->forget(['webmail_email', 'webmail_password']); + $this->redirect(route('ui.webmail.login')); + } + + public function render() + { + return view('livewire.ui.webmail.inbox'); + } +} diff --git a/app/Livewire/Ui/Webmail/Login.php b/app/Livewire/Ui/Webmail/Login.php new file mode 100644 index 0000000..c90422e --- /dev/null +++ b/app/Livewire/Ui/Webmail/Login.php @@ -0,0 +1,48 @@ +validate([ + 'email' => 'required|email', + 'password' => 'required|string', + ]); + + try { + $imap = app(ImapService::class); + $client = $imap->client($this->email, $this->password); + $client->disconnect(); + + session([ + 'webmail_email' => $this->email, + 'webmail_password' => $this->password, + ]); + + $this->redirect(route('ui.webmail.inbox')); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('Webmail login failed', [ + 'email' => $this->email, + 'error' => $e->getMessage(), + ]); + $this->addError('email', 'Anmeldung fehlgeschlagen: Bitte E-Mail und Passwort prüfen.'); + } + } + + public function render() + { + return view('livewire.ui.webmail.login'); + } +} diff --git a/app/Livewire/Ui/Webmail/LogoutButton.php b/app/Livewire/Ui/Webmail/LogoutButton.php new file mode 100644 index 0000000..540fe7a --- /dev/null +++ b/app/Livewire/Ui/Webmail/LogoutButton.php @@ -0,0 +1,32 @@ +forget(['webmail_email', 'webmail_password']); + $this->redirect(route('ui.webmail.login')); + } + + public function render() + { + return <<<'BLADE' + + BLADE; + } +} diff --git a/app/Livewire/Ui/Webmail/MailView.php b/app/Livewire/Ui/Webmail/MailView.php new file mode 100644 index 0000000..0709b4b --- /dev/null +++ b/app/Livewire/Ui/Webmail/MailView.php @@ -0,0 +1,184 @@ +redirect(route('ui.webmail.login')); + return; + } + $this->uid = $uid; + $this->loadMessage(); + } + + private function loadMessage(): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client( + session('webmail_email'), + session('webmail_password'), + ); + + $prefs = MailUser::where('email', session('webmail_email'))->value('webmail_prefs') ?? []; + $autoMarkRead = (bool) ($prefs['auto_mark_read'] ?? true); + + if ($this->folder !== 'Drafts' && $autoMarkRead) { + $imap->markSeen($client, $this->folder, $this->uid); + } + $this->message = $imap->getMessage($client, $this->folder, $this->uid); + $client->disconnect(); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('MailView load failed', ['error' => $e->getMessage()]); + session()->forget(['webmail_email', 'webmail_password']); + $this->redirect(route('ui.webmail.login')); + } + } + + public function delete(): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client( + session('webmail_email'), + session('webmail_password'), + ); + + if ($this->folder === 'Trash') { + // Already in trash → permanent delete + $imap->deleteMessage($client, $this->folder, $this->uid); + $this->dispatch('toast', type: 'done', badge: 'Webmail', + title: 'Gelöscht', text: 'Nachricht wurde endgültig gelöscht.', duration: 3000); + } else { + // Move to trash (soft delete) + $imap->moveMessage($client, $this->folder, $this->uid, 'Trash'); + $this->dispatch('toast', type: 'done', badge: 'Webmail', + title: 'In Papierkorb', text: 'Nachricht wurde in den Papierkorb verschoben.', duration: 3000); + } + $client->disconnect(); + } catch (\Throwable) {} + + $this->redirect(route('ui.webmail.inbox', ['folder' => $this->folder])); + } + + public function markSpam(): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client( + session('webmail_email'), + session('webmail_password'), + ); + $imap->moveMessage($client, $this->folder, $this->uid, 'Junk'); + $client->disconnect(); + $this->dispatch('toast', type: 'done', badge: 'Webmail', + title: 'Als Spam markiert', text: 'Nachricht in Spam-Ordner verschoben.', duration: 3000); + } catch (\Throwable) {} + + $this->redirect(route('ui.webmail.inbox', ['folder' => $this->folder])); + } + + public function notSpam(): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client( + session('webmail_email'), + session('webmail_password'), + ); + $imap->moveMessage($client, $this->folder, $this->uid, 'INBOX'); + $client->disconnect(); + $this->dispatch('toast', type: 'done', badge: 'Webmail', + title: 'Kein Spam', text: 'Nachricht in den Posteingang verschoben.', duration: 3000); + } catch (\Throwable) {} + + $this->redirect(route('ui.webmail.inbox', ['folder' => $this->folder])); + } + + public function archive(): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client( + session('webmail_email'), + session('webmail_password'), + ); + $imap->moveMessage($client, $this->folder, $this->uid, 'Archive'); + $client->disconnect(); + $this->dispatch('toast', type: 'done', badge: 'Webmail', + title: 'Archiviert', text: 'Nachricht wurde archiviert.', duration: 3000); + } catch (\Throwable) {} + + $this->redirect(route('ui.webmail.inbox', ['folder' => $this->folder])); + } + + public function toggleFlag(): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client( + session('webmail_email'), + session('webmail_password'), + ); + $flagged = $imap->toggleFlag($client, $this->folder, $this->uid); + $client->disconnect(); + $this->message['flagged'] = $flagged; + } catch (\Throwable) {} + } + + public function markUnseen(): void + { + try { + $imap = app(ImapService::class); + $client = $imap->client( + session('webmail_email'), + session('webmail_password'), + ); + $imap->markUnseen($client, $this->folder, $this->uid); + $client->disconnect(); + } catch (\Throwable) {} + + $this->redirect(route('ui.webmail.inbox', ['folder' => $this->folder])); + } + + public function moveTo(string $target): void + { + $allowed = ['INBOX', 'Sent', 'Drafts', 'Archive', 'Junk', 'Trash']; + if (! in_array($target, $allowed, true) || $target === $this->folder) return; + + try { + $imap = app(ImapService::class); + $client = $imap->client( + session('webmail_email'), + session('webmail_password'), + ); + $imap->moveMessage($client, $this->folder, $this->uid, $target); + $client->disconnect(); + } catch (\Throwable) {} + + $this->redirect(route('ui.webmail.inbox', ['folder' => $this->folder])); + } + + public function render() + { + return view('livewire.ui.webmail.mail-view'); + } +} diff --git a/app/Livewire/Ui/Webmail/Settings.php b/app/Livewire/Ui/Webmail/Settings.php new file mode 100644 index 0000000..53fbf7a --- /dev/null +++ b/app/Livewire/Ui/Webmail/Settings.php @@ -0,0 +1,192 @@ +redirect(route('ui.webmail.login')); + return; + } + + $user = MailUser::where('email', session('webmail_email'))->first(); + if (! $user) return; + + $prefs = $user->webmail_prefs ?? []; + + $this->showSubscribedOnly = (bool) ($prefs['show_subscribed_only'] ?? false); + $this->fetchUnreadAll = (bool) ($prefs['fetch_unread_all'] ?? false); + $this->threadMessages = (bool) ($prefs['thread_messages'] ?? false); + $this->showFullAddress = (bool) ($prefs['show_full_address'] ?? false); + $this->hideEmbeddedAttachments = (bool) ($prefs['hide_embedded_attachments'] ?? false); + $this->attachmentsAbove = (bool) ($prefs['attachments_above'] ?? false); + $this->autoMarkRead = (bool) ($prefs['auto_mark_read'] ?? true); + $this->autoMarkReadDelay = (int) ($prefs['auto_mark_read_delay'] ?? 0); + $this->forwardMode = (string)($prefs['forward_mode'] ?? 'inline'); + $this->replyBelowQuote = (bool) ($prefs['reply_below_quote'] ?? false); + $this->signatureNew = (bool) ($prefs['signature_new'] ?? true); + $this->signatureReply = (bool) ($prefs['signature_reply'] ?? false); + $this->signatureForward = (bool) ($prefs['signature_forward'] ?? false); + $this->composeHtml = (bool) ($prefs['compose_html'] ?? true); + $this->defaultFontSize = (int) ($prefs['default_font_size'] ?? 13); + $this->showRemoteImages = (string)($prefs['show_remote_images'] ?? 'never'); + $this->autosaveInterval = (int) ($prefs['autosave_interval'] ?? 5); + $this->signature = (string)($user->signature ?? ''); + + $this->filterRules = $user->sieve_filter_rules ?? []; + $this->vacationEnabled = (bool) $user->vacation_enabled; + $this->vacationSubject = (string)($user->vacation_subject ?? ''); + $this->vacationBody = (string)($user->vacation_body ?? ''); + $this->forwardTo = (string)($user->forward_to ?? ''); + } + + public function save(): void + { + $this->validate([ + 'forwardTo' => 'nullable|email', + 'vacationSubject' => 'nullable|string|max:255', + 'vacationBody' => 'nullable|string|max:4000', + 'newValue' => 'nullable|string|max:255', + ]); + + $user = MailUser::where('email', session('webmail_email'))->first(); + if (! $user) return; + + $user->update([ + 'webmail_prefs' => [ + 'show_subscribed_only' => $this->showSubscribedOnly, + 'fetch_unread_all' => $this->fetchUnreadAll, + 'thread_messages' => $this->threadMessages, + 'show_full_address' => $this->showFullAddress, + 'hide_embedded_attachments' => $this->hideEmbeddedAttachments, + 'attachments_above' => $this->attachmentsAbove, + 'auto_mark_read' => $this->autoMarkRead, + 'auto_mark_read_delay' => $this->autoMarkReadDelay, + 'forward_mode' => $this->forwardMode, + 'reply_below_quote' => $this->replyBelowQuote, + 'signature_new' => $this->signatureNew, + 'signature_reply' => $this->signatureReply, + 'signature_forward' => $this->signatureForward, + 'compose_html' => $this->composeHtml, + 'default_font_size' => $this->defaultFontSize, + 'show_remote_images' => $this->showRemoteImages, + 'autosave_interval' => $this->autosaveInterval, + ], + 'signature' => $this->signature ?: null, + 'sieve_filter_rules' => $this->filterRules, + 'vacation_enabled' => $this->vacationEnabled, + 'vacation_subject' => $this->vacationSubject ?: null, + 'vacation_body' => $this->vacationBody ?: null, + 'forward_to' => $this->forwardTo ?: null, + ]); + + $this->applySieve($user->fresh()); + + $this->dispatch('toast', type: 'done', badge: 'Webmail', + title: 'Gespeichert', text: 'Einstellungen wurden gespeichert.', duration: 3000); + } + + public function addFilterRule(): void + { + $this->validate(['newValue' => 'required|string|max:255']); + + $this->filterRules[] = [ + 'id' => uniqid('r', true), + 'field' => $this->newField, + 'op' => $this->newOp, + 'value' => trim($this->newValue), + 'action' => $this->newAction, + 'folder' => $this->newFolder, + ]; + + $this->newValue = ''; + } + + public function removeFilterRule(string $id): void + { + $this->filterRules = array_values( + array_filter($this->filterRules, fn($r) => $r['id'] !== $id) + ); + } + + private function applySieve(MailUser $user): void + { + $rules = json_encode($user->sieve_filter_rules ?? []); + $email = escapeshellarg($user->email); + $forward = escapeshellarg($user->forward_to ?? ''); + $vacEn = $user->vacation_enabled ? '1' : '0'; + $vacSubj = escapeshellarg($user->vacation_subject ?? 'Ich bin derzeit nicht erreichbar.'); + $vacBody = escapeshellarg($user->vacation_body ?? ''); + $rulesEsc= escapeshellarg($rules); + + $cmd = "sudo -n /usr/local/sbin/mailwolt-apply-sieve" + . " --email {$email}" + . " --forward {$forward}" + . " --vacation-enabled {$vacEn}" + . " --vacation-subject {$vacSubj}" + . " --vacation-body {$vacBody}" + . " --filter-rules {$rulesEsc}" + . " 2>&1"; + + $out = shell_exec($cmd); + if ($out) { + \Illuminate\Support\Facades\Log::info('mailwolt-apply-sieve', ['output' => $out]); + } + } + + public function render() + { + return view('livewire.ui.webmail.settings'); + } +} diff --git a/app/Models/MailUser.php b/app/Models/MailUser.php index ddcc0ba..af76a7f 100644 --- a/app/Models/MailUser.php +++ b/app/Models/MailUser.php @@ -18,16 +18,28 @@ class MailUser extends Model 'can_login', 'quota_mb', 'rate_limit_per_hour', + 'forward_to', + 'vacation_enabled', + 'vacation_subject', + 'vacation_body', + 'sieve_discard_senders', + 'webmail_prefs', + 'signature', + 'sieve_filter_rules', ]; protected $hidden = ['password_hash']; protected $casts = [ - 'is_system' => 'boolean', - 'is_active' => 'boolean', - 'can_login' => 'boolean', - 'quota_mb' => 'integer', - 'rate_limit_per_hour' => 'integer', + 'is_system' => 'boolean', + 'is_active' => 'boolean', + 'can_login' => 'boolean', + 'quota_mb' => 'integer', + 'rate_limit_per_hour' => 'integer', + 'vacation_enabled' => 'boolean', + 'sieve_discard_senders' => 'array', + 'webmail_prefs' => 'array', + 'sieve_filter_rules' => 'array', 'last_login_at' => 'datetime', 'stats_refreshed_at' => 'datetime', 'created_at' => 'datetime', diff --git a/app/Models/PersonalAccessToken.php b/app/Models/PersonalAccessToken.php new file mode 100644 index 0000000..78f75e5 --- /dev/null +++ b/app/Models/PersonalAccessToken.php @@ -0,0 +1,23 @@ + 'json', + 'last_used_at' => 'datetime', + 'expires_at' => 'datetime', + 'sandbox' => 'boolean', + ]; +} diff --git a/app/Models/SandboxMail.php b/app/Models/SandboxMail.php new file mode 100644 index 0000000..a4d338b --- /dev/null +++ b/app/Models/SandboxMail.php @@ -0,0 +1,33 @@ + 'array', + 'is_read' => 'boolean', + 'received_at' => 'datetime', + ]; + + public function getToPreviewAttribute(): string + { + $addrs = $this->to_addresses ?? []; + return implode(', ', array_slice($addrs, 0, 2)) . (count($addrs) > 2 ? ' +' . (count($addrs) - 2) : ''); + } + + public function getSenderAttribute(): string + { + return $this->from_name + ? "{$this->from_name} <{$this->from_address}>" + : $this->from_address; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 62cd759..e2dce61 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,11 +7,12 @@ use App\Enums\Role; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, HasApiTokens; protected $fillable = [ 'name', @@ -31,6 +32,7 @@ class User extends Authenticatable protected $casts = [ 'email_verified_at' => 'datetime', + 'last_login_at' => 'datetime', 'is_active' => 'boolean', 'required_change_password' => 'boolean', 'role' => Role::class, diff --git a/app/Models/Webhook.php b/app/Models/Webhook.php new file mode 100644 index 0000000..5024fb3 --- /dev/null +++ b/app/Models/Webhook.php @@ -0,0 +1,29 @@ + 'array', + 'is_active' => 'boolean', + 'last_triggered_at' => 'datetime', + ]; + + public static function allEvents(): array + { + return [ + 'mailbox.created' => 'Mailbox erstellt', + 'mailbox.updated' => 'Mailbox aktualisiert', + 'mailbox.deleted' => 'Mailbox gelöscht', + 'alias.created' => 'Alias erstellt', + 'alias.deleted' => 'Alias gelöscht', + 'domain.created' => 'Domain erstellt', + 'domain.deleted' => 'Domain gelöscht', + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 770e59b..38f04ce 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,10 +3,14 @@ namespace App\Providers; use App\Models\Domain; +use App\Models\MailUser; +use App\Models\PersonalAccessToken; use App\Observers\DomainObserver; use App\Support\SettingsRepository; use Illuminate\Support\Facades\URL; +use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; +use Laravel\Sanctum\Sanctum; class AppServiceProvider extends ServiceProvider { @@ -23,8 +27,19 @@ class AppServiceProvider extends ServiceProvider */ public function boot(\App\Support\SettingsRepository $settings): void { + Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); Domain::observe(DomainObserver::class); + View::composer('layouts.dvx', function ($view) { + try { + $view->with([ + 'mailboxCount' => MailUser::where('is_system', false)->where('is_active', true)->count(), + 'domainCount' => Domain::where('is_system', false)->where('is_server', false)->count(), + 'updateAvailable' => (bool) cache()->get('updates:latest'), + ]); + } catch (\Throwable) {} + }); + config(['app.version' => trim(@file_get_contents('/var/lib/mailwolt/version')) ?: 'dev']); if (file_exists(base_path('.git/HEAD'))) { $ref = trim(file_get_contents(base_path('.git/HEAD'))); @@ -53,14 +68,14 @@ class AppServiceProvider extends ServiceProvider } // 🌍 Domain / URL - if ($domain = $S->get('app.domain')) { - $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'); - } - } +// if ($domain = $S->get('app.domain')) { +// $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) { // Keine Exceptions beim Booten, z. B. wenn DB oder Redis noch nicht erreichbar sind diff --git a/app/Services/ImapService.php b/app/Services/ImapService.php new file mode 100644 index 0000000..59989ef --- /dev/null +++ b/app/Services/ImapService.php @@ -0,0 +1,295 @@ +manager = new ClientManager(config('imap')); + } + + public function client(string $email, string $password): Client + { + $client = $this->manager->make([ + 'host' => config('imap.accounts.webmail.host', '127.0.0.1'), + 'port' => config('imap.accounts.webmail.port', 143), + 'protocol' => 'imap', + 'encryption' => config('imap.accounts.webmail.encryption', 'notls'), + 'validate_cert' => false, + 'username' => $email, + 'password' => $password, + ]); + + $client->connect(); + return $client; + } + + public function folders(Client $client): array + { + $order = ['INBOX' => 0, 'Drafts' => 1, 'Sent' => 2, 'Junk' => 3, 'Trash' => 4, 'Archive' => 5]; + + $folders = []; + foreach ($client->getFolders(false) as $folder) { + $folders[] = [ + 'name' => $folder->name, + 'full_name' => $folder->full_name, + 'path' => $folder->path, + ]; + } + + usort($folders, fn ($a, $b) => + ($order[$a['name']] ?? 99) <=> ($order[$b['name']] ?? 99) + ); + + return $folders; + } + + public function starredMessages(Client $client, int $page = 1, int $perPage = 25): array + { + $rows = []; + foreach ($client->getFolders(false) as $folder) { + if (in_array($folder->name, ['Trash', 'Spam', 'Junk'])) continue; + try { + $msgs = $folder->query()->where('FLAGGED')->setFetchFlags(true)->setFetchBody(false)->get(); + foreach ($msgs as $msg) { + $toAttr = $msg->getTo(); + $toAddr = ''; + foreach ($toAttr ?? [] as $r) { $toAddr = (string)($r->mail ?? ''); break; } + $rows[] = [ + 'uid' => $msg->getUid(), + 'folder' => $folder->path, + 'subject' => (string) $msg->getSubject(), + 'from' => (string) ($msg->getFrom()[0]->mail ?? ''), + 'from_name' => (string) ($msg->getFrom()[0]->personal ?? ''), + 'to' => $toAddr, + 'date' => $msg->getDate()?->toDate()?->toDateTimeString(), + 'seen' => $msg->getFlags()->has('seen'), + 'flagged' => true, + 'has_attachments' => count($msg->getAttachments()) > 0, + ]; + } + } catch (\Throwable) {} + } + + usort($rows, fn($a, $b) => strcmp($b['date'] ?? '', $a['date'] ?? '')); + $total = count($rows); + $paged = array_slice($rows, ($page - 1) * $perPage, $perPage); + + return ['messages' => $paged, 'total' => $total, 'page' => $page, 'perPage' => $perPage]; + } + + public function messages(Client $client, string $folderPath = 'INBOX', int $page = 1, int $perPage = 25): array + { + $folder = $client->getFolder($folderPath); + $query = $folder->query()->all(); + $total = $query->count(); + $messages = $query + ->setFetchFlags(true) + ->setFetchBody(false) + ->limit($perPage, ($page - 1) * $perPage) + ->get(); + + $rows = []; + foreach ($messages as $msg) { + $toAttr = $msg->getTo(); + $toAddr = ''; + foreach ($toAttr ?? [] as $r) { $toAddr = (string)($r->mail ?? ''); break; } + + $rows[] = [ + 'uid' => $msg->getUid(), + 'subject' => (string) $msg->getSubject(), + 'from' => (string) ($msg->getFrom()[0]->mail ?? ''), + 'from_name' => (string) ($msg->getFrom()[0]->personal ?? ''), + 'to' => $toAddr, + 'date' => $msg->getDate()?->toDate()?->toDateTimeString(), + 'seen' => $msg->getFlags()->has('seen'), + 'flagged' => $msg->getFlags()->has('flagged'), + 'has_attachments' => count($msg->getAttachments()) > 0, + ]; + } + + return [ + 'messages' => array_reverse($rows), + 'total' => $total, + 'page' => $page, + 'perPage' => $perPage, + ]; + } + + public function getMessage(Client $client, string $folderPath, int $uid): array + { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->getMessageByUid($uid); + + if (! $message) { + return []; + } + + $html = (string) $message->getHtmlBody(); + $text = (string) $message->getTextBody(); + + $froms = $message->getFrom(); + $from = $froms[0] ?? null; + + $to = []; + foreach ($message->getTo() ?? [] as $r) { + $to[] = (string) ($r->mail ?? ''); + } + + $attachments = []; + foreach ($message->getAttachments() as $att) { + $attachments[] = [ + 'name' => $att->getName(), + 'type' => $att->getMimeType(), + 'size' => $att->getSize(), + ]; + } + + return [ + 'uid' => $message->getUid(), + 'subject' => (string) $message->getSubject(), + 'from' => (string) ($from->mail ?? ''), + 'from_name' => (string) ($from->personal ?? ''), + 'to' => implode(', ', $to), + 'date' => $message->getDate()?->toDate()?->toDateTimeString(), + 'html' => $html, + 'text' => $text, + 'attachments' => $attachments, + 'seen' => $message->getFlags()->has('seen'), + 'flagged' => $message->getFlags()->has('flagged'), + ]; + } + + public function sendMessage(string $from, string $password, string $to, string $subject, string $body, array $attachments = [], bool $isHtml = false): void + { + $attachFn = function ($msg) use ($attachments) { + foreach ($attachments as $att) { + $diskPath = \Illuminate\Support\Facades\Storage::path($att['path']); + if (! file_exists($diskPath)) continue; + $msg->attachData( + file_get_contents($diskPath), + $att['name'], + ['mime' => $att['mime'] ?: 'application/octet-stream'], + ); + } + }; + + if ($isHtml) { + \Illuminate\Support\Facades\Mail::html($body, function ($msg) use ($from, $to, $subject, $attachFn) { + $msg->from($from)->to($to)->subject($subject); + $attachFn($msg); + }); + } else { + \Illuminate\Support\Facades\Mail::raw($body, function ($msg) use ($from, $to, $subject, $attachFn) { + $msg->from($from)->to($to)->subject($subject); + $attachFn($msg); + }); + } + } + + public function saveDraft(Client $client, string $from, string $to, string $subject, string $body): void + { + $date = now()->format('D, d M Y H:i:s O'); + $raw = "Date: {$date}\r\n" + . "From: {$from}\r\n" + . "To: {$to}\r\n" + . "Subject: {$subject}\r\n" + . "MIME-Version: 1.0\r\n" + . "Content-Type: text/plain; charset=UTF-8\r\n" + . "Content-Transfer-Encoding: 8bit\r\n" + . "\r\n" + . $body; + + $folder = $client->getFolder('Drafts'); + $folder->appendMessage($raw, ['\\Draft', '\\Seen'], now()); + } + + public function moveMessage(Client $client, string $fromFolder, int $uid, string $toFolder): void + { + $folder = $client->getFolder($fromFolder); + $message = $folder->query()->getMessageByUid($uid); + $message?->move($toFolder, true); + } + + public function toggleFlag(Client $client, string $folderPath, int $uid): bool + { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->getMessageByUid($uid); + if (! $message) return false; + + $flagged = $message->getFlags()->has('flagged'); + if ($flagged) { + $message->unsetFlag('Flagged'); + return false; + } + $message->setFlag('Flagged'); + return true; + } + + public function deleteMessage(Client $client, string $folderPath, int $uid): void + { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->getMessageByUid($uid); + $message?->delete(true); + } + + public function markSeen(Client $client, string $folderPath, int $uid): void + { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->getMessageByUid($uid); + $message?->setFlag('Seen'); + } + + public function markUnseen(Client $client, string $folderPath, int $uid): void + { + $folder = $client->getFolder($folderPath); + $message = $folder->query()->getMessageByUid($uid); + $message?->unsetFlag('Seen'); + } + + public function searchMessages(Client $client, string $folderPath, string $query, int $limit = 15): array + { + $folder = $client->getFolder($folderPath); + $results = []; + $seen = []; + + foreach (['SUBJECT', 'FROM'] as $criterion) { + try { + $msgs = $folder->query() + ->where($criterion, $query) + ->setFetchFlags(true) + ->setFetchBody(false) + ->get(); + foreach ($msgs as $msg) { + $uid = $msg->getUid(); + if (isset($seen[$uid])) continue; + $seen[$uid] = true; + $froms = $msg->getFrom(); + $from = $froms[0] ?? null; + $to = []; + foreach ($msg->getTo() ?? [] as $r) { $to[] = (string)($r->mail ?? ''); } + $results[] = [ + 'uid' => $uid, + 'subject' => (string) $msg->getSubject(), + 'from' => (string) ($from->mail ?? ''), + 'from_name' => (string) ($from->personal ?? ''), + 'to' => implode(', ', $to), + 'date' => $msg->getDate()?->toDate()?->toDateTimeString(), + 'seen' => $msg->getFlags()->has('seen'), + 'flagged' => $msg->getFlags()->has('flagged'), + ]; + if (count($results) >= $limit) break 2; + } + } catch (\Throwable) {} + } + + return $results; + } +} diff --git a/app/Services/SandboxMailParser.php b/app/Services/SandboxMailParser.php new file mode 100644 index 0000000..19c45a7 --- /dev/null +++ b/app/Services/SandboxMailParser.php @@ -0,0 +1,145 @@ +splitHeadersBody($raw); + $headers = $this->parseHeaders($headerBlock); + + [$textBody, $htmlBody] = $this->extractBodies($headers, $body); + + $from = $this->parseAddress($headers['from'] ?? ''); + $toHeader = $this->parseAddressList($headers['to'] ?? ''); + $toAddresses = !empty($recipients) ? $recipients : $toHeader; + + return SandboxMail::create([ + 'message_id' => trim($headers['message-id'] ?? '', '<>') ?: null, + 'from_address' => $from['address'], + 'from_name' => $from['name'] ?: null, + 'to_addresses' => $toAddresses, + 'subject' => $this->decodeMimeHeader($headers['subject'] ?? '(kein Betreff)'), + 'body_text' => $textBody, + 'body_html' => $htmlBody, + 'raw_headers' => $headerBlock, + 'received_at' => now(), + ]); + } + + private function splitHeadersBody(string $raw): array + { + $raw = str_replace("\r\n", "\n", $raw); + $pos = strpos($raw, "\n\n"); + if ($pos === false) { + return [$raw, '']; + } + return [substr($raw, 0, $pos), substr($raw, $pos + 2)]; + } + + private function parseHeaders(string $block): array + { + $headers = []; + // Unfold multi-line headers + $block = preg_replace("/\n[ \t]+/", ' ', $block); + foreach (explode("\n", $block) as $line) { + if (str_contains($line, ':')) { + [$name, $value] = explode(':', $line, 2); + $headers[strtolower(trim($name))] = trim($value); + } + } + return $headers; + } + + private function extractBodies(array $headers, string $body): array + { + $contentType = $headers['content-type'] ?? 'text/plain'; + + if (str_starts_with($contentType, 'multipart/')) { + preg_match('/boundary="?([^";\s]+)"?/i', $contentType, $m); + $boundary = $m[1] ?? null; + if ($boundary) { + return $this->parseMultipart($body, $boundary); + } + } + + if (str_contains($contentType, 'text/html')) { + return [null, $this->decodeBody($body, $headers['content-transfer-encoding'] ?? '')]; + } + + return [$this->decodeBody($body, $headers['content-transfer-encoding'] ?? ''), null]; + } + + private function parseMultipart(string $body, string $boundary): array + { + $text = null; + $html = null; + $parts = preg_split('/--' . preg_quote($boundary, '/') . '(--)?\r?\n?/', $body); + + foreach ($parts as $part) { + $part = ltrim($part); + if (empty($part) || $part === '--') continue; + [$ph, $pb] = $this->splitHeadersBody($part); + $ph = $this->parseHeaders($ph); + $ct = $ph['content-type'] ?? ''; + $enc = $ph['content-transfer-encoding'] ?? ''; + + if (str_contains($ct, 'multipart/')) { + preg_match('/boundary="?([^";\s]+)"?/i', $ct, $m); + if (!empty($m[1])) { + [$t, $h] = $this->parseMultipart($pb, $m[1]); + $text ??= $t; + $html ??= $h; + } + } elseif (str_contains($ct, 'text/html')) { + $html ??= $this->decodeBody($pb, $enc); + } elseif (str_contains($ct, 'text/plain')) { + $text ??= $this->decodeBody($pb, $enc); + } + } + + return [$text, $html]; + } + + private function decodeBody(string $body, string $encoding): string + { + $enc = strtolower(trim($encoding)); + if ($enc === 'base64') { + return base64_decode(str_replace(["\r", "\n"], '', $body)); + } + if ($enc === 'quoted-printable') { + return quoted_printable_decode($body); + } + return $body; + } + + private function parseAddress(string $addr): array + { + $addr = $this->decodeMimeHeader($addr); + if (preg_match('/^(.+?)\s*<([^>]+)>$/', trim($addr), $m)) { + return ['name' => trim($m[1], '"'), 'address' => strtolower(trim($m[2]))]; + } + return ['name' => '', 'address' => strtolower(trim($addr, '<>'))]; + } + + private function parseAddressList(string $list): array + { + $addresses = []; + foreach (preg_split('/,(?![^<>]*>)/', $list) as $addr) { + $parsed = $this->parseAddress(trim($addr)); + if ($parsed['address']) { + $addresses[] = $parsed['address']; + } + } + return $addresses; + } + + private function decodeMimeHeader(string $value): string + { + if (!str_contains($value, '=?')) return $value; + return mb_decode_mimeheader($value); + } +} diff --git a/app/Services/TotpService.php b/app/Services/TotpService.php new file mode 100644 index 0000000..06c34f2 --- /dev/null +++ b/app/Services/TotpService.php @@ -0,0 +1,83 @@ +g2fa->generateSecretKey(32); + } + + public function verify(string $secret, string $code): bool + { + return (bool) $this->g2fa->verifyKey($secret, $code, 1); + } + + public function qrCodeSvg(User $user, string $secret): string + { + $otpauth = $this->g2fa->getQRCodeUrl( + config('app.name', 'Mailwolt'), + $user->email, + $secret + ); + + $renderer = new ImageRenderer( + new RendererStyle(200, 2), + new SvgImageBackEnd() + ); + + return (new Writer($renderer))->writeString($otpauth); + } + + public function enable(User $user, string $secret): array + { + TwoFactorMethod::updateOrCreate( + ['user_id' => $user->id, 'method' => 'totp'], + ['secret' => encrypt($secret), 'enabled' => true, 'confirmed_at' => now()] + ); + + $user->update(['two_factor_enabled' => true]); + + return TwoFactorRecoveryCode::generateNewSet($user->id); + } + + public function disable(User $user): void + { + TwoFactorMethod::where('user_id', $user->id)->where('method', 'totp')->delete(); + TwoFactorRecoveryCode::where('user_id', $user->id)->delete(); + + if (!$user->twoFactorMethods()->where('enabled', true)->exists()) { + $user->update(['two_factor_enabled' => false]); + } + } + + public function getSecret(User $user): ?string + { + $m = TwoFactorMethod::where('user_id', $user->id) + ->where('method', 'totp') + ->where('enabled', true) + ->first(); + + return $m ? decrypt($m->secret) : null; + } + + public function isEnabled(User $user): bool + { + return TwoFactorMethod::where('user_id', $user->id) + ->where('method', 'totp') + ->where('enabled', true) + ->exists(); + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 0000000..cf10f23 --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,59 @@ +whereJsonContains('events', $event) + ->get(); + + foreach ($webhooks as $webhook) { + $this->send($webhook, $event, $payload); + } + } + + private function send(Webhook $webhook, string $event, array $payload): void + { + $body = json_encode([ + 'event' => $event, + 'timestamp' => now()->toIso8601String(), + 'payload' => $payload, + ]); + + $signature = 'sha256=' . hash_hmac('sha256', $body, $webhook->secret); + + try { + $response = Http::timeout(8) + ->withHeaders([ + 'Content-Type' => 'application/json', + 'X-Mailwolt-Event' => $event, + 'X-Mailwolt-Sig' => $signature, + ]) + ->withBody($body, 'application/json') + ->post($webhook->url); + + $webhook->update([ + 'last_triggered_at' => now(), + 'last_status' => $response->status(), + ]); + } catch (\Throwable $e) { + $webhook->update([ + 'last_triggered_at' => now(), + 'last_status' => 0, + ]); + Log::warning("Webhook #{$webhook->id} failed: " . $e->getMessage()); + } + } + + public static function generateSecret(): string + { + return bin2hex(random_bytes(32)); + } +} diff --git a/app/Support/WoltGuard/MonitClient.php b/app/Support/WoltGuard/MonitClient.php new file mode 100644 index 0000000..e026193 --- /dev/null +++ b/app/Support/WoltGuard/MonitClient.php @@ -0,0 +1,79 @@ +url = "http://{$host}:{$port}/_status?format=xml"; + $this->timeout = $timeout; + } + + /** + * Returns array of service rows: ['name'=>, 'label'=>, 'hint'=>, 'ok'=>bool] + * Only process-type services (type=3) are included. + * Returns empty array if Monit is unreachable. + */ + public function services(): array + { + $xml = $this->fetch(); + if ($xml === null) return []; + + $labelMap = [ + 'postfix' => ['label' => 'Postfix', 'hint' => 'MTA / Versand'], + 'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP / POP3'], + 'mariadb' => ['label' => 'MariaDB', 'hint' => 'Datenbank'], + 'redis' => ['label' => 'Redis', 'hint' => 'Cache / Queue'], + 'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'], + 'opendkim' => ['label' => 'OpenDKIM', 'hint' => 'DKIM-Signatur'], + 'nginx' => ['label' => 'Nginx', 'hint' => 'Webserver'], + ]; + + $rows = []; + foreach ($xml->service as $svc) { + if ((int) $svc['type'] !== 3) continue; // nur Prozesse + + $name = (string) $svc->name; + $ok = (int) $svc->status === 0; + $meta = $labelMap[$name] ?? ['label' => ucfirst($name), 'hint' => '']; + + $rows[] = [ + 'name' => $name, + 'label' => $meta['label'], + 'hint' => $meta['hint'], + 'ok' => $ok, + ]; + } + + return $rows; + } + + public function reachable(): bool + { + return $this->fetch() !== null; + } + + private function fetch(): ?\SimpleXMLElement + { + $ctx = stream_context_create(['http' => [ + 'timeout' => $this->timeout, + 'ignore_errors' => true, + ]]); + + $raw = @file_get_contents($this->url, false, $ctx); + if ($raw === false || $raw === '') return null; + + try { + $prev = libxml_use_internal_errors(true); + $xml = simplexml_load_string($raw); + libxml_use_internal_errors($prev); + return $xml instanceof \SimpleXMLElement ? $xml : null; + } catch (\Throwable) { + return null; + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 90e6e8b..4d5c55f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,21 +3,49 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Support\Facades\Route; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( - web: __DIR__ . '/../routes/web.php', - api: __DIR__ . '/../routes/api.php', + api: __DIR__ . '/../routes/api.php', channels: __DIR__ . '/../routes/channels.php', commands: __DIR__ . '/../routes/console.php', - health: '/up', + health: '/up', + using: function () { + // Webmail must be registered BEFORE web.php so its domain constraint + // takes priority over the catch-all Route::get('/') in web.php. + $wmHost = config('mailwolt.domain.webmail_host'); + + if ($wmHost) { + Route::middleware('web') + ->domain($wmHost) + ->name('ui.webmail.') + ->group(base_path('routes/webmail.php')); + + // Path-based fallback (no names — avoids duplicate-name conflict) + Route::middleware('web') + ->prefix('webmail') + ->group(base_path('routes/webmail.php')); + } else { + Route::middleware('web') + ->prefix('webmail') + ->name('ui.webmail.') + ->group(base_path('routes/webmail.php')); + } + + Route::middleware('web')->group(base_path('routes/web.php')); + }, ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->trustProxies(at: '*'); + $middleware->prependToGroup('web', \App\Http\Middleware\ValidateHost::class); $middleware->alias([ 'ensure.setup' => \App\Http\Middleware\EnsureSetupCompleted::class, 'signup.open' => \App\Http\Middleware\SignupOpen::class, 'auth.user' => \App\Http\Middleware\AuthenticatedMiddleware::class, 'guest.only' => \App\Http\Middleware\GuestOnlyMiddleware::class, + 'role' => \App\Http\Middleware\RequireRole::class, + 'require2fa' => \App\Http\Middleware\Require2FA::class, ]); }) diff --git a/composer.json b/composer.json index 715d84a..d33412b 100644 --- a/composer.json +++ b/composer.json @@ -7,13 +7,17 @@ "license": "MIT", "require": { "php": "^8.2", + "ext-openssl": "*", + "dragonmantank/cron-expression": "^3.6", "laravel/framework": "^12.0", "laravel/reverb": "^1.6", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", "livewire/livewire": "^3.6", + "pragmarx/google2fa": "^9.0", "vectorface/googleauthenticator": "^3.4", - "wire-elements/modal": "^2.0", - "ext-openssl": "*" + "webklex/laravel-imap": "^6.2", + "wire-elements/modal": "^2.0" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 61a1ffe..c24cae3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b5113b6186747bf4e181997c30721794", + "content-hash": "d89a25f33ce02359bf88698c7e6f7a51", "packages": [ { "name": "bacon/bacon-qr-code", @@ -613,29 +613,28 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.4.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "8c784d071debd117328803d86b2097615b457500" + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", - "reference": "8c784d071debd117328803d86b2097615b457500", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", "shasum": "" }, "require": { - "php": "^7.2|^8.0", - "webmozart/assert": "^1.0" + "php": "^8.2|^8.3|^8.4|^8.5" }, "replace": { "mtdowling/cron-expression": "^1.0" }, "require-dev": { - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.0", - "phpunit/phpunit": "^7.0|^8.0|^9.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" }, "type": "library", "extra": { @@ -666,7 +665,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" }, "funding": [ { @@ -674,7 +673,7 @@ "type": "github" } ], - "time": "2024-10-09T13:47:03+00:00" + "time": "2025-10-31T18:51:33+00:00" }, { "name": "egulias/email-validator", @@ -1766,6 +1765,69 @@ }, "time": "2025-09-07T23:21:05+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-02-07T17:19:31+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.5", @@ -3024,6 +3086,75 @@ ], "time": "2025-05-08T08:14:37+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, { "name": "paragonie/sodium_compat", "version": "v2.4.0", @@ -3195,6 +3326,58 @@ ], "time": "2025-08-21T11:53:16+00:00" }, + { + "name": "pragmarx/google2fa", + "version": "v9.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" + }, + "time": "2025-09-19T22:51:08+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -7342,39 +7525,41 @@ "time": "2024-11-21T01:49:47+00:00" }, { - "name": "webmozart/assert", - "version": "1.11.0", + "name": "webklex/laravel-imap", + "version": "6.2.0", "source": { "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + "url": "https://github.com/Webklex/laravel-imap.git", + "reference": "57609df58c2f4ef625e4d90a47d8615cfb15f925" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "url": "https://api.github.com/repos/Webklex/laravel-imap/zipball/57609df58c2f4ef625e4d90a47d8615cfb15f925", + "reference": "57609df58c2f4ef625e4d90a47d8615cfb15f925", "shasum": "" }, "require": { - "ext-ctype": "*", - "php": "^7.2 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" + "laravel/framework": ">=6.0.0", + "php": "^8.0.2", + "webklex/php-imap": "^6.2.0" }, "type": "library", "extra": { + "laravel": { + "aliases": { + "Client": "Webklex\\IMAP\\Facades\\Client" + }, + "providers": [ + "Webklex\\IMAP\\Providers\\LaravelServiceProvider" + ] + }, "branch-alias": { - "dev-master": "1.10-dev" + "dev-master": "1.0-dev" } }, "autoload": { "psr-4": { - "Webmozart\\Assert\\": "src/" + "Webklex\\IMAP\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -7383,21 +7568,119 @@ ], "authors": [ { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" + "name": "Malte Goldenbaum", + "email": "github@webklex.com", + "role": "Developer" } ], - "description": "Assertions to validate method input/output with nice error messages.", + "description": "Laravel IMAP client", + "homepage": "https://github.com/webklex/laravel-imap", "keywords": [ - "assert", - "check", - "validate" + "idle", + "imap", + "laravel", + "laravel-imap", + "mail", + "oauth", + "pop3", + "webklex" ], "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" + "issues": "https://github.com/Webklex/laravel-imap/issues", + "source": "https://github.com/Webklex/laravel-imap/tree/6.2.0" }, - "time": "2022-06-03T18:03:27+00:00" + "funding": [ + { + "url": "https://www.buymeacoffee.com/webklex", + "type": "custom" + }, + { + "url": "https://ko-fi.com/webklex", + "type": "ko_fi" + } + ], + "time": "2025-04-25T06:06:46+00:00" + }, + { + "name": "webklex/php-imap", + "version": "6.2.0", + "source": { + "type": "git", + "url": "https://github.com/Webklex/php-imap.git", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "ext-iconv": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-zip": "*", + "illuminate/pagination": ">=5.0.0", + "nesbot/carbon": "^2.62.1|^3.2.4", + "php": "^8.0.2", + "symfony/http-foundation": ">=2.8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5.10" + }, + "suggest": { + "symfony/mime": "Recomended for better extension support", + "symfony/var-dumper": "Usefull tool for debugging" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webklex\\PHPIMAP\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Malte Goldenbaum", + "email": "github@webklex.com", + "role": "Developer" + } + ], + "description": "PHP IMAP client", + "homepage": "https://github.com/webklex/php-imap", + "keywords": [ + "imap", + "mail", + "php-imap", + "pop3", + "webklex" + ], + "support": { + "issues": "https://github.com/Webklex/php-imap/issues", + "source": "https://github.com/Webklex/php-imap/tree/6.2.0" + }, + "funding": [ + { + "url": "https://www.buymeacoffee.com/webklex", + "type": "custom" + }, + { + "url": "https://ko-fi.com/webklex", + "type": "ko_fi" + } + ], + "time": "2025-04-25T06:02:37+00:00" }, { "name": "wire-elements/modal", diff --git a/config/imap.php b/config/imap.php new file mode 100644 index 0000000..affa78d --- /dev/null +++ b/config/imap.php @@ -0,0 +1,257 @@ + env('IMAP_DEFAULT_ACCOUNT', 'webmail'), + + /* + |-------------------------------------------------------------------------- + | Default date format + |-------------------------------------------------------------------------- + | + | The default date format is used to convert any given Carbon::class object into a valid date string. + | These are currently known working formats: "d-M-Y", "d-M-y", "d M y" + | + */ + 'date_format' => 'd-M-Y', + + /* + |-------------------------------------------------------------------------- + | Available IMAP accounts + |-------------------------------------------------------------------------- + | + | Please list all IMAP accounts which you are planning to use within the + | array below. + | + */ + 'accounts' => [ + + 'webmail' => [ + 'host' => env('WEBMAIL_IMAP_HOST', '127.0.0.1'), + 'port' => env('WEBMAIL_IMAP_PORT', 993), + 'protocol' => 'imap', + 'encryption' => env('WEBMAIL_IMAP_ENCRYPTION', 'ssl'), + 'validate_cert' => false, + 'username' => null, // wird pro Request gesetzt + 'password' => null, // wird pro Request gesetzt + 'authentication' => null, + ], + + 'default' => [// account identifier + 'host' => env('IMAP_HOST', 'localhost'), + 'port' => env('IMAP_PORT', 993), + 'protocol' => env('IMAP_PROTOCOL', 'imap'), //might also use imap, [pop3 or nntp (untested)] + 'encryption' => env('IMAP_ENCRYPTION', 'ssl'), // Supported: false, 'ssl', 'tls', 'notls', 'starttls' + 'validate_cert' => env('IMAP_VALIDATE_CERT', true), + 'username' => env('IMAP_USERNAME', 'root@example.com'), + 'password' => env('IMAP_PASSWORD', ''), + 'authentication' => env('IMAP_AUTHENTICATION', null), + 'proxy' => [ + 'socket' => null, + 'request_fulluri' => false, + 'username' => null, + 'password' => null, + ], + "timeout" => 30, + "extensions" => [] + ], + + /* + 'gmail' => [ // account identifier + 'host' => 'imap.gmail.com', + 'port' => 993, + 'encryption' => 'ssl', + 'validate_cert' => true, + 'username' => 'example@gmail.com', + 'password' => 'PASSWORD', + 'authentication' => 'oauth', + ], + + 'another' => [ // account identifier + 'host' => '', + 'port' => 993, + 'encryption' => false, + 'validate_cert' => true, + 'username' => '', + 'password' => '', + 'authentication' => null, + ] + */ + ], + + /* + |-------------------------------------------------------------------------- + | Available IMAP options + |-------------------------------------------------------------------------- + | + | Available php imap config parameters are listed below + | -Delimiter (optional): + | This option is only used when calling $oClient-> + | You can use any supported char such as ".", "/", (...) + | -Fetch option: + | IMAP::FT_UID - Message marked as read by fetching the body message + | IMAP::FT_PEEK - Fetch the message without setting the "seen" flag + | -Fetch sequence id: + | IMAP::ST_UID - Fetch message components using the message uid + | IMAP::ST_MSGN - Fetch message components using the message number + | -Body download option + | Default TRUE + | -Flag download option + | Default TRUE + | -Soft fail + | Default FALSE - Set to TRUE if you want to ignore certain exception while fetching bulk messages + | -RFC822 + | Default TRUE - Set to FALSE to prevent the usage of \imap_rfc822_parse_headers(). + | See https://github.com/Webklex/php-imap/issues/115 for more information. + | -Debug enable to trace communication traffic + | -UID cache enable the UID cache + | -Fallback date is used if the given message date could not be parsed + | -Boundary regex used to detect message boundaries. If you are having problems with empty messages, missing + | attachments or anything like this. Be advised that it likes to break which causes new problems.. + | -Message key identifier option + | You can choose between the following: + | 'id' - Use the MessageID as array key (default, might cause hickups with yahoo mail) + | 'number' - Use the message number as array key (isn't always unique and can cause some interesting behavior) + | 'list' - Use the message list number as array key (incrementing integer (does not always start at 0 or 1) + | 'uid' - Use the message uid as array key (isn't always unique and can cause some interesting behavior) + | -Fetch order + | 'asc' - Order all messages ascending (probably results in oldest first) + | 'desc' - Order all messages descending (probably results in newest first) + | -Disposition types potentially considered an attachment + | Default ['attachment', 'inline'] + | -Common folders + | Default folder locations and paths assumed if none is provided + | -Open IMAP options: + | DISABLE_AUTHENTICATOR - Disable authentication properties. + | Use 'GSSAPI' if you encounter the following + | error: "Kerberos error: No credentials cache + | file found (try running kinit) (...)" + | or ['GSSAPI','PLAIN'] if you are using outlook mail + | + */ + 'options' => [ + 'delimiter' => '/', + 'fetch' => \Webklex\PHPIMAP\IMAP::FT_PEEK, + 'sequence' => \Webklex\PHPIMAP\IMAP::ST_UID, + 'fetch_body' => true, + 'fetch_flags' => true, + 'soft_fail' => false, + 'rfc822' => true, + 'debug' => false, + 'uid_cache' => true, + // 'fallback_date' => "01.01.1970 00:00:00", + 'boundary' => '/boundary=(.*?(?=;)|(.*))/i', + 'message_key' => 'list', + 'fetch_order' => 'asc', + 'dispositions' => ['attachment', 'inline'], + 'common_folders' => [ + "root" => "INBOX", + "junk" => "INBOX/Junk", + "draft" => "INBOX/Drafts", + "sent" => "INBOX/Sent", + "trash" => "INBOX/Trash", + ], + 'open' => [ + // 'DISABLE_AUTHENTICATOR' => 'GSSAPI' + ] + ], + + /** + * |-------------------------------------------------------------------------- + * | Available decoding options + * |-------------------------------------------------------------------------- + * | + * | Available php imap config parameters are listed below + * | -options: Decoder options (currently only the message subject and attachment name decoder can be set) + * | 'utf-8' - Uses imap_utf8($string) to decode a string + * | 'mimeheader' - Uses mb_decode_mimeheader($string) to decode a string + * | -decoder: Decoder to be used. Can be replaced by custom decoders if needed. + * | 'header' - HeaderDecoder + * | 'message' - MessageDecoder + * | 'attachment' - AttachmentDecoder + */ + 'decoding' => [ + 'options' => [ + 'header' => 'utf-8', // mimeheader + 'message' => 'utf-8', // mimeheader + 'attachment' => 'utf-8' // mimeheader + ], + 'decoder' => [ + 'header' => \Webklex\PHPIMAP\Decoder\HeaderDecoder::class, + 'message' => \Webklex\PHPIMAP\Decoder\MessageDecoder::class, + 'attachment' => \Webklex\PHPIMAP\Decoder\AttachmentDecoder::class + ] + ], + + /* + |-------------------------------------------------------------------------- + | Available flags + |-------------------------------------------------------------------------- + | + | List all available / supported flags. Set to null to accept all given flags. + */ + 'flags' => ['recent', 'flagged', 'answered', 'deleted', 'seen', 'draft'], + + /* + |-------------------------------------------------------------------------- + | Available events + |-------------------------------------------------------------------------- + | + */ + 'events' => [ + "message" => [ + 'new' => \Webklex\IMAP\Events\MessageNewEvent::class, + 'moved' => \Webklex\IMAP\Events\MessageMovedEvent::class, + 'copied' => \Webklex\IMAP\Events\MessageCopiedEvent::class, + 'deleted' => \Webklex\IMAP\Events\MessageDeletedEvent::class, + 'restored' => \Webklex\IMAP\Events\MessageRestoredEvent::class, + ], + "folder" => [ + 'new' => \Webklex\IMAP\Events\FolderNewEvent::class, + 'moved' => \Webklex\IMAP\Events\FolderMovedEvent::class, + 'deleted' => \Webklex\IMAP\Events\FolderDeletedEvent::class, + ], + "flag" => [ + 'new' => \Webklex\IMAP\Events\FlagNewEvent::class, + 'deleted' => \Webklex\IMAP\Events\FlagDeletedEvent::class, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Available masking options + |-------------------------------------------------------------------------- + | + | By using your own custom masks you can implement your own methods for + | a better and faster access and less code to write. + | + | Checkout the two examples custom_attachment_mask and custom_message_mask + | for a quick start. + | + | The provided masks below are used as the default masks. + */ + 'masks' => [ + 'message' => \Webklex\PHPIMAP\Support\Masks\MessageMask::class, + 'attachment' => \Webklex\PHPIMAP\Support\Masks\AttachmentMask::class + ] +]; diff --git a/config/mailwolt.php b/config/mailwolt.php index 22886b0..5879fe6 100644 --- a/config/mailwolt.php +++ b/config/mailwolt.php @@ -6,6 +6,7 @@ return [ 'mail' => env('MTA_SUB'), 'ui' => env('UI_SUB'), 'webmail' => env('WEBMAIL_SUB'), + 'webmail_host' => env('WEBMAIL_DOMAIN'), ], 'language' => [ diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/database/migrations/2026_04_21_014834_add_last_login_to_users_table.php b/database/migrations/2026_04_21_014834_add_last_login_to_users_table.php new file mode 100644 index 0000000..3131c31 --- /dev/null +++ b/database/migrations/2026_04_21_014834_add_last_login_to_users_table.php @@ -0,0 +1,22 @@ +timestamp('last_login_at')->nullable()->after('role'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('last_login_at'); + }); + } +}; diff --git a/database/migrations/2026_04_21_020816_create_personal_access_tokens_table.php b/database/migrations/2026_04_21_020816_create_personal_access_tokens_table.php new file mode 100644 index 0000000..7b0a5ab --- /dev/null +++ b/database/migrations/2026_04_21_020816_create_personal_access_tokens_table.php @@ -0,0 +1,34 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->boolean('sandbox')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_04_21_023044_create_webhooks_table.php b/database/migrations/2026_04_21_023044_create_webhooks_table.php new file mode 100644 index 0000000..6a97794 --- /dev/null +++ b/database/migrations/2026_04_21_023044_create_webhooks_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('name'); + $table->string('url'); + $table->json('events'); + $table->string('secret', 64); + $table->boolean('is_active')->default(true); + $table->timestamp('last_triggered_at')->nullable(); + $table->unsignedSmallInteger('last_status')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhooks'); + } +}; diff --git a/database/migrations/2026_04_21_023809_create_sandbox_mails_table.php b/database/migrations/2026_04_21_023809_create_sandbox_mails_table.php new file mode 100644 index 0000000..7bac108 --- /dev/null +++ b/database/migrations/2026_04_21_023809_create_sandbox_mails_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('message_id')->nullable()->index(); + $table->string('from_address'); + $table->string('from_name')->nullable(); + $table->json('to_addresses'); + $table->string('subject')->nullable(); + $table->longText('body_text')->nullable(); + $table->longText('body_html')->nullable(); + $table->text('raw_headers')->nullable(); + $table->boolean('is_read')->default(false); + $table->timestamp('received_at'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sandbox_mails'); + } +}; diff --git a/database/migrations/2026_04_21_173122_add_type_to_backup_jobs_table.php b/database/migrations/2026_04_21_173122_add_type_to_backup_jobs_table.php new file mode 100644 index 0000000..131fb54 --- /dev/null +++ b/database/migrations/2026_04_21_173122_add_type_to_backup_jobs_table.php @@ -0,0 +1,25 @@ +string('type', 10)->default('backup')->after('policy_id'); + }); + } + + public function down(): void + { + Schema::table('backup_jobs', function (Blueprint $table) { + $table->dropColumn('type'); + }); + } +}; diff --git a/database/migrations/2026_04_22_000001_add_webmail_settings_to_mail_users.php b/database/migrations/2026_04_22_000001_add_webmail_settings_to_mail_users.php new file mode 100644 index 0000000..c27d9a7 --- /dev/null +++ b/database/migrations/2026_04_22_000001_add_webmail_settings_to_mail_users.php @@ -0,0 +1,25 @@ +string('forward_to', 255)->nullable()->after('can_login'); + $table->boolean('vacation_enabled')->default(false)->after('forward_to'); + $table->string('vacation_subject', 255)->nullable()->after('vacation_enabled'); + $table->text('vacation_body')->nullable()->after('vacation_subject'); + $table->json('sieve_discard_senders')->nullable()->after('vacation_body'); + }); + } + + public function down(): void + { + Schema::table('mail_users', function (Blueprint $table) { + $table->dropColumn(['forward_to','vacation_enabled','vacation_subject','vacation_body','sieve_discard_senders']); + }); + } +}; diff --git a/database/migrations/2026_04_22_000002_add_webmail_prefs_to_mail_users.php b/database/migrations/2026_04_22_000002_add_webmail_prefs_to_mail_users.php new file mode 100644 index 0000000..2b49943 --- /dev/null +++ b/database/migrations/2026_04_22_000002_add_webmail_prefs_to_mail_users.php @@ -0,0 +1,24 @@ +json('webmail_prefs')->nullable()->after('sieve_discard_senders'); + $table->text('signature')->nullable()->after('webmail_prefs'); + $table->json('sieve_filter_rules')->nullable()->after('signature'); + }); + } + + public function down(): void + { + Schema::table('mail_users', function (Blueprint $table) { + $table->dropColumn(['webmail_prefs', 'signature', 'sieve_filter_rules']); + }); + } +}; diff --git a/installer.sh b/installer.sh new file mode 100644 index 0000000..534d609 --- /dev/null +++ b/installer.sh @@ -0,0 +1,685 @@ +#!/usr/bin/env bash +set -euo pipefail + +############################################## +# MailWolt # +# Bootstrap Installer v1.0 # +############################################## + +# ===== Branding & Pfade (einmal ändern, überall wirksam) ===== +APP_NAME="${APP_NAME:-MailWolt}" + +APP_ENV="production" +APP_DEBUG="false" + +APP_USER="${APP_USER:-mailwolt}" +APP_GROUP="${APP_GROUP:-www-data}" + +# Projektverzeichnis +APP_DIR="/var/www/${APP_USER}" + +# Konfigbasis (statt /etc/falcomail -> /etc/${APP_USER}) +CONF_BASE="/etc/${APP_USER}" +CERT_DIR="${CONF_BASE}/ssl" +CERT="${CERT_DIR}/cert.pem" +KEY="${CERT_DIR}/key.pem" + +# Nginx vHost +NGINX_SITE="/etc/nginx/sites-available/${APP_USER}.conf" +NGINX_SITE_LINK="/etc/nginx/sites-enabled/${APP_USER}.conf" + +# DB +DB_NAME="${DB_NAME:-${APP_USER}}" +DB_USER="${DB_USER:-${APP_USER}}" +# DB_PASS muss vorher generiert oder gesetzt werden + +# Git (Platzhalter; später ersetzen) +GIT_REPO="${GIT_REPO:-https://example.com/your-repo-placeholder.git}" +GIT_BRANCH="${GIT_BRANCH:-main}" + +# Node Setup (deb = apt Pakete; nodesource = NodeSource LTS) +NODE_SETUP="${NODE_SETUP:-deb}" + +# ===== Styling ===== +GREEN="\033[1;32m"; YELLOW="\033[1;33m"; RED="\033[1;31m"; CYAN="\033[1;36m"; GREY="\033[0;90m"; NC="\033[0m" +BAR="──────────────────────────────────────────────────────────────────────────────" + +header() { + echo -e "${CYAN}${BAR}${NC}" + echo -e "${CYAN} 888b d888 d8b 888 888 888 888 888 ${NC}" + echo -e "${CYAN} 8888b d8888 Y8P 888 888 o 888 888 888 ${NC}" + echo -e "${CYAN} 88888b.d88888 888 888 d8b 888 888 888 ${NC}" + echo -e "${CYAN} 888Y88888P888 8888b. 888 888 888 d888b 888 .d88b. 888 888888 ${NC}" + echo -e "${CYAN} 888 Y888P 888 '88b 888 888 888d88888b888 d88''88b 888 888 ${NC}" + echo -e "${CYAN} 888 Y8P 888 .d888888 888 888 88888P Y88888 888 888 888 888 ${NC}" + echo -e "${CYAN} 888 ' 888 888 888 888 888 8888P Y8888 Y88..88P 888 Y88b. ${NC}" + echo -e "${CYAN} 888 888 'Y888888 888 888 888P Y888 'Y88P' 888 'Y888 ${NC}" + echo -e "${CYAN}${BAR}${NC}" + echo +} + + +footer_ok() { + local ip="$1" + local app_name="${2:-$APP_NAME}" + local app_dir="${3:-$APP_DIR}" + local nginx_site="${4:-$NGINX_SITE}" + local cert_dir="${5:-$CERT_DIR}" + + echo + echo -e "${GREEN}${BAR}${NC}" + echo -e "${GREEN} ✔ ${app_name} Bootstrap erfolgreich abgeschlossen${NC}" + echo -e "${GREEN}${BAR}${NC}" + echo -e " Aufruf: ${CYAN}http://${ip}${NC} ${GREY}| https://${ip}${NC}" + echo -e " Laravel Root: ${GREY}${app_dir}${NC}" + echo -e " Nginx Site: ${GREY}${nginx_site}${NC}" + echo -e " Self-signed Cert: ${GREY}${cert_dir}/{cert.pem,key.pem}${NC}" + echo -e " Postfix/Dovecot Ports aktiv: ${GREY}25, 465, 587, 110, 995, 143, 993${NC}" + echo -e " Rspamd/OpenDKIM: ${GREY}aktiv (DKIM-Keys später im Wizard)${NC}" + echo -e " Monit (Watchdog): ${GREY}installiert, NICHT aktiviert${NC}" + echo -e "${GREEN}${BAR}${NC}" + echo +} + +log() { echo -e "${GREEN}[+]${NC} $*"; } +warn() { echo -e "${YELLOW}[!]${NC} $*"; } +err() { echo -e "${RED}[x]${NC} $*"; } +require_root() { [ "$(id -u)" -eq 0 ] || { err "Bitte als root ausführen."; exit 1; }; } + +# ===== IP ermitteln ===== +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:-}" ]] || { err "Konnte Server-IP nicht ermitteln."; exit 1; } + echo "$ip" +} + +# ===== Secrets ===== +gen() { head -c 512 /dev/urandom | tr -dc 'A-Za-z0-9' | head -c "${1:-28}" || true; } +pw() { gen 28; } +short() { gen 16; } + +# ===== Start ===== +require_root +header + +SERVER_IP="$(detect_ip)" +MAIL_HOSTNAME="${MAIL_HOSTNAME:-"bootstrap.local"}" # Wizard setzt später FQDN +TZ="${TZ:-""}" # leer; Wizard setzt final + +echo -e "${GREY}Server-IP erkannt: ${SERVER_IP}${NC}" +[ -n "$TZ" ] && { ln -fs "/usr/share/zoneinfo/${TZ}" /etc/localtime || true; } + +log "Paketquellen aktualisieren…" +export DEBIAN_FRONTEND=noninteractive +apt-get update -y + +# ---- MariaDB-Workaround (fix für mariadb-common prompt) ---- +log "MariaDB-Workaround vorbereiten…" +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 + +# ---- Basis-Pakete installieren ---- +log "Pakete installieren… (dies kann einige Minuten dauern)" +#apt-get install -y \ +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 \ + certbot python3-certbot-nginx \ + fail2ban \ + ca-certificates rsyslog sudo openssl netcat-openbsd monit + +# ===== Verzeichnisse / User ===== +log "Verzeichnisse und Benutzer anlegen…" +mkdir -p ${CERT_DIR} /etc/postfix /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 www-data "$APP_USER" + +# ===== Self-signed TLS (SAN = IP) ===== +CERT="${CERT_DIR}/cert.pem" +KEY="${CERT_DIR}/key.pem" +OSSL_CFG="${CERT_DIR}/openssl.cnf" + +if [ ! -s "$CERT" ] || [ ! -s "$KEY" ]; then + log "Erzeuge Self-Signed TLS Zertifikat (SAN=IP:${SERVER_IP})…" + cat > "$OSSL_CFG" < /etc/postfix/sql/mysql-virtual-mailbox-maps.cf < /etc/postfix/sql/mysql-virtual-alias-maps.cf < /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 < /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 + +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 + +cat > /etc/dovecot/conf.d/10-ssl.conf < /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 +# KeyTable / SigningTable später im Wizard +CONF +systemctl enable --now opendkim || true + +# ===== Redis ===== +systemctl enable --now redis-server + +# ===== Nginx: Laravel vHost (80/443) ===== +log "Nginx konfigurieren…" +rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default || true + +PHP_FPM_SOCK="/run/php/php-fpm.sock" +[ -S "/run/php/php8.2-fpm.sock" ] && PHP_FPM_SOCK="/run/php/php8.2-fpm.sock" + +cat > ${NGINX_SITE} </dev/null || true)" ]; then + sudo -u "$APP_USER" -H bash -lc "cd /var/www && COMPOSER_ALLOW_SUPERUSER=0 composer create-project laravel/laravel ${APP_USER} --no-interaction" +fi + +APP_URL="http://${SERVER_IP}" +sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && cp -n .env.example .env || true" +sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && php artisan key:generate --force" + +# .env befüllen (MariaDB & Redis Sessions) +sed -i "s|^APP_NAME=.*|APP_NAME=${APP_NAME}|g" "${APP_DIR}/.env" +sed -i "s|^APP_URL=.*|APP_URL=${APP_URL}|g" "${APP_DIR}/.env" +sed -i "s|^APP_ENV=.*|APP_ENV=${APP_ENV}|g" "${APP_DIR}/.env" +sed -i "s|^APP_DEBUG=.*|APP_DEBUG=${APP_DEBUG}|g" "${APP_DIR}/.env" + +sed -i "s|^DB_CONNECTION=.*|DB_CONNECTION=mysql|g" "${APP_DIR}/.env" +sed -i -E "s|^[#[:space:]]*DB_HOST=.*|DB_HOST=127.0.0.1|g" "${APP_DIR}/.env" +sed -i -E "s|^[#[:space:]]*DB_PORT=.*|DB_PORT=3306|g" "${APP_DIR}/.env" +sed -i -E "s|^[#[:space:]]*DB_DATABASE=.*|DB_DATABASE=${DB_NAME}|g" "${APP_DIR}/.env" +sed -i -E "s|^[#[:space:]]*DB_USERNAME=.*|DB_USERNAME=${DB_USER}|g" "${APP_DIR}/.env" +sed -i -E "s|^[#[:space:]]*DB_PASSWORD=.*|DB_PASSWORD=${DB_PASS}|g" "${APP_DIR}/.env" + +sed -i "s|^CACHE_DRIVER=.*|CACHE_DRIVER=redis|g" "${APP_DIR}/.env" +sed -i -E "s|^[#[:space:]]*CACHE_PREFIX=.*|CACHE_PREFIX=${APP_USER}|g" "${APP_DIR}/.env" + +sed -i "s|^SESSION_DRIVER=.*|SESSION_DRIVER=redis|g" "${APP_DIR}/.env" +sed -i "s|^REDIS_HOST=.*|REDIS_HOST=127.0.0.1|g" "${APP_DIR}/.env" +sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=null|g" "${APP_DIR}/.env" +sed -i "s|^REDIS_PORT=.*|REDIS_PORT=6379|g" "${APP_DIR}/.env" + + +# === Bootstrap-Admin für den ersten Login (nur .env, kein DB-User) === +BOOTSTRAP_USER="${APP_USER}" +BOOTSTRAP_EMAIL="${APP_USER}@localhost" +BOOTSTRAP_PASS="$(openssl rand -base64 18 | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 12)" +BOOTSTRAP_HASH="$(php -r 'echo password_hash($argv[1], PASSWORD_BCRYPT);' "$BOOTSTRAP_PASS")" + +grep -q '^SETUP_PHASE=' "${APP_DIR}/.env" || echo "SETUP_PHASE=bootstrap" >> "${APP_DIR}/.env" +sed -i "s|^SETUP_PHASE=.*|SETUP_PHASE=bootstrap|g" "${APP_DIR}/.env" + +grep -q '^BOOTSTRAP_ADMIN_USER=' "${APP_DIR}/.env" || echo "BOOTSTRAP_ADMIN_USER=${BOOTSTRAP_USER}" >> "${APP_DIR}/.env" +sed -i "s|^BOOTSTRAP_ADMIN_USER=.*|BOOTSTRAP_ADMIN_USER=${BOOTSTRAP_USER}|g" "${APP_DIR}/.env" + +grep -q '^BOOTSTRAP_ADMIN_EMAIL=' "${APP_DIR}/.env" || echo "BOOTSTRAP_ADMIN_EMAIL=${BOOTSTRAP_EMAIL}" >> "$> +sed -i "s|^BOOTSTRAP_ADMIN_EMAIL=.*|BOOTSTRAP_ADMIN_EMAIL=${BOOTSTRAP_EMAIL}|g" "${APP_DIR}/.env" + +grep -q '^BOOTSTRAP_ADMIN_PASSWORD_HASH=' "${APP_DIR}/.env" || echo "BOOTSTRAP_ADMIN_PASSWORD_HASH=${BOOTSTRAP_HASH}" >> "${APP_DIR}/.env" +sed -i "s|^BOOTSTRAP_ADMIN_PASSWORD_HASH=.*|BOOTSTRAP_ADMIN_PASSWORD_HASH=${BOOTSTRAP_HASH}|g" "${APP_DIR}/.env" + +# ===== Node/NPM installieren (für Vite/Tailwind Build) ===== +if [ "$NODE_SETUP" = "nodesource" ]; then + # LTS via NodeSource (empfohlen für aktuelle LTS) + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -y nodejs +else + # Debian-Repo (ok für Basics, aber u.U. älter) + apt-get install -y nodejs npm +fi + +# ===== Projekt aus Git holen (PLATZHALTER) ===== +# Falls dein Repo später bereitsteht, überschreibt dieser Block das leere/Standard-Laravel. +if [ "${GIT_REPO}" != "https://example.com/your-repo-placeholder.git" ]; then + 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 "cd ${APP_DIR} && git fetch --depth=1 origin ${GIT_BRANCH} && git checkout ${GIT_BRANCH} && git pull --ff-only" + fi +fi + +# ===== Frontend Build (nur wenn package.json existiert) ===== +if [ -f "${APP_DIR}/package.json" ]; then + sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm ci --no-audit --no-fund || npm install" + # Prod-Build (Vite/Tailwind) + sudo -u "$APP_USER" -H bash -lc "cd ${APP_DIR} && npm run build || npm run build:prod || true" +fi + + +# ===== App-User/Gruppen & Rechte (am ENDE ausführen) ===== +APP_USER="${APP_USER:-${APP_NAME}app}" +APP_GROUP="${APP_GROUP}" +APP_PW="${APP_PW:-changeme123}" +APP_DIR="${APP_DIR}" + +# User anlegen (nur falls noch nicht vorhanden) + Passwort setzen + Gruppe +if ! id -u "$APP_USER" >/dev/null 2>&1; then + adduser --disabled-password --gecos "" "$APP_USER" + echo "${APP_USER}:${APP_PW}" | chpasswd +fi +usermod -a -G "$APP_GROUP" "$APP_USER" + +# Besitz & Rechte +chown -R "$APP_USER":"$APP_GROUP" "$APP_DIR" +find "$APP_DIR" -type d -exec chmod 775 {} \; +find "$APP_DIR" -type f -exec chmod 664 {} \; +chmod -R 775 "$APP_DIR"/storage "$APP_DIR"/bootstrap/cache +if command -v setfacl >/dev/null 2>&1; then + setfacl -R -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX \ + "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true + setfacl -dR -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX \ + "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true +fi + +echo -e "${YELLOW}[i] App-User: ${APP_USER} Passwort: ${APP_PW}${NC}" + + +# Optional: ACLs, falls verfügbar (robuster bei gemischten Schreibzugriffen) +if command -v setfacl >/dev/null 2>&1; then + setfacl -R -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX \ + "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true + setfacl -dR -m u:${APP_USER}:rwX,g:${APP_GROUP}:rwX \ + "${APP_DIR}/storage" "${APP_DIR}/bootstrap/cache" || true +fi + +grep -q 'umask 002' /home/${APP_USER}/.profile 2>/dev/null || echo 'umask 002' >> /home/${APP_USER}/.profile +grep -q 'umask 002' /home/${APP_USER}/.bashrc 2>/dev/null || echo 'umask 002' >> /home/${APP_USER}/.bashrc + +# 7) npm respektiert umask – zur Sicherheit direkt setzen (für APP_USER) +sudo -u "$APP_USER" -H bash -lc "npm config set umask 0002" >/dev/null 2>&1 || true + +# 8) PHP-FPM-Worker laufen als www-data (Standard). Stelle sicher, dass der FPM-Socket group-writable ist: +PHPV=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;') +FPM_POOL="/etc/php/${PHPV}/fpm/pool.d/www.conf" +if [ -f "$FPM_POOL" ]; then + sed -i 's/^;*listen\.owner.*/listen.owner = www-data/' "$FPM_POOL" + sed -i 's/^;*listen\.group.*/listen.group = www-data/' "$FPM_POOL" + sed -i 's/^;*listen\.mode.*/listen.mode = 0660/' "$FPM_POOL" + systemctl restart php${PHPV}-fpm || true +fi + +# 9) Optional: deinem Shell-/IDE-User ebenfalls Schreibrechte geben +IDE_USER="${SUDO_USER:-}" +if [ -n "$IDE_USER" ] && id "$IDE_USER" >/dev/null 2>&1 && command -v setfacl >/dev/null 2>&1; then + usermod -a -G "$APP_GROUP" "$IDE_USER" || true + setfacl -R -m u:${IDE_USER}:rwX "$APP_DIR" + setfacl -dR -m u:${IDE_USER}:rwX "$APP_DIR" + echo -e "${YELLOW}[i]${NC} Benutzer '${IDE_USER}' wurde für Schreibzugriff freigeschaltet (ACL + Gruppe ${APP_GROUP})." +fi + +# Webstack neu laden +systemctl reload nginx || true +systemctl restart php*-fpm || true + +# Hinweis zur neuen Gruppenzugehörigkeit + +echo -e "${YELLOW}[i]${NC} SHELL: Du kannst dich nun als Benutzer '${APP_USER}' mit dem Passwort '${APP_PW}' anmelden." +echo -e "${YELLOW}[i]${NC} Hinweis: Nach dem ersten Login solltest du das Passwort mit 'passwd ${APP_USER}' ändern." +echo -e "${YELLOW}[i]${NC} Damit die Gruppenrechte (${APP_GROUP}) aktiv werden, bitte einmal ab- und wieder anmelden." + +# ===== Monit (Watchdog) – installiert, aber NICHT aktiviert ===== +log "Monit (Watchdog) installieren (deaktiviert)" +cat > /etc/monit/monitrc <<'EOF' +set daemon 60 +set logfile syslog facility log_daemon +# set mailserver + set alert werden im Wizard gesetzt + +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 + +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 + +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 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 +EOF +chmod 600 /etc/monit/monitrc +systemctl disable --now monit || true +apt-mark hold monit >/dev/null 2>&1 || true + +# ===== Smoke-Test (alle Ports, mit Timeouts) ===== +log "Smoke-Test (Ports & Banner):" +set +e +printf "[25] " && timeout 6s bash -lc 'printf "EHLO localhost\r\nQUIT\r\n" | nc -v -w 4 127.0.0.1 25 2>&1' || true +printf "[465] " && timeout 6s openssl s_client -connect 127.0.0.1:465 -brief -quiet &1' || true +printf "[995] " && timeout 6s openssl s_client -connect 127.0.0.1:995 -brief -quiet &1' || true +printf "[993] " && timeout 6s openssl s_client -connect 127.0.0.1:993 -brief -quiet 'ph-circle', 'label' => '', 'color' => 'white']) +
+
+ +
+ {!! $label !!} + +
diff --git a/resources/views/components/wm-toggle.blade.php b/resources/views/components/wm-toggle.blade.php new file mode 100644 index 0000000..63aabb7 --- /dev/null +++ b/resources/views/components/wm-toggle.blade.php @@ -0,0 +1,8 @@ +@props(['label' => '']) + diff --git a/resources/views/landing/index.blade.php b/resources/views/landing/index.blade.php new file mode 100644 index 0000000..643590f --- /dev/null +++ b/resources/views/landing/index.blade.php @@ -0,0 +1,566 @@ + + + + + +Mailwolt — Self-Hosted Mail Server Control Panel + + + + + + + + + +
+
+
+
+ Self-Hosted · Open Source · Production Ready +
+

Dein Mailserver.
Vollständig unter Kontrolle.

+

Mailwolt ist ein modernes Control Panel für selbst gehostete Mailserver. Verwalte Mailboxen, Domains, Aliases, SSL-Zertifikate und mehr — mit einer sauberen, schnellen Oberfläche.

+ + +
+
+ + MIT Lizenz +
+
+ + Laravel + Livewire +
+
+ + 2FA + Rollen +
+
+ + REST API v1 +
+
+ + +
+
+
+
+
+
+
+
mail.example.com/dashboard
+
+
+
+
Mail
+
Mailboxen
+
Aliases
+
Domains
+
Sicherheit
+
SSL/TLS
+
Fail2ban
+
System
+
API Keys
+
Webhooks
+
Benutzer
+
+
+
+
248
Mailboxen
+
12
Domains
+
91
Aliases
+
3
Queued
+
+
+
+
E-Mail
+
Domain
+
Quota
+
Status
+
+
+
alice@example.com
+
example.com
+
2.1 / 5 GB
+
Aktiv
+
+
+
bob@company.io
+
company.io
+
890 MB / 2 GB
+
Aktiv
+
+
+
dev@startup.dev
+
startup.dev
+
100 MB / 1 GB
+
Inaktiv
+
+
+
+
+
+
+
+
+ + +
+
+
+
Postfix + Dovecot
+
Rspamd Spamfilter
+
Let's Encrypt SSL
+
REST API v1
+
Mail-Sandbox
+
DKIM / SPF / DMARC
+
+
+
+ + +
+
+
+
Features
+

Alles was du brauchst.
Nichts was du nicht brauchst.

+

Mailwolt bündelt alle Werkzeuge um einen Mailserver professionell zu betreiben — ohne sich in Details zu verlieren.

+
+
+
+
+ +
+
Mailboxen & Aliases
+
Erstelle und verwalte Postfächer, Weiterleitungen und Gruppen-Aliases für beliebig viele Domains. Quota, Limits und Login-Rechte pro Box konfigurierbar.
+
IMAPQuotaGruppenCatch-All
+
+
+
+ +
+
Multi-Domain
+
Mehrere Domains auf einem Server. DKIM-Schlüssel, SPF und DMARC pro Domain verwalten — mit DNS-Vorlagen für schnelles Setup.
+
DKIMSPFDMARCDNSSEC
+
+
+
+ +
+
Zwei-Faktor-Auth
+
TOTP-basierte 2FA für jeden Account. Recovery-Codes für Notfälle, Setup-Wizard mit QR-Code, forcierbar für Admins.
+
TOTPGoogle AuthAuthy
+
+
+
+ +
+
Sicherheit & Monitoring
+
Fail2ban-Integration mit Whitelist/Blacklist-Verwaltung, SSL-Zertifikats-Übersicht, TLS-Cipher-Konfiguration und Audit-Logs.
+
Fail2banLet's EncryptAudit Log
+
+
+
+ +
+
REST API & Webhooks
+
Vollständige REST API v1 mit Scope-basierter Authentifizierung via API-Keys. Webhooks für Echtzeit-Events mit HMAC-SHA256 Signierung.
+
REST APIWebhooksHMACScopes
+
+
+
+ +
+
Mail-Sandbox
+
Fange ausgehende E-Mails ab und betrachte sie im eingebauten E-Mail-Client — ideal für Entwicklung und Tests. HTML-Preview, Plain-Text und Raw-Header direkt im Browser.
+
Postfix-PipeHTML PreviewDev-Mode
+
+
+
+
+ + +
+
+
+
+
Developer API
+

Automatisiere alles.

+

Die REST API v1 gibt dir vollständigen Zugriff auf Mailboxen, Aliases und Domains. Erstelle API Keys mit granularen Scopes — read-only oder mit Schreibrechten.

+
+ @foreach([ + ['Scope-basierte Keys', 'Jeder Key hat nur die Rechte die er braucht'], + ['Sandbox-Modus', 'Schreiboperationen simulieren ohne DB-Änderungen'], + ['HMAC-Webhooks', 'Events in Echtzeit an externe Systeme senden'], + ['/api/v1/me', 'Token-Identität und Scopes prüfen'], + ] as [$title, $desc]) +
+ +
{{ $title }} — {{ $desc }}
+
+ @endforeach +
+
+
+
+
+
+
+
+
+ bash — API Example +
+
# Mailbox erstellen
+curl -X POST https://mail.example.com/api/v1/mailboxes \
+  -H "Authorization: Bearer mwt_..." \
+  -H "Content-Type: application/json" \
+  -d '{
+    "email": "alice@example.com",
+    "password": "sicher123",
+    "quota_mb": 2048
+  }'
+
+# Antwort
+{
+  "data": {
+    "id": 42,
+    "email": "alice@example.com",
+    "quota_mb": 2048,
+    "is_active": true
+  }
+}
+
+
+
+
+ + +
+
+
+
Sicherheit
+

Sicherheit by Default.

+

Mailwolt liefert alle nötigen Werkzeuge um einen gehärteten Mailserver zu betreiben — ohne Expertenwissen voraussetzen zu müssen.

+
+
+
+
+ +
+
TOTP Zwei-Faktor-Auth
Schütze jeden Account mit einem zweiten Faktor. Kompatibel mit Google Authenticator, Authy und allen TOTP-Apps.
+
+
+
+ +
+
Fail2ban Integration
Angriffs-IPs direkt aus dem Panel sperren oder dauerhaft whitelisten. Echtzeit-Übersicht über aktive Bans und Jail-Status.
+
+
+
+ +
+
SSL/TLS Verwaltung
Let's Encrypt Zertifikate automatisch ausstellen und verlängern. TLS-Cipher-Konfiguration für optimale Kompatibilität und Sicherheit.
+
+
+
+ +
+
Rollen & Berechtigungen
Admin, Operator und Viewer — granulare Zugriffsrechte für Teams. Jeder sieht nur was er sehen darf.
+
+
+
+
+ + +
+
+
+
Preise
+

Einfache Preisgestaltung.

+

Starte kostenlos, wächst mit dir mit.

+
+
+
+
Community
+
€0 / Monat
+
Für Privatpersonen und kleine Setups.
+
+
    +
  • Bis 5 Domains
  • +
  • Bis 25 Mailboxen
  • +
  • REST API
  • +
  • 2FA & Audit Log
  • +
  • Webhooks
  • +
  • Priority Support
  • +
+ Kostenlos starten +
+ +
+
Enterprise
+
Auf Anfrage
+
Für Hoster, Agenturen und On-Premise-Installationen mit SLA.
+
+
    +
  • Alles aus Pro
  • +
  • White-Label Option
  • +
  • Dedicated Support / SLA
  • +
  • Custom Integrations
  • +
  • Migrations-Hilfe
  • +
  • Rechnungsstellung
  • +
+ Kontakt aufnehmen +
+
+
+
+ + +
+
+
+

Bereit loszulegen?

+

Installiere Mailwolt in wenigen Minuten und behalte die volle Kontrolle über deinen Mailserver.

+ +
+
+ + + + + + diff --git a/resources/views/layouts/dvx.blade.php b/resources/views/layouts/dvx.blade.php new file mode 100644 index 0000000..457aebf --- /dev/null +++ b/resources/views/layouts/dvx.blade.php @@ -0,0 +1,198 @@ + + + + + + @yield('title', config('app.name')) + @vite(['resources/css/app.css']) + @livewireStyles + + + +
+ +
+ + {{-- ═══ SIDEBAR ═══ --}} + + + {{-- ═══ MAIN ═══ --}} +
+ +
+
+ + @hasSection('breadcrumb-parent')@yield('breadcrumb-parent')@else{{ $breadcrumbParent ?? 'Dashboard' }}@endif + + @hasSection('breadcrumb')@yield('breadcrumb')@else{{ $breadcrumb ?? 'Übersicht' }}@endif +
+
+
Live
+ +
{{ gethostname() ?: '—' }}
+ +
+
+ +
+ @hasSection('content') + @yield('content') + @else + {{ $slot ?? '' }} + @endif +
+ +
+ +
+ +@vite(['resources/js/app.js']) +@livewireScripts +@livewire('wire-elements-modal') + + diff --git a/resources/views/layouts/webmail-login.blade.php b/resources/views/layouts/webmail-login.blade.php new file mode 100644 index 0000000..e2908d4 --- /dev/null +++ b/resources/views/layouts/webmail-login.blade.php @@ -0,0 +1,46 @@ + + + + + + {{ $title ?? 'Webmail' }} · {{ config('app.name') }} + @vite(['resources/css/app.css']) + @livewireStyles + + + +
+ + {{-- Brand --}} +
+
+ + + + +
+
Webmail
+
{{ config('app.name') }}
+
+ + {{-- Card --}} +
+ {{ $slot }} +
+ + {{-- Footer --}} +
+ © {{ date('Y') }} {{ config('app.name') }} +
+ +
+ +@vite(['resources/js/app.js']) +@livewireScripts + + diff --git a/resources/views/layouts/webmail.blade.php b/resources/views/layouts/webmail.blade.php new file mode 100644 index 0000000..2a0472b --- /dev/null +++ b/resources/views/layouts/webmail.blade.php @@ -0,0 +1,138 @@ + + + + + + {{ $title ?? 'Webmail' }} · {{ config('app.name') }} + @vite(['resources/css/app.css']) + @livewireStyles + + + + +
+ + {{-- Mobile Overlay --}} +
+ + {{-- ═══ SIDEBAR ═══ --}} + + + {{-- ═══ MAIN ═══ --}} +
+ {{-- Mobile Topbar --}} + +
+ {{ $slot }} +
+
+ +
+ + + +@vite(['resources/js/app.js']) +@livewireScripts + + diff --git a/resources/views/livewire/auth/two-fa-challenge.blade.php b/resources/views/livewire/auth/two-fa-challenge.blade.php new file mode 100644 index 0000000..88083e1 --- /dev/null +++ b/resources/views/livewire/auth/two-fa-challenge.blade.php @@ -0,0 +1,84 @@ +
+
+ + {{-- Logo --}} +
+
+
+ + + + +
+ MAILWOLT +
+
Zwei-Faktor-Authentifizierung
+
+ +
+ + @if(!$useRecovery) +
+
+ + + + + +
+
Authenticator-Code
+
6-stelligen Code aus deiner Authenticator-App eingeben
+
+ +
+ + @error('code')
{{ $message }}
@enderror + @if($error)
{{ $error }}
@endif +
+ + + +
+ +
+ + @else + +
+
Recovery-Code
+
Einen deiner gespeicherten Recovery-Codes eingeben
+
+ +
+ + @error('code')
{{ $message }}
@enderror + @if($error)
{{ $error }}
@endif +
+ + + +
+ +
+ @endif + +
+ +
+
diff --git a/resources/views/livewire/ui/domain/modal/domain-create-modal.blade.php b/resources/views/livewire/ui/domain/modal/domain-create-modal.blade.php index 592ae89..59b266b 100644 --- a/resources/views/livewire/ui/domain/modal/domain-create-modal.blade.php +++ b/resources/views/livewire/ui/domain/modal/domain-create-modal.blade.php @@ -1,271 +1,158 @@ -@push('modal.header') -
-
-
-

Domain hinzufügen

-

- Lege Limits & Quotas fest. DKIM & empfohlene DNS-Records werden automatisch vorbereitet. -

-
- -
-
-@endpush - -
-
- {{-- SECTION: Basisdaten --}} -
- {{-- Domain --}} -
- - - @error('domain')

{{ $message }}

@enderror -
- - {{-- Beschreibung --}} -
-
- - optional -
- - @error('description')

{{ $message }}

@enderror -
-
- - {{-- Tags --}} -
-
-
- - optional -
- - {{-- Liste der Tags --}} -
- @foreach($tags as $i => $t) -
-
- {{-- Label --}} -
- - -
- - {{-- Farbe (HEX + Picker) --}} -
- -
- - -
-
-
- - {{-- Palette + Entfernen --}} -
-
- Palette: - @foreach($tagPalette as $hex) - - @endforeach -
- -
-
- @endforeach -
- - {{-- Tag hinzufügen --}} -
- -
- - @error('tags.*.label')

{{ $message }}

@enderror - @error('tags.*.color')

{{ $message }}

@enderror -
-
- - {{-- SECTION: Limits (Anzahl) --}} -
-
-
- - - @error('max_aliases')

{{ $message }}

@enderror -
-
- - - @error('max_mailboxes')

{{ $message }}

@enderror -
-
-
- - {{-- SECTION: Quotas / Speicher --}} -
-
-
-
- - Voreinstellung -
- -

Startwert für neue Postfächer (pro Postfach änderbar).

- @error('default_quota_mb')

{{ $message }}

@enderror -
- -
-
- - Obergrenze - optional -
- -

-

- @error('max_quota_per_mailbox_mb')

{{ $message }}

@enderror -
- -
- - -
-
- - Verfügbar (nach Systemreserve): {{ number_format($available_mib) }} MiB -
-

Beachte: Summe aller Postfach-Quotas darf diese Domain-Größe - nicht überschreiten.

-
- @error('total_quota_mb')

{{ $message }}

@enderror -
-
-
- - {{-- SECTION: Versandlimits & Status --}} -
-
-
-
- - optional -
- - @error('rate_limit_per_hour')

{{ $message }}

@enderror -
- -
- - - -
- -{{--
--}} -{{-- --}} -{{--
--}} -
-
- - {{-- SECTION: DKIM --}} -
-
-
- - - @error('dkim_selector')

{{ $message }}

@enderror -
-
- - -

- - 2048 ist meist ideal. 4096 erzeugt sehr lange TXT-Records – DNS-Provider prüfen. -

- @error('dkim_bits')

{{ $message }}

@enderror -
-
-
+
+ +
+
+

Domain hinzufügen

+ DKIM & DNS-Records werden automatisch vorbereitet.
+
-@push('modal.footer') -
-
- +
- +
+ + + @error('domain')
{{ $message }}
@enderror +
+ +
+ + + @error('description')
{{ $message }}
@enderror +
+ +
+ +
+
+ + + @error('max_aliases')
{{ $message }}
@enderror +
+
+ + + @error('max_mailboxes')
{{ $message }}
@enderror
-@endpush + +
+ +
+
+ + + @error('default_quota_mb')
{{ $message }}
@enderror +
+
+ + + @error('max_quota_per_mailbox_mb')
{{ $message }}
@enderror +
+
+ + +
Verfügbar (nach Systemreserve): {{ number_format($available_mib) }} MiB
+ @error('total_quota_mb')
{{ $message }}
@enderror +
+
+ +
+ +
+
+ + + @error('rate_limit_per_hour')
{{ $message }}
@enderror +
+
+ + +
+
+ +
+ +
+
+ + + @error('dkim_selector')
{{ $message }}
@enderror +
+
+ + + @error('dkim_bits')
{{ $message }}
@enderror +
+
+ +
+ + {{-- Tags --}} +
+
+ Tags (optional) + +
+ + @if(count($tags)) +
+ @foreach($tags as $i => $t) +
+
+
+ + + @error("tags.$i.label")
{{ $message }}
@enderror +
+
+ +
+ + +
+ @error("tags.$i.color")
{{ $message }}
@enderror +
+
+
+ @foreach($tagPalette as $hex) + + @endforeach + +
+
+ @endforeach +
+ @endif +
+ +
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/domain/modal/domain-delete-modal.blade.php b/resources/views/livewire/ui/domain/modal/domain-delete-modal.blade.php index 5249859..fdf0e5f 100644 --- a/resources/views/livewire/ui/domain/modal/domain-delete-modal.blade.php +++ b/resources/views/livewire/ui/domain/modal/domain-delete-modal.blade.php @@ -1,67 +1,60 @@ -@push('modal.header') -
-
-

Domain löschen

- -
-
-@endpush - -
-
-
- - Diese Aktion kann nicht rückgängig gemacht werden. -
-
- -
-
- Du bist im Begriff, die Domain {{ $domain }} zu löschen. -
- -
- - {{ $mailboxes }} Postfächer - - - {{ $aliases }} Aliasse - -
- - @if($mailboxes > 0 || $aliases > 0) -
- Löschen ist aktuell blockiert: Entferne zuerst alle Postfächer und Aliasse. -
- @endif -
- -
- - +
+ +
+
+

Domain löschen

+ {{ $domain }}
+
-@push('modal.footer') -
-
- +
- @php $blocked = ($mailboxes > 0 || $aliases > 0); @endphp - -
+
+ + Diese Aktion kann nicht rückgängig gemacht werden.
-@endpush + +
+ Du bist im Begriff, die Domain {{ $domain }} zu löschen. +
+ +
+ + + {{ $mailboxes }} Postfächer + + + + {{ $aliases }} Aliasse + +
+ + @if($mailboxes > 0 || $aliases > 0) +
+ + Löschen blockiert: Entferne zuerst alle Postfächer und Aliasse. +
+ @endif + +
+ + +
+ +
+ +
+ + @php $blocked = ($mailboxes > 0 || $aliases > 0); @endphp + +
+ +
diff --git a/resources/views/livewire/ui/domain/modal/domain-dns-modal.blade.php b/resources/views/livewire/ui/domain/modal/domain-dns-modal.blade.php index 3ecb0c2..d47ee43 100644 --- a/resources/views/livewire/ui/domain/modal/domain-dns-modal.blade.php +++ b/resources/views/livewire/ui/domain/modal/domain-dns-modal.blade.php @@ -1,387 +1,137 @@ -{{-- resources/views/livewire/domains/dns-assistant-modal.blade.php --}} -@push('modal.header') -
- - TTL: {{ $ttl }} - +
-

DNS-Einträge

-

- Setze die folgenden Records für - {{ $zone }}. -

-
-@endpush - -
-
- {{-- Step 1: Mail-Records (domain-spezifisch) --}} -
-
- Step 1 - Mail-Records - - Absenderdomain +
+
+

DNS-Assistent

+ + {{ $zone }}  ·  TTL {{ $ttl }} -
- -
- @foreach ($dynamic as $r) -
-
-
- - {{ $r['type'] }} - - {{ $r['name'] }} -
-
- -
-
- -
-
{{ $r['value'] }}
- - @if($checked && ($r['state'] ?? 'neutral') !== 'ok' && !empty($r['display_actual'])) -
- Ist: - {{ $r['display_actual'] }} -
- @endif -
-
- @endforeach -
-
- - {{-- Step 2: Globale Infrastruktur (MTA-Host) --}} -
-
- Step 2 - Globale Infrastruktur (MTA-Host) - gilt für alle Domains -
- -
- @foreach ($static as $r) -
-{{--
--}} -{{--
--}} -{{-- {{ $r['type'] }}--}} -{{-- {{ $r['name'] }}--}} -{{--
--}} -{{--
--}} -{{-- --}} -{{--
--}} -{{--
--}} -
-
- - {{ $r['type'] }} - -
- {{ $r['name'] }} -
-
-
- -
-
-
-
{{ $r['value'] }}
- @if($checked && ($r['state'] ?? 'neutral') !== 'ok' && !empty($r['display_actual'])) -
- Ist: - {{ $r['display_actual'] }} -
- @endif -
-
- @endforeach -
-
- - {{-- Optional-Block unterhalb (einspaltig) --}} - @if(!empty($optional)) -
-
- Optional - empfohlene Zusatz-Records -
- -
- @foreach ($optional as $r) -
-
-
- - {{ $r['type'] }} - - {{ $r['name'] }} - - Optional - -
-
- -
-
- -
-
{{ $r['value'] }}
- @if($checked && ($r['state'] ?? 'neutral') !== 'ok' && !empty($r['display_actual'])) -
- Ist: - {{ $r['display_actual'] }} -
- @endif - @if(!empty($r['helpUrl'])) -
- {{ $r['info'] }} -
- - {{ $r['helpLabel'] }} - - @endif -
-
- @endforeach -
-
- @endif +
-@push('modal.footer') -
-
+
- - vorhanden - - - Syntaxfehler - - - fehlt (nur Pflicht) - +
- + {{-- Step 1: Mail-Records (domain-spezifisch) --}} +
+
+ Step 1 + Mail-Records + Absenderdomain +
+
+ @foreach ($dynamic as $r) +
+
+
+ {{ $r['type'] }} + {{ $r['name'] }} +
+ +
+
{{ $r['value'] }}
+ @if($checked && ($r['state'] ?? 'neutral') !== 'ok' && !empty($r['display_actual'])) +
+ Ist: + {{ $r['display_actual'] }} +
+ @endif +
+ @endforeach +
+
- + {{-- Step 2: Globale Infrastruktur --}} +
+
+ Step 2 + Globale Infrastruktur + gilt für alle Domains +
+
+ @foreach ($static as $r) +
+
+
+ {{ $r['type'] }} + {{ $r['name'] }} +
+ +
+
{{ $r['value'] }}
+ @if($checked && ($r['state'] ?? 'neutral') !== 'ok' && !empty($r['display_actual'])) +
+ Ist: + {{ $r['display_actual'] }} +
+ @endif +
+ @endforeach +
+
- +
+ + {{-- Optional-Records --}} + @if(!empty($optional)) +
+
+ Optional + Empfohlene Zusatz-Records +
+
+ @foreach ($optional as $r) +
+
+
+ {{ $r['type'] }} + {{ $r['name'] }} + Optional +
+ +
+
{{ $r['value'] }}
+ @if(!empty($r['info'])) +
{{ $r['info'] }}
+ @endif +
+ @endforeach
-@endpush + @endif -{{--@push('modal.header')--}} -{{--
--}} -{{-- --}} -{{-- TTL: {{ $ttl }}--}} -{{-- --}} -{{--

DNS-Einträge

--}} +
-{{--

--}} -{{-- Setze die folgenden Records für--}} -{{-- {{ $zone }}.--}} -{{--

--}} -{{--
--}} -{{--@endpush--}} +
+
+ + OK + + + Syntaxfehler + + + Fehlt + +
+
+ + +
+
-{{--
--}} -{{--
--}} -{{-- --}}{{-- GRID: links (Mail-Records), rechts (Infrastruktur) --}} -{{--
--}} -{{-- --}}{{-- Mail-Records --}} -{{--
--}} -{{--
--}} -{{-- Step 1--}} -{{-- Mail-Records--}} -{{-- --}} -{{-- Absenderdomain--}} -{{-- --}} -{{--
--}} - -{{--
--}} -{{-- @foreach ($dynamic as $r)--}} -{{--
--}} -{{--
--}} -{{--
--}} -{{-- --}} -{{-- {{ $r['type'] }}--}} -{{-- --}} -{{-- {{ $r['name'] }}--}} -{{--
--}} -{{--
--}} -{{-- --}} -{{--
--}} -{{--
--}} -{{--
--}} -{{--
{{ $r['value'] }}
--}} -{{-- @if(!empty($r['helpUrl']))--}} -{{-- --}} -{{-- {{ $r['helpLabel'] }}--}} -{{-- --}} -{{-- @endif--}} -{{--
--}} -{{--
--}} -{{--
--}} -{{--
--}} -{{--
--}} -{{-- {{ $r['type'] }}--}} -{{-- {{ $r['name'] }}--}} -{{--
--}} -{{--
--}} -{{-- --}} -{{--
--}} -{{--
--}} -{{--
--}} -{{--
{{ $r['value'] }}
--}} -{{-- @if(!empty($r['helpUrl']))--}} -{{-- --}} -{{-- {{ $r['helpLabel'] }}--}} -{{-- --}} -{{-- @endif--}} -{{--
--}} -{{--
--}} -{{-- @endforeach--}} - -{{-- @foreach ($optional as $r)--}} -{{--
--}} -{{--
--}} -{{--
--}} -{{-- {{ $r['type'] }}--}} -{{-- {{ $r['name'] }}--}} -{{--
--}} -{{--
--}} -{{-- Optional--}} -{{-- --}} -{{--
--}} -{{--
--}} -{{--
--}} -{{--
{{ $r['value'] }}
--}} -{{-- @if(!empty($r['helpUrl']))--}} -{{-- --}} -{{-- {{ $r['helpLabel'] }}--}} -{{-- --}} -{{-- @endif--}} -{{--
--}} -{{--
--}} -{{-- @endforeach--}} -{{--
--}} -{{--
--}} - -{{-- --}}{{-- Globale Infrastruktur --}} -{{--
--}} -{{--
--}} -{{-- Step 2--}} -{{-- Globale Infrastruktur (MTA-Host)--}} -{{-- gilt für alle Domains--}} -{{--
--}} - -{{--
--}} -{{-- @foreach ($static as $r)--}} -{{--
--}} -{{--
--}} -{{--
--}} -{{-- --}} -{{-- {{ $r['type'] }}--}} -{{-- --}} -{{-- {{ $r['name'] }}--}} -{{--
--}} -{{--
--}} -{{-- --}} -{{--
--}} -{{--
--}} -{{--
--}} -{{--
{{ $r['value'] }}
--}} -{{--
--}} -{{--
--}} - -{{--
--}} -{{--
--}} -{{--
--}} -{{-- {{ $r['type'] }}--}} -{{-- {{ $r['name'] }}--}} -{{--
--}} -{{--
--}} -{{-- --}} -{{--
--}} -{{--
--}} -{{--
--}} -{{--
{{ $r['value'] }}
--}} -{{--
--}} -{{--
--}} -{{-- @endforeach--}} -{{--
--}} -{{--
--}} -{{--
--}} -{{--
--}} -{{--
--}} - -{{--@push('modal.footer')--}} -{{--
--}} -{{--
--}} -{{-- --}} -{{-- vorhanden--}} -{{-- --}} -{{-- --}} -{{-- abweichend--}} -{{-- --}} -{{-- --}} -{{-- fehlt--}} -{{-- --}} - -{{-- --}} -{{--
--}} -{{-- --}} -{{-- --}} -{{--
--}} -{{--
--}} -{{--
--}} -{{--
--}} -{{--@endpush--}} -{{--@push('modal.footer')--}} -{{--
--}} -{{--
--}} -{{-- --}} -{{--
--}} -{{--
--}} -{{--@endpush--}} +
diff --git a/resources/views/livewire/ui/domain/modal/domain-edit-modal.blade.php b/resources/views/livewire/ui/domain/modal/domain-edit-modal.blade.php index d42843b..bd6deb9 100644 --- a/resources/views/livewire/ui/domain/modal/domain-edit-modal.blade.php +++ b/resources/views/livewire/ui/domain/modal/domain-edit-modal.blade.php @@ -1,129 +1,103 @@ -@push('modal.header') -
-
-

Domain bearbeiten

- -
+
+ +
+
+

Domain bearbeiten

+ DKIM- und DNS-Einträge werden nicht geändert.
-@endpush + +
-
-

- Hier kannst du die grundlegenden Eigenschaften der Domain ändern. - DKIM- oder DNS-Einträge werden dabei nicht neu erzeugt. -

-
-
- -
-
- - -
-
- - - @error('description') -
{{ $message }}
- @enderror -
-
-
- - optional -
-
- @foreach($tags as $i => $t) -
-
- {{-- Label --}} -
- - -
-
- -
- - -
-
-
-
- Palette: - @foreach($tagPalette as $hex) - - @endforeach - -
-
- @endforeach -
-
+
-
- @error('tags.*.label')
{{ $message }}
@enderror - @error('tags.*.color')
{{ $message }}
@enderror + @if(count($tags)) +
+ @foreach($tags as $i => $t) +
+ + {{-- Label + Farbe --}} +
+
+ + + @error("tags.$i.label")
{{ $message }}
@enderror +
+
+ +
+ + +
+ @error("tags.$i.color")
{{ $message }}
@enderror +
+
+ + {{-- Palette + Löschen --}} +
+ @foreach($tagPalette as $hex) + + @endforeach + +
+ +
+ @endforeach +
+ @endif
+ +
-@push('modal.footer') -
-
- +
+ + +
- -
-
-@endpush +
diff --git a/resources/views/livewire/ui/domain/modal/domain-limits-modal.blade.php b/resources/views/livewire/ui/domain/modal/domain-limits-modal.blade.php index bfe33af..0eeda56 100644 --- a/resources/views/livewire/ui/domain/modal/domain-limits-modal.blade.php +++ b/resources/views/livewire/ui/domain/modal/domain-limits-modal.blade.php @@ -1,84 +1,74 @@ -@push('modal.header') -
-
-

Domain-Limits

- -
+
+ +
+
+

Domain-Limits

+ Speicher- und Mengenbeschränkungen
-@endpush + +
-
-
-
-
- - - @error('max_aliases')
{{ $message }}
@enderror +
+ + +
+
+ + + @error('max_aliases')
{{ $message }}
@enderror
-
- - - @error('max_mailboxes')
{{ $message }}
@enderror +
+ + + @error('max_mailboxes')
{{ $message }}
@enderror
-
-
- - - @error('default_quota_mb')
{{ $message }}
@enderror +
+ +
+
+ + + @error('default_quota_mb')
{{ $message }}
@enderror
-
- - - @error('max_quota_per_mailbox_mb')
{{ $message }}
@enderror +
+ + + @error('max_quota_per_mailbox_mb')
{{ $message }}
@enderror
-
- - - @error('total_quota_mb')
{{ $message }}
@enderror +
+ + + @error('total_quota_mb')
{{ $message }}
@enderror
-
-
- - - @error('rate_limit_per_hour')
{{ $message }}
@enderror +
+ +
+
+ + + @error('rate_limit_per_hour')
{{ $message }}
@enderror
-
-
+
-@push('modal.footer') -
-
- +
+ + +
- -
-
-@endpush +
diff --git a/resources/views/livewire/ui/mail/modal/alias-delete-modal.blade.php b/resources/views/livewire/ui/mail/modal/alias-delete-modal.blade.php index 5e0c514..5fb2bff 100644 --- a/resources/views/livewire/ui/mail/modal/alias-delete-modal.blade.php +++ b/resources/views/livewire/ui/mail/modal/alias-delete-modal.blade.php @@ -1,99 +1,45 @@ -@push('modal.header') -
-
-

Alias löschen

- -
-
-@endpush +
-
- {{-- Warnung --}} -
-
- - Diese Aktion kann nicht rückgängig gemacht werden. -
+
+
+

Alias löschen

+ {{ $aliasEmail }} +
+ +
+ +
+ +
Diese Aktion kann nicht rückgängig gemacht werden.
+ +
+ Du bist im Begriff, den Alias {{ $aliasEmail }} zu löschen.
- {{-- Alias-Info --}} -
-
- Du bist im Begriff, den Alias - {{ $aliasEmail }} - zu löschen. -
- -
- - Typ: {{ $isSingle ? 'Single' : 'Gruppe' }} - - - Weiterleitung: {{ $targetLabel }} - -
- +
+ Typ: {{ $isSingle ? 'Einzel' : 'Gruppe' }} + Ziel: {{ $targetLabel }} @if($extraRecipients > 0) -
+{{ $extraRecipients }} weitere Empfänger
+ +{{ $extraRecipients }} weitere @endif
- {{-- Bestätigung --}} -
- - - @error('confirm')
{{ $message }}
@enderror +
+ + + @error('confirm')
{{ $message }}
@enderror
+
-@push('modal.footer') -
-
- +
+ + +
- -
-
- - {{--
--}} -{{--
--}} -{{-- --}} - -{{-- --}} -{{-- Endgültig löschen--}} -{{-- --}} -{{--
--}} -{{--
--}} -@endpush +
diff --git a/resources/views/livewire/ui/mail/modal/alias-form-modal.blade.php b/resources/views/livewire/ui/mail/modal/alias-form-modal.blade.php index d3804fb..01eb224 100644 --- a/resources/views/livewire/ui/mail/modal/alias-form-modal.blade.php +++ b/resources/views/livewire/ui/mail/modal/alias-form-modal.blade.php @@ -1,321 +1,139 @@ -@push('modal.header') -
-
-
-

- {{ $aliasId ? 'Alias bearbeiten' : 'Alias anlegen' }} -

-

- {{ $aliasId ? 'Passe den Alias und seine Empfänger an.' : 'Lege Adresse und Empfänger fest.' }} -

-
- -
+
+ +
+
+

{{ $aliasId ? 'Alias bearbeiten' : 'Alias anlegen' }}

+ {{ $aliasId ? 'Passe den Alias und seine Empfänger an.' : 'Lege Adresse und Empfänger fest.' }}
-@endpush + +
-
- {{-- alles in ein Formular, wie beim Limits-Modal --}} -
- {{-- Domain + Adresse + Status --}} +
+ -
- -
+ - {{-- Row 1: Domain + Typ --}} -
- {{-- DOMAIN (TailwindPlus Elements) --}} -
- +
- - {{-- Trigger --}} - - - {{-- Optionen --}} - - @foreach($domains as $d) - 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)"> -
- - {{ $d->domain }} -
- -
- @endforeach -
-
- - @error('domainId') -
{{ $message }}
@enderror -
- - {{-- TYP (TailwindPlus Elements) --}} -
- - - - {{-- Trigger --}} - - - {{-- Optionen --}} - - - {{-- Single --}} - -
- - Single -
- -
- - {{-- Gruppe --}} - -
- - Gruppe -
- -
-
-
- - @error('type') -
{{ $message }}
@enderror + {{-- Domain --}} +
+ +
+ {{ optional($domain)->domain ?? '—' }}
- {{-- Row 2: Adresse (volle Breite, gleiche Höhe) --}} -
- -
- - - @ {{ optional($domain)->domain }} - -
- @error('local') -
{{ $message }}
@enderror + {{-- Typ --}} +
+ + + @error('type')
{{ $message }}
@enderror
- @if($type === 'group') -
- - - @error('group_name')
{{ $message }}
@enderror -
- @endif +
- {{-- Empfänger --}} -
-
-

Empfänger

-
- @if($type === 'single') -
Bei „Single“ ist nur ein Empfänger erlaubt.
- @else - - @endif -
-
+ {{-- Adresse --}} +
+ +
+ + @ {{ optional($domain)->domain }} +
+ @error('local')
{{ $message }}
@enderror +
- @error('recipients') -
{{ $message }}
- @enderror + @if($type === 'group') +
+ + + @error('group_name')
{{ $message }}
@enderror +
+ @endif - {{-- WICHTIG: stabiler Container-Key um die Liste --}} -
- @foreach ($recipients as $idx => $r) -
- {{-- Labels (dynamisches Grid – 3 oder 4 Spalten je nach Typ) --}} -
-
Interner Empfänger (Postfach)
-
-
Externe E-Mail
- @if($type === 'group') -
- @endif -
- - {{-- Eingaben + oder + (optional) Löschen --}} -
- {{-- Interner Empfänger --}} -
- -
- - {{-- ODER --}} -
oder
- - {{-- Externe E-Mail --}} -
- - @error("recipients.$idx.email") -
{{ $message }}
- @enderror -
- - {{-- Löschen (nur bei Gruppe sichtbar) --}} - @if($type === 'group') -
- -
- @endif -
-
- @endforeach -
- - @if($type === 'single' && count($recipients) > 1) -
- Hinweis: Bei „Single“ wird nur der erste Empfänger gespeichert. -
+ {{-- Empfänger --}} +
+
+ Empfänger + @if($type === 'group') + + @else + Bei „Einzel" nur ein Empfänger. @endif
-
- - - @error('notes') -
{{ $message }}
@enderror + @error('recipients')
{{ $message }}
@enderror + +
+ @foreach($recipients as $idx => $r) +
+
+ Internes Postfach + + Externe E-Mail + @if($type === 'group')@endif +
+
+ + oder +
+ + @error("recipients.$idx.email")
{{ $message }}
@enderror +
+ @if($type === 'group') + + @endif +
+
+ @endforeach
- + @if($type === 'single' && count($recipients) > 1) +
Nur der erste Empfänger wird gespeichert.
+ @endif +
+ + {{-- Notizen --}} +
+ + + @error('notes')
{{ $message }}
@enderror +
+ +
-@push('modal.footer') -
-
- - -
-
-@endpush +
+ + +
+ +
diff --git a/resources/views/livewire/ui/mail/modal/mailbox-create-modal.blade.php b/resources/views/livewire/ui/mail/modal/mailbox-create-modal.blade.php index 353de22..d81222a 100644 --- a/resources/views/livewire/ui/mail/modal/mailbox-create-modal.blade.php +++ b/resources/views/livewire/ui/mail/modal/mailbox-create-modal.blade.php @@ -1,199 +1,103 @@ -@push('modal.header') -
-
-

Neues Postfach

- -
-
-@endpush - -
- @if(!$can_create) -
-
- - {{ $block_reason }} -
-
- @endif - -
- {{-- Domain + Localpart --}} -
-
- - - - {{-- Trigger / sichtbarer Button --}} - - - {{-- Dropdown / Options (Glas, rund) --}} - - @foreach($domains as $d) - @php - // Optional: Status fürs Label - $label = $d['domain']; - $locked = false; // falls du einzelne Domains sperren willst - @endphp - - -
- - {{ $label }} -
- - {{-- Haken beim aktiven Eintrag --}} - -
- @endforeach -
-
- - @error('domain_id') -
{{ $message }}
- @enderror -
- -
- - -
Nur Buchstaben, Zahlen und „.-_+“
- @error('localpart') -
{{ $message }}
@enderror -
-
- - {{-- Anzeigename --}} -
- - - @error('display_name') -
{{ $message }}
@enderror -
- - {{-- Adresse Preview --}} -
- Adresse: - - - {{ $email_preview ?: '–' }} - -
- -
- - {{-- Passwort / Quota --}} -
-
- - - @error('password') -
{{ $message }}
@enderror -
Leer lassen, wenn extern gesetzt wird.
-
- -
- - - @error('quota_mb') -
{{ $message }}
@enderror -
{{ $quota_hint }}
-
-
- - {{-- Rate / Flags --}} -
-
- - - @if($rate_limit_readonly) -
Von der Domain vorgegeben (Override deaktiviert).
- @endif - @error('rate_limit_per_hour') -
{{ $message }}
@enderror -
- -
- - -{{-- --}} -
-
+
+ +
+
+

Neues Postfach

+
-@push('modal.footer') -
-
- +
- + @if(!$can_create) +
{{ $block_reason }}
+ @endif + +
+
+ + +
 
+ @error('domain_id')
{{ $message }}
@enderror +
+
+ + +
Nur Buchstaben, Zahlen und „.-_+"
+ @error('localpart')
{{ $message }}
@enderror
-@endpush + +
+ + + @error('display_name')
{{ $message }}
@enderror +
+ + @if($email_preview) +
+ Adresse: + {{ $email_preview }} +
+ @endif + +
+ +
+
+ + +
Leer lassen, wenn extern gesetzt wird.
+ @error('password')
{{ $message }}
@enderror +
+
+ + +
{{ $quota_hint ?: '0 = unbegrenzt' }}
+ @error('quota_mb')
{{ $message }}
@enderror +
+
+ +
+
+ + + @if($rate_limit_readonly) +
Von der Domain vorgegeben.
+ @else +
 
+ @endif + @error('rate_limit_per_hour')
{{ $message }}
@enderror +
+
+ +
+
+ +
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/mail/modal/mailbox-delete-modal.blade.php b/resources/views/livewire/ui/mail/modal/mailbox-delete-modal.blade.php index d079fa6..d26c1a3 100644 --- a/resources/views/livewire/ui/mail/modal/mailbox-delete-modal.blade.php +++ b/resources/views/livewire/ui/mail/modal/mailbox-delete-modal.blade.php @@ -1,60 +1,43 @@ -@push('modal.header') -
-
-
-

Postfach löschen

-
- {{ $email }} -
-
- -
-
-@endpush - -
-
-
- Achtung: Das Postfach wird gelöscht. - @if(!$keep_server_mail) - Alle E-Mails werden entfernt. - @else - E-Mails bleiben am Server erhalten. - @endif -
- - - - +
+ +
+
+

Postfach löschen

+ {{ $email }}
+
-@push('modal.footer') -
-
- +
- -
+
+ Achtung: Das Postfach wird gelöscht. + @if(!$keep_server_mail) + Alle E-Mails werden entfernt. + @else + E-Mails bleiben am Server erhalten. + @endif
-@endpush + + + + + +
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/mail/modal/mailbox-edit-modal.blade.php b/resources/views/livewire/ui/mail/modal/mailbox-edit-modal.blade.php index 0591948..03e5b5b 100644 --- a/resources/views/livewire/ui/mail/modal/mailbox-edit-modal.blade.php +++ b/resources/views/livewire/ui/mail/modal/mailbox-edit-modal.blade.php @@ -1,84 +1,57 @@ -@push('modal.header') -
-
-
-

Postfach bearbeiten

-
- {{ $email_readonly }} -
-
- -
-
-@endpush - -
-
- - -
-
- - - @error('display_name') -
{{ $message }}
@enderror -
- -
- - - @error('password') -
{{ $message }}
@enderror -
-
- -
-
- - - @error('quota_mb') -
{{ $message }}
@enderror -
-
- - - @error('rate_limit_per_hour') -
{{ $message }}
@enderror -
-
- -
-
{{ $quota_hint }}
-
+
+ +
+
+

Postfach bearbeiten

+ {{ $email_readonly }}
+
-@push('modal.footer') -
-
- +
- + + +
+
+ + + @error('display_name')
{{ $message }}
@enderror +
+
+ + + @error('password')
{{ $message }}
@enderror
-@endpush + +
+
+ + + @error('quota_mb')
{{ $message }}
@enderror +
+
+ + + @error('rate_limit_per_hour')
{{ $message }}
@enderror +
+
+ + @if($quota_hint) +
{{ $quota_hint }}
+ @endif + +
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/nx/dashboard.blade.php b/resources/views/livewire/ui/nx/dashboard.blade.php new file mode 100644 index 0000000..11a3bf8 --- /dev/null +++ b/resources/views/livewire/ui/nx/dashboard.blade.php @@ -0,0 +1,322 @@ +Dashboard +Übersicht + +
+ +{{-- Hero Banner --}} +
+
+ + + + + + + + +
+
+
Mail Server
+
+
+
+
+
{{ $domainCount }}
+
Domains
+
+
+
{{ $mailboxCount }}
+
Postfächer
+
+
+
{{ $servicesActive }}/{{ $servicesTotal }}
+
Dienste
+
+
+
{{ $alertCount }}
+
Warnungen
+
+
+
{{ $mailHostname }}
+
+ +{{-- System-Ressourcen --}} +
+ System-Ressourcen +
+
+ +
+ + @php $cpuClass = $cpu > 80 ? 'mw-bar-high' : ($cpu > 50 ? 'mw-bar-mid' : 'mw-bar-low'); @endphp +
+
+ CPU +
+
+
{{ $cpu }}%
+
{{ $cpuCores }} Cores · {{ $cpuMhz }} GHz
+
+
+ + @php $ramClass = $ramPercent > 80 ? 'mw-bar-high' : ($ramPercent > 50 ? 'mw-bar-mid' : 'mw-bar-low'); @endphp +
+
+ RAM +
+
+
{{ $ramPercent }}%
+
{{ $ramUsed }} GB / {{ $ramTotal }} GB
+
+
+ + @php + $loadVal = floatval($load1); + $loadMax = max(1, $cpuCores); + $loadPct = min(100, round($loadVal / $loadMax * 100)); + $loadClass = $loadPct > 80 ? 'mw-bar-high' : ($loadPct > 50 ? 'mw-bar-mid' : 'mw-bar-low'); + @endphp +
+
+ Load +
+
+
{{ $load1 }}
+
{{ $load5 }} · {{ $load15 }} (1/5/15m)
+
+
+ +
+
+ Uptime +
+
+
{{ $uptimeDays }}d {{ $uptimeHours }}h
+
Stabil · kein Neustart
+
+
+ +
+ +{{-- Dienste & Schutz --}} +
+ Dienste & Schutz +
+
+ +
+ +
+
+
+ +
+
+
WoltGuard
+
System-Wächter
+
+ alle Dienste OK +
+
{{ $servicesActive }}/{{ $servicesTotal }} Dienste aktiv
+
+ +
+
+
+ +
+
+
Updates
+
{{ config('app.version', 'vdev') }}
+
+ aktuell +
+
System ist auf dem neuesten Stand.
+
+
+ +
+
+
+ +
+
+
Backup
+
+ @if($backup['last_at']) + Zuletzt {{ $backup['last_at'] }} + @elseif($backup['status'] === 'unconfigured') + Nicht konfiguriert + @elseif($backup['status'] === 'running') + Läuft gerade… + @else + Noch nicht ausgeführt + @endif +
+
+ @php + $bStatus = $backup['status'] ?? 'unknown'; + $bBadge = match($bStatus) { + 'ok' => ['class' => 'mw-badge-ok', 'label' => 'OK'], + 'failed' => ['class' => 'mw-badge-fail', 'label' => 'Fehler'], + 'running' => ['class' => 'mw-badge-info', 'label' => 'Läuft'], + 'queued' => ['class' => 'mw-badge-info', 'label' => 'Warteschlange'], + 'unconfigured' => ['class' => 'mw-badge-mute', 'label' => 'Nicht konfiguriert'], + 'pending' => ['class' => 'mw-badge-warn', 'label' => 'Ausstehend'], + default => ['class' => 'mw-badge-warn', 'label' => 'Unbekannt'], + }; + @endphp + {{ $bBadge['label'] }} +
+
+ @if($backup['size']) + Größe: {{ $backup['size'] }} + @else + Größe: — + @endif + @if($backup['duration']) +  ·  {{ $backup['duration'] }} + @endif + @if($backup['next_at']) +
Nächstes: {{ $backup['next_at'] }} + @elseif($backup['status'] !== 'unconfigured' && !$backup['enabled']) +
Zeitplan deaktiviert + @endif +
+ @if($backup['status'] === 'unconfigured') + Konfigurieren + @endif +
+ +
+
+
+ +
+
+
Alerts
+
System-Warnungen
+
+ {{ $alertCount }} offen +
+
+ {{ $alertCount === 0 ? 'Keine Warnungen.' : ($alertCount . ' aktive Warnungen.') }} +
+
+ +
+ +{{-- Infrastruktur --}} +
+ Infrastruktur +
+
+ +
+ +
+
Dienste & Ports
+ @php $svcHalf = (int) ceil(count($services) / 2); @endphp +
+
+ @foreach($services as $svc) + @if($loop->index < $svcHalf) + @php $st = $svc['status']; @endphp +
+
+
{{ $svc['name'] }} {{ $svc['type'] }}
+ {{ $st === 'online' ? 'Online' : ($st === 'offline' ? 'Offline' : 'N/A') }} +
+ @endif + @endforeach +
+
+ @foreach($services as $svc) + @if($loop->index >= $svcHalf) + @php $st = $svc['status']; @endphp +
+
+
{{ $svc['name'] }} {{ $svc['type'] }}
+ {{ $st === 'online' ? 'Online' : ($st === 'offline' ? 'Offline' : 'N/A') }} +
+ @endif + @endforeach +
+
+
+ @foreach([25, 465, 587, 110, 143, 993, 995, 80, 443] as $port) + @php $isActive = $ports[$port] ?? false; @endphp + :{{ $port }} + @endforeach +
+
+ +
+ +
+
Storage
+
+ + + + {{ $diskUsedPercent }}% + +
+
+
+
Belegt
+
{{ $diskUsedGb }} GB
+
+
+
+
Frei
+
{{ $diskFreeGb }} GB
+
+
+
+
Gesamt
+
{{ $diskTotalGb }} GB
+
+
+
+
+ +
+
Mail & Sicherheit
+
+
+
{{ $spamBlocked }}
+
Spam geblockt
+
+
+
{{ $spamTagged }}
+
Tagged
+
+
+
{{ $hamCount }}
+
Ham erkannt
+
+
+ +
+ +
+
+ +
diff --git a/resources/views/livewire/ui/nx/domain/dns-dkim.blade.php b/resources/views/livewire/ui/nx/domain/dns-dkim.blade.php new file mode 100644 index 0000000..fdc2173 --- /dev/null +++ b/resources/views/livewire/ui/nx/domain/dns-dkim.blade.php @@ -0,0 +1,177 @@ +Domains +Domain + +
+ +
+
+ + + + + Domain + {{ $systemDomains->count() + $userDomains->count() }} +
+
+ DNS-Assistent prüft Records live via dig +
+
+ +
+ + {{-- ══ SYSTEM ══ --}} + @if($systemDomains->count()) +
+
+
+
+ System + {{ $systemDomains->count() }} {{ Str::plural('Domain', $systemDomains->count()) }} +
+
+
+ + + + + + + + + + + + @foreach($systemDomains as $d) + + + + + + + + @endforeach + +
DomainDKIM-StatusSelectorTXT-Record (Auszug)Aktionen
+
+
+ + + + + +
+ {{ $d->domain }} +
+
+ @if($d->dkim_ready) + Aktiv + @else + Fehlt + @endif + + {{ $d->dkim_selector }} + + @if($d->dkim_txt) + {{ Str::limit($d->dkim_txt, 60) }} + @else + — kein Schlüssel — + @endif + +
+ + +
+
+
+
+
+ @endif + + {{-- ══ USER ══ --}} +
+
+
+
+ User + {{ $userDomains->count() }} {{ Str::plural('Domain', $userDomains->count()) }} +
+
+ + @if($userDomains->count()) +
+ + + + + + + + + + + + @foreach($userDomains as $d) + + + + + + + + @endforeach + +
DomainDKIM-StatusSelectorTXT-Record (Auszug)Aktionen
+
+
+ + + + + +
+ {{ $d->domain }} +
+
+ @if($d->dkim_ready) + Aktiv + @else + Fehlt + @endif + + {{ $d->dkim_selector }} + + @if($d->dkim_txt) + {{ Str::limit($d->dkim_txt, 60) }} + @else + — kein Schlüssel — + @endif + +
+ + + +
+
+
+ @else +
Keine User-Domains vorhanden.
+ @endif + +
+
+ +
+ +
diff --git a/resources/views/livewire/ui/nx/domain/domain-list.blade.php b/resources/views/livewire/ui/nx/domain/domain-list.blade.php new file mode 100644 index 0000000..362e839 --- /dev/null +++ b/resources/views/livewire/ui/nx/domain/domain-list.blade.php @@ -0,0 +1,246 @@ +Domains +Übersicht + +@php +$_wmSub = config('mailwolt.domain.webmail'); +$_wmBase = config('mailwolt.domain.base'); +$_wmUrl = ($_wmSub && $_wmBase) + ? 'https://'.$_wmSub.'.'.$_wmBase.'/inbox' + : url('/webmail/inbox'); +@endphp + +
+ +
+
+ + + + + + Domains + {{ $total }} +
+
+
+ + + + + +
+
+
+ + {{-- System Domain --}} + @if($systemDomain) +
+
+
+
+ System + {{ $systemDomain->domain }} +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
DomainStatusPostfächerAliasseDKIMAktionen
+
+
+ + + + + +
+ {{ $systemDomain->domain }} +
+
+ @if($systemDomain->is_active) + Aktiv + @else + Inaktiv + @endif + {{ $systemDomain->mailboxes_count ?? 0 }}{{ $systemDomain->aliases_count ?? 0 }} + @if($systemDomain->dkimKeys?->isNotEmpty()) + OK + @else + + @endif + +
+ + + + +
+
+
+
+
+ @endif + + {{-- User Domains --}} + @if($domains->count()) +
+
+
+
+ User + {{ $total }} {{ Str::plural('Domain', $total) }} +
+ +
+
+ + + + + + + + + + + + + + @foreach($domains as $d) + + + + + + + + + + + + + + + + + + @endforeach + +
DomainStatusPostfächerAliasseDKIMMax. Postf.Aktionen
+
+
+ + + + + +
+
+
{{ $d->domain }}
+ @if(!empty($d->description)) +
{{ Str::limit($d->description, 50) }}
+ @endif + @if(count($d->visible_tags)) +
+ @foreach($d->visible_tags as $tag) + + {{ $tag['label'] ?? '' }} + + @endforeach + @if($d->extra_tags > 0) + +{{ $d->extra_tags }} + @endif +
+ @endif +
+
+
+ @if($d->is_active) + Aktiv + @else + Inaktiv + @endif + + {{ $d->mailboxes_count }} + @if($d->max_mailboxes > 0) + / {{ $d->max_mailboxes }} + @endif + + {{ $d->aliases_count }} + @if($d->max_aliases > 0) + / {{ $d->max_aliases }} + @endif + + @if($d->dns_verified) + OK + @else + + @endif + + {{ $d->max_mailboxes ?: '∞' }} + +
+ + + + + + + +
+
+
+
+
+ @else +
+ + + + + + @if($search !== '') + Keine Domain gefunden für „{{ $search }}" + @else + Noch keine Domains vorhanden. + + @endif +
+ @endif + +
diff --git a/resources/views/livewire/ui/nx/mail/alias-list.blade.php b/resources/views/livewire/ui/nx/mail/alias-list.blade.php new file mode 100644 index 0000000..f9dd073 --- /dev/null +++ b/resources/views/livewire/ui/nx/mail/alias-list.blade.php @@ -0,0 +1,137 @@ +Mail +Aliasse + +
+ +
+
+ + + + Aliasse + {{ $totalAliases }} +
+
+
+ + + + + +
+
+
+ + @if($domains->count()) +
+ @foreach($domains as $domain) +
+ +
+
+
+ + + + + +
+ {{ $domain->domain }} + {{ $domain->aliases_count }} Aliasse + @if(!($domain->is_active ?? true)) + Domain inaktiv + @endif +
+ +
+ + @if($domain->mailAliases->count()) +
+ + + + + + + + + + + + @foreach($domain->mailAliases as $alias) + + + + + + + + @endforeach + +
AliasZielTypStatusAktionen
+
+
{{ strtoupper(substr($alias->local, 0, 1)) }}
+ {{ $alias->local . '@' . $domain->domain }} +
+
+ @if($alias->type === 'group' && $alias->group_name) + Gruppe: {{ $alias->group_name }} + @else + {{ $alias->destination ?? '—' }} + @endif + + @if($alias->type === 'group') + Gruppe + @else + Einzel + @endif + + @if($alias->is_active) + Aktiv + @else + Inaktiv + @endif + +
+ + +
+
+
+ @else +
+ Keine Aliasse vorhanden. + +
+ @endif + +
+ @endforeach +
+ @else +
+ + + + @if(trim($search)) + Keine Ergebnisse für „{{ $search }}". + @else + Noch keine Aliasse vorhanden. + @endif +
+ @endif + +
diff --git a/resources/views/livewire/ui/nx/mail/mailbox-list.blade.php b/resources/views/livewire/ui/nx/mail/mailbox-list.blade.php new file mode 100644 index 0000000..28a0e9f --- /dev/null +++ b/resources/views/livewire/ui/nx/mail/mailbox-list.blade.php @@ -0,0 +1,149 @@ +Mail +Postfächer + +
+ +
+
+ + + + + Postfächer + {{ $totalMailboxes }} +
+
+
+ + + + + +
+ +
+
+ + @if($domains->count()) +
+ @foreach($domains as $domain) +
+ +
+
+
+ + + + + +
+ {{ $domain->domain }} + {{ $domain->mail_users_count }} Postfächer + @if(!($domain->is_active ?? true)) + Domain inaktiv + @endif +
+ +
+ + @if(count($domain->prepared_mailboxes)) +
+ + + + + + + + + + + + + @foreach($domain->prepared_mailboxes as $u) + + + + + + + + + @endforeach + +
E-MailStatusQuotaAuslastungE-MailsAktionen
+
+
{{ strtoupper(substr($u['localpart'], 0, 1)) }}
+ {{ $u['localpart'] . '@' . $domain->domain }} +
+
+ @if($u['is_effective_active']) + Aktiv + @elseif($u['inactive_reason']) + {{ $u['inactive_reason'] }} + @else + Inaktiv + @endif + {{ $u['quota_mb'] }} MiB +
+
+
+
+ {{ $u['usage_percent'] }}% +
+
{{ number_format($u['message_count']) }} +
+ + + +
+
+
+ @else +
+ Keine Postfächer vorhanden. + +
+ @endif + +
+ @endforeach +
+ @else +
+ + + + + @if(trim($search)) + Keine Ergebnisse für „{{ $search }}". + @else + Noch keine Postfächer vorhanden. + @endif +
+ @endif + +
diff --git a/resources/views/livewire/ui/nx/mail/modal/quarantine-message-modal.blade.php b/resources/views/livewire/ui/nx/mail/modal/quarantine-message-modal.blade.php new file mode 100644 index 0000000..0e977d0 --- /dev/null +++ b/resources/views/livewire/ui/nx/mail/modal/quarantine-message-modal.blade.php @@ -0,0 +1,111 @@ +
+ +
+
+

Nachrichtendetails

+ Rspamd · {{ $message['msg_id'] ?? $msgId }} +
+ +
+ +
+ + @php + $actionClass = match($message['action'] ?? '') { + 'reject' => 'off', + 'add header' => 'warn', + 'greylist' => 'na', + default => 'ok', + }; + @endphp + + {{-- Score bar --}} + @php + $ratio = ($message['required'] ?? 0) > 0 + ? min(100, round(($message['score'] ?? 0) / $message['required'] * 100)) + : 0; + @endphp +
+
+ Spam-Score + {{ $message['score'] ?? 0 }} / {{ $message['required'] ?? '—' }} +
+
+
+
+
+ +
+
+ Aktion + @if($message['action'] === 'reject') + Reject + @elseif($message['action'] === 'add header') + Tagged + @elseif($message['action'] === 'greylist') + Greylist + @else + {{ $message['action'] ?? '—' }} + @endif +
+
+ IP + {{ $message['ip'] ?? '—' }} +
+
+ +
+ Von + {{ $message['from'] ?? '—' }} +
+
+ An + {{ $message['rcpt'] ?? '—' }} +
+
+ Betreff + {{ $message['subject'] ?? '—' }} +
+ +
+ @if(($message['time'] ?? 0) > 0) +
+ Zeitpunkt + {{ date('d.m.Y H:i:s', $message['time']) }} +
+ @endif + @if(($message['size'] ?? 0) > 0) +
+ Größe + {{ number_format($message['size'] / 1024, 1) }} KB +
+ @endif +
+ + {{-- Triggered symbols --}} + @if(!empty($message['symbols'])) +
+ Ausgelöste Regeln ({{ count($message['symbols']) }}) +
+ @foreach($message['symbols'] as $sym) +
+ {{ $sym['name'] }} + {{ $sym['score'] > 0 ? '+' : '' }}{{ $sym['score'] }} + @if($sym['description']) + {{ $sym['description'] }} + @endif +
+ @endforeach +
+
+ @endif + +
+ +
+ +
+ +
diff --git a/resources/views/livewire/ui/nx/mail/modal/queue-message-modal.blade.php b/resources/views/livewire/ui/nx/mail/modal/queue-message-modal.blade.php new file mode 100644 index 0000000..e3cc5fe --- /dev/null +++ b/resources/views/livewire/ui/nx/mail/modal/queue-message-modal.blade.php @@ -0,0 +1,96 @@ +
+ +
+
+

Queue-Nachricht

+ {{ $message['id'] ?? $queueId }} +
+ +
+ +
+ +
+
+ Status + @php $q = $message['queue'] ?? '—'; @endphp + @if($q === 'active') + Aktiv + @elseif($q === 'hold') + Hold + @elseif($q === 'deferred') + Deferred + @else + {{ $q }} + @endif +
+
+ Größe + {{ ($message['size'] ?? 0) > 0 ? number_format(($message['size']) / 1024, 1) . ' KB' : '—' }} +
+
+ +
+ Absender + {{ $message['sender'] ?? '—' }} +
+ +
+ Empfänger +
+ @foreach($message['recipients'] ?? [] as $r) +
+ {{ $r['address'] }} + @if($r['reason']) +
{{ $r['reason'] }}
+ @endif +
+ @endforeach + @if(empty($message['recipients'])) + + @endif +
+
+ + @if(($message['arrival'] ?? 0) > 0) +
+ Eingang + {{ date('d.m.Y H:i:s', $message['arrival']) }} +
+ @endif + + @if(!empty($message['header'])) +
+ Mail-Header +
{{ $message['header'] }}
+
+ @endif + +
+ +
+
+ @if(($message['queue'] ?? '') === 'hold') + + @else + + @endif +
+
+ + +
+
+ +
diff --git a/resources/views/livewire/ui/nx/mail/quarantine-list.blade.php b/resources/views/livewire/ui/nx/mail/quarantine-list.blade.php new file mode 100644 index 0000000..6cd8437 --- /dev/null +++ b/resources/views/livewire/ui/nx/mail/quarantine-list.blade.php @@ -0,0 +1,119 @@ +Mail +Quarantäne + +
+ +
+
+ + Quarantäne + {{ $counts['all'] }} + Rspamd · letzte {{ count($messages) }} Einträge +
+
+
+ + +
+ +
+
+ +{{-- Filter tabs --}} +
+ + + + + +
+ +{{-- Table --}} +@if(count($messages) > 0) +
+ + + + + + + + + + + + + + @foreach($messages as $msg) + @php + $actionClass = match($msg['action']) { + 'reject' => 'off', + 'add header' => 'warn', + 'greylist' => 'na', + default => 'ok', + }; + $scoreRatio = $msg['required'] > 0 + ? min(100, round($msg['score'] / $msg['required'] * 100)) + : 0; + @endphp + + + + + + + + + + @endforeach + +
VonAnBetreffScoreAktionZeitpunkt
{{ $msg['from'] }}{{ Str::limit($msg['rcpt'], 35) }}{{ Str::limit($msg['subject'], 50) }} +
+ {{ $msg['score'] }} +
+
+
+ @if($msg['action'] === 'reject') + Reject + @elseif($msg['action'] === 'add header') + Tagged + @elseif($msg['action'] === 'greylist') + Greylist + @elseif($msg['action'] === 'no action') + Passiert + @else + {{ $msg['action'] }} + @endif + {{ $msg['time'] > 0 ? date('d.m H:i', $msg['time']) : '—' }} + +
+
+@elseif($counts['all'] === 0) +
+ +

Keine Rspamd-History verfügbar

+ Prüfe ob Rspamd unter 127.0.0.1:11334 erreichbar ist. +
+@else +
+

Keine Treffer für „{{ $search }}"

+
+@endif + +
diff --git a/resources/views/livewire/ui/nx/mail/queue-list.blade.php b/resources/views/livewire/ui/nx/mail/queue-list.blade.php new file mode 100644 index 0000000..de218c2 --- /dev/null +++ b/resources/views/livewire/ui/nx/mail/queue-list.blade.php @@ -0,0 +1,108 @@ +Mail +Mail-Queue + +
+ +
+
+ + Mail-Queue + {{ $counts['all'] }} +
+
+
+ + +
+ + @if(count($selected) > 0) + + @endif + +
+
+ +{{-- Filter tabs --}} +
+ + + + +
+ +{{-- Table --}} +@if(count($messages) > 0) +
+ + + + + + + + + + + + + + + + @foreach($messages as $msg) + + + + + + + + + + + + @endforeach + +
Queue-IDSenderEmpfängerGrößeEingangStatusGrund
{{ $msg['id'] }}{{ $msg['sender'] ?: '—' }}{{ $msg['recipient'] ?: '—' }}{{ $msg['size'] > 0 ? number_format($msg['size'] / 1024, 1) . ' KB' : '—' }}{{ $msg['arrival'] > 0 ? date('d.m H:i', $msg['arrival']) : '—' }} + @if($msg['queue'] === 'active') + Aktiv + @elseif($msg['queue'] === 'hold') + Hold + @else + Deferred + @endif + {{ $msg['reason'] ?: '—' }} + +
+
+@else +
+ + @if($search !== '') +

Keine Treffer für „{{ $search }}"

+ @elseif($filter !== 'all') +

Keine {{ $filter }}-Nachrichten

+ @else +

Queue ist leer

+ @endif +
+@endif + +
diff --git a/resources/views/livewire/ui/security/audit-logs-table.blade.php b/resources/views/livewire/ui/security/audit-logs-table.blade.php index 6a52160..a4285b9 100644 --- a/resources/views/livewire/ui/security/audit-logs-table.blade.php +++ b/resources/views/livewire/ui/security/audit-logs-table.blade.php @@ -1,3 +1,85 @@ +Sicherheit +Audit-Logs +
- {{-- If your happiness depends on money, you will never be happy with yourself. --}} + +
+
+ + + + + Audit-Logs + {{ count($logs) }} +
+
+
+ + + + + +
+
+
+ +
+
+ + {{-- Toolbar: level filter --}} +
+ Level: + @foreach($levels as $lvl) + + @endforeach + @if($search !== '' || $level !== '') + + @endif +
+ + @if(empty($logs)) +
+ + + + +
+ @if($search !== '' || $level !== '') + Keine Einträge für diese Filter + @else + Keine Log-Einträge gefunden + @endif +
+ @if($search === '' && $level === '') +
{{ storage_path('logs/laravel.log') }}
+ @endif +
+ @else + @foreach($logs as $entry) +
+ {{ $entry['time'] }} + {{ strtoupper($entry['level']) }} + {{ $entry['message'] }} +
+ @endforeach + + @if($limit <= count($logs)) +
+ +
+ @endif + @endif + +
+
+
diff --git a/resources/views/livewire/ui/security/fail2ban-banlist.blade.php b/resources/views/livewire/ui/security/fail2ban-banlist.blade.php index f4f59d2..39470b9 100644 --- a/resources/views/livewire/ui/security/fail2ban-banlist.blade.php +++ b/resources/views/livewire/ui/security/fail2ban-banlist.blade.php @@ -1,36 +1,44 @@ -
-
-

Aktuell gebannte IPs

-
- @if (empty($rows)) -
Keine aktiven Banns vorhanden.
+ @if(empty($rows)) +
+ Keine aktiven Banns vorhanden. +
@else -
- @foreach ($rows as $r) -
-
- {{-- Statuspunkt: rot=permanent, gelb=temporär --}} - - - {{-- IP klein + monospace, ohne Jail-Text --}} - - {{ $r['ip'] }} - -
- - +
+ @foreach($rows as $r) +
+
+
+ {{ $r['ip'] }} + {{ $r['jail'] }} + {{ $r['label'] }}
+ +
@endforeach
@endif +
diff --git a/resources/views/livewire/ui/security/fail2ban-settings.blade.php b/resources/views/livewire/ui/security/fail2ban-settings.blade.php index b0bb751..5656927 100644 --- a/resources/views/livewire/ui/security/fail2ban-settings.blade.php +++ b/resources/views/livewire/ui/security/fail2ban-settings.blade.php @@ -1,141 +1,157 @@ -
- {{-- LEFT 2/3 --}} -
-
-
-
- - Fail2Ban Konfiguration -
- -
+Sicherheit +Fail2Ban -
-
- - -

Standard-Sperrzeit.

-
+
-
- - -

Obergrenze bei dynamischer Erhöhung.

-
+
+
+ + + + Fail2Ban +
+
+ +
+
-
- - -

Zeitraum für Wiederholungen.

-
+
-
- - -

Fehlversuche bis Bann.

-
+ {{-- ═══ Main ═══ --}} +
-
-
- {{-- RIGHT 1/3 --}} -
-
-
- - Whitelist -
- - @forelse($whitelist as $ip) -
- {{ $ip }} - -
- @empty -
Keine Einträge.
- @endforelse - - -
- -
-
- - Blacklist -
- - @forelse($blacklist as $ip) -
- {{ $ip }} - -
- @empty -
Keine Einträge.
- @endforelse - - -
-
diff --git a/resources/views/livewire/ui/security/modal/fail2ban-ip-modal.blade.php b/resources/views/livewire/ui/security/modal/fail2ban-ip-modal.blade.php index 87b5615..9595244 100644 --- a/resources/views/livewire/ui/security/modal/fail2ban-ip-modal.blade.php +++ b/resources/views/livewire/ui/security/modal/fail2ban-ip-modal.blade.php @@ -1,55 +1,70 @@ -
- {{-- Header --}} -
-
- - - {{ strtoupper($type) }} – {{ $mode === 'add' ? 'hinzufügen' : 'entfernen' }} - -
+
-
- {{-- Body --}} -
+
@if($mode === 'add')
- - - @error('ip')

{{ $message }}

@enderror + + + @error('ip') +
{{ $message }}
+ @enderror +
IPv4, IPv6 und CIDR-Notation werden unterstützt.
- @if($type === 'blacklist') -

- Wird sofort im Jail mailwolt-blacklist gebannt (bantime = permanent). -

+
+ Wird sofort im Jail mailwolt-blacklist permanent gebannt. +
@endif @else -
-
IP: {{ $prefill ?? $ip }}
-
Wird aus der {{ $type }} entfernt - @if($type === 'blacklist') und im Blacklist-Jail entbannt @endif. +
+
{{ $prefill ?? $ip }}
+
+ Wird aus der {{ ucfirst($type) }} entfernt{{ $type === 'blacklist' ? ' und im Blacklist-Jail entbannt' : '' }}.
-
+ +
+ + @if($mode === 'add') + @if($type === 'blacklist') + + @else + + @endif + @else + @endif
+
diff --git a/resources/views/livewire/ui/security/rspamd-form.blade.php b/resources/views/livewire/ui/security/rspamd-form.blade.php index 6a52160..2470541 100644 --- a/resources/views/livewire/ui/security/rspamd-form.blade.php +++ b/resources/views/livewire/ui/security/rspamd-form.blade.php @@ -1,3 +1,104 @@ +Sicherheit +Rspamd +
- {{-- If your happiness depends on money, you will never be happy with yourself. --}} + +
+
+ + + + + + Rspamd + @if($running) + Aktiv + @else + Gestoppt + @endif +
+
+ +
+
+ +
+ + {{-- Main --}} +
+
+
+
+
+ Score-Schwellwerte +
+
+
+
+
+ + +
Ab diesem Score → Greylist
+
+
+ + +
Ab diesem Score → X-Spam-Header
+
+
+ + +
Ab diesem Score → Ablehnen
+
+
+
+
Score-Verlauf
+
+ 0–{{ $greylist_score - 0.5 }} OK + + {{ $greylist_score }}–{{ $spam_score - 0.5 }} Greylist + + {{ $spam_score }}–{{ $reject_score - 0.5 }} Header + + ≥{{ $reject_score }} Reject +
+
+
+
+
+
+ + {{-- Sidebar --}} +
+
+
+
+
+ Info +
+
+
+
+ Konfigurationsdatei:
+ /etc/rspamd/local.d/
mailwolt-actions.conf
+
+
+ Änderungen werden sofort gespeichert und rspamd neu geladen. +
+ @if(!$running) +
+ rspamd ist derzeit nicht aktiv. +
+ @endif +
+
+
+
+ +
+
diff --git a/resources/views/livewire/ui/security/ssl-certificates-table.blade.php b/resources/views/livewire/ui/security/ssl-certificates-table.blade.php index d5f5aa4..74961e7 100644 --- a/resources/views/livewire/ui/security/ssl-certificates-table.blade.php +++ b/resources/views/livewire/ui/security/ssl-certificates-table.blade.php @@ -1,3 +1,116 @@ +Sicherheit +SSL/TLS +
- {{-- The best athlete wants his opponent at his best. --}} + +
+
+ + + + + + SSL/TLS + @php $realCerts = array_filter($certs, fn($c) => !isset($c['_error'])); @endphp + {{ count($realCerts) }} +
+
+ +
+
+ +
+
+
+
+ Let's Encrypt Zertifikate +
+
+ + @php $unavailable = !empty($certs) && isset($certs[0]['_error']); @endphp + @if($unavailable) +
+ + + + +
certbot nicht erreichbar
+
sudo-Recht für /usr/bin/certbot fehlt oder certbot nicht installiert
+
+ @elseif(empty($realCerts)) +
+ + + + +
Noch keine Zertifikate ausgestellt
+
certbot ist installiert — führe certbot certonly aus um ein Zertifikat zu erstellen
+
+ @else +
+ + + + + + + + + + + + @foreach($realCerts as $cert) + + + + + + + + @endforeach + +
NameDomainsAblaufStatusAktionen
+ {{ $cert['name'] }} + @if($cert['cert_path']) +
{{ $cert['cert_path'] }}
+ @endif +
+
+ @foreach($cert['domains'] as $d) + {{ $d }} + @endforeach +
+
+ @if($cert['days_left'] !== null) + {{ $cert['days_left'] }} Tage + @else + + @endif + + @if($cert['expired']) + Abgelaufen + @elseif($cert['days_left'] !== null && $cert['days_left'] < 14) + Bald fällig + @else + Gültig + @endif + +
+ +
+
+
+ @endif +
+
+
diff --git a/resources/views/livewire/ui/security/tls-ciphers-form.blade.php b/resources/views/livewire/ui/security/tls-ciphers-form.blade.php index cdda2ce..cb0cb73 100644 --- a/resources/views/livewire/ui/security/tls-ciphers-form.blade.php +++ b/resources/views/livewire/ui/security/tls-ciphers-form.blade.php @@ -1,3 +1,134 @@ +Sicherheit +TLS-Ciphers +
- {{-- In work, do what you enjoy. --}} + +
+
+ + + + + + TLS-Ciphers +
+
+ +
+
+ +
+ + {{-- Main --}} +
+ +
+ + {{-- Preset --}} +
+
+
+ Preset +
+
+
+
Mozilla-Sicherheitsprofil wählen. Felder werden automatisch befüllt.
+
+ @foreach($presets as $p) + + @endforeach +
+ @if($preset === 'modern') +
Nur TLS 1.3 — maximale Sicherheit, ggf. inkompatibel mit älteren Clients.
+ @elseif($preset === 'intermediate') +
TLS 1.2 + 1.3 — empfohlenes Profil für die meisten Mail-Server.
+ @elseif($preset === 'old') +
TLS 1.0+ — nur für Legacy-Clients. Nicht empfohlen.
+ @endif +
+
+ + {{-- Postfix --}} +
+
+
+ Postfix +
+
+
+
+ + +
Deaktivierte Protokolle mit ! Präfix
+
+
+ + +
high, medium, low oder OpenSSL-Cipher-String
+
+
+
+ + {{-- Dovecot --}} +
+
+
+ Dovecot +
+
+
+
+ + +
z. B. TLSv1.2 oder TLSv1.3
+
+
+ + +
OpenSSL-Cipher-String für Dovecot
+
+
+
+ +
+ +
+ + {{-- Sidebar --}} +
+
+
+
+
+ Zieldateien +
+
+
+
+
Postfix
+ /etc/postfix/mailwolt-tls.cf +
+
+
Dovecot
+ /etc/dovecot/conf.d/
99-mailwolt-tls.conf
+
+
+
Postfix und Dovecot werden nach dem Speichern automatisch neu geladen.
+
+
+
+
+ +
+
diff --git a/resources/views/livewire/ui/system/api-key-table.blade.php b/resources/views/livewire/ui/system/api-key-table.blade.php new file mode 100644 index 0000000..b53d092 --- /dev/null +++ b/resources/views/livewire/ui/system/api-key-table.blade.php @@ -0,0 +1,296 @@ +System +API Keys + +
+ +
+
+ + + + + + API Keys + {{ $tokens->count() }} +
+
+ +
+
+ +
+ + {{-- ═══ Left: Keys + Endpoint-Docs ═══ --}} +
+ + {{-- Keys --}} +
+
+
+ Aktive Keys +
+
+ + @if($tokens->isEmpty()) +
+ + + + +
Keine API Keys vorhanden
+
Erstelle deinen ersten Key um externe Anwendungen zu verbinden.
+
+ @else +
+ + + + + + + + + + + + + @foreach($tokens as $token) + + + + + + + + + @endforeach + +
NameScopesModusZuletzt genutztErstellt
+
+
+ + + + +
+ {{ $token->name }} +
+
+
+ @foreach($token->abilities as $scope) + {{ $scope }} + @endforeach +
+
+ @if($token->sandbox) + Sandbox + @else + Live + @endif + {{ $token->last_used_at?->diffForHumans() ?? '—' }}{{ $token->created_at->format('d.m.Y') }} +
+ +
+
+
+ @endif +
+ + {{-- Endpoint Reference --}} +
+
+
+ Endpunkte + Basis-URL: /api/v1 +
+ {{-- Tab switcher --}} +
+ @foreach([ + ['mailboxes', 'Mailboxen'], + ['aliases', 'Aliases'], + ['domains', 'Domains'], + ] as [$key, $label]) + + @endforeach +
+
+ +
+ + {{-- Mailboxen --}} +
+ @php $mbRoutes = [ + ['GET', '/mailboxes', 'mailboxes:read', 'Alle Mailboxen auflisten', '?domain=example.com&active=true'], + ['GET', '/mailboxes/{id}', 'mailboxes:read', 'Einzelne Mailbox abrufen', null], + ['POST', '/mailboxes', 'mailboxes:write', 'Neue Mailbox erstellen', null], + ['PATCH', '/mailboxes/{id}', 'mailboxes:write', 'Mailbox aktualisieren', null], + ['DELETE', '/mailboxes/{id}', 'mailboxes:write', 'Mailbox löschen', null], + ]; @endphp + @include('livewire.ui.system.partials.api-endpoint-list', ['routes' => $mbRoutes]) + +
+
POST-Body (Erstellen)
+
{
+  "email": "user@example.com",
+  "password": "sicher123",
+  "display_name": "Max Mustermann",
+  "quota_mb": 1024,
+  "is_active": true
+}
+
+
+ + {{-- Aliases --}} +
+ @php $alRoutes = [ + ['GET', '/aliases', 'aliases:read', 'Alle Aliases auflisten', '?domain=example.com'], + ['GET', '/aliases/{id}', 'aliases:read', 'Einzelnen Alias abrufen', null], + ['POST', '/aliases', 'aliases:write', 'Neuen Alias erstellen', null], + ['DELETE', '/aliases/{id}', 'aliases:write', 'Alias löschen', null], + ]; @endphp + @include('livewire.ui.system.partials.api-endpoint-list', ['routes' => $alRoutes]) + +
+
POST-Body (Erstellen)
+
{
+  "local": "info",
+  "domain": "example.com",
+  "recipients": ["admin@example.com"],
+  "is_active": true
+}
+
+
+ + {{-- Domains --}} +
+ @php $doRoutes = [ + ['GET', '/domains', 'domains:read', 'Alle Domains auflisten', null], + ['GET', '/domains/{id}', 'domains:read', 'Einzelne Domain abrufen', null], + ['POST', '/domains', 'domains:write', 'Neue Domain erstellen', null], + ['DELETE', '/domains/{id}', 'domains:write', 'Domain löschen', null], + ]; @endphp + @include('livewire.ui.system.partials.api-endpoint-list', ['routes' => $doRoutes]) + +
+
POST-Body (Erstellen)
+
{
+  "domain": "example.com",
+  "description": "Hauptdomain",
+  "max_mailboxes": 100,
+  "max_aliases": 200,
+  "default_quota_mb": 1024
+}
+
+
+ +
+
+ +
+ + {{-- ═══ Right: Sidebar ═══ --}} +
+ + {{-- Auth-Info --}} +
+
+
+ Authentifizierung +
+
+
+
Sende den Token als HTTP-Header mit jeder Anfrage:
+
Authorization: Bearer <token>
+Content-Type: application/json
+
+
Token prüfen:
+
GET /api/v1/me
+
+
+
+ + {{-- Sandbox --}} +
+
+
+ Sandbox +
+
+
+
Sandbox-Keys simulieren alle Schreiboperationen. Die API antwortet realistisch — aber es werden keine Änderungen gespeichert.
+
+ "sandbox": true +
+
Jede Write-Response enthält dieses Feld wenn der Key im Sandbox-Modus ist.
+
+
+ + {{-- Scopes --}} +
+
+
+ Scopes +
+
+
+ @foreach([ + ['mailboxes:read', 'Mailboxen lesen'], + ['mailboxes:write', 'Mailboxen erstellen / ändern / löschen'], + ['aliases:read', 'Aliases lesen'], + ['aliases:write', 'Aliases erstellen / löschen'], + ['domains:read', 'Domains lesen'], + ['domains:write', 'Domains erstellen / löschen'], + ] as [$scope, $desc]) +
+ {{ $scope }} + {{ $desc }} +
+ @endforeach +
+
+ + {{-- Response codes --}} +
+
+
+ Status-Codes +
+
+
+ @foreach([ + ['200', 'var(--mw-t3)', 'OK — Abfrage erfolgreich'], + ['201', '#34d399', 'Created — Ressource erstellt'], + ['204', 'var(--mw-t3)', 'No Content — Gelöscht'], + ['401', '#f87171', 'Unauthorized — Token fehlt'], + ['403', '#f87171', 'Forbidden — Scope fehlt'], + ['404', '#fb923c', 'Not Found — nicht gefunden'], + ['422', '#fb923c', 'Unprocessable — Validierung'], + ] as [$code, $color, $desc]) +
+ {{ $code }} + {{ $desc }} +
+ @endforeach +
+
+ +
+ +
+ +
diff --git a/resources/views/livewire/ui/system/backup-job-list.blade.php b/resources/views/livewire/ui/system/backup-job-list.blade.php new file mode 100644 index 0000000..4fc044b --- /dev/null +++ b/resources/views/livewire/ui/system/backup-job-list.blade.php @@ -0,0 +1,161 @@ +System +Backups + +
+ +
+
+ + Backups +
+
+ + Zeitplan +
+
+ + {{-- Policy Info --}} + @if($policy) +
+ + + Zeitplan: + {{ $policy->schedule_cron }} +  ·  + @if($policy->enabled) + Aktiv + @else + Deaktiviert + @endif +  ·  Aufbewahrung: {{ $policy->retention_count }} Backups + +
+ @else +
+ Kein Backup-Zeitplan konfiguriert. Jetzt einrichten → +
+ @endif + + {{-- Jobs Table --}} +
+
+
+ Backup-Verlauf +
+ {{ $jobs->total() }} Einträge +
+ + @if($jobs->isEmpty()) +
+ Noch keine Backups vorhanden. +
Klicke auf „Jetzt sichern" um das erste Backup zu starten.
+
+ @else +
+ + + + + + + + + + + + + @foreach($jobs as $job) + @php + $badge = match($job->status) { + 'ok' => ['class' => 'mbx-badge-ok', 'label' => 'OK'], + 'failed' => ['class' => 'mbx-badge-err', 'label' => 'Fehler'], + 'running' => ['class' => 'mbx-badge-info-sm', 'label' => 'Läuft'], + 'queued' => ['class' => 'mbx-badge-mute', 'label' => 'Warteschlange'], + 'canceled'=> ['class' => 'mbx-badge-mute', 'label' => 'Abgebrochen'], + default => ['class' => 'mbx-badge-mute', 'label' => ucfirst($job->status)], + }; + $duration = ($job->started_at && $job->finished_at) + ? $job->started_at->diffInSeconds($job->finished_at) . 's' + : ($job->started_at ? '…' : '—'); + $size = $job->size_bytes > 0 + ? (function($b) { + $u = ['B','KB','MB','GB']; $i = 0; $v = (float)$b; + while ($v >= 1024 && $i < 3) { $v /= 1024; $i++; } + return number_format($v, $i <= 1 ? 0 : 1) . ' ' . $u[$i]; + })($job->size_bytes) + : '—'; + @endphp + + + + + + + + + @endforeach + +
StatusGestartetDauerGrößeDateiAktionen
+ {{ $badge['label'] }} + @if($job->status === 'running') + + @endif + + {{ $job->started_at?->diffForHumans() ?? '—' }} + {{ $duration }}{{ $size }} + {{ $job->artifact_path ? basename($job->artifact_path) : '—' }} + +
+ @if(in_array($job->status, ['queued','running'])) + + @endif + @if($job->status === 'ok' && $job->artifact_path) + + @endif + @if($job->error || $job->log_excerpt) + + @endif + +
+
+
+ + @if($jobs->hasPages()) +
+ {{ $jobs->links() }} +
+ @endif + + @endif +
+ + + +
diff --git a/resources/views/livewire/ui/system/domains-ssl-form.blade.php b/resources/views/livewire/ui/system/domains-ssl-form.blade.php index cfebb84..9ced344 100644 --- a/resources/views/livewire/ui/system/domains-ssl-form.blade.php +++ b/resources/views/livewire/ui/system/domains-ssl-form.blade.php @@ -8,10 +8,25 @@ Domains & SSL
- +
+ @if($domainsSaving) + Wird übernommen … + @endif + +
@@ -43,7 +58,7 @@
+ placeholder="leer = /webmail/…">
.{{ $base_domain }} diff --git a/resources/views/livewire/ui/system/installer-page.blade.php b/resources/views/livewire/ui/system/installer-page.blade.php new file mode 100644 index 0000000..3088f4b --- /dev/null +++ b/resources/views/livewire/ui/system/installer-page.blade.php @@ -0,0 +1,237 @@ +System +Installer + +
+ + {{-- ═══ Page Header ═══ --}} +
+
+ + + + + Installer +
+
+ +
+
+ + {{-- Polling when running --}} + @if($state === 'running' || $running) +
+ @endif + +
+ + {{-- ═══ Warning Banner ═══ --}} +
+ + + + + + + Achtung: Diese Seite führt Systemänderungen durch. + Installationen und Neukonfigurationen werden mit Root-Rechten ausgeführt und können laufende Dienste kurzzeitig unterbrechen. + +
+ + {{-- ═══ Section 1: Komponentenstatus ═══ --}} +
+
+
+ Komponentenstatus +
+ +
+
+
+ + @php + $componentIcons = [ + 'nginx' => '', + 'postfix' => '', + 'dovecot' => '', + 'rspamd' => '', + 'fail2ban' => '', + 'certbot' => '', + ]; + @endphp + + @foreach($componentStatus as $key => $info) +
+
+
+ + {!! $componentIcons[$key] ?? '' !!} + +
+
+
{{ $info['label'] }}
+
+ @if($info['installed'] && $info['active']) + Aktiv + @elseif($info['installed']) + Inaktiv + @else + Nicht installiert + @endif +
+
+
+ +
+ @endforeach + + @if(empty($componentStatus)) +
+ Status wird geladen … +
+ @endif + +
+
+
+ + {{-- ═══ Section 2: Status ═══ --}} +
+
+
+ Installations-Status +
+
+
+ + @if($state === 'running') +
+
+ +
+
+
+ Installation läuft … + @if($component !== 'all') + ({{ ucfirst($component) }}) + @endif +
+
Bitte nicht unterbrechen. Die Seite aktualisiert sich automatisch.
+
+
+ @elseif($rc !== null && $rc !== 0) +
+ +
+
Installation fehlgeschlagen (rc={{ $rc }})
+
Bitte das Log unten prüfen.
+
+
+ @elseif($rc === 0) +
+ + Installation erfolgreich abgeschlossen. +
+ @else +
Keine Installation aktiv. Wähle eine Komponente oder starte die Komplett-Installation.
+ @endif + + {{-- Progress Bar --}} + @if($state === 'running' || $progressPct > 0) +
+
+ Fortschritt + {{ $progressPct }}% +
+
+
+
+
+ @endif + +
+
+ + {{-- ═══ Section 3: Log Viewer ═══ --}} +
+
+
+ Installer-Log +
+
+ + +
+
+
+
+ @if(count($logLines) === 0) + Keine Log-Einträge vorhanden. + @else + @foreach($logLines as $line) + @php + $color = 'inherit'; + if (str_contains($line, '[!]') || str_contains($line, 'error') || str_contains($line, 'Error') || str_contains($line, 'fehlgeschlagen')) { + $color = '#f87171'; + } elseif (str_contains($line, '[✓]') || str_contains($line, 'beendet') || str_contains($line, 'abgeschlossen')) { + $color = 'rgba(34,197,94,.85)'; + } elseif (str_contains($line, '[i]')) { + $color = 'var(--mw-t3)'; + } elseif (str_contains($line, '=====')) { + $color = 'rgba(14,165,233,.8)'; + } + @endphp + {{ $line }} +
+ @endforeach + @endif +
+
+ {{ count($logLines) }} Zeilen · /var/log/mailwolt-install.log +
+
+
+ +
+ +
diff --git a/resources/views/livewire/ui/system/modal/api-key-create-modal.blade.php b/resources/views/livewire/ui/system/modal/api-key-create-modal.blade.php new file mode 100644 index 0000000..0af684f --- /dev/null +++ b/resources/views/livewire/ui/system/modal/api-key-create-modal.blade.php @@ -0,0 +1,62 @@ +
+ +
+
+ API Keys + — Neuen Key erstellen +
+ +
+ +
+ +
+ + + @error('name')
{{ $message }}
@enderror +
+ +
+
+ + +
+
+ @foreach($scopes as $key => $label) + + @endforeach +
+ @error('selected')
{{ $message }}
@enderror +
+ +
+ +
+ +
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/system/modal/api-key-show-modal.blade.php b/resources/views/livewire/ui/system/modal/api-key-show-modal.blade.php new file mode 100644 index 0000000..11280b1 --- /dev/null +++ b/resources/views/livewire/ui/system/modal/api-key-show-modal.blade.php @@ -0,0 +1,43 @@ +
+ +
+
+ Key erstellt + — einmalig anzeigen +
+ +
+ +
+ +
+
Diesen Key jetzt kopieren!
+
Der Token wird nur dieses eine Mal angezeigt. Danach kann er nicht mehr eingesehen werden.
+
+ +
+
+ {{ $plainText }} +
+ +
+ +
+ Verwende diesen Token im HTTP-Header:
+ Authorization: Bearer <token> +
+ +
+ +
+ +
+ +
diff --git a/resources/views/livewire/ui/system/modal/backup-delete-modal.blade.php b/resources/views/livewire/ui/system/modal/backup-delete-modal.blade.php new file mode 100644 index 0000000..8129754 --- /dev/null +++ b/resources/views/livewire/ui/system/modal/backup-delete-modal.blade.php @@ -0,0 +1,31 @@ +
+ +
+
+

Backup löschen

+ {{ $filename }} +
+ +
+ +
+
+ + Diese Aktion kann nicht rückgängig gemacht werden. +
+
+ Der Backup-Eintrag und die Archiv-Datei werden endgültig gelöscht. +
+
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/system/modal/backup-progress-modal.blade.php b/resources/views/livewire/ui/system/modal/backup-progress-modal.blade.php new file mode 100644 index 0000000..eaddfa2 --- /dev/null +++ b/resources/views/livewire/ui/system/modal/backup-progress-modal.blade.php @@ -0,0 +1,158 @@ +
+ + @php + $isRestore = !empty($restoreToken); + if ($isRestore) { + $rs = $this->restoreStatus; + $status = $rs['status'] ?? 'queued'; + $log = $rs['log'] ?? ''; + } else { + $job = $this->job; + $status = $job?->status ?? 'queued'; + $log = $job?->log_excerpt ?: ($job?->error ?? ''); + } + $done = in_array($status, ['ok','failed','canceled']); + $elapsed = 0; + if (!$isRestore && ($job?->started_at ?? null)) { + $elapsed = $done && ($job->finished_at ?? null) + ? $job->started_at->diffInSeconds($job->finished_at) + : now()->diffInSeconds($job->started_at); + } + $elapsedFmt = $elapsed >= 60 + ? floor($elapsed/60) . ' min ' . ($elapsed % 60) . 's' + : $elapsed . 's'; + @endphp + +
+
+ {{ $isRestore ? 'Restore' : 'Backup' }} + — {{ $isRestore ? 'Wiederherstellung' : 'Sicherung' }} +
+ @if($done) + + @endif +
+ +
+ + {{-- Status-Zeile --}} +
+ + @if($status === 'queued') +
+ +
+
+
Warteschlange…
+
Prozess wird gestartet
+
+ + @elseif($status === 'running') +
+ +
+
+
+ {{ $isRestore ? 'Wiederherstellung läuft…' : 'Sicherung läuft…' }} +
+ @if(!$isRestore && $elapsed > 0) +
Verstrichene Zeit: {{ $elapsedFmt }}
+ @endif +
+ + @elseif($status === 'ok') +
+ +
+
+
+ {{ $isRestore ? 'Wiederherstellung erfolgreich' : 'Backup erfolgreich' }} +
+ @if(!$isRestore) +
+ Dauer: {{ $elapsedFmt }} + @if($job?->size_bytes > 0) +  ·  + @php $u=['B','KB','MB','GB']; $i=0; $v=(float)$job->size_bytes; + while($v>=1024&&$i<3){$v/=1024;$i++;} echo number_format($v,$i<=1?0:1).' '.$u[$i]; @endphp + @endif +
+ @endif +
+ + @elseif($status === 'failed') +
+ +
+
+
+ {{ $isRestore ? 'Wiederherstellung fehlgeschlagen' : 'Backup fehlgeschlagen' }} +
+ @if(!$isRestore) +
Dauer: {{ $elapsedFmt }}
+ @endif +
+ @endif + +
+ + {{-- Fortschrittsbalken --}} + @if(!$done) +
+
+
+ @endif + + {{-- Log --}} + @if($log) +
+
Ausgabe
+
{{ $log }}
+
+ @endif + + {{-- Hinweis --}} + @if(!$done) +
+ + {{ $isRestore ? 'Die Wiederherstellung läuft im Hintergrund weiter.' : 'Du kannst dieses Fenster schließen — das Backup läuft im Hintergrund weiter.' }} +
+ @endif + + @if($isRestore && $status === 'ok') +
+ + Seite neu laden um die wiederhergestellten Daten zu sehen. +
+ @endif + +
+ +
+ @if(!$isRestore) + Verlauf + @else +
+ @endif +
+ + @if($isRestore && $status === 'ok') + + @endif +
+
+ + + +
diff --git a/resources/views/livewire/ui/system/modal/backup-restore-confirm-modal.blade.php b/resources/views/livewire/ui/system/modal/backup-restore-confirm-modal.blade.php new file mode 100644 index 0000000..7b0c952 --- /dev/null +++ b/resources/views/livewire/ui/system/modal/backup-restore-confirm-modal.blade.php @@ -0,0 +1,35 @@ +
+ +
+
+

Backup wiederherstellen

+ {{ $startedAt }} +
+ +
+ +
+
+ + Der aktuelle Mailserver-Zustand wird überschrieben. +
+
+ Datenbank, E-Mails und Konfiguration werden aus dem Backup vom + {{ $startedAt }} wiederhergestellt. +
+
+ {{ $filename }} +
+
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/system/modal/installer-confirm-modal.blade.php b/resources/views/livewire/ui/system/modal/installer-confirm-modal.blade.php new file mode 100644 index 0000000..2e1949a --- /dev/null +++ b/resources/views/livewire/ui/system/modal/installer-confirm-modal.blade.php @@ -0,0 +1,43 @@ +
+ +
+
+

Installation bestätigen

+ + {{ $component === 'all' ? 'Komplett-Installation' : ucfirst($component) }} + +
+ +
+ +
+
+ + Diese Aktion führt Systemänderungen mit Root-Rechten durch. +
+
+ @if($component === 'all') + Die Komplett-Installation konfiguriert alle Komponenten + (Nginx, Postfix, Dovecot, Rspamd, Fail2ban, SSL) neu. + @else + Die Komponente {{ ucfirst($component) }} + wird installiert bzw. neu konfiguriert. + @endif +
+
+ Laufende Dienste können kurzzeitig unterbrochen werden. + Der Fortschritt wird im Log-Viewer angezeigt. +
+
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/system/modal/ssl-provision-modal.blade.php b/resources/views/livewire/ui/system/modal/ssl-provision-modal.blade.php new file mode 100644 index 0000000..4d02c77 --- /dev/null +++ b/resources/views/livewire/ui/system/modal/ssl-provision-modal.blade.php @@ -0,0 +1,32 @@ +
+ +
+
+

SSL manuell erzwingen

+ certbot · Let's Encrypt +
+ +
+ +
+
+ + Bereits vorhandene Zertifikate (z. B. von NPM) können überschrieben werden. +
+
+ certbot wird jetzt für alle drei konfigurierten Domains ausgeführt. + Stelle sicher, dass Port 80 erreichbar ist und kein anderer Dienst den ACME-Challenge blockiert. +
+
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/system/modal/totp-setup-modal.blade.php b/resources/views/livewire/ui/system/modal/totp-setup-modal.blade.php new file mode 100644 index 0000000..f9a043f --- /dev/null +++ b/resources/views/livewire/ui/system/modal/totp-setup-modal.blade.php @@ -0,0 +1,74 @@ +
+ +
+
+ 2FA + — TOTP einrichten +
+ +
+ +
+ + @if($step === 'scan') +
+
QR-Code mit Google Authenticator, Authy oder einer TOTP-App scannen.
+ + {{-- QR Code --}} +
+ {!! $qrSvg !!} +
+ + {{-- Manual secret --}} +
Manueller Schlüssel
+
+ {{ chunk_split($secret, 4, ' ') }} +
+
+ +
+ + + @error('code')
{{ $message }}
@enderror +
6-stelligen Code aus deiner App eingeben um die Einrichtung abzuschließen.
+
+ + @elseif($step === 'codes') +
+
+
Recovery-Codes — jetzt speichern!
+
Diese Codes werden nur einmal angezeigt. Bewahre sie sicher auf.
+
+
+ @foreach($recoveryCodes as $rc) +
+ {{ $rc }} +
+ @endforeach +
+
+ @endif + +
+ +
+ @if($step === 'scan') + + + @elseif($step === 'codes') + + @endif +
+ +
diff --git a/resources/views/livewire/ui/system/modal/user-create-modal.blade.php b/resources/views/livewire/ui/system/modal/user-create-modal.blade.php new file mode 100644 index 0000000..175f02b --- /dev/null +++ b/resources/views/livewire/ui/system/modal/user-create-modal.blade.php @@ -0,0 +1,58 @@ +
+ +
+
+ Benutzer + — Neu anlegen +
+ +
+ +
+
+
+ + + @error('name')
{{ $message }}
@enderror +
+
+ + + @error('email')
{{ $message }}
@enderror +
+
+
+ + + @error('password')
{{ $message }}
@enderror +
+
+
+ + +
Admin = voller Zugriff · Operator = Mail/Domains · Viewer = nur lesen
+
+
+ +
+
+
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/system/modal/user-delete-modal.blade.php b/resources/views/livewire/ui/system/modal/user-delete-modal.blade.php new file mode 100644 index 0000000..b5179d5 --- /dev/null +++ b/resources/views/livewire/ui/system/modal/user-delete-modal.blade.php @@ -0,0 +1,30 @@ +
+ +
+
+ Benutzer + — Löschen +
+ +
+ +
+
+
{{ $userName }}
+
+ Dieser Benutzer wird dauerhaft gelöscht und kann sich nicht mehr anmelden. +
+
+
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/system/modal/user-edit-modal.blade.php b/resources/views/livewire/ui/system/modal/user-edit-modal.blade.php new file mode 100644 index 0000000..6efbf8d --- /dev/null +++ b/resources/views/livewire/ui/system/modal/user-edit-modal.blade.php @@ -0,0 +1,60 @@ +
+ +
+
+ Benutzer + — {{ $name }} +
+ +
+ +
+
+
+ + + @error('name')
{{ $message }}
@enderror +
+
+ + + @error('email')
{{ $message }}
@enderror +
+
+
+ + + @error('password')
{{ $message }}
@enderror +
+
+
+ + + @if($isSelf) +
Eigene Rolle kann nicht geändert werden.
+ @endif +
+
+ +
+
+
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/system/modal/webhook-create-modal.blade.php b/resources/views/livewire/ui/system/modal/webhook-create-modal.blade.php new file mode 100644 index 0000000..22027d6 --- /dev/null +++ b/resources/views/livewire/ui/system/modal/webhook-create-modal.blade.php @@ -0,0 +1,69 @@ +
+ +
+
+ Webhooks + — Neu anlegen +
+ +
+ +
+ +
+
+ + + @error('name')
{{ $message }}
@enderror +
+
+ +
+
+ +
+ + + @error('url')
{{ $message }}
@enderror +
+ +
+
+ + +
+
+ @foreach($allEvents as $key => $label) + + @endforeach +
+ @error('selected')
{{ $message }}
@enderror +
+ +
+ Ein HMAC-SHA256 Secret wird automatisch generiert und kann nach dem Erstellen im Edit-Dialog eingesehen werden. +
+ +
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/system/modal/webhook-delete-modal.blade.php b/resources/views/livewire/ui/system/modal/webhook-delete-modal.blade.php new file mode 100644 index 0000000..4e46f96 --- /dev/null +++ b/resources/views/livewire/ui/system/modal/webhook-delete-modal.blade.php @@ -0,0 +1,30 @@ +
+ +
+
+ Löschen + — Webhook entfernen +
+ +
+ +
+
+ Webhook {{ $webhookName }} wirklich löschen? +
+
+ Diese Aktion kann nicht rückgängig gemacht werden. Das Secret wird unwiderruflich gelöscht. +
+
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/system/modal/webhook-edit-modal.blade.php b/resources/views/livewire/ui/system/modal/webhook-edit-modal.blade.php new file mode 100644 index 0000000..f8f3440 --- /dev/null +++ b/resources/views/livewire/ui/system/modal/webhook-edit-modal.blade.php @@ -0,0 +1,81 @@ +
+ +
+
+ Webhooks + — Bearbeiten +
+ +
+ +
+ +
+
+ + + @error('name')
{{ $message }}
@enderror +
+
+ +
+
+ +
+ + + @error('url')
{{ $message }}
@enderror +
+ +
+
+ + +
+
+ @foreach($allEvents as $key => $label) + + @endforeach +
+ @error('selected')
{{ $message }}
@enderror +
+ + {{-- Secret --}} +
+ +
+ + +
+
Für HMAC-SHA256 Signaturverifikation. Nur einmalig sicher speichern.
+
+ +
+ +
+ + +
+ +
diff --git a/resources/views/livewire/ui/system/partials/api-endpoint-list.blade.php b/resources/views/livewire/ui/system/partials/api-endpoint-list.blade.php new file mode 100644 index 0000000..37dd590 --- /dev/null +++ b/resources/views/livewire/ui/system/partials/api-endpoint-list.blade.php @@ -0,0 +1,24 @@ +@php +$methodColors = [ + 'GET' => ['bg' => 'rgba(59,130,246,.1)', 'bd' => 'rgba(59,130,246,.25)', 'tx' => '#93c5fd'], + 'POST' => ['bg' => 'rgba(16,185,129,.1)', 'bd' => 'rgba(16,185,129,.25)', 'tx' => '#6ee7b7'], + 'PATCH' => ['bg' => 'rgba(251,191,36,.1)', 'bd' => 'rgba(251,191,36,.25)', 'tx' => '#fcd34d'], + 'DELETE' => ['bg' => 'rgba(239,68,68,.1)', 'bd' => 'rgba(239,68,68,.25)', 'tx' => '#fca5a5'], +]; +@endphp + +@foreach($routes as [$method, $path, $scope, $desc, $query]) +@php $c = $methodColors[$method]; @endphp +
+ {{ $method }} +
+
+ {{ $path }}{{ $query ? '' . $query . '' : '' }} +
+
+ {{ $desc }} + {{ $scope }} +
+
+
+@endforeach diff --git a/resources/views/livewire/ui/system/sandbox-mailbox.blade.php b/resources/views/livewire/ui/system/sandbox-mailbox.blade.php new file mode 100644 index 0000000..6275144 --- /dev/null +++ b/resources/views/livewire/ui/system/sandbox-mailbox.blade.php @@ -0,0 +1,235 @@ +System +Mail-Sandbox + +
+ +
+
+ + + + + Mail-Sandbox + @if($unread > 0) + {{ $unread }} neu + @endif +
+
+
+ + +
+ @if($mails->isNotEmpty()) + + @endif +
+
+ + {{-- Mail-Client Layout --}} +
+ + {{-- ═══ Left: Mail-Liste ═══ --}} +
+ + @if($mails->isEmpty()) +
+ + + + +
+ {{ $search ? 'Keine Treffer' : 'Postfach leer' }} +
+
+ {{ $search ? 'Suche anpassen.' : 'Eingehende Mails erscheinen hier sobald der Sandbox-Transport aktiv ist.' }} +
+
+ @else + @foreach($mails as $mail) +
+ +
+
+ @if(!$mail->is_read) +
+ @endif + + {{ $mail->from_name ?: $mail->from_address }} + +
+ {{ $mail->received_at->format('H:i') }} +
+ +
+ {{ $mail->subject ?: '(kein Betreff)' }} +
+
+ An: {{ $mail->to_preview }} +
+
+ @endforeach + @endif + +
+ + {{-- ═══ Right: Mail-Detail ═══ --}} +
+ + @if(!$selected) +
+ + + + +
Nachricht auswählen
+
+ @else + {{-- Header --}} +
+
+

{{ $selected->subject ?: '(kein Betreff)' }}

+ +
+ +
+
+ Von + {{ $selected->sender }} +
+
+ An + {{ implode(', ', $selected->to_addresses) }} +
+
+ Zeit + {{ $selected->received_at->format('d.m.Y H:i:s') }} +
+ @if($selected->message_id) +
+ ID + {{ $selected->message_id }} +
+ @endif +
+
+ + {{-- Body Tabs --}} +
+ +
+ @if($selected->body_html) + + @endif + @if($selected->body_text) + + @endif + +
+ + @if($selected->body_html) +
+ +
+ @endif + + @if($selected->body_text) +
+
{{ $selected->body_text }}
+
+ @endif + +
+
{{ $selected->raw_headers }}
+
+ + @if(!$selected->body_html && !$selected->body_text) +
Kein Inhalt
+ @endif + +
+ @endif + +
+ +
+ + {{-- Setup Guide --}} +
+
+
+
+ Postfix-Konfiguration + — Sandbox-Transport aktivieren +
+
+
+ +
+
1. master.cf — Transport anlegen
+
sandbox unix - n n - - pipe
+  flags=Rq user=www-data
+  argv=/usr/bin/php
+  {{ base_path('artisan') }}
+  sandbox:receive
+  --to=${recipient}
+
+ +
+
2. main.cf — Transport aktivieren
+
# Alle Mails abfangen:
+default_transport = sandbox
+
+# Oder nur bestimmte Domains:
+transport_maps =
+  hash:/etc/postfix/transport
+
+# /etc/postfix/transport:
+# example.com  sandbox:
+
+ +
+
3. Reload & Test
+
postmap /etc/postfix/transport
+postfix reload
+
+# Test:
+echo "Test" | mail \
+  -s "Sandbox-Test" \
+  user@example.com
+
+ Die Mail erscheint nach wenigen Sekunden hier (Auto-Refresh alle 5s). +
+
+ +
+
+
+ +
diff --git a/resources/views/livewire/ui/system/settings-form.blade.php b/resources/views/livewire/ui/system/settings-form.blade.php index 4bc922b..382857c 100644 --- a/resources/views/livewire/ui/system/settings-form.blade.php +++ b/resources/views/livewire/ui/system/settings-form.blade.php @@ -1,106 +1,343 @@ -{{-- resources/views/livewire/ui/system/settings-form.blade.php --}} -
- {{-- Tabs --}} -
- - - -
+System +Einstellungen - {{-- Allgemein --}} -
-
-
Instanzname
-
{{ $instance_name }}
+
+ +
+
+ + + + + Einstellungen
- -
- - -
- -
- - -
- -
- - +
+
- {{-- Domains & SSL --}} -
-
- - -
+
-
- - -
+ {{-- ═══ Main ═══ --}} +
+
-
- - -
- -
-
-
-
SSL automatisch erstellen (Let's Encrypt)
-
Zertifikate werden automatisch angelegt und verlängert.
+ {{-- Allgemein --}} +
+
+
+ Allgemein +
+
+
+
+
+ + +
Wird aus config/app.php gelesen (APP_NAME)
+
+
+ + +
+
+ + +
Aktuelle Serverzeit: {{ now()->format('H:i:s') }}
+
+
+ + +
5–1440 Minuten (24 h max.)
+
+
+
- -
-
-
- {{-- Sicherheit --}} -
-
-
-
-
Zwei-Faktor-Authentifizierung
-
TOTP/WebAuthn aktivieren.
+ {{-- 2FA --}} + + + {{-- Backup --}} +
+
+
+ Backup-Zeitplan +
+
+ @if($backup_enabled) + Aktiv + @else + Deaktiviert + @endif + Verlauf +
+
+
+ + {{-- Enable toggle --}} + + + {{-- Was sichern --}} +
+ +
+ + + +
+ @if($backup_include_maildirs) +
+ +
Maildir-Pfad – leer lassen für automatische Erkennung
+
+ @endif +
+ + @if($backup_enabled) + + {{-- Preset buttons --}} +
+ +
+ @foreach(['hourly' => 'Stündlich', 'daily' => 'Täglich', 'weekly' => 'Wöchentlich', 'monthly' => 'Monatlich', 'custom' => 'Benutzerdefiniert'] as $val => $label) + + @endforeach +
+
+ + {{-- Time / day pickers --}} + @if($backup_preset !== 'hourly' && $backup_preset !== 'custom') +
+
+ + +
+ + @if($backup_preset === 'weekly') +
+ + +
+ @elseif($backup_preset === 'monthly') +
+ + +
+ @endif +
+ @endif + + @if($backup_preset === 'custom') +
+ + +
Standard Cron-Syntax: Minute Stunde Tag Monat Wochentag
+
+ @endif + + {{-- Cron-Vorschau --}} +
+ + Cron: + {{ $backup_cron }} +
+ + {{-- Aufbewahrung --}} +
+ + +
Ältere Backups werden automatisch gelöscht. Empfehlung: 7–14.
+
+ + @endif + +
- + + {{-- Sicherheit --}} +
+
+
+ Sicherheit +
+
+
+
+
+ + +
Max. Versuche pro Minute
+
+
+ + +
Zeichen (min. 6)
+
+
+
+
+
-
- - + {{-- ═══ Sidebar ═══ --}} +
+
+ + {{-- Domains --}} +
+
+
+ Domains +
+
+
+ +
+ + + + + + + Änderungen hier wirken sich auf das gesamte Routing aus — UI, Webmail und Mailserver sind betroffen. Nur ändern wenn DNS bereits korrekt konfiguriert ist. + +
+ +
+ + + @error('ui_domain')
{{ $message }}
@enderror +
+
+ + + @error('mail_domain')
{{ $message }}
@enderror +
+
+ + + @error('webmail_domain')
{{ $message }}
@enderror +
+ + @if(app()->isProduction()) +
+ + Let's Encrypt SSL wird automatisch eingerichtet +
+ @else +
+ + Local-Modus — SSL wird beim Speichern übersprungen (NPM übernimmt) +
+ + @endif +
+
+ + {{-- Info --}} +
+
+
+ System-Info +
+
+
+
+ PHP + {{ PHP_VERSION }} +
+
+ Laravel + {{ app()->version() }} +
+
+ Umgebung + {{ app()->environment() }} +
+
+ Hostname + {{ gethostname() }} +
+
+ Certbot + {{ trim(@shell_exec('certbot --version 2>&1') ?? '—') ?: '—' }} +
+
+
+ +
-
- - -
- {{-- Footer: Aktionen --}} -
- -
diff --git a/resources/views/livewire/ui/system/two-fa-status.blade.php b/resources/views/livewire/ui/system/two-fa-status.blade.php new file mode 100644 index 0000000..da7cd40 --- /dev/null +++ b/resources/views/livewire/ui/system/two-fa-status.blade.php @@ -0,0 +1,55 @@ +
+
+
+ Zwei-Faktor-Authentifizierung +
+ @if($enabled) + Aktiv + @else + Nicht eingerichtet + @endif +
+
+ + {{-- Login-Flow Hinweis --}} +
+ + + + + +
+ Beim Login wird geprüft ob 2FA aktiv ist — wenn ja, wird nach dem Passwort ein + 6-stelliger TOTP-Code verlangt. + Ohne eingerichtetes 2FA ist kein Code nötig. +
+
+ + @if($enabled) +
+ Dein Account ist mit TOTP geschützt. Generiere deinen Code mit + Google Authenticator, Authy oder einer kompatiblen App. +
+
+ +
+ @else +
+ Ohne 2FA ist dein Account nur mit Passwort geschützt. Richte TOTP ein um die Sicherheit zu erhöhen. +
+
+ +
+ @endif + +
+
diff --git a/resources/views/livewire/ui/system/update-page.blade.php b/resources/views/livewire/ui/system/update-page.blade.php new file mode 100644 index 0000000..38acbe0 --- /dev/null +++ b/resources/views/livewire/ui/system/update-page.blade.php @@ -0,0 +1,211 @@ +System +Updates + +
+ + {{-- ═══ Page Header ═══ --}} +
+
+ + + + + Updates +
+
+ + + @if($hasUpdate) + + @endif +
+
+ + {{-- Polling when running --}} + @if($state === 'running' || $running) +
+ @endif + +
+ + {{-- ═══ Section 1: Version Info ═══ --}} +
+
+
+ Versionsinformation +
+
+ @if($state === 'running') + + Update läuft … + + @elseif($rc !== null && $rc !== 0) + Fehlgeschlagen (rc={{ $rc }}) + @elseif($hasUpdate) + Update verfügbar + @else + Aktuell + @endif +
+
+
+
+
+
Installierte Version
+
+ {{ $displayCurrent ?? '—' }} +
+
+
+
Verfügbare Version
+
+ {{ $displayLatest ?? '—' }} +
+
+
+ @if(!$hasUpdate && $displayCurrent) +
+ + Du bist auf dem neuesten Stand. +
+ @endif +
+
+ + {{-- ═══ Section 2: Status ═══ --}} +
+
+
+ Update-Status +
+
+
+ + @if($state === 'running') + {{-- Running state --}} +
+
+ +
+
+
Update wird installiert …
+
Bitte nicht unterbrechen. Die Seite aktualisiert sich automatisch.
+
+
+ @elseif($rc !== null && $rc !== 0) + {{-- Error state --}} +
+ +
+
Update fehlgeschlagen (rc={{ $rc }})
+
Bitte das Log unten prüfen. Der Mailserver bleibt weiter in Betrieb.
+
+
+ @elseif($rc === 0) + {{-- Success state --}} +
+ + Update erfolgreich abgeschlossen. +
+ @else + {{-- Idle state --}} +
Kein Update aktiv. Klicke "Auf Updates prüfen" um die neueste Version zu ermitteln.
+ @endif + + {{-- Progress Bar --}} + @if($state === 'running' || $progressPct > 0) +
+
+ Fortschritt + {{ $progressPct }}% +
+
+
+
+
+ @endif + +
+
+ + {{-- ═══ Section 3: Log Viewer ═══ --}} +
+
+
+ Update-Log +
+
+ + +
+
+
+
+ @if(count($logLines) === 0) + Keine Log-Einträge vorhanden. + @else + @foreach($logLines as $line) + @php + $color = 'inherit'; + if (str_contains($line, '[!]') || str_contains($line, 'error') || str_contains($line, 'Error') || str_contains($line, 'fehlgeschlagen')) { + $color = '#f87171'; + } elseif (str_contains($line, '[✓]') || str_contains($line, 'beendet') || str_contains($line, 'abgeschlossen')) { + $color = 'rgba(34,197,94,.85)'; + } elseif (str_contains($line, '[i]')) { + $color = 'var(--mw-t3)'; + } elseif (str_contains($line, '=====')) { + $color = 'rgba(14,165,233,.8)'; + } + @endphp + {{ $line }} +
+ @endforeach + @endif +
+
+ {{ count($logLines) }} Zeilen · /var/log/mailwolt-update.log +
+
+
+ +
+ +
diff --git a/resources/views/livewire/ui/system/user-table.blade.php b/resources/views/livewire/ui/system/user-table.blade.php new file mode 100644 index 0000000..f35e9cf --- /dev/null +++ b/resources/views/livewire/ui/system/user-table.blade.php @@ -0,0 +1,136 @@ +System +Benutzer + +
+ +
+
+ + + + + + Benutzer + {{ $users->count() }} +
+
+
+ + + + + +
+ +
+
+ +
+
+ + {{-- Role filter --}} +
+ Rolle: + + @foreach($roles as $role) + + @endforeach +
+ + @if($users->isEmpty()) +
+ + + + +
Keine Benutzer gefunden
+
+ @else +
+ + + + + + + + + + + + + @foreach($users as $user) + + + + + + + + + @endforeach + +
BenutzerE-MailRolleStatusLetzter LoginAktionen
+
+
+ {{ strtoupper(substr($user->name, 0, 2)) }} +
+
+
{{ $user->name }}
+ @if($user->id === auth()->id()) +
Du
+ @endif +
+
+
{{ $user->email }} + + {{ $user->role?->label() ?? '—' }} + + + @if($user->is_active) + Aktiv + @else + Inaktiv + @endif + + {{ $user->last_login_at?->diffForHumans() ?? '—' }} + +
+ + @if($user->id !== auth()->id()) + + + @endif +
+
+
+ @endif + +
+
+ +
diff --git a/resources/views/livewire/ui/system/webhook-table.blade.php b/resources/views/livewire/ui/system/webhook-table.blade.php new file mode 100644 index 0000000..9aa7c12 --- /dev/null +++ b/resources/views/livewire/ui/system/webhook-table.blade.php @@ -0,0 +1,214 @@ +System +Webhooks + +
+ +
+
+ + + + + + Webhooks + {{ $webhooks->count() }} +
+
+ +
+
+ +
+ + {{-- ═══ Left ═══ --}} +
+ +
+
+
+ Konfigurierte Webhooks +
+
+ + @if($webhooks->isEmpty()) +
+ + + + +
Keine Webhooks konfiguriert
+
Verbinde externe Systeme — sie werden bei Events automatisch benachrichtigt.
+
+ @else +
+ + + + + + + + + + + + + @foreach($webhooks as $wh) + + + + + + + + + @endforeach + +
WebhookEventsStatusHTTPZuletzt ausgelöstAktionen
+
+
+
+
{{ $wh->name }}
+
{{ $wh->url }}
+
+
+
+
+ @foreach($wh->events as $ev) + {{ $ev }} + @endforeach +
+
+ @if($wh->is_active) + Aktiv + @else + Pausiert + @endif + + @if($wh->last_status === null) + + @elseif($wh->last_status >= 200 && $wh->last_status < 300) + {{ $wh->last_status }} + @elseif($wh->last_status === 0) + Timeout + @else + {{ $wh->last_status }} + @endif + + {{ $wh->last_triggered_at?->diffForHumans() ?? '—' }} + +
+ + + +
+
+
+ @endif +
+ +
+ + {{-- ═══ Right: Docs ═══ --}} +
+ + {{-- Payload --}} +
+
+
+ Payload-Format +
+
+
+
Mailwolt sendet einen HTTP POST mit JSON-Body:
+
{
+  "event": "mailbox.created",
+  "timestamp": "2026-04-21T12:00:00Z",
+  "payload": { ... }
+}
+
+
+ + {{-- Signatur --}} +
+
+
+ Signatur prüfen +
+
+
+
Jeder Request enthält den Header:
+
X-Mailwolt-Sig: sha256=<hmac>
+X-Mailwolt-Event: mailbox.created
+
HMAC-SHA256 über den rohen Request-Body mit deinem Webhook-Secret.
+
# PHP
+hash_hmac('sha256', $body, $secret);
+
+# Python
+hmac.new(secret, body, sha256).hexdigest()
+
+
+ + {{-- Events --}} +
+
+
+ Verfügbare Events +
+
+
+ @foreach($allEvents as $ev => $label) +
+ {{ $ev }} + {{ $label }} +
+ @endforeach +
+
+ + {{-- Retry --}} +
+
+
+ Verhalten +
+
+
+
+ + Timeout: 8 Sekunden +
+
+ + Kein automatischer Retry — HTTP-Status wird gespeichert +
+
+ + Antwort-Code 2xx = Erfolg, alles andere wird als Fehler markiert +
+
+
+ +
+ +
+ +
diff --git a/resources/views/livewire/ui/v2/mail/mailbox-list.blade.php b/resources/views/livewire/ui/v2/mail/mailbox-list.blade.php new file mode 100644 index 0000000..f179c2b --- /dev/null +++ b/resources/views/livewire/ui/v2/mail/mailbox-list.blade.php @@ -0,0 +1,154 @@ +
+ + {{-- Page Header --}} +
+
+ + + + + Postfächer + {{ $totalMailboxes }} +
+
+
+ + + + + +
+ +
+
+ + {{-- Domain Sections --}} + @if($domains->count()) +
+ @foreach($domains as $domain) +
+ + {{-- Domain Header --}} +
+
+
+ + + + + +
+ {{ $domain->domain }} + {{ $domain->mail_users_count }} Postfächer + @if(!($domain->is_active ?? true)) + Domain inaktiv + @endif +
+ +
+ + {{-- Mailbox Table --}} + @if(count($domain->prepared_mailboxes)) +
+ + + + + + + + + + + + + @foreach($domain->prepared_mailboxes as $u) + + + + + + + + + @endforeach + +
E-MailStatusQuotaAuslastungE-MailsAktionen
+
+
{{ strtoupper(substr($u['localpart'], 0, 1)) }}
+ {{ $u['localpart'] }}@{{ $domain->domain }} +
+
+ @if($u['is_effective_active']) + Aktiv + @elseif($u['inactive_reason']) + {{ $u['inactive_reason'] }} + @else + Inaktiv + @endif + {{ $u['quota_mb'] }} MiB +
+
+
+
+ {{ $u['usage_percent'] }}% +
+
{{ number_format($u['message_count']) }} +
+ + +
+
+
+ @else +
+ Keine Postfächer vorhanden. + +
+ @endif + +
+ @endforeach +
+ @else +
+ + + + + @if(trim($search)) + Keine Ergebnisse für „{{ $search }}". + @else + Noch keine Postfächer vorhanden. + @endif +
+ @endif + +
diff --git a/resources/views/livewire/ui/webmail/compose.blade.php b/resources/views/livewire/ui/webmail/compose.blade.php new file mode 100644 index 0000000..28df66b --- /dev/null +++ b/resources/views/livewire/ui/webmail/compose.blade.php @@ -0,0 +1,178 @@ +
+ +
+
+ + @if($isDraft) Entwurf bearbeiten + @elseif($isReply) Antworten + @else Neue Nachricht + @endif +
+
+ +
+
+ + @if($isDraft) +
+ + + + + Entwurf — Änderungen werden beim Zurückgehen automatisch gespeichert. +
+ @elseif($isReply) +
+ + + + Antwort auf: {{ $subject }} +
+ @endif + + @if($sent) +
+ + + + Nachricht erfolgreich gesendet. +
+ @endif + +
+
+ + {{-- An --}} +
+ An + + @error('to') + {{ $message }} + @enderror +
+ + {{-- Betreff --}} +
+ Betreff + + @error('subject') + {{ $message }} + @enderror +
+ + {{-- Body --}} +
+ + @error('body') +
{{ $message }}
+ @enderror +
+ + {{-- Signatur-Vorschau --}} + @if($signatureRaw) +
+
Signatur
+ @if($signatureIsHtml) +
+ {!! $signatureRaw !!} +
+ @else +
{{ $signatureRaw }}
+ @endif +
+ @endif + + {{-- Anhänge --}} + @if(count($stagedAttachments) > 0) +
+ Anhänge: + @foreach($stagedAttachments as $i => $att) + + + + + + {{ $att['name'] }} + ({{ round($att['size'] / 1024, 1) }} KB) + + + @endforeach + Lädt … +
+ @else +
+ Datei wird hochgeladen … +
+ @endif + +
+ + {{-- Aktionen --}} +
+ + + + {{-- Datei anhängen --}} + +
+
+ +
diff --git a/resources/views/livewire/ui/webmail/folder-sidebar-placeholder.blade.php b/resources/views/livewire/ui/webmail/folder-sidebar-placeholder.blade.php new file mode 100644 index 0000000..d7781d0 --- /dev/null +++ b/resources/views/livewire/ui/webmail/folder-sidebar-placeholder.blade.php @@ -0,0 +1,14 @@ +
+ @for($i = 0; $i < 6; $i++) + @php + $w = [62, 50, 68, 55, 72, 45][$i]; + $delay = round($i * 0.1, 1); + @endphp +
+
+
+
+ @endfor +
diff --git a/resources/views/livewire/ui/webmail/folder-sidebar.blade.php b/resources/views/livewire/ui/webmail/folder-sidebar.blade.php new file mode 100644 index 0000000..7b8b4f5 --- /dev/null +++ b/resources/views/livewire/ui/webmail/folder-sidebar.blade.php @@ -0,0 +1,74 @@ +@php +$currentFolder = request()->query('folder', 'INBOX'); +$starredActive = $currentFolder === '_starred'; +$folderIcons = [ + 'INBOX' => '', + 'Sent' => '', + 'Drafts' => '', + 'Junk' => '', + 'Trash' => '', + 'Archive' => '', +]; +$folderLabels = [ + 'INBOX' => 'Posteingang', + 'Sent' => 'Gesendet', + 'Drafts' => 'Entwürfe', + 'Junk' => 'Spam', + 'Trash' => 'Papierkorb', + 'Archive' => 'Archiv', +]; +@endphp + +
+ @php + $inboxFolder = collect($folders)->firstWhere('name', 'INBOX'); + $otherFolders = collect($folders)->filter(fn($f) => $f['name'] !== 'INBOX')->values(); + @endphp + + {{-- Posteingang (immer oben, prominent) --}} + @if($inboxFolder) + @php $active = $currentFolder === $inboxFolder['path']; @endphp + + @if($active) + + @endif + + {!! $folderIcons['INBOX'] !!} + + Posteingang + + @endif + + {{-- Markiert (zweite Stelle) --}} + + + Markiert + + + {{-- Trennlinie --}} +
+ + {{-- Restliche Ordner --}} + @foreach($otherFolders as $f) + @php + $active = $currentFolder === $f['path']; + $label = $folderLabels[$f['name']] ?? $f['name']; + $icon = $folderIcons[$f['name']] ?? ''; + @endphp + + @if($active) + + @endif + + {!! $icon !!} + + {{ $label }} + + @endforeach +
diff --git a/resources/views/livewire/ui/webmail/inbox.blade.php b/resources/views/livewire/ui/webmail/inbox.blade.php new file mode 100644 index 0000000..4dd14fb --- /dev/null +++ b/resources/views/livewire/ui/webmail/inbox.blade.php @@ -0,0 +1,612 @@ +
+@php + $folderLabels = ['INBOX'=>'Posteingang','Sent'=>'Gesendet','Drafts'=>'Entwürfe','Junk'=>'Spam','Trash'=>'Papierkorb','Archive'=>'Archiv','_starred'=>'Markiert']; + $isDrafts = $folder === 'Drafts'; + $isTrash = $folder === 'Trash'; + $hasTabs = in_array($folder, ['INBOX', '_starred']); + $unread = collect($messages)->where('seen', false)->count(); + $mailbox = session('webmail_email'); + $allUids = collect($messages)->pluck('uid')->values()->toJson(); + $moveTargets = array_filter( + ['INBOX'=>'Posteingang','Sent'=>'Gesendet','Archive'=>'Archiv','Junk'=>'Spam','Trash'=>'Papierkorb'], + fn($k) => $k !== $folder, ARRAY_FILTER_USE_KEY + ); + $tabCounts = $hasTabs ? [ + 'all' => count($messages), + 'general' => collect($messages)->where('category', 'general')->count(), + 'promo' => collect($messages)->where('category', 'promo')->count(), + 'social' => collect($messages)->where('category', 'social')->count(), + ] : []; +@endphp + +
+ + {{-- ═══ 1. TITEL ═══ --}} +
+
{{ $mailbox }}
+
+ {{ $folderLabels[$folder] ?? $folder }} + @if($unread > 0) + {{ $unread }} ungelesen + @endif +
+
+ + {{-- ═══ 2. SUCHE ═══ --}} +
+
+ + + + + + @if($searching) + + + + @elseif($search) + + @endif +
+ + @if($search && !$searching) + + @endif +
+ + {{-- ═══ 3. TOOLBAR ═══ --}} +
+ + + + + + +
+ + + +
+ + @if($isTrash && $total > 0) + + @endif + + + + + + + + Schreiben + +
+ + {{-- ═══ 4+5. TABS + TABELLE ═══ --}} +
+ + @if($hasTabs && !empty($messages)) + @php + $tabs = ['all'=>'Alle', 'general'=>'Allgemein', 'promo'=>'Werbung', 'social'=>'Soziale Medien']; + @endphp +
+ @foreach($tabs as $tabKey => $tabLabel) + @php $cnt = $tabCounts[$tabKey] ?? 0; $isActive = $tab === $tabKey; @endphp + @if($tabKey === 'all' || $cnt > 0) + + @endif + @endforeach +
+ @endif + + {{-- Skeleton: sichtbar während Livewire lädt --}} + @php $wt = 'load,switchFolder,switchTab,nextPage,prevPage,bulkDelete,bulkMoveTo,bulkMarkSeen,bulkMarkUnseen,toggleFlag,deleteDraft,emptyTrash'; @endphp + + + {{-- Tabelle: thead immer sichtbar, tbody wechselt zwischen Skeleton und Inhalt --}} + + + + + + + {{-- Betreff: nimmt den gesamten Rest --}} + + + + + + + + + + + + + + + + {{-- Skeleton tbody: via Custom-Events gesteuert --}} + + @for($i = 0; $i < 7; $i++) + @php + $senderW = [55,75,45,82,50,70,42,78,60,68][$i]; + $subjW = [72,88,55,92,65,80,60,85,70,78][$i]; + $delay = round($i * 0.09, 2); + $delay2 = round($i * 0.09 + 0.12, 2); + @endphp + + + + + + + + + + @endfor + + + {{-- Echter Inhalt tbody --}} + + @if(empty($messages)) + + + + @else + @foreach($messages as $msg) + @php + $uid = $msg['uid']; + $unseen = !($msg['seen'] ?? true); + $msgCat = $msg['category'] ?? 'general'; + if ($hasTabs && $tab !== 'all' && $msgCat !== $tab) continue; + $rowUrl = $isDrafts + ? route('ui.webmail.compose', ['draftUid'=>$uid,'draftFolder'=>$folder]) + : route('ui.webmail.view', ['uid'=>$uid,'folder'=>$folder]); + $sName = $msg['from_name'] ?: $msg['from']; + $sEmail = $msg['from']; + @endphp + + + {{-- Checkbox --}} + + + {{-- Ungelesen-Dot --}} + + + {{-- Stern --}} + + + {{-- Absender (mit Hover-Tooltip nur auf dem Namen) --}} + + + {{-- Betreff --}} + + + {{-- Datum --}} + + + {{-- ··· Aktionen --}} + + + @endforeach + @endif + +
{{ $isDrafts ? 'An' : 'Von' }}BetreffDatum
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + Keine Nachrichten +
+ + + @if($unseen) + + @endif + + @if($isDrafts) + + @else + + @endif + + @if($isDrafts) + {{ $msg['to'] ?? '—' }} + @else + {{ $sName }} + @endif + + + {{ $msg['subject'] ?: '(kein Betreff)' }} + @if($msg['flagged'] ?? false) + + @endif + @if($msg['has_attachments'] ?? false) + + + + @endif + + + {{ $msg['date'] ? \Carbon\Carbon::parse($msg['date'])->format('d.m. H:i') : '—' }} + + + +
+ + @if($total > $perPage) +
+ + {{ ($page-1)*$perPage+1 }}–{{ min($page*$perPage,$total) }} von {{ $total }} + +
+ + +
+
+ @endif + +
{{-- /mbx-section --}} + + {{-- ═══ SENDER-TOOLTIP (position:fixed, kein x-teleport) ═══ --}} +
+
+
+
+ + {{-- ═══ RECHTSKLICK-MENÜ (position:fixed, kein x-teleport) ═══ --}} +
+ + @if(!$isDrafts) + @php $btnStyle = "display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:7px 11px;border:none;background:none;cursor:pointer;border-radius:6px;font-size:12.5px;color:var(--mw-t2);"; @endphp + + + @endif + +
+ + +
+ +
+
diff --git a/resources/views/livewire/ui/webmail/login.blade.php b/resources/views/livewire/ui/webmail/login.blade.php new file mode 100644 index 0000000..5294125 --- /dev/null +++ b/resources/views/livewire/ui/webmail/login.blade.php @@ -0,0 +1,45 @@ +
+
+
Anmelden
+
Melde dich mit deiner E-Mail-Adresse an
+
+ + @error('email') +
+ {{ $message }} +
+ @enderror + +
+
+ + +
+
+ + +
+ + +
+
diff --git a/resources/views/livewire/ui/webmail/mail-view.blade.php b/resources/views/livewire/ui/webmail/mail-view.blade.php new file mode 100644 index 0000000..ca8a342 --- /dev/null +++ b/resources/views/livewire/ui/webmail/mail-view.blade.php @@ -0,0 +1,216 @@ +
+@php + $isSpam = in_array($folder, ['Junk', 'Spam']); + $isTrash = $folder === 'Trash'; + $isDraft = $folder === 'Drafts'; + $isArchive = $folder === 'Archive'; + $flagged = $message['flagged'] ?? false; +@endphp + +
+
+ {{-- Favorit-Stern --}} + + + {{ $message['subject'] ?? '(kein Betreff)' }} + +
+
+ ← Zurück + + @if($isDraft) + Weiter bearbeiten + @else + + + + + Antworten + + @endif + + {{-- Weitere Aktionen Dropdown --}} +
+ +
+ + {{-- Favorit --}} + + + {{-- Als ungelesen markieren --}} + @if(!$isDraft) + + @endif + + @if($isSpam) + + @elseif(!$isDraft && !$isTrash) + + @endif + + {{-- Verschieben nach --}} + @if(!$isDraft) + @php + $moveTargets = [ + 'INBOX' => 'Posteingang', + 'Sent' => 'Gesendet', + 'Archive' => 'Archiv', + 'Junk' => 'Spam', + 'Trash' => 'Papierkorb', + ]; + $moveTargets = array_filter($moveTargets, fn($k) => $k !== $folder, ARRAY_FILTER_USE_KEY); + @endphp +
+ +
+ @foreach($moveTargets as $key => $label) + + @endforeach +
+
+ @endif + +
+ + +
+
+
+
+ +{{-- Meta-Info --}} +
+ + Von: + {{ $message['from_name'] ?: $message['from'] }} + @if($message['from_name']) <{{ $message['from'] }}> @endif + + An: {{ $message['to'] ?? '' }} + + Datum: + {{ isset($message['date']) ? \Carbon\Carbon::parse($message['date'])->format('d.m.Y H:i') : '—' }} + + @if($flagged) + ★ Markiert + @endif +
+ +@if(!empty($message['attachments'])) +
+ Anhänge: + @foreach($message['attachments'] as $att) + {{ $att['name'] }} ({{ round($att['size'] / 1024, 1) }} KB) + @endforeach +
+@endif + +{{-- Nachrichteninhalt --}} +
+ @if(!empty($message['html']) && !$isSpam) + + @elseif(!empty($message['html']) && $isSpam) +
+ + + + + + HTML-Inhalt wurde aus Sicherheitsgründen blockiert (Spam-Ordner). +
+
{{ $message['text'] ?: strip_tags($message['html']) }}
+ @elseif(!empty($message['text'])) +
{{ $message['text'] }}
+ @else +
Kein Inhalt
+ @endif +
+ +
diff --git a/resources/views/livewire/ui/webmail/settings.blade.php b/resources/views/livewire/ui/webmail/settings.blade.php new file mode 100644 index 0000000..136dd0d --- /dev/null +++ b/resources/views/livewire/ui/webmail/settings.blade.php @@ -0,0 +1,331 @@ +
+ + +
+
Einstellungen
+
+ + {{-- ── Tab-Nav ─────────────────────────────────────────────────────────── --}} +
+ @foreach(['allgemein'=>'Allgemein','filter'=>'Filter','abwesenheit'=>'Abwesenheit','weiterleitung'=>'Weiterleitung'] as $key=>$label) + + @endforeach +
+ +
+ + {{-- ══════════════════════════════════════════════════════════════════════ --}} + {{-- TAB: ALLGEMEIN --}} + {{-- ══════════════════════════════════════════════════════════════════════ --}} + @if($activeTab === 'allgemein') + + {{-- E-Mails lesen --}} +
+
+
+
E-Mails lesen
+
Verhalten beim Öffnen von Nachrichten
+
+
+ + {{-- autoMarkRead --}} +
+
+
Automatisch als gelesen markieren
+
+ E-Mails werden beim Öffnen automatisch als gelesen markiert. Deaktivieren, wenn du den Gelesentstatus manuell steuern möchtest. +
+ @if($autoMarkRead) +
+ Markieren nach + +
+ @endif +
+ +
+
+ + {{-- Signatur --}} +
+
+
Signatur
+
Wird automatisch an neue Nachrichten angehängt
+
+ + {{-- signatureNew --}} +
+
+
Signatur bei neuer Nachricht
+
+ Beim Öffnen von „Neue Nachricht" wird der Signaturtext automatisch ins Textfeld eingefügt. +
+
+ +
+ + {{-- signatureReply --}} +
+
+
Signatur bei Antwort
+
+ Beim Antworten auf eine E-Mail wird die Signatur automatisch oberhalb des zitierten Textes eingefügt. +
+
+ +
+ + {{-- Signaturtext --}} +
+
+ + + HTML erkannt + +
+ + + {{-- HTML-Vorschau --}} +
+
Vorschau
+
+
+ +
+ Unterstützt HTML (z.B. <b>, + <a href="…">, + <br>). + Ohne HTML: Mit -- als erste Zeile als E-Mail-Standard-Trenner. +
+
+
+ +
+ Wird gespeichert … + +
+ + {{-- ══════════════════════════════════════════════════════════════════════ --}} + {{-- TAB: FILTER --}} + {{-- ══════════════════════════════════════════════════════════════════════ --}} + @elseif($activeTab === 'filter') + +
+ + + + + + Filterregeln werden direkt auf dem Mailserver ausgeführt (Sieve). Sie wirken auf alle eingehenden E-Mails, bevor diese im Posteingang erscheinen. +
+ + {{-- Bestehende Regeln --}} + @if(count($filterRules)) +
+
Aktive Regeln
+ + + + + + + + + + + @foreach($filterRules as $rule) + @php + $fieldLabel = ['from'=>'Von','to'=>'An','subject'=>'Betreff'][$rule['field']] ?? $rule['field']; + $opLabel = ['is'=>'ist','contains'=>'enthält','domain'=>'Domain ist'][$rule['op']] ?? $rule['op']; + $actionLabel = ['discard'=>'Verwerfen','move'=>'Verschieben nach','forward'=>'Weiterleiten an'][$rule['action']] ?? $rule['action']; + $actionSuffix= in_array($rule['action'],['move','forward']) ? (' → '.($rule['folder'] ?? '')) : ''; + @endphp + + + + + + + @endforeach + +
BedingungWertAktion
{{ $fieldLabel }} {{ $opLabel }}{{ $rule['value'] }}{{ $actionLabel }}{{ $actionSuffix }} + +
+
+ @endif + + {{-- Neue Regel --}} +
+
Neue Regel
+
+
+
Feld
+ +
+
+
Operator
+ +
+
+
Wert
+ + @error('newValue')
{{ $message }}
@enderror +
+
+
Aktion
+ +
+ @if(in_array($newAction, ['move','forward'])) +
+
{{ $newAction === 'move' ? 'Zielordner' : 'Zieladresse' }}
+ +
+ @endif +
+ +
+
+
+ +
+ +
+ + {{-- ══════════════════════════════════════════════════════════════════════ --}} + {{-- TAB: ABWESENHEIT --}} + {{-- ══════════════════════════════════════════════════════════════════════ --}} + @elseif($activeTab === 'abwesenheit') + +
+ + + + + + Die Abwesenheitsnotiz wird serverseitig aktiviert und antwortet automatisch auf eingehende E-Mails — auch wenn du nicht eingeloggt bist. Antworten werden maximal einmal pro Tag an denselben Absender gesendet. +
+ +
+
+
+
Abwesenheitsnotiz aktivieren
+
+ Wenn aktiviert, erhalten Absender automatisch eine Antwort mit dem unten eingegebenen Text. +
+
+ +
+ +
+
+ + + @error('vacationSubject')
{{ $message }}
@enderror +
+
+ + + @error('vacationBody')
{{ $message }}
@enderror +
+
+
+ +
+ +
+ + {{-- ══════════════════════════════════════════════════════════════════════ --}} + {{-- TAB: WEITERLEITUNG --}} + {{-- ══════════════════════════════════════════════════════════════════════ --}} + @elseif($activeTab === 'weiterleitung') + +
+ + + + + + Die Weiterleitung wird serverseitig konfiguriert. Eingehende E-Mails werden sofort und automatisch an die Zieladresse weitergeleitet — auch ohne aktive Sitzung. Originale werden im Postfach behalten. +
+ +
+
+
+ + + @error('forwardTo')
{{ $message }}
@enderror +
+ Feld leer lassen um die Weiterleitung zu deaktivieren. +
+
+
+
+ +
+ +
+ + @endif + +
+ +
diff --git a/resources/views/ui/dashboard/redesign.blade.php b/resources/views/ui/dashboard/redesign.blade.php new file mode 100644 index 0000000..009b79c --- /dev/null +++ b/resources/views/ui/dashboard/redesign.blade.php @@ -0,0 +1,275 @@ +@extends('layouts.dvx') +@section('title', 'Dashboard · Mailwolt') +@section('breadcrumb-parent', 'Dashboard') +@section('breadcrumb', 'Übersicht') + +@section('content') + +{{-- Hero Banner --}} +
+
+ + + + + + + + +
+
+
Mail Server
+
+ Postfix + Dovecot + Rspamd + OpenDKIM + ClamAV +
+
+
+
+
+
{{ $domainCount ?? 0 }}
+
Domains
+
+
+
{{ $mailboxCount ?? 0 }}
+
Postfächer
+
+
+
{{ $servicesActive ?? 0 }}/{{ $servicesTotal ?? 0 }}
+
Dienste
+
+
+
{{ $alertCount ?? 0 }}
+
Warnungen
+
+
+
{{ $mailHostname ?? gethostname() }}
+
+ +{{-- System-Ressourcen --}} +
+ System-Ressourcen +
+
+ +
+ + @php $cpuClass = ($cpu ?? 0) > 80 ? 'mw-bar-high' : (($cpu ?? 0) > 50 ? 'mw-bar-mid' : 'mw-bar-low'); @endphp +
+
+ CPU +
+
+
{{ $cpu ?? 0 }}%
+
{{ $cpuCores ?? '—' }} Cores · {{ $cpuMhz ?? '—' }} GHz
+
+
+ + @php $ramClass = ($ramPercent ?? 0) > 80 ? 'mw-bar-high' : (($ramPercent ?? 0) > 50 ? 'mw-bar-mid' : 'mw-bar-low'); @endphp +
+
+ RAM +
+
+
{{ $ramPercent ?? 0 }}%
+
{{ $ramUsed ?? '—' }} GB / {{ $ramTotal ?? '—' }} GB
+
+
+ + @php + $loadVal = floatval($load1 ?? 0); + $loadMax = max(1, $cpuCores ?? 4); + $loadPct = min(100, round($loadVal / $loadMax * 100)); + $loadClass = $loadPct > 80 ? 'mw-bar-high' : ($loadPct > 50 ? 'mw-bar-mid' : 'mw-bar-low'); + @endphp +
+
+ Load +
+
+
{{ $load1 ?? '0.00' }}
+
{{ $load5 ?? '—' }} · {{ $load15 ?? '—' }} (1/5/15m)
+
+
+ +
+
+ Uptime +
+
+
{{ $uptimeDays ?? 0 }}d {{ $uptimeHours ?? 0 }}h
+
Stabil · kein Neustart
+
+
+ +
+ +{{-- Dienste & Schutz --}} +
+ Dienste & Schutz +
+
+ +
+ +
+
+
+ +
+
+
WoltGuard
+
System-Wächter
+
+ alle Dienste OK +
+
{{ $servicesActive ?? 0 }}/{{ $servicesTotal ?? 0 }} Dienste aktiv
+
+ +
+
+
+ +
+
+
Updates
+
{{ config('app.version', 'vdev') }}
+
+ aktuell +
+
System ist auf dem neuesten Stand.
+
+
+ +
+
+
+ +
+
+
Backup
+
Letztes: {{ $lastBackup ?? '—' }}
+
+ unbekannt +
+
Größe: {{ $backupSize ?? '—' }} · Dauer: {{ $backupDuration ?? '—' }}
+ +
+ +
+
+
+ +
+
+
Alerts
+
System-Warnungen
+
+ {{ $alertCount ?? 0 }} offen +
+
+ {{ ($alertCount ?? 0) === 0 ? 'Keine Warnungen.' : ($alertCount . ' aktive Warnungen.') }} +
+
+ +
+ +{{-- Infrastruktur --}} +
+ Infrastruktur +
+
+ +
+ +
+
Dienste & Ports
+
+
+ @foreach($services ?? [] as $svc) + @if($loop->index < 4) +
+
+
{{ $svc['name'] }} {{ $svc['type'] }}
+ {{ $svc['online'] ? 'Online' : 'Offline' }} +
+ @endif + @endforeach +
+
+ @foreach($services ?? [] as $svc) + @if($loop->index >= 4) +
+
+
{{ $svc['name'] }} {{ $svc['type'] }}
+ {{ $svc['online'] ? 'Online' : 'Offline' }} +
+ @endif + @endforeach +
+
+
+ @foreach([25, 465, 587, 110, 143, 993, 995, 80, 443] as $port) + :{{ $port }} + @endforeach +
+
+ +
+ +
+
Storage
+
+ + + + {{ $diskUsedPercent ?? 0 }}% + +
+
+
+
Belegt
+
{{ $diskUsedGb ?? '—' }} GB
+
+
+
+
Frei
+
{{ $diskFreeGb ?? '—' }} GB
+
+
+
+
Gesamt
+
{{ $diskTotalGb ?? '—' }} GB
+
+
+
+
+ +
+
Mail & Sicherheit
+
+
+
{{ $bounceCount ?? 0 }}
+
Bounces
+
+
+
{{ $spamCount ?? 0 }}
+
Spam
+
+
+
OK
+
RBL
+
+
+
+ +
+
+ +@endsection diff --git a/resources/views/ui/v2/mail/mailbox-index.blade.php b/resources/views/ui/v2/mail/mailbox-index.blade.php new file mode 100644 index 0000000..5fc72ad --- /dev/null +++ b/resources/views/ui/v2/mail/mailbox-index.blade.php @@ -0,0 +1,8 @@ +@extends('layouts.dvx') +@section('title', 'Postfächer · Mailwolt') +@section('breadcrumb-parent', 'Mail') +@section('breadcrumb', 'Postfächer') + +@section('content') +@livewire('ui.v2.mail.mailbox-list') +@endsection diff --git a/resources/views/vendor/wire-elements-modal/modal.blade.php b/resources/views/vendor/wire-elements-modal/modal.blade.php index 65b862c..2c44f3f 100644 --- a/resources/views/vendor/wire-elements-modal/modal.blade.php +++ b/resources/views/vendor/wire-elements-modal/modal.blade.php @@ -1,11 +1,9 @@ {{-- resources/views/vendor/livewire-ui-modal/modal.blade.php --}}
@isset($jsPath)@endisset - @isset($cssPath)@endisset - {{-- ❌ overflow hier entfernen (zieht sonst Artefakte) --}}
- {{-- ✅ Overlay in ZWEI Ebenen: Dim + Blur (keine Ränder/Scroll hier) --}} + {{-- Backdrop --}}
- {{-- Dim-Layer: NUR Farbe --}} -
- {{-- Blur-Layer: NUR Blur (GPU hint) --}} -
+
@@ -38,29 +33,17 @@ 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"> + class="mw-modal-box inline-block w-full sm:max-w-md relative z-10"> -
- - - - - +
+ @forelse($components as $id => $component) +
+ @livewire($component['name'], $component['arguments'], key($id)) +
+ @empty @endforelse
diff --git a/routes/api.php b/routes/api.php index 55274c1..50f3499 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,27 +1,38 @@ group(function () { -// Route::get('/tasks/active', [\App\Http\Controllers\TaskFeedController::class, 'active']); -// Route::post('/tasks/ack', [\App\Http\Controllers\TaskFeedController::class, 'ack']); -//}); -//Route::middleware('auth:sanctum')->get('tasks/active', [TaskStatusController::class, 'active']); +Route::middleware(['auth:sanctum', \App\Http\Middleware\InjectSandboxMode::class])->prefix('v1')->name('api.v1.')->group(function () { -//Route::get('tasks/active', [TaskStatusController::class, 'active']); -//Route::middleware('auth:sanctum')->group(function () { + // Mailboxes + Route::get('/mailboxes', [MailboxController::class, 'index'])->middleware('ability:mailboxes:read'); + Route::get('/mailboxes/{id}', [MailboxController::class, 'show'])->middleware('ability:mailboxes:read'); + Route::post('/mailboxes', [MailboxController::class, 'store'])->middleware('ability:mailboxes:write'); + Route::patch('/mailboxes/{id}',[MailboxController::class, 'update'])->middleware('ability:mailboxes:write'); + Route::delete('/mailboxes/{id}',[MailboxController::class, 'destroy'])->middleware('ability:mailboxes:write'); -// Route::get('/tasks/active', function (Request $r) { -//// return response()->json([ -//// 'items' => ToastBus::listForUser($r->user()->id), -//// ]); -//// }); -// -// Route::post('/tasks/{taskId}/ack', function (Request $r, string $taskId) { -// ToastBus::ack($r->user()->id, $taskId); -// return response()->noContent(); -// }); -//}); + // Aliases + Route::get('/aliases', [AliasController::class, 'index'])->middleware('ability:aliases:read'); + Route::get('/aliases/{id}', [AliasController::class, 'show'])->middleware('ability:aliases:read'); + Route::post('/aliases', [AliasController::class, 'store'])->middleware('ability:aliases:write'); + Route::delete('/aliases/{id}', [AliasController::class, 'destroy'])->middleware('ability:aliases:write'); + + // Domains + Route::get('/domains', [DomainController::class, 'index'])->middleware('ability:domains:read'); + Route::get('/domains/{id}', [DomainController::class, 'show'])->middleware('ability:domains:read'); + Route::post('/domains', [DomainController::class, 'store'])->middleware('ability:domains:write'); + Route::delete('/domains/{id}', [DomainController::class, 'destroy'])->middleware('ability:domains:write'); + + // Auth info + Route::get('/me', function (Request $request) { + $token = $request->user()->currentAccessToken(); + return response()->json([ + 'user' => ['id' => $request->user()->id, 'name' => $request->user()->name, 'email' => $request->user()->email], + 'token' => ['name' => $token->name, 'abilities' => $token->abilities, 'sandbox' => $token->sandbox], + ]); + }); +}); diff --git a/routes/console.php b/routes/console.php index e407f97..b32bec1 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,7 @@ weeklyOn(0, '3:30')->withoutOverlapping(); Schedule::command('mail:update-stats')->everyFiveMinutes()->withoutOverlapping(); Schedule::command('health:probe-disk', ['target' => '/', '--ttl' => 900])->everyTenMinutes(); Schedule::command('health:collect')->everyTenSeconds(); + +// Backup-Policies dynamisch einplanen +try { + BackupPolicy::where('enabled', true)->each(function (BackupPolicy $policy) { + Schedule::command("backup:scheduled {$policy->id}") + ->cron($policy->schedule_cron) + ->withoutOverlapping() + ->runInBackground(); + }); +} catch (\Throwable) { + // DB noch nicht verfügbar (z.B. während Migration) +} diff --git a/routes/web.php b/routes/web.php index 6b306d4..e3c2177 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,9 +3,11 @@ use App\Http\Controllers\Api\TaskFeedController; use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Auth\SignUpController; +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\V2\Mail\MailboxController as V2MailboxController; use App\Http\Controllers\UI\Security\SecurityController; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Route; @@ -16,39 +18,74 @@ Route::get('/', function () { : redirect()->route('login'); }); -Route::middleware('auth.user')->name('ui.')->group(function () { +Route::get('/hp', function () { + return view('landing.index'); +})->name('landing'); + +Route::middleware('auth.user')->name('auth.')->group(function () { + Route::get('/auth/2fa', \App\Livewire\Auth\TwoFaChallenge::class)->name('2fa'); +}); + +Route::middleware(['auth.user', 'require2fa'])->name('ui.')->group(function () { #DASHBOARD ROUTE - Route::get('/dashboard', [\App\Http\Controllers\UI\DashboardController::class, 'index'])->name('dashboard'); + Route::get('/', \App\Livewire\Ui\Nx\Dashboard::class)->name('dashboard'); +// Route::get('/dashboard', [\App\Http\Controllers\UI\DashboardController::class, 'index'])->name('dashboard'); +// Route::get('/dashboard-redesign', [\App\Http\Controllers\UI\DashboardController::class, 'redesign'])->name('dashboard.redesign'); #SYSTEM ROUTES Route::prefix('system')->name('system.')->group(function () { - Route::get('/settings', [\App\Http\Controllers\UI\System\SettingsController::class, 'index'])->name('settings'); + Route::get('/settings', \App\Livewire\Ui\System\SettingsForm::class)->name('settings'); + Route::get('/users', \App\Livewire\Ui\System\UserTable::class)->middleware('role:admin')->name('users'); + Route::get('/api', \App\Livewire\Ui\System\ApiKeyTable::class)->name('api'); + Route::get('/webhooks', \App\Livewire\Ui\System\WebhookTable::class)->name('webhooks'); + Route::get('/sandbox', \App\Livewire\Ui\System\SandboxMailbox::class)->name('sandbox'); + Route::get('/backups', \App\Livewire\Ui\System\BackupJobList::class)->name('backups'); + Route::get('/update', \App\Livewire\Ui\System\UpdatePage::class)->name('update'); }); #SECURITY ROUTES Route::prefix('security')->name('security.')->group(function () { Route::get('/', [SecurityController::class, 'index'])->name('index'); - Route::get('/ssl', [SecurityController::class, 'ssl'])->name('ssl'); - Route::get('/fail2ban', [SecurityController::class, 'fail2ban'])->name('fail2ban'); - Route::get('/rspamd', [SecurityController::class, 'rspamd'])->name('rspamd'); - Route::get('/tls-ciphers', [SecurityController::class, 'tlsCiphers'])->name('tls'); - Route::get('/audit-logs', [SecurityController::class, 'auditLogs'])->name('audit'); + Route::get('/fail2ban', \App\Livewire\Ui\Security\Fail2banSettings::class)->name('fail2ban'); + Route::get('/ssl', \App\Livewire\Ui\Security\SslCertificatesTable::class)->name('ssl'); + Route::get('/rspamd', \App\Livewire\Ui\Security\RspamdForm::class)->name('rspamd'); + Route::get('/tls-ciphers', \App\Livewire\Ui\Security\TlsCiphersForm::class)->name('tls'); + Route::get('/audit-logs', \App\Livewire\Ui\Security\AuditLogsTable::class)->name('audit'); }); #DOMAIN ROUTES Route::name('domains.')->group(function () { - Route::get('/domains', [DomainDnsController::class, 'index'])->name('index'); + Route::get('/domains', \App\Livewire\Ui\Nx\Domain\DomainList::class)->name('index'); + Route::get('/domains/dns', \App\Livewire\Ui\Nx\Domain\DnsDkim::class)->name('dns'); }); #MAIL ROUTES Route::name('mail.')->group(function () { - Route::get('/mailboxes', [MailboxController::class, 'index'])->name('mailboxes.index'); - Route::get('/aliases', [AliasController::class, 'index'])->name('aliases.index'); - Route::get('/quarantine', function () {return 'Quarantäne';})->name('quarantine.index'); - Route::get('/queues', function () {return 'Queues';})->name('queues.index'); +// Route::get('/mailboxes', [MailboxController::class, 'index'])->name('mailboxes.index'); +// Route::get('/aliases', [AliasController::class, 'index'])->name('aliases.index'); + Route::get('/mailboxes', \App\Livewire\Ui\Nx\Mail\MailboxList::class)->name('mailboxes.index'); + Route::get('/aliases', \App\Livewire\Ui\Nx\Mail\AliasList::class)->name('aliases.index'); + Route::get('/quarantine', \App\Livewire\Ui\Nx\Mail\QuarantineList::class)->name('quarantine.index'); + Route::get('/queues', \App\Livewire\Ui\Nx\Mail\QueueList::class)->name('queues.index'); }); + #V2 REDESIGN ROUTES — prefix + name('v2.') entfernen wenn live + Route::prefix('v2')->name('v2.')->group(function () { + Route::name('mail.')->group(function () { + Route::get('/mailboxes', [V2MailboxController::class, 'index'])->name('mailboxes.index'); + }); + }); + + #NX ROUTES — prefix('nx')->name('nx.') entfernen wenn live, dann identisch zu Prod-Namen +// Route::prefix('nx')->name('nx.')->group(function () { +// Route::get('/dashboard', \App\Livewire\Ui\Nx\Dashboard::class)->name('dashboard.redesign'); +// Route::prefix('mail')->name('mail.')->group(function () { +// Route::get('/mailboxes', \App\Livewire\Ui\Nx\Mail\MailboxList::class)->name('mailboxes.index'); +// Route::get('/aliases', \App\Livewire\Ui\Nx\Mail\AliasList::class)->name('aliases.index'); +// }); +// }); + #LOGOUT ROUTE Route::post('/logout', [LoginController::class, 'logout'])->name('logout'); }); @@ -65,8 +102,13 @@ Route::middleware(['web', 'auth']) // nutzt Session, kein Token nötig ->get('/ui/tasks/active', [TaskFeedController::class, 'active']) ->name('ui.tasks.active'); + Route::middleware('guest.only')->group(function () { Route::get('/login', [LoginController::class, 'show'])->name('login'); Route::get('/signup', [SignUpController::class, 'show'])->middleware('signup.open')->name('signup'); }); +Route::middleware('auth')->group(function () { + Route::get('/setup', [SetupWizard::class, 'show'])->name('setup'); +}); + diff --git a/routes/webmail.php b/routes/webmail.php new file mode 100644 index 0000000..f8c5d71 --- /dev/null +++ b/routes/webmail.php @@ -0,0 +1,19 @@ + redirect()->away(config('app.url') . '/login'))->name('root'); +Route::get('/login', Login::class)->name('login'); +Route::get('/inbox', Inbox::class)->name('inbox'); +Route::get('/compose', Compose::class)->name('compose'); +Route::get('/message/{uid}', MailView::class)->name('view'); +Route::get('/settings', Settings::class)->name('settings'); diff --git a/vite.config.js b/vite.config.js index 2b7f7c3..57abc13 100644 --- a/vite.config.js +++ b/vite.config.js @@ -19,17 +19,33 @@ export default ({mode}) => { tailwindcss(), ], server: { - host, - port, - https: false, // TLS übernimmt Nginx - strictPort: true, + host: '0.0.0.0', + port: 5173, + hmr: { - protocol: env.VITE_HMR_PROTOCOL || 'wss', - host: hmrHost, - clientPort: Number(env.VITE_HMR_CLIENT_PORT || 443), + host: 'ui.dev.mail.nexlab.at', + protocol: 'wss', + clientPort: 443, + path: '/vite-hmr', + }, + cors: true, + watch: { + ignored: ['**/storage/framework/views/**'], }, - origin, }, + + // server: { + // host, + // port, + // https: false, // TLS übernimmt Nginx + // strictPort: true, + // hmr: { + // protocol: env.VITE_HMR_PROTOCOL || 'wss', + // host: hmrHost, + // clientPort: Number(env.VITE_HMR_CLIENT_PORT || 443), + // }, + // origin, + // }, resolve: { alias: { '@plugins': fileURLToPath(new URL('./resources/js/plugins', import.meta.url)),