Compare commits

..

13 Commits

23 changed files with 278 additions and 35 deletions

View File

@ -17,7 +17,7 @@ class BackupRun extends Command
$job->update(['status' => 'running']);
$policy = $job->policy;
$tmpDir = sys_get_temp_dir() . '/mailwolt_backup_' . $job->id;
$tmpDir = sys_get_temp_dir() . '/clubird_backup_' . $job->id;
mkdir($tmpDir, 0700, true);
$sources = [];
@ -37,7 +37,7 @@ class BackupRun extends Command
}
}
$this->finalize($job, $exitCode, implode("\n", array_slice($output, -30)), $artifact);
$this->finalize($job, $exitCode, implode("\n", array_slice($output, -30)), $artifact);
$this->cleanTmp($tmpDir);
return $exitCode === 0 ? self::SUCCESS : self::FAILURE;
}
@ -97,12 +97,12 @@ class BackupRun extends Command
$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';
$outDir = sys_get_temp_dir() . '/clubird_backups';
mkdir($outDir, 0750, true);
}
$stamp = now()->format('Y-m-d_H-i-s');
$outFile = "{$outDir}/mailwolt_{$stamp}.tar.gz";
$outFile = "{$outDir}/clubird_{$stamp}.tar.gz";
$srcArgs = implode(' ', array_map('escapeshellarg', $sources));
$tarOutput = [];

View File

@ -133,6 +133,7 @@ class UpdatePage extends Component
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 config:cache >/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;

View File

@ -14,7 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
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');
$wmHost = config('clubird.domain.webmail_host');
if ($wmHost) {
Route::middleware('web')
@ -27,14 +27,23 @@ return Application::configure(basePath: dirname(__DIR__))
->prefix('webmail')
->name('webmail.')
->group(base_path('routes/webmail.php'));
// UI-Routes nur auf UI-Domain binden, damit webmail.* sie nicht beantwortet
$uiHost = config('clubird.domain.ui') && config('clubird.domain.base')
? config('clubird.domain.ui') . '.' . config('clubird.domain.base')
: parse_url(config('app.url'), PHP_URL_HOST);
Route::middleware('web')
->domain($uiHost)
->group(base_path('routes/web.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'));
Route::middleware('web')->group(base_path('routes/web.php'));
}
},
)
->withMiddleware(function (Middleware $middleware): void {

View File

@ -3,6 +3,7 @@
@import "../fonts/BaiJamjuree/font.css";
@import "../fonts/Space/font.css";
@import "../fonts/Syne/font.css";
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../vendor/wire-elements/modal/resources/views/*.blade.php';
@ -16,6 +17,7 @@
--font-bai: 'Bai Jamjuree';
--font-space: 'Space Age';
--font-syne: 'Syne';
--color-glass-bg: rgba(17, 24, 39, 0.55);
--color-glass-border: rgba(255, 255, 255, 0.08);
@ -1041,8 +1043,8 @@ select {
background: var(--mw-v); display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 12px rgba(124,58,237,.3);
}
.mw-sb-name { font-size: 13px; font-weight: 700; letter-spacing: .3px; color: var(--mw-t1); }
.mw-sb-sub { font-size: 9.5px; color: var(--mw-t4); letter-spacing: .8px; text-transform: uppercase; margin-top: 1px; }
.mw-sb-name { font-family: 'Syne', sans-serif; font-size: 15px; font-weight: 800; letter-spacing: -.2px; line-height: 1; color: var(--mw-t1); }
.mw-sb-sub { font-size: 9px; color: var(--mw-t4); letter-spacing: 1.6px; text-transform: uppercase; margin-top: 3px; }
.mw-nav { flex: 1; padding: 10px 8px; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; }
.mw-nav::-webkit-scrollbar { display: none; }

View File

@ -0,0 +1,15 @@
@font-face {
font-display: swap;
font-family: 'Syne';
font-style: normal;
font-weight: 700;
src: url('Syne-Bold.ttf') format('truetype');
}
@font-face {
font-display: swap;
font-family: 'Syne';
font-style: normal;
font-weight: 800;
src: url('Syne-ExtraBold.ttf') format('truetype');
}

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 400,
'type' => 'client',
'title' => 'Ungültige Anfrage',
'desc' => 'Die Anfrage enthält fehlerhafte oder unvollständige Daten.',
'icon' => '<path d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 401,
'type' => 'client',
'title' => 'Nicht angemeldet',
'desc' => 'Bitte melde dich an, um auf diese Seite zuzugreifen.',
'icon' => '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 403,
'type' => 'client',
'title' => 'Kein Zugriff',
'desc' => 'Du hast keine Berechtigung, diese Seite aufzurufen.',
'icon' => '<circle cx="12" cy="12" r="9"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 404,
'type' => 'client',
'title' => 'Seite nicht gefunden',
'desc' => 'Die aufgerufene Seite existiert nicht oder wurde verschoben.',
'icon' => '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 405,
'type' => 'client',
'title' => 'Methode nicht erlaubt',
'desc' => 'Diese HTTP-Methode ist für die aufgerufene URL nicht zulässig.',
'icon' => '<circle cx="12" cy="12" r="9"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 419,
'type' => 'client',
'title' => 'Sitzung abgelaufen',
'desc' => 'Deine Sitzung ist abgelaufen. Lade die Seite neu und versuche es erneut.',
'icon' => '<circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 15"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 422,
'type' => 'client',
'title' => 'Ungültige Eingabe',
'desc' => 'Die übermittelten Daten konnten nicht verarbeitet werden.',
'icon' => '<path d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 429,
'type' => 'client',
'title' => 'Zu viele Anfragen',
'desc' => 'Du hast das Anfrage-Limit erreicht. Bitte warte einen Moment.',
'icon' => '<path d="M13 10V3L4 14h7v7l9-11h-7z"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 500,
'type' => 'server',
'title' => 'Serverfehler',
'desc' => 'Ein interner Fehler ist aufgetreten. Wir wurden benachrichtigt.',
'icon' => '<path d="M13 10V3L4 14h7v7l9-11h-7z"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 502,
'type' => 'server',
'title' => 'Bad Gateway',
'desc' => 'Der Server hat eine ungültige Antwort erhalten. Bitte versuche es gleich erneut.',
'icon' => '<path d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
])

View File

@ -0,0 +1,7 @@
@include('errors._base', [
'code' => 504,
'type' => 'server',
'title' => 'Gateway-Timeout',
'desc' => 'Der Server antwortet nicht rechtzeitig. Bitte versuche es in Kürze erneut.',
'icon' => '<circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 15"/>',
])

View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $code }} · CluBird</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; min-height: 100vh;
background: #0f1117; color: #e2e4ea;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.card {
width: 100%; max-width: 480px; padding: 44px 40px;
background: #181c24; border: 1px solid #252938;
border-radius: 18px; text-align: center;
animation: fadein .35s ease;
}
@keyframes fadein { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.logo { display: inline-flex; align-items: center; gap: 14px; margin-bottom: 36px; }
.logo-icon {
position: relative; width: 42px; height: 42px;
background: linear-gradient(135deg, #6366f1, #4338ca);
border-radius: 12px; display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 22px rgba(99,102,241,.45);
}
.logo-icon::after {
content: ''; position: absolute; inset: -4px; border-radius: 16px;
border: 1px solid rgba(99,102,241,.3);
animation: glow 2s ease-in-out infinite;
}
@keyframes glow { 0%, 100% { opacity: .3; } 50% { opacity: 1; } }
.logo-text { font-size: 24px; font-weight: 700; letter-spacing: -.3px; }
.logo-text span { color: #6366f1; }
.err-icon {
width: 56px; height: 56px; margin: 0 auto 20px;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
background: {{ $type === 'server' ? 'rgba(239,68,68,.1)' : 'rgba(99,102,241,.12)' }};
}
.err-icon svg {
width: 26px; height: 26px; fill: none; stroke-width: 1.8;
stroke-linecap: round; stroke-linejoin: round;
stroke: {{ $type === 'server' ? '#f87171' : '#818cf8' }};
}
.err-code {
font-size: 64px; font-weight: 800; line-height: 1;
margin-bottom: 12px; letter-spacing: -2px;
color: {{ $type === 'server' ? '#f87171' : '#6366f1' }};
}
h1 { font-size: 17px; font-weight: 600; color: #e2e4ea; margin-bottom: 8px; letter-spacing: -.2px; }
p { font-size: 13px; color: #4b5563; line-height: 1.65; margin-bottom: 28px; }
.divider { border: none; border-top: 1px solid #252938; margin: 0 0 28px; }
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 20px; border-radius: 9px;
background: #1f2433; border: 1px solid #2d3348;
font-size: 13px; font-weight: 500; text-decoration: none; cursor: pointer;
transition: background .15s, border-color .15s;
color: {{ $type === 'server' ? '#fca5a5' : '#a5b4fc' }};
}
.btn:hover { background: #252b3d; border-color: #3d4565; }
.btn svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.btn-primary {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 20px; border-radius: 9px; margin-left: 8px;
background: linear-gradient(135deg, #6366f1, #4338ca); border: none;
color: #fff; font-size: 13px; font-weight: 500; text-decoration: none;
box-shadow: 0 4px 14px rgba(99,102,241,.35); transition: opacity .15s;
}
.btn-primary:hover { opacity: .88; }
.btn-primary svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
</style>
</head>
<body>
<div class="card">
<div class="logo">
<div class="logo-icon">
<svg width="22" height="22" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="19" cy="8" r="2" fill="white"/>
</svg>
</div>
<div class="logo-text">Clu<span>Bird</span></div>
</div>
<div class="err-icon">
<svg viewBox="0 0 24 24">{!! $icon !!}</svg>
</div>
<div class="err-code">{{ $code }}</div>
<h1>{{ $title }}</h1>
<p>{{ $desc }}</p>
<hr class="divider">
@if($type === 'server')
<a class="btn" href="javascript:location.reload()">
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.1-6.87"/></svg>
Neu laden
</a>
@else
<a class="btn" href="javascript:history.back()">
<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
Zurück
</a>
@endif
<a class="btn-primary" href="/">
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Startseite
</a>
</div>
</body>
</html>

View File

@ -18,15 +18,17 @@
<aside class="mw-sidebar" id="mw-sidebar">
<div class="mw-sb-head">
<div class="mw-sb-mark" style="background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);box-shadow:0 0 10px rgba(99,102,241,.35);">
<svg width="15" height="15" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="2" fill="white"/>
<div class="mw-sb-mark" style="background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);box-shadow:0 0 12px rgba(99,102,241,.35);">
<svg width="18" height="18" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>
<div class="mw-sb-name"><span style="color:var(--mw-t1)">Clu</span><span style="color:#6366f1">Bird</span></div>
<div class="mw-sb-name"><span style="color:#e8eaf6">Clu</span><span style="color:#6366f1">Bird</span></div>
<div class="mw-sb-sub">Control Panel</div>
</div>
</div>
@ -224,14 +226,18 @@
@livewireScripts
@livewire('wire-elements-modal')
<script>
// 503 (Wartungsmodus) → sofortiger Page-Reload statt Livewire-Fehlerdialog
// 503 (Wartungsmodus) → Reload | 419 (Session abgelaufen) → Login
;(function () {
var _fetch = window.fetch
window.fetch = function () {
return _fetch.apply(this, arguments).then(function (resp) {
if (resp.status === 503) {
window.location.reload()
return new Promise(function () {}) // Livewire-Chain abbrechen
return new Promise(function () {})
}
if (resp.status === 419) {
window.location.href = '{{ route('login') }}'
return new Promise(function () {})
}
return resp
})

View File

@ -17,15 +17,21 @@
{{-- 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"/>
width:48px;height:48px;border-radius:13px;
background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);
margin-bottom:14px;box-shadow:0 0 20px rgba(99,102,241,.35);">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</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 style="font-family:'Syne',sans-serif;font-size:24px;font-weight:800;letter-spacing:-.3px;line-height:1;margin-bottom:4px">
<span style="color:#e8eaf6">Clu</span><span style="color:#6366f1">Bird</span>
</div>
<div style="font-size:11px;color:var(--mw-t4);margin-top:5px;">Webmail</div>
</div>
{{-- Card --}}

View File

@ -1,16 +1,19 @@
<div style="width:100%;max-width:420px;padding:24px 16px">
{{-- Logo --}}
<div style="display:flex;align-items:center;gap:10px;margin-bottom:32px;justify-content:center">
<div style="width:32px;height:32px;background:linear-gradient(135deg,#6366f1,#4f46e5);border-radius:8px;display:flex;align-items:center;justify-content:center;box-shadow:0 0 16px rgba(99,102,241,.4)">
<svg width="16" height="16" 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"/>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:32px;justify-content:center">
<div style="width:36px;height:36px;background:linear-gradient(135deg,#6366f1 0%,#4338ca 100%);border-radius:10px;display:flex;align-items:center;justify-content:center;box-shadow:0 0 16px rgba(99,102,241,.35);flex-shrink:0">
<svg width="21" height="21" viewBox="0 0 28 28" fill="none">
<path d="M5 19 C5 19 8 10 15 9 C19 8 23 11 24 15" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M15 9 C15 9 12 5 8 5 C9.5 7 10 9 11 10" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<path d="M24 15 C24 15 27 13 28 10 C26 11 24 12 23 13.5" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<circle cx="19" cy="8" r="1.5" fill="white" opacity=".95"/>
<path d="M5 19 C6.5 22 9 23.5 12 23.5 C15 23.5 18 22 19.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div>
<div style="font-size:14px;font-weight:700;letter-spacing:.3px;color:var(--mw-t1)">CLUBIRD</div>
<div style="font-size:11px;color:var(--mw-t4);margin-top:-1px">Control Panel</div>
<div style="font-family:'Syne',sans-serif;font-size:22px;font-weight:800;letter-spacing:-.3px;line-height:1"><span style="color:#e8eaf6">Clu</span><span style="color:#6366f1">Bird</span></div>
<div style="font-size:9px;font-weight:500;color:var(--mw-t4);text-transform:uppercase;letter-spacing:1.8px;margin-top:2px">Mail Server Management</div>
</div>
</div>

View File

@ -10,8 +10,8 @@ use Illuminate\Support\Facades\Route;
// Domain-wrapping and naming are applied in bootstrap/app.php.
// This file only defines the routes themselves.
// Root → redirect to UI admin login; webmail login is at /login
Route::get('/', fn() => redirect()->away(config('app.url') . '/login'))->name('root');
// Root → redirect to webmail login
Route::get('/', fn() => redirect()->to(route('ui.webmail.login')))->name('root');
Route::get('/login', Login::class)->name('login');
Route::get('/inbox', Inbox::class)->name('inbox');
Route::get('/compose', Compose::class)->name('compose');

View File

@ -6,8 +6,8 @@ APP_DIR="/var/www/mailwolt"
APP_USER="www-data"
OUT_DIR="${APP_DIR}/storage/app/backups"
STAMP="$(date +%Y-%m-%d_%H-%M-%S)"
OUT_FILE="${OUT_DIR}/mailwolt_${STAMP}.tar.gz"
TMP_DIR="$(mktemp -d /tmp/mailwolt_backup_XXXXXX)"
OUT_FILE="${OUT_DIR}/clubird_${STAMP}.tar.gz"
TMP_DIR="$(mktemp -d /tmp/clubird_backup_XXXXXX)"
cleanup(){ rm -rf "$TMP_DIR"; }
trap cleanup EXIT

View File

@ -224,6 +224,15 @@ ensure_system(){
[[ -f "$f" ]] || continue
_sudoers_add "$f" 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/mailwolt-backup'
done
# RSpamd: Loopback als lokale Adresse eintragen (verhindert 127.0.0.1-Einträge in History)
local rspamd_opts="/etc/rspamd/local.d/options.inc"
if [[ -d /etc/rspamd/local.d ]] && ! grep -q "127.0.0.1" "${rspamd_opts}" 2>/dev/null; then
printf 'local_addrs = [127.0.0.1, ::1, 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, fd00::/8, 169.254.0.0/16, fe80::/10];\n' \
> "${rspamd_opts}"
systemctl is-active --quiet rspamd && systemctl reload rspamd || true
echo "[✓] RSpamd: local_addrs mit Loopback gesetzt."
fi
# Laravel Scheduler cron (schedule:run muss jede Minute laufen)
local cron_file="/etc/cron.d/mailwolt"
local cron_line="* * * * * www-data php ${APP_DIR}/artisan schedule:run >> /dev/null 2>&1"