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
parent
af369acbf6
commit
7bb922191f
12
.env.example
12
.env.example
|
|
@ -31,9 +31,11 @@ SESSION_DRIVER=database
|
|||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
# For cross-subdomain session sharing (e.g. webmail on mail.example.com):
|
||||
# SESSION_DOMAIN=.example.com
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
#BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
|
|
@ -63,3 +65,11 @@ AWS_BUCKET=
|
|||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Mailwolt domain config
|
||||
BASE_DOMAIN=example.com
|
||||
UI_SUB=admin
|
||||
MTA_SUB=mail
|
||||
# Custom webmail subdomain — users access webmail at WEBMAIL_SUB.BASE_DOMAIN
|
||||
# Leave empty to use only the path-based fallback (/webmail on main domain)
|
||||
WEBMAIL_SUB=webmail
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
# MailWolt Install Report
|
||||
Datum: 2026-04-17
|
||||
|
||||
## Befunde & Fixes
|
||||
|
||||
### Migrationen
|
||||
- **Status:** Alle 21 Migrationen erfolgreich durchgeführt (Batch 1–15)
|
||||
- Tabellen vorhanden: `domains`, `mail_users`, `mail_aliases`, `mail_alias_recipients`, `dkim_keys`, `settings`, `system_tasks`, `spf_records`, `dmarc_records`, `tlsa_records`, `backup_*`, `fail2ban_*`, `two_factor_*`
|
||||
- Feldbezeichnungen weichen von der Spec ab (z. B. `local` statt `source` bei Aliases) – ist konsistent mit der restlichen Anwendung
|
||||
|
||||
### Setup-Wizard
|
||||
- **Fix:** Route `/setup` fehlte in `routes/web.php` → hinzugefügt
|
||||
- Controller: `App\Http\Controllers\Setup\SetupWizard` (existiert)
|
||||
- Middleware `EnsureSetupCompleted` leitet nicht-abgeschlossene Setups korrekt auf `/setup` weiter
|
||||
- `SETUP_PHASE=bootstrap` in `.env` → Setup noch nicht abgeschlossen
|
||||
|
||||
### Nginx vHost (`/etc/nginx/sites-available/mailwolt.conf`)
|
||||
- `$uri`-Escapes: korrekt (kein Escaping-Problem)
|
||||
- PHP-FPM Socket: `unix:/run/php/php8.2-fpm.sock` ✓
|
||||
- `nginx -t`: **syntax ok / test successful** ✓
|
||||
|
||||
## Dienste-Status
|
||||
```
|
||||
postfix active
|
||||
dovecot active
|
||||
nginx active
|
||||
mariadb active
|
||||
redis-server active
|
||||
rspamd active
|
||||
opendkim activating ← startet noch / Sockets werden ggf. verzögert gebunden
|
||||
fail2ban active
|
||||
php8.2-fpm active
|
||||
laravel-queue active
|
||||
reverb active
|
||||
```
|
||||
|
||||
## Port-Test (Smoke Test)
|
||||
```
|
||||
Port 25: OPEN (SMTP)
|
||||
Port 465: OPEN (SMTPS)
|
||||
Port 587: OPEN (Submission)
|
||||
Port 143: OPEN (IMAP)
|
||||
Port 993: OPEN (IMAPS)
|
||||
Port 80: OPEN (HTTP → HTTPS Redirect)
|
||||
Port 443: OPEN (HTTPS)
|
||||
```
|
||||
|
||||
## Noch ausstehend (manuell)
|
||||
|
||||
- **Setup-Wizard aufrufen:** https://10.10.70.58/setup
|
||||
(Domain, Admin-E-Mail, Admin-Passwort setzen)
|
||||
|
||||
- **DKIM-Keys für Domain generieren** (nach Setup, wenn Domain angelegt):
|
||||
```
|
||||
rspamadm dkim_keygen -s mail -d example.com
|
||||
```
|
||||
|
||||
- **DNS-Einträge setzen** (beim Domain-Hoster):
|
||||
- `MX` → `mail.example.com`
|
||||
- `SPF` → `v=spf1 mx ~all`
|
||||
- `DKIM` → TXT-Eintrag aus `rspamadm dkim_keygen`
|
||||
- `DMARC` → `v=DMARC1; p=none; rua=mailto:dmarc@example.com`
|
||||
|
||||
- **Let's Encrypt Zertifikat** (nach DNS-Propagation):
|
||||
```
|
||||
certbot --nginx -d mail.example.com
|
||||
```
|
||||
|
||||
- **opendkim** prüfen ob Dienst vollständig gestartet ist:
|
||||
```
|
||||
systemctl status opendkim
|
||||
```
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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' => '—'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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' => ''];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
||||
]);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
];
|
||||
|
|
@ -6,6 +6,7 @@ return [
|
|||
'mail' => env('MTA_SUB'),
|
||||
'ui' => env('UI_SUB'),
|
||||
'webmail' => env('WEBMAIL_SUB'),
|
||||
'webmail_host' => env('WEBMAIL_DOMAIN'),
|
||||
],
|
||||
|
||||
'language' => [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit c443c5a42641f725bc3794fcad88b62a2d48f56a
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 & 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);">
|
||||
© {{ date('Y') }} {{ config('app.name') }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@vite(['resources/js/app.js'])
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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
Loading…
Reference in New Issue