Fix: Mailbox Stats über Dovecot mit config/mailpool.php

main v1.0.07
boban 2025-10-21 18:30:20 +02:00
parent 2fecb09984
commit 323c6032af
8 changed files with 271 additions and 39 deletions

View File

@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Models\MailUser;
use App\Models\Setting;
use Illuminate\Console\Command;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
@ -58,12 +59,11 @@ class UpdateMailboxStats extends Command
$messageCount = $this->messageCountViaFilesystem($maildir);
}
// Update DB
$u->forceFill([
'used_bytes' => $usedBytes,
'message_count' => (int) $messageCount,
'stats_refreshed_at' => now(),
])->save();
Setting::set("mailbox.{$email}", [
'used_bytes' => $usedBytes,
'message_count' => $messageCount,
'updated_at' => now()->toDateTimeString(),
]);
$this->line(sprintf(
"%-35s %7.1f MiB %5d msgs",
@ -127,7 +127,7 @@ class UpdateMailboxStats extends Command
$count++;
}
}
closedir($dh);
closedir($dh);
}
}
}

View File

@ -4,6 +4,7 @@
namespace App\Livewire\Ui\Mail;
use App\Models\Domain;
use App\Models\Setting;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Str;
use Livewire\Attributes\On;
@ -72,6 +73,19 @@ class MailboxList extends Component
);
}
public function updateMailboxStatsOne(string $email)
{
Artisan::call('mail:update-stats', ['--user' => $email]);
$this->dispatch('$refresh');
$this->dispatch('toast',
type: 'done',
badge: 'Mailbox',
title: 'Mailbox aktualisiert',
text: 'Die Mailbox-Statistiken wurden aktualisiert.',
duration: 6000
);
}
public function render()
{
$system = Domain::query()->where('is_system', true)->first();
@ -82,17 +96,17 @@ class MailboxList extends Component
$domains = Domain::query()
->when($system, fn ($q) => $q->whereKeyNot($system->id))
// Domain selbst ODER MailUser/ Aliasse müssen matchen
// Domain selbst ODER MailUser müssen matchen
->when($hasTerm, function ($q) use ($needle) {
$q->where(function ($w) use ($needle) {
$w->where('domain', 'like', $needle)
->orWhereHas('mailUsers', fn($u) => $u->where('localpart', 'like', $needle));
->orWhereHas('mailUsers', fn($u) => $u->where('localpart', 'like', $needle));
});
})
->withCount(['mailUsers'])
// Beziehungen zunächst gefiltert laden (damit "test" nur passende Mailboxen zeigt)
// Relationen zunächst ggf. gefiltert laden
->with([
'mailUsers' => function ($q) use ($hasTerm, $needle) {
if ($hasTerm) $q->where('localpart', 'like', $needle);
@ -103,32 +117,39 @@ class MailboxList extends Component
->orderBy('domain')
->get();
// Domains, deren NAME den Suchbegriff trifft → ALLE Mailboxen/Aliasse zeigen
// Wenn der Domainname selbst matched → alle Mailboxen/Aliasse vollständig nachladen
if ($hasTerm) {
$lower = Str::lower($term);
foreach ($domains as $d) {
if (Str::contains(Str::lower($d->domain), $lower)) {
// volle Relationen nachladen (überschreibt die gefilterten)
$d->setRelation('mailUsers', $d->mailUsers()->orderBy('localpart')->get());
$d->setRelation('mailAliases', $d->mailAliases()->orderBy('local')->get());
}
}
}
// Vorbereitung für Blade (unverändert, arbeitet auf den ggf. gefilterten Relationen)
// Vorbereitung für Blade
foreach ($domains as $d) {
$prepared = [];
$domainActive = (bool)($d->is_active ?? true);
foreach ($d->mailUsers as $u) {
$usedMB = (int) round(($u->used_bytes ?? 0) / 1024 / 1024);
// E-Mail sicher bestimmen (Fallback aus localpart + Domain)
$email = $u->email ?? ($u->localpart ? ($u->localpart.'@'.$d->domain) : null);
// Stats aus Redis/DB holen (kann array, json-string oder null sein)
$statsRaw = $email ? Setting::get("mailbox.$email") : null;
$stats = is_array($statsRaw)
? $statsRaw
: (is_string($statsRaw) ? (json_decode($statsRaw, true) ?: null) : null);
// Werte priorisieren: Setting → DB-Felder → 0
$usedBytes = (int)($stats['used_bytes'] ?? ($u->used_bytes ?? 0));
$messageCount = (int)($stats['message_count'] ?? ($u->message_count ?? 0));
$usedMB = (int) round($usedBytes / 1024 / 1024);
$quota = (int)($u->quota_mb ?? 0);
$usage = $quota > 0 ? min(100, (int) round($usedMB / max(1,$quota) * 100)) : 0;
// $quota = (int)($u->quota_mb ?? 0);
// $used = (int)($u->used_mb ?? 0);
// $usage = $quota > 0 ? min(100, (int)round($used / max(1, $quota) * 100)) : 0;
$usage = $quota > 0 ? min(100, (int) round($usedMB / max(1, $quota) * 100)) : 0;
$mailboxActive = (bool)($u->is_active ?? true);
$effective = $domainActive && $mailboxActive;
@ -144,17 +165,14 @@ class MailboxList extends Component
'localpart' => (string)$u->localpart,
'quota_mb' => $quota,
'usage_percent' => $usage,
'used_mb' => $usedMB,
'message_count' => (int) ($u->message_count ?? 0),
// 'used_mb' => $used,
// 'message_count' => (int)($u->message_count ?? $u->mails_count ?? 0),
'used_mb' => $usedMB, // MiB fürs UI
'message_count' => $messageCount,
'is_active' => $mailboxActive,
'is_effective_active' => $effective,
'inactive_reason' => $reason,
];
}
// für Blade
$d->prepared_mailboxes = $prepared;
}
@ -163,6 +181,98 @@ class MailboxList extends Component
'system' => $this->showSystemCard ? $system : null,
]);
}
// public function render()
// {
// $system = Domain::query()->where('is_system', true)->first();
// $term = trim($this->search);
// $hasTerm = $term !== '';
// $needle = '%'.str_replace(['%','_'], ['\%','\_'], $term).'%'; // LIKE-sicher
//
// $domains = Domain::query()
// ->when($system, fn ($q) => $q->whereKeyNot($system->id))
//
// // Domain selbst ODER MailUser/ Aliasse müssen matchen
// ->when($hasTerm, function ($q) use ($needle) {
// $q->where(function ($w) use ($needle) {
// $w->where('domain', 'like', $needle)
// ->orWhereHas('mailUsers', fn($u) => $u->where('localpart', 'like', $needle));
// });
// })
//
// ->withCount(['mailUsers'])
//
// // Beziehungen zunächst gefiltert laden (damit "test" nur passende Mailboxen zeigt)
// ->with([
// 'mailUsers' => function ($q) use ($hasTerm, $needle) {
// if ($hasTerm) $q->where('localpart', 'like', $needle);
// $q->orderBy('localpart');
// },
// ])
//
// ->orderBy('domain')
// ->get();
//
// // Domains, deren NAME den Suchbegriff trifft → ALLE Mailboxen/Aliasse zeigen
// if ($hasTerm) {
// $lower = Str::lower($term);
// foreach ($domains as $d) {
// if (Str::contains(Str::lower($d->domain), $lower)) {
// // volle Relationen nachladen (überschreibt die gefilterten)
// $d->setRelation('mailUsers', $d->mailUsers()->orderBy('localpart')->get());
// $d->setRelation('mailAliases', $d->mailAliases()->orderBy('local')->get());
// }
// }
// }
//
// // Vorbereitung für Blade (unverändert, arbeitet auf den ggf. gefilterten Relationen)
// foreach ($domains as $d) {
// $prepared = [];
// $domainActive = (bool)($d->is_active ?? true);
//
// foreach ($d->mailUsers as $u) {
// $stats = Setting::get("mailbox.{$u->email}");
// $usedBytes = $stats['used_bytes'] ?? ($u->used_bytes ?? 0);
// $messageCount = $stats['message_count'] ?? ($u->message_count ?? 0);
// $usedMB = (int) round(($usedBytes) / 1024 / 1024);
// $quota = (int)($u->quota_mb ?? 0);
// $usage = $quota > 0 ? min(100, (int) round($usedMB / max(1,$quota) * 100)) : 0;
//
//
//// $quota = (int)($u->quota_mb ?? 0);
//// $used = (int)($u->used_mb ?? 0);
//// $usage = $quota > 0 ? min(100, (int)round($used / max(1, $quota) * 100)) : 0;
//
// $mailboxActive = (bool)($u->is_active ?? true);
// $effective = $domainActive && $mailboxActive;
//
// $reason = null;
// if (!$effective) {
// $reason = !$domainActive ? 'Domain inaktiv'
// : (!$mailboxActive ? 'Postfach inaktiv' : null);
// }
//
// $prepared[] = [
// 'id' => $u->id,
// 'localpart' => (string)$u->localpart,
// 'quota_mb' => $quota,
// 'usage_percent' => $usage,
// 'used_mb' => $usedMB,
// 'message_count' => $messageCount,
// 'is_active' => $mailboxActive,
// 'is_effective_active' => $effective,
// 'inactive_reason' => $reason,
// ];
// }
//
// // für Blade
// $d->prepared_mailboxes = $prepared;
// }
//
// return view('livewire.ui.mail.mailbox-list', [
// 'domains' => $domains,
// 'system' => $this->showSystemCard ? $system : null,
// ]);
// }
// public function render()
// {

View File

@ -0,0 +1,72 @@
<?php
namespace App\Livewire\Ui\System;
use Livewire\Component;
class UpdateManager extends Component
{
public bool $running = false;
public string $log = '';
public ?int $rc = null;
public ?string $latest = null; // optional: von Cache o.ä.
public function mount(): void
{
$this->refreshState();
// Optional: latest Tag/Version aus Cache anzeigen
$this->latest = cache('mailwolt.update_available');
}
public function refreshState(): void
{
$state = @trim(@file_get_contents('/var/lib/mailwolt/update/state') ?: '');
$this->running = ($state === 'running');
$rcRaw = @trim(@file_get_contents('/var/lib/mailwolt/update/rc') ?: '');
$this->rc = is_numeric($rcRaw) ? (int)$rcRaw : null;
// letzte 200 Zeilen Log
$this->log = @shell_exec('tail -n 200 /var/log/mailwolt-update.log 2>/dev/null') ?? '';
}
public function runUpdate(): void
{
// Hinweis „Update verfügbar“ ausblenden
cache()->forget('mailwolt.update_available');
// Update im Hintergrund starten
@shell_exec('nohup sudo -n /usr/local/sbin/mw-update >/dev/null 2>&1 &');
$this->running = true;
$this->dispatch('toast',
type: 'info',
badge: 'Update',
title: 'Update gestartet',
text: 'Das System wird aktualisiert …',
duration: 6000
);
}
public function poll(): void
{
$before = $this->running;
$this->refreshState();
if ($before && !$this->running && $this->rc !== null) {
$ok = ($this->rc === 0);
$this->dispatch('toast',
type: $ok ? 'done' : 'warn',
badge: 'Update',
title: $ok ? 'Update abgeschlossen' : 'Update fehlgeschlagen',
text: $ok ? 'Die neue Version ist aktiv.' : "Fehlercode: {$this->rc}. Log prüfen.",
duration: 8000
);
}
}
public function render()
{
return view('livewire.ui.system.update-manager');
}
}

View File

@ -9,20 +9,19 @@
<button class="sidebar-toggle translate-0 right-5 block sm:hidden text-white/60 hover:text-white text-2xl">
<i class="ph ph-list"></i>
</button>
@if ($latest = cache('mailwolt.update_available'))
<div class="bg-blue-900/40 text-blue-100 p-4 rounded-xl border border-blue-800">
<div class="flex justify-between items-center">
<div>
<strong>Neue Version verfügbar:</strong> {{ $latest }}
</div>
<button wire:click="runUpdate"
class="bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded">
Jetzt aktualisieren
</button>
</div>
</div>
@endif
{{-- @if ($latest = cache('mailwolt.update_available'))--}}
{{-- <div class="bg-blue-900/40 text-blue-100 p-4 rounded-xl border border-blue-800">--}}
{{-- <div class="flex justify-between items-center">--}}
{{-- <div>--}}
{{-- <strong>Neue Version verfügbar:</strong> {{ $latest }}--}}
{{-- </div>--}}
{{-- <button wire:click="runUpdate"--}}
{{-- class="bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded">--}}
{{-- Jetzt aktualisieren--}}
{{-- </button>--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @endif--}}
{{-- <button id="sidebar-toggle-btn"--}}
{{-- class="action-button sidebar-toggle flex items-center justify-center p-1.5 shadow-2xl rounded">--}}
{{-- <svg class="size-5 group-[.expanded]/side:rotate-180"--}}

View File

@ -65,6 +65,15 @@
<x-button.copy-btn :text="$r['value']" />
</div>
</div>
<div class="px-4 pb-3">
<pre class="text-[12px] w-full rounded-lg bg-white/5 border border-white/10 text-white px-3 py-2 text-sm opacity-70 whitespace-pre-wrap break-all">{{ $r['value'] }}</pre>
@if(!empty($r['helpUrl']))
<a href="{{ $r['helpUrl'] }}" target="_blank" rel="noopener"
class="mt-2 inline-flex items-center gap-1 text-[12px] text-sky-300 hover:text-sky-200">
<i class="ph ph-arrow-square-out"></i>{{ $r['helpLabel'] }}
</a>
@endif
</div>
</div>
@endforeach
</div>

View File

@ -304,6 +304,8 @@
<td class="px-3 py-2 rounded-r-xl">
<div class="flex items-center gap-2 justify-end">
<button wire:click="updateMailboxStatsOne('{{ $u['localpart'].'@'.$domain->domain }}')">Jetzt aktualisieren</button>
<button wire:click="updateMailboxStats"
class="px-3 py-1 text-sm rounded-md bg-blue-600 text-white hover:bg-blue-700 transition">
Jetzt aktualisieren

View File

@ -0,0 +1,36 @@
<div class="bg-white/5 border border-white/10 rounded-xl p-4"
@if($running) wire:poll.3s="poll" @endif>
<div class="flex items-center justify-between gap-3">
<div class="text-white/80">
<strong>MailWolt Update</strong>
@if($latest)
<span class="ml-2 px-2 py-0.5 rounded-full text-xs border border-blue-400/40 bg-blue-500/10 text-blue-200">
verfügbar: {{ $latest }}
</span>
@endif
@if($running)
<span class="ml-2 px-2 py-0.5 rounded-full text-xs border border-white/20 bg-white/10">läuft </span>
@elseif(!is_null($rc))
<span class="ml-2 px-2 py-0.5 rounded-full text-xs border {{ $rc===0 ? 'border-emerald-400/40 bg-emerald-500/10 text-emerald-200' : 'border-rose-400/40 bg-rose-500/10 text-rose-200' }}">
{{ $rc===0 ? 'fertig' : 'fehler' }}
</span>
@endif
</div>
<div class="flex items-center gap-2">
<button wire:click="runUpdate"
@disabled($running)
class="bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-1 rounded">
Jetzt aktualisieren
</button>
<button wire:click="refreshState"
class="border border-white/10 bg-white/5 text-white/80 px-3 py-1 rounded hover:border-white/20">
Neu laden
</button>
</div>
</div>
@if($running || !empty($log))
<pre class="mt-3 max-h-64 overflow-auto text-xs bg-black/40 rounded p-3 border border-white/10 text-white/80">{{ $log }}</pre>
@endif
</div>

View File

@ -6,6 +6,10 @@
@section('content')
<div class="max-w-7xl mx-auto space-y-6 px-2 md:px-4">
<div class="max-w-fit col-span-4 #lg:col-span-6">
<livewire:ui.system.update-manager />
</div>
<div class="col-span-12 lg:col-span-6">
<livewire:ui.dashboard.top-bar />
</div>