diff --git a/app/Console/Commands/UpdateMailboxStats.php b/app/Console/Commands/UpdateMailboxStats.php index 64cc982..ddd2959 100644 --- a/app/Console/Commands/UpdateMailboxStats.php +++ b/app/Console/Commands/UpdateMailboxStats.php @@ -1,6 +1,5 @@ info('=== mail:update-stats START ===', [ - 'userOpt' => $this->option('user') - ]); + Log::info('=== mail:update-stats START ===', ['userOpt' => $this->option('user')]); $q = MailUser::query() - ->where('is_active', true) - ->whereNotNull('email') - ->where('email', 'like', '%@%'); + ->with('domain:id,domain') + ->active(); - if ($email = trim((string)$this->option('user'))) { - $q->where('email', $email); + if ($only = trim((string)$this->option('user'))) { + $q->byEmail($only); } $users = $q->get(); - if ($users->isEmpty()) { - Log::channel('mailstats')->warning('Keine passenden Mailboxen gefunden.'); + Log::warning('Keine passenden Mailboxen gefunden.'); $this->warn('Keine passenden Mailboxen gefunden.'); return self::SUCCESS; } foreach ($users as $u) { - $email = trim((string)$u->email); + // 1) sichere E-Mail ermitteln: roher DB-Wert → Accessor → zusammengesetzt + $raw = (string)($u->getRawOriginal('email') ?? ''); + $email = $raw !== '' ? $raw : ($u->email ?? $u->address ?? ''); - if (!preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $email)) { - Log::channel('mailstats')->warning('Überspringe ungültige Adresse', ['email' => $email]); + if (!preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', (string)$email)) { + Log::warning('Ungültige effektive Adresse – skip', [ + 'user_id' => $u->id, 'raw' => $raw, 'computed' => $email + ]); continue; } [$local, $domain] = explode('@', $email, 2); $maildir = "/var/mail/vhosts/{$domain}/{$local}"; - // 1) Größe berechnen (Dateisystem) + // 2) Größe (Dateisystem) $usedBytes = 0; if (is_dir($maildir)) { $it = new RecursiveIteratorIterator( @@ -60,22 +59,23 @@ class UpdateMailboxStats extends Command } } - // 2) Nachrichten zählen – bevorzugt via doveadm als vmail + // 3) Message-Count – prefer doveadm als vmail; Fallback: Files $messageCount = $this->countViaDoveadm($email); if ($messageCount === null) { $messageCount = $this->countViaFilesystem($maildir); } - // optional: in Settings/Redis cachen (für UI) + // 4) In Settings (→ Redis + DB-Backup) persistieren Setting::set("mailbox.{$email}", [ 'used_bytes' => (int)$usedBytes, 'message_count' => (int)$messageCount, 'updated_at' => now()->toDateTimeString(), ]); - $line = sprintf("%-35s %7.1f MiB %5d msgs", $email, $usedBytes / 1024 / 1024, $messageCount); - $this->line($line); - Log::channel('mailstats')->info('OK', [ + $this->line(sprintf("%-35s %7.1f MiB %5d msgs", + $email, $usedBytes / 1024 / 1024, $messageCount)); + + Log::info('Mailbox-Stat OK', [ 'email' => $email, 'maildir' => $maildir, 'used_bytes' => $usedBytes, @@ -83,23 +83,20 @@ class UpdateMailboxStats extends Command ]); } - Log::channel('mailstats')->info('=== mail:update-stats DONE ==='); + Log::info('=== mail:update-stats DONE ==='); $this->info('Mailbox-Statistiken aktualisiert.'); return self::SUCCESS; } protected function countViaDoveadm(string $email): ?int { - $cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u " . escapeshellarg($email) . " messages INBOX 2>&1"; + $cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u " + . escapeshellarg($email) . " messages INBOX 2>&1"; $out = []; $rc = 0; exec($cmd, $out, $rc); - - Log::channel('mailstats')->debug('doveadm exec', ['cmd' => $cmd, 'rc' => $rc, 'out' => $out]); - if ($rc !== 0) return null; - // Erwartet: "mailbox\tmessages\nINBOX\t" foreach ($out as $line) { if (preg_match('/^\s*INBOX\s+(\d+)\s*$/i', trim($line), $m)) { return (int)$m[1]; @@ -114,19 +111,265 @@ class UpdateMailboxStats extends Command foreach (['cur', 'new'] as $sub) { $dir = "{$maildir}/{$sub}"; if (!is_dir($dir)) continue; - $dh = opendir($dir); - if (!$dh) continue; - while (($fn = readdir($dh)) !== false) { + $h = opendir($dir); + if (!$h) continue; + while (($fn = readdir($h)) !== false) { if ($fn === '.' || $fn === '..' || $fn[0] === '.') continue; $n++; } - closedir($dh); + closedir($h); } - Log::channel('mailstats')->debug('fs count', ['maildir' => $maildir, 'count' => $n]); return $n; } } +//namespace App\Console\Commands; +// +//use Illuminate\Console\Command; +//use Illuminate\Support\Facades\Log; +//use App\Models\MailUser; +//use App\Models\Setting; +//use RecursiveDirectoryIterator; +//use RecursiveIteratorIterator; + + +//class UpdateMailboxStats extends Command +//{ +// protected $signature = 'mail:update-stats {--user=}'; +// protected $description = 'Aktualisiert Mailquota und Nachrichtenzahl'; +// +// public function handle(): int +// { +// Log::info('=== mail:update-stats START ===', ['userOpt' => $this->option('user')]); +// +// $q = MailUser::query() +// ->with('domain:id,domain') // <— Domain fürs Fallback laden +// ->where('is_active', true); +// +// if ($only = trim((string)$this->option('user'))) { +// $q->where('email', $only); +// } else { +// $q->whereNotNull('email')->where('email', 'like', '%@%'); +// } +// +// $users = $q->get(); +// if ($users->isEmpty()) { +// Log::warning('Keine passenden Mailboxen gefunden.'); +// $this->warn('Keine passenden Mailboxen gefunden.'); +// return self::SUCCESS; +// } +// +// foreach ($users as $u) { +// // 1) rohen DB-Wert nehmen (ohne Accessor/Casts) +// $rawEmail = (string) $u->getRawOriginal('email'); +// +// // 2) Fallback aus localpart + domain, falls roher Wert leer +// $fallback = ($u->localpart ?? '') . '@' . ($u->domain->domain ?? ''); +// $email = trim($rawEmail !== '' ? $rawEmail : $fallback); +// +// if (!preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $email)) { +// Log::warning('Ungültige effektive Adresse – skip', [ +// 'raw' => $rawEmail, +// 'fallback' => $fallback, +// 'got' => $email, +// 'user_id' => $u->id, +// ]); +// continue; +// } +// +// [$local, $domain] = explode('@', $email, 2); +// $maildir = "/var/mail/vhosts/{$domain}/{$local}"; +// +// // Größe (Dateisystem) +// $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(); +// } +// } +// +// // Zähler via doveadm (als vmail), Fallback: Files zählen +// $messageCount = $this->countViaDoveadm($email); +// if ($messageCount === null) { +// $messageCount = $this->countViaFilesystem($maildir); +// } +// +// // Optional zusätzlich cachen (UI liest das bereits) +// Setting::set("mailbox.{$email}", [ +// 'used_bytes' => (int)$usedBytes, +// 'message_count' => (int)$messageCount, +// 'updated_at' => now()->toDateTimeString(), +// ]); +// +// $this->line(sprintf("%-35s %7.1f MiB %5d msgs", $email, $usedBytes/1024/1024, $messageCount)); +// Log::info('Mailbox-Stat OK', compact('email','maildir','usedBytes','messageCount')); +// } +// +// Log::info('=== mail:update-stats DONE ==='); +// $this->info('Mailbox-Statistiken aktualisiert.'); +// return self::SUCCESS; +// } +// +// protected function countViaDoveadm(string $email): ?int +// { +// $cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u " . escapeshellarg($email) . " messages INBOX 2>&1"; +// $out = []; $rc = 0; +// exec($cmd, $out, $rc); +// if ($rc !== 0) return null; +// foreach ($out as $line) { +// if (preg_match('/^\s*INBOX\s+(\d+)\s*$/i', trim($line), $m)) return (int)$m[1]; +// } +// return null; +// } +// +// protected function countViaFilesystem(string $maildir): int +// { +// $n = 0; +// foreach (['cur','new'] as $sub) { +// $dir = "{$maildir}/{$sub}"; +// if (!is_dir($dir)) continue; +// $h = opendir($dir); +// if (!$h) continue; +// while (($fn = readdir($h)) !== false) { +// if ($fn === '.' || $fn === '..' || $fn[0] === '.') continue; +// $n++; +// } +// closedir($h); +// } +// return $n; +// } +//} + + +//namespace App\Console\Commands; +// +//use App\Models\MailUser; +//use App\Models\Setting; +//use Illuminate\Console\Command; +//use Illuminate\Support\Facades\Log; +//use RecursiveDirectoryIterator; +//use RecursiveIteratorIterator; +// +//class UpdateMailboxStats extends Command +//{ +// protected $signature = 'mail:update-stats {--user=}'; +// protected $description = 'Aktualisiert Mailquota und Nachrichtenzahl für alle Mailboxen (oder einen Benutzer)'; +// +// public function handle(): int +// { +// Log::channel('mailstats')->info('=== mail:update-stats START ===', [ +// 'userOpt' => $this->option('user') +// ]); +// +// $q = MailUser::query() +// ->where('is_active', true) +// ->whereNotNull('email') +// ->where('email', 'like', '%@%'); +// +// if ($email = trim((string)$this->option('user'))) { +// $q->where('email', $email); +// } +// +// $users = $q->get(); +// +// if ($users->isEmpty()) { +// Log::channel('mailstats')->warning('Keine passenden Mailboxen gefunden.'); +// $this->warn('Keine passenden Mailboxen gefunden.'); +// return self::SUCCESS; +// } +// +// foreach ($users as $u) { +// $email = trim((string)$u->email); +// +// if (!preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $email)) { +// Log::channel('mailstats')->warning('Überspringe ungültige Adresse', ['email' => $email]); +// continue; +// } +// +// [$local, $domain] = explode('@', $email, 2); +// $maildir = "/var/mail/vhosts/{$domain}/{$local}"; +// +// // 1) Größe berechnen (Dateisystem) +// $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(); +// } +// } +// +// // 2) Nachrichten zählen – bevorzugt via doveadm als vmail +// $messageCount = $this->countViaDoveadm($email); +// if ($messageCount === null) { +// $messageCount = $this->countViaFilesystem($maildir); +// } +// +// // optional: in Settings/Redis cachen (für UI) +// Setting::set("mailbox.{$email}", [ +// 'used_bytes' => (int)$usedBytes, +// 'message_count' => (int)$messageCount, +// 'updated_at' => now()->toDateTimeString(), +// ]); +// +// $line = sprintf("%-35s %7.1f MiB %5d msgs", $email, $usedBytes / 1024 / 1024, $messageCount); +// $this->line($line); +// Log::channel('mailstats')->info('OK', [ +// 'email' => $email, +// 'maildir' => $maildir, +// 'used_bytes' => $usedBytes, +// 'message_count' => $messageCount, +// ]); +// } +// +// Log::channel('mailstats')->info('=== mail:update-stats DONE ==='); +// $this->info('Mailbox-Statistiken aktualisiert.'); +// return self::SUCCESS; +// } +// +// protected function countViaDoveadm(string $email): ?int +// { +// $cmd = "sudo -n -u vmail /usr/bin/doveadm -f tab mailbox status -u " . escapeshellarg($email) . " messages INBOX 2>&1"; +// $out = []; +// $rc = 0; +// exec($cmd, $out, $rc); +// +// Log::channel('mailstats')->debug('doveadm exec', ['cmd' => $cmd, 'rc' => $rc, 'out' => $out]); +// +// if ($rc !== 0) return null; +// +// // Erwartet: "mailbox\tmessages\nINBOX\t" +// foreach ($out as $line) { +// if (preg_match('/^\s*INBOX\s+(\d+)\s*$/i', trim($line), $m)) { +// return (int)$m[1]; +// } +// } +// return null; +// } +// +// protected function countViaFilesystem(string $maildir): int +// { +// $n = 0; +// foreach (['cur', 'new'] as $sub) { +// $dir = "{$maildir}/{$sub}"; +// if (!is_dir($dir)) continue; +// $dh = opendir($dir); +// if (!$dh) continue; +// while (($fn = readdir($dh)) !== false) { +// if ($fn === '.' || $fn === '..' || $fn[0] === '.') continue; +// $n++; +// } +// closedir($dh); +// } +// Log::channel('mailstats')->debug('fs count', ['maildir' => $maildir, 'count' => $n]); +// return $n; +// } +//} + //namespace App\Console\Commands; // //use App\Models\MailUser; diff --git a/app/Models/MailUser.php b/app/Models/MailUser.php index 14b711f..7f39f56 100644 --- a/app/Models/MailUser.php +++ b/app/Models/MailUser.php @@ -7,55 +7,136 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MailUser extends Model { -// protected $table = 'mail_users'; - protected $fillable = [ - 'domain_id','localpart','email','display_name','password_hash', - 'is_active','quota_mb','is_system' + 'domain_id', 'localpart', 'email', 'display_name', 'password_hash', + 'is_active', 'quota_mb', 'is_system' ]; protected $hidden = ['password_hash']; - protected $casts = [ - 'is_active'=>'bool', - 'is_system' => 'boolean', - 'quota_mb'=>'int', - 'last_login_at'=>'datetime', + + protected $casts = [ + 'is_active' => 'bool', + 'is_system' => 'bool', + 'quota_mb' => 'int', + 'last_login_at' => 'datetime', ]; - public function domain(): BelongsTo { + public function domain(): BelongsTo + { return $this->belongsTo(Domain::class); } - public function setPasswordAttribute(string $plain): void { - // optional: allow 'password' virtual attribute + // optional: virtueller Setter + public function setPasswordAttribute(string $plain): void + { $this->attributes['password_hash'] = password_hash($plain, PASSWORD_BCRYPT); } - // Ausgabe: vollständige Adresse - public function getEmailAttribute(): string { - return "{$this->local_part}@{$this->domain->name}"; + /** + * KORREKT: DB-Wert hat Vorrang; falls leer → Fallback localpart@domain.domain + */ + public function getEmailAttribute($value): ?string + { + if (!empty($value)) { + return $value; + } + $local = (string)($this->attributes['localpart'] ?? $this->localpart ?? ''); + $dom = $this->relationLoaded('domain') + ? (string)($this->domain->domain ?? '') + : (string)($this->attributes['domain'] ?? ''); + + if ($local !== '' && $dom !== '') { + return "{$local}@{$dom}"; + } + // nichts zusammenfummeln → nicht "@" + return null; } + /** + * Adresse ohne Accessor/DB-Fallback erzwingen (z.B. in Queries vor-joined) + */ public function getAddressAttribute(): string { - $local = (string)($this->attributes['localpart'] ?? $this->localpart ?? ''); - // bevorzugt den in Queries selektierten Alias `dom`, sonst Relation, sonst Roh-Attribut - $dom = (string)( - $this->attributes['dom'] - ?? ($this->relationLoaded('domain') ? ($this->domain->domain ?? '') : '') - ?? ($this->attributes['domain'] ?? '') - ); + $raw = (string)($this->attributes['email'] ?? ''); + if ($raw !== '') return $raw; - if ($local !== '' && $dom !== '') return "{$local}@{$dom}"; - if (($this->attributes['email'] ?? null) !== null) return (string)$this->attributes['email']; - return $dom !== '' ? "@{$dom}" : ''; + $local = (string)($this->attributes['localpart'] ?? $this->localpart ?? ''); + $dom = $this->relationLoaded('domain') + ? (string)($this->domain->domain ?? '') + : (string)($this->attributes['domain'] ?? ''); + + return ($local !== '' && $dom !== '') ? "{$local}@{$dom}" : ''; } // Scopes - public function scopeActive($q) { return $q->where('is_active', true); } + public function scopeActive($q) + { + return $q->where('is_active', true); + } - public function scopeSystem($q) { return $q->where('is_system', true); } - - public function scopeByEmail($q, string $email) { return $q->where('email', $email); } + public function scopeSystem($q) + { + return $q->where('is_system', true); + } + public function scopeByEmail($q, string $email) + { + return $q->where('email', $email); + } } + + +//class MailUser extends Model +//{ +//// protected $table = 'mail_users'; +// +// protected $fillable = [ +// 'domain_id','localpart','email','display_name','password_hash', +// 'is_active','quota_mb','is_system' +// ]; +// +// protected $hidden = ['password_hash']; +// protected $casts = [ +// 'is_active'=>'bool', +// 'is_system' => 'boolean', +// 'quota_mb'=>'int', +// 'last_login_at'=>'datetime', +// ]; +// +// public function domain(): BelongsTo { +// return $this->belongsTo(Domain::class); +// } +// +// public function setPasswordAttribute(string $plain): void { +// // optional: allow 'password' virtual attribute +// $this->attributes['password_hash'] = password_hash($plain, PASSWORD_BCRYPT); +// } +// +// // Ausgabe: vollständige Adresse +// public function getEmailAttribute(): string { +// return "{$this->local_part}@{$this->domain->name}"; +// } +// +// public function getAddressAttribute(): string +// { +// $local = (string)($this->attributes['localpart'] ?? $this->localpart ?? ''); +// // bevorzugt den in Queries selektierten Alias `dom`, sonst Relation, sonst Roh-Attribut +// $dom = (string)( +// $this->attributes['dom'] +// ?? ($this->relationLoaded('domain') ? ($this->domain->domain ?? '') : '') +// ?? ($this->attributes['domain'] ?? '') +// ); +// +// if ($local !== '' && $dom !== '') return "{$local}@{$dom}"; +// if (($this->attributes['email'] ?? null) !== null) return (string)$this->attributes['email']; +// return $dom !== '' ? "@{$dom}" : ''; +// } +// +// // Scopes +// public function scopeActive($q) { return $q->where('is_active', true); } +// +// public function scopeSystem($q) { return $q->where('is_system', true); } +// +// public function scopeByEmail($q, string $email) { return $q->where('email', $email); } +// +//}