Feature: Update-System, Backup-Cron, SSL-Workflow, UI-Verbesserungen

- Update-Seite (/system/update) mit Log-Viewer, Fortschrittsbalken und goldenem Nav-Badge
- /usr/local/sbin/mailwolt-update Wrapper + backup:scheduled Cron-Command
- SSL: Checkbox entfernt, immer automatisch in Prod; local-Modus überspringt certbot mit manuellem Erzwingen-Modal
- Domain-Felder: live Validierung via updatedUiDomain/updatedMailDomain/updatedWebmailDomain
- DNS-Check in applyDomains() wiederhergestellt
- Backup-Cron: BackupScheduled Command + Laravel-Scheduler Eintrag in console.php
- /etc/cron.d/mailwolt-scheduler angelegt für schedule:run
- mailwolt-installer als regulärer Ordner (kein Submodule)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-04-23 01:23:43 +02:00
parent af369acbf6
commit 7bb922191f
168 changed files with 18500 additions and 2144 deletions

View File

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

72
INSTALL_REPORT.md Normal file
View File

@ -0,0 +1,72 @@
# MailWolt Install Report
Datum: 2026-04-17
## Befunde & Fixes
### Migrationen
- **Status:** Alle 21 Migrationen erfolgreich durchgeführt (Batch 115)
- 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
```

View File

@ -0,0 +1,201 @@
<?php
namespace App\Console\Commands;
use App\Models\BackupJob;
use App\Models\Setting;
use Illuminate\Console\Command;
class BackupRun extends Command
{
protected $signature = 'backup:run {jobId : ID des BackupJob-Eintrags}';
protected $description = 'Führt einen Backup-Job aus und aktualisiert den Status';
public function handle(): int
{
$job = BackupJob::with('policy')->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);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Models\BackupJob;
use App\Models\BackupPolicy;
use Illuminate\Console\Command;
class BackupScheduled extends Command
{
protected $signature = 'backup:scheduled {policyId}';
protected $description = 'Legt einen BackupJob an und startet backup:run (wird vom Scheduler aufgerufen)';
public function handle(): int
{
$policy = BackupPolicy::find($this->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;
}
}

View File

@ -0,0 +1,152 @@
<?php
namespace App\Console\Commands;
use App\Models\BackupJob;
use Illuminate\Console\Command;
class RestoreRun extends Command
{
protected $signature = 'restore:run {backupJobId : ID des Quell-Backups} {token : Statusdatei-Token}';
protected $description = 'Stellt ein Backup wieder her und schreibt den Status in eine Temp-Datei';
public function handle(): int
{
$sourceJob = BackupJob::find($this->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(),
]));
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Console\Commands;
use App\Services\SandboxMailParser;
use Illuminate\Console\Command;
class SandboxReceive extends Command
{
protected $signature = 'sandbox:receive {--to=* : Envelope recipients}';
protected $description = 'Receive a raw email from Postfix pipe and store in sandbox';
public function handle(SandboxMailParser $parser): int
{
$raw = '';
$stdin = fopen('php://stdin', 'r');
while (!feof($stdin)) {
$raw .= fread($stdin, 8192);
}
fclose($stdin);
if (empty(trim($raw))) {
return 1;
}
$recipients = $this->option('to') ?? [];
$parser->parseAndStore($raw, $recipients);
return 0;
}
}

View File

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

View File

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

View File

@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Domain;
use App\Models\MailAlias;
use App\Services\WebhookService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AliasController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = MailAlias::with(['domain', 'recipients'])
->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(),
];
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Domain;
use App\Services\WebhookService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DomainController extends Controller
{
public function index(): JsonResponse
{
$domains = Domain::where('is_system', false)
->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(),
];
}
}

View File

@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Domain;
use App\Models\MailUser;
use App\Services\WebhookService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MailboxController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = MailUser::with('domain')
->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(),
];
}
}

View File

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

View File

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

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class InjectSandboxMode
{
public function handle(Request $request, Closure $next): Response
{
$token = $request->user()?->currentAccessToken();
if ($token && $token->sandbox) {
$request->merge(['isSandbox' => true]);
}
return $next($request);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use App\Services\TotpService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class Require2FA
{
public function __construct(private TotpService $totp) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->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');
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use App\Enums\Role;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequireRole
{
public function handle(Request $request, Closure $next, string ...$roles): Response
{
$user = $request->user();
if (!$user || !in_array($user->role?->value, $roles, true)) {
abort(403, 'Keine Berechtigung.');
}
return $next($request);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ValidateHost
{
public function handle(Request $request, Closure $next): Response
{
$host = $request->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);
}
}

View File

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

View File

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

View File

@ -0,0 +1,47 @@
<?php
namespace App\Livewire\Auth;
use App\Models\TwoFactorRecoveryCode;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class TwoFaChallenge extends Component
{
public string $code = '';
public bool $useRecovery = false;
public ?string $error = null;
public function verify(): mixed
{
$this->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');
}
}

View File

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

View File

@ -0,0 +1,211 @@
<?php
namespace App\Livewire\Ui\Nx;
use App\Models\BackupJob;
use App\Models\BackupPolicy;
use App\Models\Domain;
use App\Models\MailUser;
use App\Models\Setting as SettingModel;
use App\Support\CacheVer;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Dashboard · Mailwolt')]
class Dashboard extends Component
{
public function render()
{
$cached = Cache::get(CacheVer::k('health:services'), []);
$rows = $cached['rows'] ?? [];
$services = array_map(fn($r) => [
'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];
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Livewire\Ui\Nx\Domain;
use App\Models\Domain;
use App\Services\DkimService;
use Illuminate\Support\Facades\Process;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Domain · Mailwolt')]
class DnsDkim extends Component
{
#[On('domain-updated')]
#[On('domain-created')]
#[On('domain:delete')]
public function refresh(): void {}
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
{
$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 <b>{$domain->domain}</b> 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'));
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Livewire\Ui\Nx\Domain;
use App\Models\Domain;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Domains · Mailwolt')]
class DomainList extends Component
{
#[Url(as: 'q', keep: true)]
public string $search = '';
#[On('domain-updated')]
#[On('domain-created')]
#[On('domain:delete')]
public function refresh(): void {}
public function openCreate(): void
{
$this->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'));
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Livewire\Ui\Nx\Mail;
use App\Models\Domain;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Aliasse · Mailwolt')]
class AliasList extends Component
{
public string $search = '';
#[On('alias:created')]
#[On('alias:updated')]
#[On('alias:deleted')]
public function refreshAliasList(): void
{
$this->dispatch('$refresh');
}
public function openAliasCreate(int $domainId): void
{
$this->dispatch('openModal', component: 'ui.mail.modal.alias-form-modal', arguments: [
'domainId' => $domainId,
]);
}
public function openAliasEdit(int $aliasId): void
{
$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,
]);
}
}

View File

@ -0,0 +1,169 @@
<?php
namespace App\Livewire\Ui\Nx\Mail;
use App\Models\Domain;
use App\Models\MailUser;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Postfächer · Mailwolt')]
class MailboxList extends Component
{
public string $search = '';
public function mount(): void
{
Artisan::call('mail:update-stats');
}
#[On('mailbox:updated')]
#[On('mailbox:deleted')]
#[On('mailbox:created')]
public function refreshMailboxList(): void
{
$this->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'),
]);
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Livewire\Ui\Nx\Mail\Modal;
use LivewireUI\Modal\ModalComponent;
class QuarantineMessageModal extends ModalComponent
{
public string $msgId;
public array $message = [];
public function mount(string $msgId): void
{
$this->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' => '—'];
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Livewire\Ui\Nx\Mail\Modal;
use LivewireUI\Modal\ModalComponent;
class QueueMessageModal extends ModalComponent
{
public string $queueId;
public array $message = [];
public function mount(string $queueId): void
{
$this->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' => ''];
}
}

View File

@ -0,0 +1,109 @@
<?php
namespace App\Livewire\Ui\Nx\Mail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Quarantäne · Mailwolt')]
class QuarantineList extends Component
{
#[Url(as: 'filter', keep: true)]
public string $filter = 'suspicious';
#[Url(as: 'q', keep: true)]
public string $search = '';
#[Url(as: 'rows', keep: true)]
public int $rows = 200;
#[On('quarantine:updated')]
public function refresh(): void {}
public function openMessage(string $msgId): void
{
$this->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);
}
}

View File

@ -0,0 +1,165 @@
<?php
namespace App\Livewire\Ui\Nx\Mail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Mail-Queue · Mailwolt')]
class QueueList extends Component
{
#[Url(as: 'filter', keep: true)]
public string $filter = 'all';
#[Url(as: 'q', keep: true)]
public string $search = '';
public array $selected = [];
public bool $selectAll = false;
#[On('queue:updated')]
public function refresh(): void {}
public function updatedSelectAll(bool $val): void
{
$messages = $this->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;
}
}

View File

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

View File

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

View File

@ -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 = <<<CONF
actions {
reject = {$this->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'));
}
}

View File

@ -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 <b>{$safe}</b> 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');

View File

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

View File

@ -0,0 +1,34 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\PersonalAccessToken;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('API Keys · Mailwolt')]
class ApiKeyTable extends Component
{
public function deleteToken(int $id): void
{
$token = PersonalAccessToken::where('tokenable_id', Auth::id())
->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'));
}
}

View File

@ -0,0 +1,130 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\BackupJob;
use App\Models\BackupPolicy;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Backups · Mailwolt')]
class BackupJobList extends Component
{
public function runNow(): void
{
$policy = BackupPolicy::first();
if (!$policy) {
$this->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'));
}
}

View File

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

View File

@ -0,0 +1,222 @@
<?php
namespace App\Livewire\Ui\System;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Installer · Mailwolt')]
class InstallerPage extends Component
{
/* ===== Run state ===== */
public string $state = 'idle'; // idle | running
public bool $running = false;
public ?int $rc = null;
public ?string $lowState = null;
public array $logLines = [];
public int $progressPct = 0;
public string $component = 'all';
public bool $postActionsDone = false;
/* ===== Component status ===== */
public array $componentStatus = [];
private const STATE_DIR = '/var/lib/mailwolt/install';
private const INSTALL_LOG = '/var/log/mailwolt-install.log';
private const COMPONENTS = [
'nginx' => ['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;
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
class ApiKeyCreateModal extends ModalComponent
{
public string $name = '';
public bool $sandbox = false;
public array $selected = [];
public static array $availableScopes = [
'mailboxes:read' => '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,
]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use LivewireUI\Modal\ModalComponent;
class ApiKeyShowModal extends ModalComponent
{
public string $plainText = '';
public function mount(string $plainText): void
{
$this->plainText = $plainText;
}
public static function modalMaxWidth(): string
{
return 'md';
}
public function render()
{
return view('livewire.ui.system.modal.api-key-show-modal');
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\BackupJob;
use Livewire\Attributes\On;
use LivewireUI\Modal\ModalComponent;
class BackupDeleteModal extends ModalComponent
{
public int $jobId;
public string $filename = '';
public static function modalMaxWidth(): string { return 'sm'; }
public function mount(int $jobId): void
{
$job = BackupJob::findOrFail($jobId);
$this->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');
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\BackupJob;
use LivewireUI\Modal\ModalComponent;
class BackupProgressModal extends ModalComponent
{
public int $jobId;
public string $restoreToken = '';
public bool $notifiedDone = false;
public static function modalMaxWidth(): string { return 'md'; }
public function mount(int $jobId, string $restoreToken = ''): void
{
$this->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');
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\BackupJob;
use LivewireUI\Modal\ModalComponent;
class BackupRestoreConfirmModal extends ModalComponent
{
public int $jobId;
public string $filename = '';
public string $startedAt = '';
public static function modalMaxWidth(): string { return 'sm'; }
public function mount(int $jobId): void
{
$job = BackupJob::findOrFail($jobId);
$this->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');
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use LivewireUI\Modal\ModalComponent;
class InstallerConfirmModal extends ModalComponent
{
public string $component = 'all';
public static function modalMaxWidth(): string { return 'sm'; }
public function mount(string $component = 'all'): void
{
$this->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');
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use LivewireUI\Modal\ModalComponent;
class SslProvisionModal extends ModalComponent
{
public static function modalMaxWidth(): string { return 'sm'; }
public function confirm(): void
{
$this->closeModal();
$this->dispatch('ssl:provision');
}
public function render()
{
return view('livewire.ui.system.modal.ssl-provision-modal');
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
class TotpSetupModal extends ModalComponent
{
public string $step = 'scan'; // scan → verify → codes
public string $code = '';
public string $secret = '';
public array $recoveryCodes = [];
public string $qrSvg = '';
public static function modalMaxWidth(): string { return 'md'; }
public function mount(): void
{
$totp = app(TotpService::class);
$this->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');
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Enums\Role;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use LivewireUI\Modal\ModalComponent;
class UserCreateModal extends ModalComponent
{
public string $name = '';
public string $email = '';
public string $password = '';
public string $role = Role::Operator->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 <b>{$this->name}</b> 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'));
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\User;
use LivewireUI\Modal\ModalComponent;
class UserDeleteModal extends ModalComponent
{
public int $userId;
public string $userName = '';
public function mount(int $userId): void
{
$user = User::findOrFail($userId);
$this->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 <b>{$this->userName}</b> wurde entfernt.", duration: 4000);
$this->dispatch('$refresh');
$this->closeModal();
}
public function render()
{
return view('livewire.ui.system.modal.user-delete-modal');
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Enums\Role;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use LivewireUI\Modal\ModalComponent;
class UserEditModal extends ModalComponent
{
public int $userId;
public string $name = '';
public string $email = '';
public string $password = '';
public string $role = '';
public bool $is_active = true;
public function mount(int $userId): void
{
$user = User::findOrFail($userId);
$this->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 <b>{$this->name}</b> 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'));
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\Webhook;
use App\Services\WebhookService;
use LivewireUI\Modal\ModalComponent;
class WebhookCreateModal extends ModalComponent
{
public string $name = '';
public string $url = '';
public array $selected = [];
public bool $is_active = true;
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.',
'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(),
]);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\Webhook;
use LivewireUI\Modal\ModalComponent;
class WebhookDeleteModal extends ModalComponent
{
public int $webhookId;
public string $webhookName = '';
public function mount(int $webhookId): void
{
$webhook = Webhook::findOrFail($webhookId);
$this->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');
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Ui\System\Modal;
use App\Models\Webhook;
use LivewireUI\Modal\ModalComponent;
class WebhookEditModal extends ModalComponent
{
public int $webhookId;
public string $name = '';
public string $url = '';
public array $selected = [];
public bool $is_active = true;
public string $secret = '';
public function mount(int $webhookId): void
{
$webhook = Webhook::findOrFail($webhookId);
$this->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(),
]);
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\SandboxMail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Mail-Sandbox · Mailwolt')]
class SandboxMailbox extends Component
{
#[Url]
public string $search = '';
public ?int $selectedId = null;
public function select(int $id): void
{
$this->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'));
}
}

View File

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

View File

@ -0,0 +1,40 @@
<?php
namespace App\Livewire\Ui\System;
use App\Services\TotpService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;
class TwoFaStatus extends Component
{
public bool $enabled = false;
public function mount(): void
{
$this->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');
}
}

View File

@ -0,0 +1,295 @@
<?php
namespace App\Livewire\Ui\System;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Updates · Mailwolt')]
class UpdatePage extends Component
{
/* ===== Version ===== */
public ?string $current = null;
public ?string $latest = null;
public ?string $displayCurrent = null;
public ?string $displayLatest = null;
public bool $hasUpdate = false;
/* ===== Run state ===== */
public string $state = 'idle'; // idle | running
public bool $running = false;
public ?int $rc = null;
public ?string $lowState = null; // running | done | null
public array $logLines = [];
public int $progressPct = 0;
public bool $postActionsDone = false;
protected string $cacheStartedAtKey = 'mw.update.started_at';
protected int $failsafeSeconds = 20 * 60;
private const VERSION_FILE = '/var/lib/mailwolt/version';
private const VERSION_FILE_RAW = '/var/lib/mailwolt/version_raw';
private const BUILD_INFO = '/etc/mailwolt/build.info';
private const UPDATE_LOG = '/var/log/mailwolt-update.log';
private const STATE_DIR = '/var/lib/mailwolt/update';
/* ========================================================= */
public function mount(): void
{
$this->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;
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Livewire\Ui\System;
use App\Enums\Role;
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Benutzer · Mailwolt')]
class UserTable extends Component
{
#[Url(as: 'q', keep: true)]
public string $search = '';
#[Url(as: 'role', keep: true)]
public string $filterRole = '';
public function toggleActive(int $id): void
{
$user = User::findOrFail($id);
if ($user->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'));
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Livewire\Ui\System;
use App\Models\Webhook;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.dvx')]
#[Title('Webhooks · Mailwolt')]
class WebhookTable extends Component
{
public function toggleActive(int $id): void
{
$webhook = Webhook::findOrFail($id);
$webhook->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(),
]);
}
}

View File

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

View File

@ -0,0 +1,298 @@
<?php
namespace App\Livewire\Ui\Webmail;
use App\Models\MailUser;
use App\Services\ImapService;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('layouts.webmail')]
#[Title('Schreiben · Webmail')]
class Compose extends Component
{
use WithFileUploads;
public string $to = '';
public string $subject = '';
public string $body = '';
public bool $sent = false;
// Livewire temp upload binding — cleared immediately after moving to stagedAttachments
public array $attachments = [];
// Persistent staged attachments: [{name, size, mime, path}] — survives reloads via session
public array $stagedAttachments = [];
// Signature — loaded from DB, kept separate from body
public string $signatureRaw = '';
public bool $signatureIsHtml = false;
// Reply mode
#[Url] public int $replyUid = 0;
#[Url] public string $replyFolder = 'INBOX';
public bool $isReply = false;
public string $replyMeta = '';
// Draft editing
#[Url] public int $draftUid = 0;
#[Url] public string $draftFolder = 'Drafts';
public bool $isDraft = false;
public function mount(): void
{
if (! session('webmail_email')) {
$this->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 = '<div style="font-family:inherit;font-size:13px;line-height:1.6;white-space:pre-wrap;">'
. htmlspecialchars($this->body)
. '</div>'
. '<br><div style="border-top:1px solid #e5e7eb;margin-top:12px;padding-top:10px;font-size:12.5px;">'
. $this->signatureRaw
. '</div>';
$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');
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Ui\Webmail;
use App\Services\ImapService;
use Livewire\Attributes\Lazy;
use Livewire\Component;
#[Lazy]
class FolderSidebar extends Component
{
public array $folders = [];
public function mount(): void
{
if (! session('webmail_email')) {
return;
}
try {
$imap = app(ImapService::class);
$client = $imap->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');
}
}

View File

@ -0,0 +1,246 @@
<?php
namespace App\Livewire\Ui\Webmail;
use App\Services\ImapService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.webmail')]
#[Title('Posteingang · Webmail')]
class Inbox extends Component
{
#[Url(as: 'folder')]
public string $folder = 'INBOX';
#[Url(as: 'page')]
public int $page = 1;
#[Url(as: 'tab')]
public string $tab = 'all';
public array $messages = [];
public array $categories = [];
public int $total = 0;
public array $folders = [];
public int $perPage = 25;
public string $search = '';
public array $searchResults = [];
public bool $searching = false;
public function mount(): void
{
if (! session('webmail_email')) {
$this->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');
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Livewire\Ui\Webmail;
use App\Services\ImapService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.webmail-login')]
#[Title('Webmail · Mailwolt')]
class Login extends Component
{
public string $email = '';
public string $password = '';
public function login(): void
{
$this->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');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Livewire\Ui\Webmail;
use Livewire\Component;
class LogoutButton extends Component
{
public function logout(): void
{
session()->forget(['webmail_email', 'webmail_password']);
$this->redirect(route('ui.webmail.login'));
}
public function render()
{
return <<<'BLADE'
<button wire:click="logout"
style="display:flex;align-items:center;justify-content:center;padding:6px 8px;
border-radius:5px;font-size:11.5px;color:var(--mw-t4);cursor:pointer;
background:none;border:none;"
onmouseover="this.style.background='var(--mw-bg3)';this.style.color='var(--mw-rd)'"
onmouseout="this.style.background='none';this.style.color='var(--mw-t4)'"
title="Abmelden">
<svg width="12" height="12" viewBox="0 0 15 15" fill="none">
<path d="M10 7.5H2M8 5l2.5 2.5L8 10M6 2H3a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h3"
stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
BLADE;
}
}

View File

@ -0,0 +1,184 @@
<?php
namespace App\Livewire\Ui\Webmail;
use App\Models\MailUser;
use App\Services\ImapService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.webmail')]
#[Title('Nachricht · Webmail')]
class MailView extends Component
{
#[Url(as: 'folder')]
public string $folder = 'INBOX';
public int $uid = 0;
public array $message = [];
public function mount(int $uid): void
{
if (! session('webmail_email')) {
$this->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');
}
}

View File

@ -0,0 +1,192 @@
<?php
namespace App\Livewire\Ui\Webmail;
use App\Models\MailUser;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.webmail')]
#[Title('Einstellungen · Webmail')]
class Settings extends Component
{
#[Url(as: 'tab')]
public string $activeTab = 'allgemein';
// ── Allgemein ────────────────────────────────────────────────────────────
public bool $showSubscribedOnly = false;
public bool $fetchUnreadAll = false;
public bool $threadMessages = false;
public bool $showFullAddress = false;
public bool $hideEmbeddedAttachments = false;
public bool $attachmentsAbove = false;
public bool $autoMarkRead = true;
public int $autoMarkReadDelay = 0;
public string $forwardMode = 'inline';
public bool $replyBelowQuote = false;
public bool $signatureNew = true;
public bool $signatureReply = false;
public bool $signatureForward = false;
public bool $composeHtml = true;
public int $defaultFontSize = 13;
public string $showRemoteImages = 'never';
public int $autosaveInterval = 5;
public string $signature = '';
// ── Filter ───────────────────────────────────────────────────────────────
public array $filterRules = [];
public string $newField = 'from';
public string $newOp = 'is';
public string $newValue = '';
public string $newAction = 'discard';
public string $newFolder = 'Junk';
// ── Abwesenheit ──────────────────────────────────────────────────────────
public bool $vacationEnabled = false;
public string $vacationSubject = '';
public string $vacationBody = '';
// ── Weiterleitung ─────────────────────────────────────────────────────────
public string $forwardTo = '';
// ─────────────────────────────────────────────────────────────────────────
public function mount(): void
{
if (! session('webmail_email')) {
$this->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');
}
}

View File

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

View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Laravel\Sanctum\PersonalAccessToken as SanctumToken;
class PersonalAccessToken extends SanctumToken
{
protected $fillable = [
'name',
'token',
'abilities',
'expires_at',
'sandbox',
];
protected $casts = [
'abilities' => 'json',
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
'sandbox' => 'boolean',
];
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SandboxMail extends Model
{
protected $fillable = [
'message_id', 'from_address', 'from_name', 'to_addresses',
'subject', 'body_text', 'body_html', 'raw_headers',
'is_read', 'received_at',
];
protected $casts = [
'to_addresses' => '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;
}
}

View File

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

29
app/Models/Webhook.php Normal file
View File

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Webhook extends Model
{
protected $fillable = ['name', 'url', 'events', 'secret', 'is_active', 'last_triggered_at', 'last_status'];
protected $casts = [
'events' => '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',
];
}
}

View File

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

View File

@ -0,0 +1,295 @@
<?php
namespace App\Services;
use Webklex\PHPIMAP\ClientManager;
use Webklex\PHPIMAP\Client;
use Webklex\PHPIMAP\Folder;
class ImapService
{
private ClientManager $manager;
public function __construct()
{
$this->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;
}
}

View File

@ -0,0 +1,145 @@
<?php
namespace App\Services;
use App\Models\SandboxMail;
class SandboxMailParser
{
public function parseAndStore(string $raw, array $recipients = []): SandboxMail
{
[$headerBlock, $body] = $this->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);
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Services;
use App\Models\TwoFactorMethod;
use App\Models\TwoFactorRecoveryCode;
use App\Models\User;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use PragmaRX\Google2FA\Google2FA;
class TotpService
{
public function __construct(private readonly Google2FA $g2fa = new Google2FA()) {}
public function generateSecret(): string
{
return $this->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();
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Services;
use App\Models\Webhook;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WebhookService
{
public function dispatch(string $event, array $payload): void
{
$webhooks = Webhook::where('is_active', true)
->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));
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Support\WoltGuard;
class MonitClient
{
private string $url;
private int $timeout;
public function __construct(string $host = '127.0.0.1', int $port = 2812, int $timeout = 3)
{
$this->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;
}
}
}

View File

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

View File

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

359
composer.lock generated
View File

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

257
config/imap.php Normal file
View File

@ -0,0 +1,257 @@
<?php
/*
* File: imap.php
* Category: config
* Author: M. Goldenbaum
* Created: 24.09.16 22:36
* Updated: -
*
* Description:
* -
*/
return [
/*
|--------------------------------------------------------------------------
| IMAP default account
|--------------------------------------------------------------------------
|
| The default account identifier. It will be used as default for any missing account parameters.
| If however the default account is missing a parameter the package default will be used.
| Set to 'false' [boolean] to disable this functionality.
|
*/
'default' => 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
]
];

View File

@ -6,6 +6,7 @@ return [
'mail' => env('MTA_SUB'),
'ui' => env('UI_SUB'),
'webmail' => env('WEBMAIL_SUB'),
'webmail_host' => env('WEBMAIL_DOMAIN'),
],
'language' => [

84
config/sanctum.php Normal file
View File

@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => 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,
],
];

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('last_login_at')->nullable()->after('role');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('last_login_at');
});
}
};

View File

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

View File

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

View File

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

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('backup_jobs', function (Blueprint $table) {
$table->string('type', 10)->default('backup')->after('policy_id');
});
}
public function down(): void
{
Schema::table('backup_jobs', function (Blueprint $table) {
$table->dropColumn('type');
});
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('mail_users', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('mail_users', function (Blueprint $table) {
$table->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']);
});
}
};

685
installer.sh Normal file
View File

@ -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" <<CFG
[req]
default_bits = 2048
prompt = no
default_md = sha256
req_extensions = req_ext
distinguished_name = dn
[dn]
CN = ${SERVER_IP}
O = ${APP_NAME}
C = DE
[req_ext]
subjectAltName = @alt_names
[alt_names]
IP.1 = ${SERVER_IP}
CFG
openssl req -x509 -newkey rsa:2048 -days 825 -nodes \
-keyout "$KEY" -out "$CERT" -config "$OSSL_CFG"
chmod 600 "$KEY"; chmod 644 "$CERT"
fi
# ===== MariaDB vorbereiten =====
log "MariaDB vorbereiten…"
systemctl enable --now mariadb
DB_NAME="${DB_USER}"
DB_USER="${DB_USER}"
DB_PASS="$(pw)"
mysql -uroot <<SQL
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
FLUSH PRIVILEGES;
SQL
# ===== Postfix konfigurieren (25/465/587) =====
log "Postfix konfigurieren…"
postconf -e "myhostname = ${MAIL_HOSTNAME}"
postconf -e "myorigin = \$myhostname"
postconf -e "mydestination = "
postconf -e "inet_interfaces = all"
postconf -e "inet_protocols = ipv4"
postconf -e "smtpd_banner = \$myhostname ESMTP"
postconf -e "smtp_dns_support_level = disabled"
postconf -e "smtpd_tls_cert_file = ${CERT}"
postconf -e "smtpd_tls_key_file = ${KEY}"
postconf -e "smtpd_tls_security_level = may"
postconf -e "smtpd_tls_auth_only = yes"
postconf -e "smtp_tls_security_level = may"
# Rspamd + OpenDKIM als Milter (accept)
postconf -e "milter_default_action = accept"
postconf -e "milter_protocol = 6"
postconf -e "smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332, inet:127.0.0.1:8891"
# Auth via Dovecot
postconf -e "smtpd_sasl_type = dovecot"
postconf -e "smtpd_sasl_path = private/auth"
postconf -e "smtpd_sasl_auth_enable = yes"
postconf -e "smtpd_sasl_security_options = noanonymous"
postconf -e "smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination"
# Master-Services
postconf -M "smtp/inet=smtp inet n - n - - smtpd \
-o smtpd_peername_lookup=no \
-o smtpd_timeout=30s"
postconf -M "submission/inet=submission inet n - n - - smtpd \
-o syslog_name=postfix/submission \
-o smtpd_peername_lookup=no \
-o smtpd_tls_security_level=encrypt \
-o smtpd_sasl_auth_enable=yes \
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject \
-o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
postconf -M "smtps/inet=smtps inet n - n - - smtpd \
-o syslog_name=postfix/smtps \
-o smtpd_peername_lookup=no \
-o smtpd_tls_wrappermode=yes \
-o smtpd_sasl_auth_enable=yes \
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject \
-o smtpd_recipient_restrictions=permit_sasl_authenticated,reject"
postconf -M "pickup/unix=pickup unix n - y 60 1 pickup"
postconf -M "cleanup/unix=cleanup unix n - y - 0 cleanup"
postconf -M "qmgr/unix=qmgr unix n - n 300 1 qmgr"
# --- Postfix SQL-Maps (Platzhalter) ---
install -d -o root -g postfix -m 750 /etc/postfix/sql
install -o root -g postfix -m 640 /dev/null /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
cat > /etc/postfix/sql/mysql-virtual-mailbox-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
# query = SELECT 1 FROM mail_users WHERE email = '%s' AND is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-mailbox-maps.cf
install -o root -g postfix -m 640 /dev/null /etc/postfix/sql/mysql-virtual-alias-maps.cf
cat > /etc/postfix/sql/mysql-virtual-alias-maps.cf <<CONF
hosts = 127.0.0.1
user = ${DB_USER}
password = ${DB_PASS}
dbname = ${DB_NAME}
# query = SELECT destination FROM mail_aliases WHERE source = '%s' AND is_active = 1 LIMIT 1;
CONF
chown root:postfix /etc/postfix/sql/mysql-virtual-alias-maps.cf
chmod 640 /etc/postfix/sql/mysql-virtual-alias-maps.cf
systemctl enable --now postfix
# ===== Dovecot konfigurieren (IMAP/POP3 + SSL) =====
log "Dovecot konfigurieren…"
cat > /etc/dovecot/dovecot.conf <<'CONF'
!include_try /etc/dovecot/conf.d/*.conf
CONF
cat > /etc/dovecot/conf.d/10-mail.conf <<'CONF'
protocols = imap pop3 lmtp
mail_location = maildir:/var/mail/vhosts/%d/%n
namespace inbox {
inbox = yes
}
mail_privileged_group = mail
CONF
cat > /etc/dovecot/conf.d/10-auth.conf <<'CONF'
disable_plaintext_auth = yes
auth_mechanisms = plain login
!include_try auth-sql.conf.ext
CONF
cat > /etc/dovecot/dovecot-sql.conf.ext <<CONF
driver = mysql
connect = host=127.0.0.1 dbname=${DB_NAME} user=${DB_USER} password=${DB_PASS}
default_pass_scheme = BLF-CRYPT
# password_query = SELECT email AS user, password_hash AS password
# FROM mail_users
# WHERE email = '%u' AND is_active = 1
# LIMIT 1;
CONF
chown root:dovecot /etc/dovecot/dovecot-sql.conf.ext
chmod 640 /etc/dovecot/dovecot-sql.conf.ext
cat > /etc/dovecot/conf.d/auth-sql.conf.ext <<'CONF'
passdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
}
userdb {
driver = static
args = uid=vmail gid=vmail home=/var/mail/vhosts/%d/%n
}
CONF
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 <<CONF
ssl = required
ssl_cert = <${CERT}
ssl_key = <${KEY}
CONF
systemctl enable --now dovecot
# ===== Rspamd & OpenDKIM =====
log "Rspamd + OpenDKIM aktivieren…"
cat > /etc/rspamd/local.d/worker-controller.inc <<'CONF'
password = "admin";
bind_socket = "127.0.0.1:11334";
CONF
systemctl enable --now rspamd || true
cat > /etc/opendkim.conf <<'CONF'
Syslog yes
UMask 002
Mode sv
Socket inet:8891@127.0.0.1
Canonicalization relaxed/simple
On-BadSignature accept
On-Default accept
On-KeyNotFound accept
On-NoSignature accept
LogWhy yes
OversignHeaders From
# 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} <<CONF
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root ${APP_DIR}/public;
index index.php index.html;
access_log /var/log/nginx/${APP_USER}_access.log;
error_log /var/log/nginx/${APP_USER}_error.log;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ {
try_files $uri /index.php?$query_string;
}
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ {
expires 30d;
access_log off;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name _;
ssl_certificate ${CERT};
ssl_certificate_key ${KEY};
ssl_protocols TLSv1.2 TLSv1.3;
root ${APP_DIR}/public;
index index.php index.html;
access_log /var/log/nginx/${APP_USER}_ssl_access.log;
error_log /var/log/nginx/${APP_USER}_ssl_error.log;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:${PHP_FPM_SOCK};
}
location ^~ /livewire/ {
try_files $uri /index.php?$query_string;
}
location ~* \.(jpg|jpeg|png|gif|css|js|ico|svg)\$ {
expires 30d;
access_log off;
}
}
CONF
ln -sf ${NGINX_SITE} ${NGINX_SITE_LINK}
nginx -t && systemctl enable --now nginx
while [[ $# -gt 0 ]]; do
case "$1" in
-dev)
APP_ENV="local"
APP_DEBUG="true"
;;
-stag|-staging)
APP_ENV="staging"
APP_DEBUG="false"
;;
esac
shift
done
# ===== Laravel installieren (als eigener User) =====
log "Laravel installieren…"
mkdir -p "$(dirname "$APP_DIR")"
chown -R "$APP_USER":$APP_GROUP "$(dirname "$APP_DIR")"
if [ ! -d "${APP_DIR}" ] || [ -z "$(ls -A "$APP_DIR" 2>/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 </dev/null || echo "[465] Verbindung fehlgeschlagen"
printf "[587] " && timeout 6s openssl s_client -starttls smtp -connect 127.0.0.1:587 -brief -quiet </dev/null || echo "[587] Verbindung fehlgeschlagen"
printf "[110] " && timeout 6s bash -lc 'printf "QUIT\r\n" | nc -v -w 4 127.0.0.1 110 2>&1' || true
printf "[995] " && timeout 6s openssl s_client -connect 127.0.0.1:995 -brief -quiet </dev/null || echo "[995] Verbindung fehlgeschlagen"
printf "[143] " && timeout 6s bash -lc 'printf ". CAPABILITY\r\n. LOGOUT\r\n" | nc -v -w 4 127.0.0.1 143 2>&1' || true
printf "[993] " && timeout 6s openssl s_client -connect 127.0.0.1:993 -brief -quiet </dev/null || echo "[993] Verbindung fehlgeschlagen"
set -e
echo
echo "=============================================================="
echo " Bootstrap-Login (nur für ERSTEN Login & Wizard):"
echo " User: ${BOOTSTRAP_USER}"
echo " Passwort: ${BOOTSTRAP_PASS}"
echo "=============================================================="
echo
footer_ok "$SERVER_IP"

1
mailwolt-installer Submodule

@ -0,0 +1 @@
Subproject commit c443c5a42641f725bc3794fcad88b62a2d48f56a

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
@props(['icon' => 'ph-circle', 'label' => '', 'color' => 'white'])
<div class="flex items-center gap-3 mb-4">
<div class="flex items-center justify-center w-6 h-6 rounded-lg
bg-violet-500/15 border border-violet-400/25">
<i class="ph {{ $icon }} text-violet-300/80 text-[12px]"></i>
</div>
<span class="text-[11px] uppercase tracking-widest text-violet-300/60 font-medium">{!! $label !!}</span>
<span class="flex-1 h-px bg-gradient-to-r from-violet-500/20 to-transparent"></span>
</div>

View File

@ -0,0 +1,8 @@
@props(['label' => ''])
<label style="display:flex;align-items:center;gap:10px;padding:7px 16px;cursor:pointer;user-select:none;
border-radius:0;transition:background .1s;"
onmouseover="this.style.background='var(--mw-bg3)'" onmouseout="this.style.background='transparent'">
<input {{ $attributes }} type="checkbox"
style="width:15px;height:15px;accent-color:var(--mw-v);cursor:pointer;flex-shrink:0;">
<span style="font-size:13px;color:var(--mw-t2);">{{ $label }}</span>
</label>

View File

@ -0,0 +1,566 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mailwolt Self-Hosted Mail Server Control Panel</title>
<meta name="description" content="Mailwolt ist ein modernes Control Panel für selbst gehostete Mailserver. Mailboxen, Domains, Sicherheit, API und Webhooks — alles in einer Oberfläche.">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg0:#07070e;--bg1:#0d0d18;--bg2:#111120;--bg3:#181828;--bg4:#1e1e30;
--b1:rgba(255,255,255,.06);--b2:rgba(255,255,255,.1);
--t1:#f0f0f8;--t2:#b8b8d0;--t3:#7878a0;--t4:#4a4a68;
--v:#7c3aed;--v2:#a78bfa;--vg:linear-gradient(135deg,#7c3aed,#6d28d9);
--green:#10b981;--amber:#f59e0b;--red:#ef4444;--blue:#3b82f6;
--r:10px;
}
html{scroll-behavior:smooth}
body{background:var(--bg0);color:var(--t2);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;line-height:1.6;overflow-x:hidden}
a{color:inherit;text-decoration:none}
img{display:block;max-width:100%}
/* ── Layout ── */
.container{max-width:1140px;margin:0 auto;padding:0 24px}
.section{padding:96px 0}
/* ── Nav ── */
nav{position:fixed;top:0;left:0;right:0;z-index:100;padding:0 24px;height:60px;display:flex;align-items:center;justify-content:space-between;background:rgba(7,7,14,.8);backdrop-filter:blur(16px);border-bottom:1px solid var(--b1)}
.nav-logo{display:flex;align-items:center;gap:9px}
.nav-logo-icon{width:28px;height:28px;background:var(--vg);border-radius:7px;display:flex;align-items:center;justify-content:center}
.nav-logo-text{font-size:14px;font-weight:700;color:var(--t1);letter-spacing:.8px}
.nav-links{display:flex;align-items:center;gap:28px}
.nav-links a{font-size:13px;color:var(--t3);transition:color .15s}
.nav-links a:hover{color:var(--t1)}
.nav-cta{display:flex;align-items:center;gap:10px}
.btn{display:inline-flex;align-items:center;gap:7px;padding:8px 18px;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s;border:none}
.btn-ghost{background:transparent;color:var(--t2);border:1px solid var(--b2)}
.btn-ghost:hover{border-color:var(--b2);background:var(--bg3);color:var(--t1)}
.btn-primary{background:var(--vg);color:#fff;box-shadow:0 0 20px rgba(124,58,237,.3)}
.btn-primary:hover{opacity:.9;box-shadow:0 0 28px rgba(124,58,237,.45)}
.btn-lg{padding:13px 28px;font-size:14.5px;border-radius:10px}
.btn-outline-lg{padding:12px 26px;font-size:14px;border-radius:10px;border:1px solid var(--b2);color:var(--t2);background:transparent}
.btn-outline-lg:hover{border-color:var(--v2);color:var(--v2)}
/* ── Hero ── */
.hero{padding:160px 0 96px;text-align:center;position:relative;overflow:hidden}
.hero::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 70% 50% at 50% -10%,rgba(124,58,237,.18) 0%,transparent 70%);pointer-events:none}
.hero-eyebrow{display:inline-flex;align-items:center;gap:6px;padding:4px 14px;background:rgba(124,58,237,.12);border:1px solid rgba(124,58,237,.25);border-radius:20px;font-size:12px;color:var(--v2);margin-bottom:24px}
.hero-eyebrow-dot{width:6px;height:6px;border-radius:50%;background:var(--v2);animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
h1{font-size:clamp(36px,5vw,64px);font-weight:800;line-height:1.1;color:var(--t1);letter-spacing:-1.5px;margin-bottom:24px}
.gradient-text{background:linear-gradient(135deg,#a78bfa 0%,#7c3aed 40%,#c4b5fd 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.hero p{font-size:clamp(16px,2vw,19px);color:var(--t3);max-width:580px;margin:0 auto 40px;line-height:1.7}
.hero-actions{display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap}
.hero-meta{margin-top:56px;display:flex;align-items:center;justify-content:center;gap:32px;flex-wrap:wrap}
.hero-meta-item{display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--t4)}
.hero-meta-item svg{color:var(--green)}
/* ── Mockup ── */
.mockup-wrap{margin-top:64px;position:relative}
.mockup-glow{position:absolute;top:0;left:50%;transform:translateX(-50%);width:80%;height:1px;background:linear-gradient(90deg,transparent,rgba(124,58,237,.6),transparent)}
.mockup{background:var(--bg2);border:1px solid var(--b1);border-radius:14px;overflow:hidden;box-shadow:0 40px 80px rgba(0,0,0,.5),0 0 0 1px rgba(124,58,237,.08)}
.mockup-bar{height:38px;background:var(--bg3);border-bottom:1px solid var(--b1);display:flex;align-items:center;padding:0 14px;gap:6px}
.mockup-dot{width:10px;height:10px;border-radius:50%}
.mockup-url{flex:1;height:22px;background:var(--bg4);border-radius:5px;margin:0 12px;display:flex;align-items:center;padding:0 10px;font-size:10.5px;color:var(--t4);font-family:monospace}
.mockup-body{display:grid;grid-template-columns:200px 1fr;min-height:340px}
.mockup-sidebar{background:var(--bg2);border-right:1px solid var(--b1);padding:14px 10px}
.mock-nav-label{font-size:9px;color:var(--t4);text-transform:uppercase;letter-spacing:.8px;padding:0 8px;margin:10px 0 4px}
.mock-nav-item{display:flex;align-items:center;gap:7px;padding:6px 8px;border-radius:6px;font-size:11px;color:var(--t3);margin-bottom:1px}
.mock-nav-item.active{background:rgba(124,58,237,.12);color:var(--v2)}
.mock-dot{width:8px;height:8px;border-radius:50%;background:var(--bg4);border:1px solid var(--b2)}
.mock-dot.active{background:var(--v);border-color:var(--v)}
.mockup-content{padding:16px}
.mock-stat-row{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:12px}
.mock-stat{background:var(--bg3);border:1px solid var(--b1);border-radius:8px;padding:10px 12px}
.mock-stat-val{font-size:18px;font-weight:700;color:var(--t1)}
.mock-stat-label{font-size:9.5px;color:var(--t4);margin-top:2px}
.mock-table{background:var(--bg3);border:1px solid var(--b1);border-radius:8px;overflow:hidden}
.mock-table-head{display:grid;grid-template-columns:2fr 1.5fr 1fr 80px;padding:7px 12px;border-bottom:1px solid var(--b1)}
.mock-th{font-size:9.5px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px}
.mock-row{display:grid;grid-template-columns:2fr 1.5fr 1fr 80px;padding:8px 12px;border-bottom:1px solid var(--b1);align-items:center}
.mock-row:last-child{border-bottom:none}
.mock-cell{font-size:10.5px;color:var(--t2)}
.mock-badge{font-size:9px;padding:2px 7px;border-radius:4px;font-weight:500;display:inline-block}
.mock-badge.ok{background:rgba(16,185,129,.1);color:#34d399;border:1px solid rgba(16,185,129,.2)}
.mock-badge.warn{background:rgba(251,191,36,.1);color:#fcd34d;border:1px solid rgba(251,191,36,.2)}
/* ── Badges strip ── */
.strip{padding:28px 0;border-top:1px solid var(--b1);border-bottom:1px solid var(--b1);overflow:hidden}
.strip-inner{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap}
.strip-item{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--t4)}
.strip-icon{width:18px;height:18px;flex-shrink:0}
/* ── Features ── */
.features-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
@media(max-width:860px){.features-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:560px){.features-grid{grid-template-columns:1fr}}
.feature-card{background:var(--bg2);border:1px solid var(--b1);border-radius:var(--r);padding:24px;transition:border-color .2s,transform .2s}
.feature-card:hover{border-color:rgba(124,58,237,.3);transform:translateY(-2px)}
.feature-icon{width:38px;height:38px;border-radius:9px;display:flex;align-items:center;justify-content:center;margin-bottom:16px}
.feature-title{font-size:14.5px;font-weight:600;color:var(--t1);margin-bottom:8px}
.feature-desc{font-size:13px;color:var(--t3);line-height:1.6}
.feature-tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:14px}
.tag{font-size:10.5px;padding:2px 8px;border-radius:4px;background:var(--bg4);border:1px solid var(--b1);color:var(--t4)}
/* ── API Block ── */
.api-grid{display:grid;grid-template-columns:1fr 1fr;gap:48px;align-items:center}
@media(max-width:760px){.api-grid{grid-template-columns:1fr}}
.code-block{background:var(--bg2);border:1px solid var(--b1);border-radius:var(--r);overflow:hidden}
.code-header{padding:10px 14px;background:var(--bg3);border-bottom:1px solid var(--b1);display:flex;align-items:center;justify-content:space-between}
.code-lang{font-size:10.5px;color:var(--t4);font-family:monospace}
.code-dots{display:flex;gap:5px}
.code-dot{width:9px;height:9px;border-radius:50%}
pre.code{padding:18px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.75;color:var(--t2);overflow-x:auto}
.c-key{color:#93c5fd}.c-str{color:#86efac}.c-num{color:#fcd34d}.c-op{color:var(--t4)}.c-com{color:var(--t4);font-style:italic}.c-method{color:#c4b5fd}
/* ── Security ── */
.security-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}
@media(max-width:640px){.security-grid{grid-template-columns:1fr}}
.sec-card{background:var(--bg2);border:1px solid var(--b1);border-radius:var(--r);padding:20px 22px;display:flex;gap:14px}
.sec-card-icon{flex-shrink:0;width:36px;height:36px;border-radius:8px;display:flex;align-items:center;justify-content:center}
.sec-card-title{font-size:13.5px;font-weight:600;color:var(--t1);margin-bottom:4px}
.sec-card-desc{font-size:12.5px;color:var(--t3);line-height:1.5}
/* ── Section headings ── */
.section-eyebrow{font-size:11.5px;color:var(--v2);font-weight:600;letter-spacing:.8px;text-transform:uppercase;margin-bottom:12px}
.section-title{font-size:clamp(26px,3.5vw,40px);font-weight:800;color:var(--t1);letter-spacing:-1px;line-height:1.15;margin-bottom:16px}
.section-sub{font-size:16px;color:var(--t3);max-width:520px;line-height:1.7}
/* ── Pricing ── */
.pricing-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;align-items:start}
@media(max-width:760px){.pricing-grid{grid-template-columns:1fr;max-width:380px;margin:0 auto}}
.price-card{background:var(--bg2);border:1px solid var(--b1);border-radius:12px;padding:28px 24px;position:relative}
.price-card.featured{background:linear-gradient(160deg,rgba(124,58,237,.12),var(--bg2));border-color:rgba(124,58,237,.35);box-shadow:0 0 40px rgba(124,58,237,.1)}
.price-badge{position:absolute;top:-12px;left:50%;transform:translateX(-50%);background:var(--vg);color:#fff;font-size:10.5px;font-weight:700;padding:3px 14px;border-radius:20px;letter-spacing:.5px;white-space:nowrap}
.price-plan{font-size:12px;color:var(--t4);text-transform:uppercase;letter-spacing:.8px;margin-bottom:10px}
.price-amount{font-size:36px;font-weight:800;color:var(--t1);line-height:1}
.price-amount span{font-size:16px;color:var(--t3);font-weight:400}
.price-desc{font-size:12.5px;color:var(--t4);margin:10px 0 20px;line-height:1.5}
.price-divider{height:1px;background:var(--b1);margin:20px 0}
.price-features{list-style:none;display:flex;flex-direction:column;gap:9px;margin-bottom:24px}
.price-features li{display:flex;align-items:flex-start;gap:8px;font-size:12.5px;color:var(--t3)}
.price-features li svg{flex-shrink:0;margin-top:1px}
.check-green{color:var(--green)}
.check-gray{color:var(--t4)}
/* ── CTA ── */
.cta-section{padding:96px 0;text-align:center;position:relative;overflow:hidden}
.cta-section::before{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:60%;height:1px;background:linear-gradient(90deg,transparent,rgba(124,58,237,.5),transparent)}
.cta-glow{position:absolute;inset:0;background:radial-gradient(ellipse 50% 80% at 50% 120%,rgba(124,58,237,.12),transparent);pointer-events:none}
/* ── Footer ── */
footer{padding:40px 0;border-top:1px solid var(--b1)}
.footer-inner{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:16px}
.footer-links{display:flex;gap:20px}
.footer-links a{font-size:12.5px;color:var(--t4);transition:color .15s}
.footer-links a:hover{color:var(--t2)}
/* ── Theme toggle ── */
body.light{
--bg0:#f5f5f9;--bg1:#eeeeF4;--bg2:#ffffff;--bg3:#f0f0f6;--bg4:#e8e8f0;
--b1:rgba(0,0,0,.07);--b2:rgba(0,0,0,.12);
--t1:#0f0f1a;--t2:#2a2a40;--t3:#5a5a78;--t4:#9090b0;
}
body.light nav{background:rgba(245,245,249,.88)}
body.light .mockup{box-shadow:0 20px 60px rgba(0,0,0,.12)}
.theme-btn{background:var(--bg3);border:1px solid var(--b2);border-radius:7px;padding:5px 10px;cursor:pointer;color:var(--t3);font-size:12px;display:flex;align-items:center;gap:5px;transition:all .15s}
.theme-btn:hover{color:var(--t1)}
</style>
</head>
<body>
<!-- ═══ NAV ═══ -->
<nav>
<div class="nav-logo">
<div class="nav-logo-icon">
<svg width="14" height="14" viewBox="0 0 18 18" fill="none">
<path d="M9 2L15.5 5.5v7L9 16 2.5 12.5v-7L9 2Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M9 6L12 7.5v3L9 12 6 10.5v-3L9 6Z" fill="white" opacity=".9"/>
</svg>
</div>
<span class="nav-logo-text">MAILWOLT</span>
</div>
<div class="nav-links">
<a href="#features">Features</a>
<a href="#api">API</a>
<a href="#security">Sicherheit</a>
<a href="#pricing">Preise</a>
</div>
<div class="nav-cta">
<button class="theme-btn" onclick="document.body.classList.toggle('light')">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="3" stroke="currentColor" stroke-width="1.3"/><path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13M2.9 2.9l1.1 1.1M10 10l1.1 1.1M2.9 11.1l1.1-1.1M10 4l1.1-1.1" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
Theme
</button>
<a href="{{ route('login') }}" class="btn btn-ghost" style="padding:6px 14px;font-size:12.5px">Login</a>
<a href="{{ route('login') }}" class="btn btn-primary" style="padding:6px 14px;font-size:12.5px">Demo </a>
</div>
</nav>
<!-- ═══ HERO ═══ -->
<section class="hero">
<div class="container">
<div class="hero-eyebrow">
<div class="hero-eyebrow-dot"></div>
Self-Hosted · Open Source · Production Ready
</div>
<h1>Dein Mailserver.<br><span class="gradient-text">Vollständig unter Kontrolle.</span></h1>
<p>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.</p>
<div class="hero-actions">
<a href="{{ route('login') }}" class="btn btn-primary btn-lg">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Jetzt starten
</a>
<a href="#features" class="btn-outline-lg btn">Features ansehen</a>
</div>
<div class="hero-meta">
<div class="hero-meta-item">
<svg class="strip-icon" viewBox="0 0 14 14" fill="none"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
MIT Lizenz
</div>
<div class="hero-meta-item">
<svg class="strip-icon" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 7l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
Laravel + Livewire
</div>
<div class="hero-meta-item">
<svg class="strip-icon" viewBox="0 0 14 14" fill="none"><rect x="2" y="4" width="10" height="7" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M5 4V3a2 2 0 0 1 4 0v1" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
2FA + Rollen
</div>
<div class="hero-meta-item">
<svg class="strip-icon" viewBox="0 0 14 14" fill="none"><path d="M3 7a4 4 0 1 0 8 0A4 4 0 0 0 3 7Z" stroke="currentColor" stroke-width="1.3"/><path d="M7 4v3l2 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
REST API v1
</div>
</div>
<!-- Mockup -->
<div class="mockup-wrap">
<div class="mockup-glow"></div>
<div class="mockup">
<div class="mockup-bar">
<div class="mockup-dot" style="background:#ff5f56"></div>
<div class="mockup-dot" style="background:#ffbd2e"></div>
<div class="mockup-dot" style="background:#27c93f"></div>
<div class="mockup-url">mail.example.com/dashboard</div>
</div>
<div class="mockup-body">
<div class="mockup-sidebar">
<div class="mock-nav-label">Mail</div>
<div class="mock-nav-item active"><div class="mock-dot active"></div>Mailboxen</div>
<div class="mock-nav-item"><div class="mock-dot"></div>Aliases</div>
<div class="mock-nav-item"><div class="mock-dot"></div>Domains</div>
<div class="mock-nav-label">Sicherheit</div>
<div class="mock-nav-item"><div class="mock-dot"></div>SSL/TLS</div>
<div class="mock-nav-item"><div class="mock-dot"></div>Fail2ban</div>
<div class="mock-nav-label">System</div>
<div class="mock-nav-item"><div class="mock-dot"></div>API Keys</div>
<div class="mock-nav-item"><div class="mock-dot"></div>Webhooks</div>
<div class="mock-nav-item"><div class="mock-dot"></div>Benutzer</div>
</div>
<div class="mockup-content">
<div class="mock-stat-row">
<div class="mock-stat"><div class="mock-stat-val" style="color:#a78bfa">248</div><div class="mock-stat-label">Mailboxen</div></div>
<div class="mock-stat"><div class="mock-stat-val" style="color:#34d399">12</div><div class="mock-stat-label">Domains</div></div>
<div class="mock-stat"><div class="mock-stat-val" style="color:#60a5fa">91</div><div class="mock-stat-label">Aliases</div></div>
<div class="mock-stat"><div class="mock-stat-val" style="color:#fcd34d">3</div><div class="mock-stat-label">Queued</div></div>
</div>
<div class="mock-table">
<div class="mock-table-head">
<div class="mock-th">E-Mail</div>
<div class="mock-th">Domain</div>
<div class="mock-th">Quota</div>
<div class="mock-th">Status</div>
</div>
<div class="mock-row">
<div class="mock-cell">alice@example.com</div>
<div class="mock-cell" style="color:var(--t4)">example.com</div>
<div class="mock-cell" style="color:var(--t4)">2.1 / 5 GB</div>
<div class="mock-cell"><span class="mock-badge ok">Aktiv</span></div>
</div>
<div class="mock-row">
<div class="mock-cell">bob@company.io</div>
<div class="mock-cell" style="color:var(--t4)">company.io</div>
<div class="mock-cell" style="color:var(--t4)">890 MB / 2 GB</div>
<div class="mock-cell"><span class="mock-badge ok">Aktiv</span></div>
</div>
<div class="mock-row">
<div class="mock-cell">dev@startup.dev</div>
<div class="mock-cell" style="color:var(--t4)">startup.dev</div>
<div class="mock-cell" style="color:var(--t4)">100 MB / 1 GB</div>
<div class="mock-cell"><span class="mock-badge warn">Inaktiv</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ STRIP ═══ -->
<div class="strip">
<div class="container">
<div class="strip-inner">
<div class="strip-item"><svg class="strip-icon" viewBox="0 0 14 14" fill="none"><rect x="1" y="3" width="12" height="8" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M1 5.5l6 3.5 6-3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>Postfix + Dovecot</div>
<div class="strip-item"><svg class="strip-icon" viewBox="0 0 14 14" fill="none"><path d="M7 1l1.5 4.5H13l-3.7 2.7 1.4 4.3L7 9.8l-3.7 2.7 1.4-4.3L1 5.5h4.5L7 1Z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/></svg>Rspamd Spamfilter</div>
<div class="strip-item"><svg class="strip-icon" viewBox="0 0 14 14" fill="none"><rect x="2" y="4" width="10" height="7" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M5 4V3a2 2 0 0 1 4 0v1" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>Let's Encrypt SSL</div>
<div class="strip-item"><svg class="strip-icon" viewBox="0 0 14 14" fill="none"><path d="M5 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" stroke="currentColor" stroke-width="1.3"/><path d="M8 5.5L12.5 10" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>REST API v1</div>
<div class="strip-item"><svg class="strip-icon" viewBox="0 0 14 14" fill="none"><path d="M1.5 3.5h11v7a1.5 1.5 0 0 1-1.5 1.5H3A1.5 1.5 0 0 1 1.5 10.5v-7Z" stroke="currentColor" stroke-width="1.3"/><path d="M1.5 3.5l5.5 4 5.5-4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>Mail-Sandbox</div>
<div class="strip-item"><svg class="strip-icon" viewBox="0 0 14 14" fill="none"><path d="M7 1l1.5 3h3l-2.5 2 1 3L7 7.5 4 9l1-3L2.5 4h3L7 1Z" stroke="currentColor" stroke-width="1.1"/></svg>DKIM / SPF / DMARC</div>
</div>
</div>
</div>
<!-- ═══ FEATURES ═══ -->
<section class="section" id="features">
<div class="container">
<div style="margin-bottom:48px">
<div class="section-eyebrow">Features</div>
<h2 class="section-title">Alles was du brauchst.<br>Nichts was du nicht brauchst.</h2>
<p class="section-sub">Mailwolt bündelt alle Werkzeuge um einen Mailserver professionell zu betreiben ohne sich in Details zu verlieren.</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon" style="background:rgba(124,58,237,.12)">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><rect x="1.5" y="3.5" width="13" height="9" rx="1.5" stroke="#a78bfa" stroke-width="1.3"/><path d="M1.5 5.5l6.5 4 6.5-4" stroke="#a78bfa" stroke-width="1.3" stroke-linecap="round"/></svg>
</div>
<div class="feature-title">Mailboxen & Aliases</div>
<div class="feature-desc">Erstelle und verwalte Postfächer, Weiterleitungen und Gruppen-Aliases für beliebig viele Domains. Quota, Limits und Login-Rechte pro Box konfigurierbar.</div>
<div class="feature-tags"><span class="tag">IMAP</span><span class="tag">Quota</span><span class="tag">Gruppen</span><span class="tag">Catch-All</span></div>
</div>
<div class="feature-card">
<div class="feature-icon" style="background:rgba(59,130,246,.1)">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="#60a5fa" stroke-width="1.3"/><path d="M8 4v1M8 11v1M4 8h1M11 8h1" stroke="#60a5fa" stroke-width="1.3" stroke-linecap="round"/></svg>
</div>
<div class="feature-title">Multi-Domain</div>
<div class="feature-desc">Mehrere Domains auf einem Server. DKIM-Schlüssel, SPF und DMARC pro Domain verwalten mit DNS-Vorlagen für schnelles Setup.</div>
<div class="feature-tags"><span class="tag">DKIM</span><span class="tag">SPF</span><span class="tag">DMARC</span><span class="tag">DNSSEC</span></div>
</div>
<div class="feature-card">
<div class="feature-icon" style="background:rgba(16,185,129,.1)">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><rect x="3" y="7" width="10" height="7" rx="1.5" stroke="#34d399" stroke-width="1.3"/><path d="M5 7V5a3 3 0 0 1 6 0v2" stroke="#34d399" stroke-width="1.3" stroke-linecap="round"/><circle cx="8" cy="10.5" r="1" fill="#34d399"/></svg>
</div>
<div class="feature-title">Zwei-Faktor-Auth</div>
<div class="feature-desc">TOTP-basierte 2FA für jeden Account. Recovery-Codes für Notfälle, Setup-Wizard mit QR-Code, forcierbar für Admins.</div>
<div class="feature-tags"><span class="tag">TOTP</span><span class="tag">Google Auth</span><span class="tag">Authy</span></div>
</div>
<div class="feature-card">
<div class="feature-icon" style="background:rgba(239,68,68,.1)">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><path d="M8 1.5L13.5 4v4c0 3-2.5 5.5-5.5 6.5C5 13.5 2.5 11 2.5 8V4L8 1.5Z" stroke="#f87171" stroke-width="1.3" stroke-linejoin="round"/><path d="M5.5 8l2 2 3-3" stroke="#f87171" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="feature-title">Sicherheit & Monitoring</div>
<div class="feature-desc">Fail2ban-Integration mit Whitelist/Blacklist-Verwaltung, SSL-Zertifikats-Übersicht, TLS-Cipher-Konfiguration und Audit-Logs.</div>
<div class="feature-tags"><span class="tag">Fail2ban</span><span class="tag">Let's Encrypt</span><span class="tag">Audit Log</span></div>
</div>
<div class="feature-card">
<div class="feature-icon" style="background:rgba(251,191,36,.1)">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><path d="M5 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" stroke="#fcd34d" stroke-width="1.3"/><path d="M8.5 7.5L13 12" stroke="#fcd34d" stroke-width="1.3" stroke-linecap="round"/></svg>
</div>
<div class="feature-title">REST API & Webhooks</div>
<div class="feature-desc">Vollständige REST API v1 mit Scope-basierter Authentifizierung via API-Keys. Webhooks für Echtzeit-Events mit HMAC-SHA256 Signierung.</div>
<div class="feature-tags"><span class="tag">REST API</span><span class="tag">Webhooks</span><span class="tag">HMAC</span><span class="tag">Scopes</span></div>
</div>
<div class="feature-card">
<div class="feature-icon" style="background:rgba(124,58,237,.1)">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><rect x="1.5" y="3.5" width="13" height="9" rx="1.5" stroke="#a78bfa" stroke-width="1.3"/><path d="M4.5 8h3M4.5 10h5" stroke="#a78bfa" stroke-width="1.3" stroke-linecap="round"/></svg>
</div>
<div class="feature-title">Mail-Sandbox</div>
<div class="feature-desc">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.</div>
<div class="feature-tags"><span class="tag">Postfix-Pipe</span><span class="tag">HTML Preview</span><span class="tag">Dev-Mode</span></div>
</div>
</div>
</div>
</section>
<!-- ═══ API ═══ -->
<section class="section" id="api" style="background:var(--bg1);border-top:1px solid var(--b1);border-bottom:1px solid var(--b1)">
<div class="container">
<div class="api-grid">
<div>
<div class="section-eyebrow">Developer API</div>
<h2 class="section-title" style="font-size:clamp(24px,3vw,36px)">Automatisiere alles.</h2>
<p class="section-sub" style="font-size:15px">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.</p>
<div style="margin-top:28px;display:flex;flex-direction:column;gap:10px">
@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])
<div style="display:flex;gap:10px;align-items:flex-start">
<svg width="16" height="16" viewBox="0 0 14 14" fill="none" style="flex-shrink:0;margin-top:1px"><circle cx="7" cy="7" r="6" stroke="#34d399" stroke-width="1.2"/><path d="M4.5 7l2 2 3-3" stroke="#34d399" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
<div><span style="font-size:13px;font-weight:600;color:var(--t1)">{{ $title }}</span><span style="font-size:12.5px;color:var(--t4)"> {{ $desc }}</span></div>
</div>
@endforeach
</div>
</div>
<div class="code-block">
<div class="code-header">
<div class="code-dots">
<div class="code-dot" style="background:#ff5f56"></div>
<div class="code-dot" style="background:#ffbd2e"></div>
<div class="code-dot" style="background:#27c93f"></div>
</div>
<span class="code-lang">bash API Example</span>
</div>
<pre class="code"><span class="c-com"># Mailbox erstellen</span>
<span class="c-method">curl</span> -X POST <span class="c-str">https://mail.example.com/api/v1/mailboxes</span> \
-H <span class="c-str">"Authorization: Bearer mwt_..."</span> \
-H <span class="c-str">"Content-Type: application/json"</span> \
-d <span class="c-str">'{
"email": "alice@example.com",
"password": "sicher123",
"quota_mb": 2048
}'</span>
<span class="c-com"># Antwort</span>
<span class="c-op">{</span>
<span class="c-key">"data"</span>: <span class="c-op">{</span>
<span class="c-key">"id"</span>: <span class="c-num">42</span>,
<span class="c-key">"email"</span>: <span class="c-str">"alice@example.com"</span>,
<span class="c-key">"quota_mb"</span>: <span class="c-num">2048</span>,
<span class="c-key">"is_active"</span>: <span class="c-num">true</span>
<span class="c-op">}</span>
<span class="c-op">}</span></pre>
</div>
</div>
</div>
</section>
<!-- ═══ SECURITY ═══ -->
<section class="section" id="security">
<div class="container">
<div style="margin-bottom:48px">
<div class="section-eyebrow">Sicherheit</div>
<h2 class="section-title">Sicherheit by Default.</h2>
<p class="section-sub">Mailwolt liefert alle nötigen Werkzeuge um einen gehärteten Mailserver zu betreiben ohne Expertenwissen voraussetzen zu müssen.</p>
</div>
<div class="security-grid">
<div class="sec-card">
<div class="sec-card-icon" style="background:rgba(124,58,237,.1)">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><rect x="3" y="7" width="10" height="7" rx="1.5" stroke="#a78bfa" stroke-width="1.3"/><path d="M5 7V5a3 3 0 0 1 6 0v2" stroke="#a78bfa" stroke-width="1.3" stroke-linecap="round"/></svg>
</div>
<div><div class="sec-card-title">TOTP Zwei-Faktor-Auth</div><div class="sec-card-desc">Schütze jeden Account mit einem zweiten Faktor. Kompatibel mit Google Authenticator, Authy und allen TOTP-Apps.</div></div>
</div>
<div class="sec-card">
<div class="sec-card-icon" style="background:rgba(239,68,68,.1)">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><path d="M4 4l8 8M12 4l-8 8" stroke="#f87171" stroke-width="1.3" stroke-linecap="round"/><circle cx="8" cy="8" r="7" stroke="#f87171" stroke-width="1.3"/></svg>
</div>
<div><div class="sec-card-title">Fail2ban Integration</div><div class="sec-card-desc">Angriffs-IPs direkt aus dem Panel sperren oder dauerhaft whitelisten. Echtzeit-Übersicht über aktive Bans und Jail-Status.</div></div>
</div>
<div class="sec-card">
<div class="sec-card-icon" style="background:rgba(16,185,129,.1)">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><rect x="1.5" y="4.5" width="13" height="9" rx="1.5" stroke="#34d399" stroke-width="1.3"/><path d="M5 4.5V3a3 3 0 0 1 6 0v1.5" stroke="#34d399" stroke-width="1.3" stroke-linecap="round"/></svg>
</div>
<div><div class="sec-card-title">SSL/TLS Verwaltung</div><div class="sec-card-desc">Let's Encrypt Zertifikate automatisch ausstellen und verlängern. TLS-Cipher-Konfiguration für optimale Kompatibilität und Sicherheit.</div></div>
</div>
<div class="sec-card">
<div class="sec-card-icon" style="background:rgba(59,130,246,.1)">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><path d="M8 2L13 4.5v4C13 11 10.8 13.2 8 14c-2.8-.8-5-3-5-5.5v-4L8 2Z" stroke="#60a5fa" stroke-width="1.3" stroke-linejoin="round"/></svg>
</div>
<div><div class="sec-card-title">Rollen & Berechtigungen</div><div class="sec-card-desc">Admin, Operator und Viewer granulare Zugriffsrechte für Teams. Jeder sieht nur was er sehen darf.</div></div>
</div>
</div>
</div>
</section>
<!-- ═══ PRICING ═══ -->
<section class="section" id="pricing" style="background:var(--bg1);border-top:1px solid var(--b1)">
<div class="container">
<div style="text-align:center;margin-bottom:56px">
<div class="section-eyebrow">Preise</div>
<h2 class="section-title">Einfache Preisgestaltung.</h2>
<p class="section-sub" style="margin:0 auto">Starte kostenlos, wächst mit dir mit.</p>
</div>
<div class="pricing-grid">
<div class="price-card">
<div class="price-plan">Community</div>
<div class="price-amount">€0<span> / Monat</span></div>
<div class="price-desc">Für Privatpersonen und kleine Setups.</div>
<div class="price-divider"></div>
<ul class="price-features">
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>Bis 5 Domains</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>Bis 25 Mailboxen</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>REST API</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>2FA &amp; Audit Log</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-gray"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 4l5 5M9 4l-5 5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg><span style="color:var(--t4)">Webhooks</span></li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-gray"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 4l5 5M9 4l-5 5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg><span style="color:var(--t4)">Priority Support</span></li>
</ul>
<a href="{{ route('login') }}" class="btn btn-ghost" style="width:100%;justify-content:center">Kostenlos starten</a>
</div>
<div class="price-card featured">
<div class="price-badge">BELIEBT</div>
<div class="price-plan">Pro</div>
<div class="price-amount">€19<span> / Monat</span></div>
<div class="price-desc">Für Teams und wachsende Organisationen.</div>
<div class="price-divider"></div>
<ul class="price-features">
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>Unbegrenzte Domains</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>Unbegrenzte Mailboxen</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>REST API + Webhooks</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>Mail-Sandbox</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>Team-Rollen (Admin/Op/Viewer)</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>E-Mail Support</li>
</ul>
<a href="{{ route('login') }}" class="btn btn-primary" style="width:100%;justify-content:center">Pro starten </a>
</div>
<div class="price-card">
<div class="price-plan">Enterprise</div>
<div class="price-amount" style="font-size:28px;line-height:1.2">Auf Anfrage</div>
<div class="price-desc">Für Hoster, Agenturen und On-Premise-Installationen mit SLA.</div>
<div class="price-divider"></div>
<ul class="price-features">
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>Alles aus Pro</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>White-Label Option</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>Dedicated Support / SLA</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>Custom Integrations</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>Migrations-Hilfe</li>
<li><svg width="13" height="13" viewBox="0 0 13 13" fill="none" class="check-green"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M4 6.5l2 2 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>Rechnungsstellung</li>
</ul>
<a href="mailto:hello@mailwolt.com" class="btn btn-ghost" style="width:100%;justify-content:center">Kontakt aufnehmen</a>
</div>
</div>
</div>
</section>
<!-- ═══ CTA ═══ -->
<section class="cta-section">
<div class="cta-glow"></div>
<div class="container" style="position:relative;z-index:1">
<h2 class="section-title" style="text-align:center;margin-bottom:16px">Bereit loszulegen?</h2>
<p style="text-align:center;font-size:16px;color:var(--t3);margin-bottom:36px">Installiere Mailwolt in wenigen Minuten und behalte die volle Kontrolle über deinen Mailserver.</p>
<div style="display:flex;justify-content:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('login') }}" class="btn btn-primary btn-lg">
Demo starten
</a>
<a href="https://github.com" target="_blank" class="btn-outline-lg btn">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M8 1a7 7 0 0 0-2.213 13.641c.35.064.478-.152.478-.337 0-.166-.006-.606-.009-1.19-1.947.423-2.358-.94-2.358-.94-.318-.809-.777-1.024-.777-1.024-.635-.434.048-.425.048-.425.702.049 1.072.72 1.072.72.624 1.069 1.637.76 2.037.582.063-.452.244-.761.444-.936-1.554-.177-3.188-.777-3.188-3.456 0-.763.272-1.387.72-1.876-.072-.177-.312-.888.068-1.85 0 0 .587-.188 1.923.716A6.7 6.7 0 0 1 8 4.979c.594.003 1.192.08 1.75.235 1.334-.904 1.92-.717 1.92-.717.381.963.141 1.674.069 1.851.448.489.72 1.113.72 1.876 0 2.686-1.637 3.277-3.196 3.45.251.217.475.644.475 1.299 0 .937-.008 1.692-.008 1.922 0 .187.126.405.482.336A7.001 7.001 0 0 0 8 1Z" fill="currentColor"/></svg>
GitHub
</a>
</div>
</div>
</section>
<!-- ═══ FOOTER ═══ -->
<footer>
<div class="container">
<div class="footer-inner">
<div class="nav-logo">
<div class="nav-logo-icon">
<svg width="12" height="12" viewBox="0 0 18 18" fill="none"><path d="M9 2L15.5 5.5v7L9 16 2.5 12.5v-7L9 2Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/><path d="M9 6L12 7.5v3L9 12 6 10.5v-3L9 6Z" fill="white" opacity=".9"/></svg>
</div>
<span class="nav-logo-text" style="font-size:12px">MAILWOLT</span>
<span style="font-size:11px;color:var(--t4);margin-left:4px">© {{ date('Y') }}</span>
</div>
<div class="footer-links">
<a href="#features">Features</a>
<a href="#pricing">Preise</a>
<a href="{{ route('login') }}">Login</a>
<a href="mailto:hello@mailwolt.com">Kontakt</a>
</div>
</div>
</div>
</footer>
</body>
</html>

View File

@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_','-',app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>@yield('title', config('app.name'))</title>
@vite(['resources/css/app.css'])
@livewireStyles
</head>
<body style="color:var(--mw-t1);font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',sans-serif;font-size:13.5px;-webkit-font-smoothing:antialiased;margin:0;">
<div class="mw-sidebar-overlay" id="mw-overlay" onclick="document.getElementById('mw-sidebar').classList.remove('open');this.classList.remove('open');"></div>
<div class="mw-shell">
{{-- ═══ SIDEBAR ═══ --}}
<aside class="mw-sidebar" id="mw-sidebar">
<div class="mw-sb-head">
<div class="mw-sb-mark">
<svg width="15" height="15" viewBox="0 0 18 18" fill="none">
<path d="M9 2L15.5 5.5v7L9 16 2.5 12.5v-7L9 2Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M9 6L12 7.5v3L9 12 6 10.5v-3L9 6Z" fill="white" opacity=".9"/>
</svg>
</div>
<div>
<div class="mw-sb-name">MAILWOLT</div>
<div class="mw-sb-sub">Control Panel</div>
</div>
</div>
<nav class="mw-nav">
<p class="mw-nav-label">Übersicht</p>
<a href="{{ route('ui.dashboard') }}" class="mw-nav-item {{ request()->routeIs('ui.dashboard') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x=".5" y=".5" width="5.5" height="5.5" rx="1.2" stroke="currentColor" stroke-width="1.2"/><rect x="8" y=".5" width="5.5" height="5.5" rx="1.2" stroke="currentColor" stroke-width="1.2"/><rect x=".5" y="8" width="5.5" height="5.5" rx="1.2" stroke="currentColor" stroke-width="1.2"/><rect x="8" y="8" width="5.5" height="5.5" rx="1.2" stroke="currentColor" stroke-width="1.2"/></svg>
Dashboard
<div class="mw-nav-dot"></div>
</a>
<p class="mw-nav-label">Domains</p>
<a href="{{ route('ui.domains.index') }}" class="mw-nav-item {{ request()->routeIs('ui.domains*') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/><ellipse cx="7" cy="7" rx="2.2" ry="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M1.5 5.5h11M1.5 8.5h11" stroke="currentColor" stroke-width="1.2"/></svg>
Übersicht
<span class="mw-nav-badge">{{ $domainCount ?? '—' }}</span>
</a>
<a href="{{ route('ui.domains.dns') }}" class="mw-nav-item {{ request()->routeIs('ui.domains.dns') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1.5L11 5.5h-2.5v7H5.5v-7H3L7 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
DNS / DKIM
<div class="mw-nav-dot"></div>
</a>
<p class="mw-nav-label">Mail</p>
<a href="{{ route('ui.mail.mailboxes.index') }}" class="mw-nav-item {{ request()->routeIs('ui.mail.mailboxes*') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x=".5" y="2" width="13" height="10" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M.5 4L7 8.5l6.5-4.5" stroke="currentColor" stroke-width="1.2"/></svg>
Postfächer
<span class="mw-nav-badge">{{ $mailboxCount ?? '—' }}</span>
</a>
<a href="{{ route('ui.mail.aliases.index') }}" class="mw-nav-item {{ request()->routeIs('ui.mail.aliases*') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M1 7L5 11 13 3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
Aliasse
<div class="mw-nav-dot"></div>
</a>
<a href="{{ route('ui.mail.queues.index') }}" class="mw-nav-item {{ request()->routeIs('ui.mail.queues*') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M1 4h12M4 4V2h6v2M4 12V7m6 5V7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Mail-Queue
<div class="mw-nav-dot"></div>
</a>
<a href="{{ route('ui.mail.quarantine.index') }}" class="mw-nav-item {{ request()->routeIs('ui.mail.quarantine*') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M7 4v4l2.5 2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Quarantäne
<div class="mw-nav-dot"></div>
</a>
<p class="mw-nav-label">Sicherheit</p>
<a href="{{ route('ui.security.fail2ban') }}" class="mw-nav-item {{ request()->routeIs('ui.security.fail2ban') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1.5L12.5 4v4.8c0 2.8-2.3 4.8-5.5 5.2C3.8 13.6 1.5 11.6 1.5 8.8V4L7 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
Fail2Ban
<div class="mw-nav-dot"></div>
</a>
<a href="{{ route('ui.security.ssl') }}" class="mw-nav-item {{ request()->routeIs('ui.security.ssl') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1.5" y="5" width="11" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M4.5 5V3.5a2.5 2.5 0 0 1 5 0V5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
SSL/TLS
<div class="mw-nav-dot"></div>
</a>
<a href="{{ route('ui.security.tls') }}" class="mw-nav-sub-item {{ request()->routeIs('ui.security.tls') ? 'active' : '' }}">
TLS-Ciphers
</a>
<a href="{{ route('ui.security.rspamd') }}" class="mw-nav-item {{ request()->routeIs('ui.security.rspamd') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="5.5" stroke="currentColor" stroke-width="1.2"/><path d="M7 4.5v3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="7" cy="10" r=".7" fill="currentColor"/></svg>
Rspamd
<div class="mw-nav-dot"></div>
</a>
<a href="{{ route('ui.security.audit') }}" class="mw-nav-item {{ request()->routeIs('ui.security.audit') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M1 3.5h12M1 7h12M1 10.5h12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Audit-Logs
<div class="mw-nav-dot"></div>
</a>
<p class="mw-nav-label">System</p>
<a href="{{ route('ui.system.users') }}" class="mw-nav-item {{ request()->routeIs('ui.system.users') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="5.5" cy="4.5" r="2.5" stroke="currentColor" stroke-width="1.2"/><path d="M1 13c0-2.5 2-4.5 4.5-4.5S10 10.5 10 13" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><path d="M11 6v4M9 8h4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Benutzer
<div class="mw-nav-dot"></div>
</a>
<a href="{{ route('ui.system.api') }}" class="mw-nav-item {{ request()->routeIs('ui.system.api') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M6 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" stroke="currentColor" stroke-width="1.2"/><path d="M9.5 8.5L14 13" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
API Keys
<div class="mw-nav-dot"></div>
</a>
<a href="{{ route('ui.system.webhooks') }}" class="mw-nav-item {{ request()->routeIs('ui.system.webhooks') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8a6 6 0 1 0 12 0A6 6 0 0 0 2 8Z" stroke="currentColor" stroke-width="1.2"/><path d="M8 5v3l2 2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/><path d="M1 3l2 1.5M15 3l-2 1.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Webhooks
<div class="mw-nav-dot"></div>
</a>
<a href="{{ route('ui.system.sandbox') }}" class="mw-nav-item {{ request()->routeIs('ui.system.sandbox') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="1.5" y="3.5" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M1.5 5.5l6.5 4 6.5-4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Mail-Sandbox
<div class="mw-nav-dot"></div>
</a>
<a href="{{ route('ui.system.backups') }}" class="mw-nav-item {{ request()->routeIs('ui.system.backups') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M2.5 9H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><rect x="3" y="8.5" width="8" height="5" rx="1.5" stroke="currentColor" stroke-width="1.2"/></svg>
Backups
<div class="mw-nav-dot"></div>
</a>
<a href="{{ route('ui.system.update') }}" class="mw-nav-item {{ request()->routeIs('ui.system.update') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M12 7A5 5 0 1 1 9 2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><path d="M9 .5l2.5 2L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
Updates
@if(!empty($updateAvailable))
<span style="margin-left:auto;background:rgba(234,179,8,.18);border:1px solid rgba(234,179,8,.4);color:#fbbf24;font-size:10px;font-weight:600;line-height:1;padding:2px 6px;border-radius:99px">1</span>
@else
<div class="mw-nav-dot"></div>
@endif
</a>
<a href="{{ route('ui.system.settings') }}" class="mw-nav-item {{ request()->routeIs('ui.system.settings') ? 'active' : '' }}">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><circle cx="7" cy="7" r="2" stroke="currentColor" stroke-width="1.2"/><path d="M7 1.5v1.2M7 11.3v1.2M1.5 7h1.2M11.3 7h1.2M2.9 2.9l.85.85M10.25 10.25l.85.85M2.9 11.1l.85-.85M10.25 3.75l.85-.85" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Einstellungen
<div class="mw-nav-dot"></div>
</a>
</nav>
<div class="mw-sb-foot">
<div class="mw-avatar">{{ strtoupper(substr(auth()->user()->name ?? 'MW', 0, 2)) }}</div>
<div style="min-width:0;flex:1;overflow:hidden;">
<div style="font-size:12px;font-weight:500;color:var(--mw-t2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ auth()->user()->name ?? 'Admin' }}</div>
<div style="font-size:10px;color:var(--mw-t4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ auth()->user()->email ?? '' }}</div>
</div>
<form method="POST" action="{{ route('ui.logout') }}" style="flex-shrink:0;">
@csrf
<button type="submit" style="background:none;border:none;cursor:pointer;color:var(--mw-t4);padding:4px;" title="Abmelden">
<svg width="14" height="14" viewBox="0 0 15 15" fill="none"><path d="M10 7.5H2M8 5l2.5 2.5L8 10M6 2H3a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</form>
</div>
</aside>
{{-- ═══ MAIN ═══ --}}
<div class="mw-main">
<header class="mw-topbar">
<div class="mw-breadcrumb">
<button class="mw-hamburger" style="display:none" onclick="document.getElementById('mw-sidebar').classList.add('open');document.getElementById('mw-overlay').classList.add('open');">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M2 4h14M2 9h14M2 14h14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</button>
@hasSection('breadcrumb-parent')@yield('breadcrumb-parent')@else{{ $breadcrumbParent ?? 'Dashboard' }}@endif
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"><path d="M3.5 2l4 3.5-4 3.5" stroke="var(--mw-b3)" stroke-width="1.3" stroke-linecap="round"/></svg>
<b>@hasSection('breadcrumb')@yield('breadcrumb')@else{{ $breadcrumb ?? 'Übersicht' }}@endif</b>
</div>
<div class="mw-topbar-right">
<div class="mw-live"><div class="mw-live-dot"></div>Live</div>
<button wire:click="$dispatch('openModal',{component:'ui.search.modal.search-palette-modal'})" class="mw-search">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><circle cx="5" cy="5" r="3.8" stroke="currentColor" stroke-width="1.2"/><path d="M8 8l2.5 2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Suche <kbd>⌘K</kbd>
</button>
<div class="mw-ip">{{ gethostname() ?: '—' }}</div>
<button class="mw-btn-primary" onclick="Livewire.dispatch('openModal',{component:'ui.domain.modal.domain-create-modal'})">+ Domain</button>
</div>
</header>
<div class="mw-content">
@hasSection('content')
@yield('content')
@else
{{ $slot ?? '' }}
@endif
</div>
</div>
</div>
@vite(['resources/js/app.js'])
@livewireScripts
@livewire('wire-elements-modal')
</body>
</html>

View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title ?? 'Webmail' }} · {{ config('app.name') }}</title>
@vite(['resources/css/app.css'])
@livewireStyles
</head>
<body style="margin:0;min-height:100dvh;background:var(--mw-bg);color:var(--mw-t1);
font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',sans-serif;
font-size:13.5px;-webkit-font-smoothing:antialiased;
display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;">
<div style="width:100%;max-width:380px;">
{{-- Brand --}}
<div style="text-align:center;margin-bottom:28px;">
<div style="display:inline-flex;align-items:center;justify-content:center;
width:52px;height:52px;border-radius:14px;background:var(--mw-v);
margin-bottom:14px;box-shadow:0 4px 18px rgba(109,40,217,.25);">
<svg width="22" height="22" viewBox="0 0 14 14" fill="none">
<rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="white" stroke-width="1.3"/>
<path d="M.5 5l6.5 4 6.5-4" stroke="white" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</div>
<div style="font-size:20px;font-weight:700;letter-spacing:-.3px;color:var(--mw-t1);">Webmail</div>
<div style="font-size:12px;color:var(--mw-t4);margin-top:3px;">{{ config('app.name') }}</div>
</div>
{{-- Card --}}
<div style="background:var(--mw-bg2);border:1px solid var(--mw-b1);border-radius:12px;padding:28px;">
{{ $slot }}
</div>
{{-- Footer --}}
<div style="text-align:center;margin-top:20px;font-size:11.5px;color:var(--mw-t5);">
&copy; {{ date('Y') }} {{ config('app.name') }}
</div>
</div>
@vite(['resources/js/app.js'])
@livewireScripts
</body>
</html>

View File

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title ?? 'Webmail' }} · {{ config('app.name') }}</title>
@vite(['resources/css/app.css'])
@livewireStyles
<style>
.wm-sidebar-overlay {
display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:199;
}
@media(max-width:700px) {
.wm-sidebar {
position:fixed!important;left:-220px;top:0;bottom:0;z-index:200;
transition:left .22s cubic-bezier(.4,0,.2,1);
box-shadow:2px 0 16px rgba(0,0,0,.18);
}
.wm-sidebar.open { left:0!important; }
.wm-sidebar.open ~ .wm-overlay { display:block; }
.wm-hamburger { display:flex!important; }
.wm-main { margin-left:0!important; }
}
</style>
</head>
<body style="margin:0;background:var(--mw-bg);color:var(--mw-t1);font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',sans-serif;font-size:13.5px;-webkit-font-smoothing:antialiased;">
<div class="mw-shell" style="position:relative;">
{{-- Mobile Overlay --}}
<div class="wm-sidebar-overlay wm-overlay" id="wm-overlay" onclick="wmCloseSidebar()"></div>
{{-- ═══ SIDEBAR ═══ --}}
<aside class="mw-sidebar wm-sidebar" id="wm-sidebar" style="width:200px;min-width:200px;">
{{-- Branding + Mobile Close --}}
<div class="mw-sb-head" style="display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:10px;">
<div class="mw-sb-mark">
<svg width="15" height="15" viewBox="0 0 14 14" fill="none">
<rect x=".5" y="2.5" width="13" height="9" rx="1.5" stroke="white" stroke-width="1.3"/>
<path d="M.5 5l6.5 4 6.5-4" stroke="white" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</div>
<div>
<div class="mw-sb-name">WEBMAIL</div>
<div class="mw-sb-sub">{{ config('app.name') }}</div>
</div>
</div>
<button onclick="wmCloseSidebar()"
style="display:none;background:none;border:none;cursor:pointer;color:var(--mw-t3);padding:4px;margin-right:4px;"
class="wm-hamburger"></button>
</div>
@if(session('webmail_email'))
<div style="padding:10px 10px 4px;">
<a href="{{ route('ui.webmail.compose') }}" wire:navigate
style="display:flex;align-items:center;gap:8px;padding:8px 12px;
background:var(--mw-v);border-radius:7px;color:#fff;
font-size:12.5px;font-weight:500;text-decoration:none;"
onclick="wmCloseSidebar()">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M7 1.5v11M1.5 7h11" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
Neue Nachricht
</a>
</div>
@endif
<nav class="mw-nav" style="padding-top:6px;" onclick="wmCloseSidebar()">
@if(session('webmail_email'))
<livewire:ui.webmail.folder-sidebar />
@endif
</nav>
@if(session('webmail_email'))
<div style="padding:10px;border-top:1px solid var(--mw-b1);display:flex;flex-direction:column;gap:4px;">
<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;overflow:hidden;">
<div style="width:28px;height:28px;border-radius:50%;background:var(--mw-vbg);border:1px solid var(--mw-vbd);
display:flex;align-items:center;justify-content:center;flex-shrink:0;
font-size:10px;font-weight:600;color:var(--mw-v2);">
{{ strtoupper(substr(session('webmail_email'), 0, 1)) }}
</div>
<div style="min-width:0;flex:1;overflow:hidden;">
<div style="font-size:11.5px;font-weight:500;color:var(--mw-t2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ session('webmail_email') }}
</div>
</div>
</div>
<div style="display:flex;gap:4px;">
<a href="{{ route('ui.webmail.settings') }}" wire:navigate
style="flex:1;display:flex;align-items:center;justify-content:center;gap:5px;
padding:6px 8px;border-radius:5px;font-size:11.5px;color:var(--mw-t4);
text-decoration:none;background:{{ request()->routeIs('ui.webmail.settings') ? 'var(--mw-bg3)' : 'none' }};"
onclick="wmCloseSidebar()">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="2" stroke="currentColor" stroke-width="1.2"/>
<path d="M7 1.5v1.2M7 11.3v1.2M1.5 7h1.2M11.3 7h1.2M2.9 2.9l.85.85M10.25 10.25l.85.85M2.9 11.1l.85-.85M10.25 3.75l.85-.85" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
Einstellungen
</a>
<livewire:ui.webmail.logout-button />
</div>
</div>
@endif
</aside>
{{-- ═══ MAIN ═══ --}}
<div class="mw-main wm-main" style="flex:1;overflow-y:auto;display:flex;flex-direction:column;min-width:0;">
{{-- Mobile Topbar --}}
<div style="display:none;align-items:center;gap:10px;padding:10px 14px;
border-bottom:1px solid var(--mw-b1);background:var(--mw-bg);"
class="wm-hamburger" id="wm-topbar">
<button onclick="wmOpenSidebar()"
style="background:none;border:none;cursor:pointer;color:var(--mw-t2);padding:4px;display:flex;flex-direction:column;gap:3px;">
<span style="display:block;width:18px;height:1.5px;background:currentColor;border-radius:2px;"></span>
<span style="display:block;width:18px;height:1.5px;background:currentColor;border-radius:2px;"></span>
<span style="display:block;width:18px;height:1.5px;background:currentColor;border-radius:2px;"></span>
</button>
<span style="font-size:13px;font-weight:600;color:var(--mw-t2);">Webmail</span>
</div>
<div class="mw-content" style="flex:1;padding:20px 24px;">
{{ $slot }}
</div>
</div>
</div>
<script>
function wmOpenSidebar() { document.getElementById('wm-sidebar').classList.add('open'); document.getElementById('wm-overlay').style.display='block'; }
function wmCloseSidebar() { document.getElementById('wm-sidebar').classList.remove('open'); document.getElementById('wm-overlay').style.display='none'; }
</script>
@vite(['resources/js/app.js'])
@livewireScripts
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More