diff --git a/app/Console/Commands/UpdateMailboxStats.php b/app/Console/Commands/UpdateMailboxStats.php index ddd2959..8fab4ce 100644 --- a/app/Console/Commands/UpdateMailboxStats.php +++ b/app/Console/Commands/UpdateMailboxStats.php @@ -1,5 +1,6 @@ $this->option('user')]); + $log = Log::channel('mailstats'); + + $onlyUser = trim((string)$this->option('user')) ?: null; + $started = microtime(true); $q = MailUser::query() - ->with('domain:id,domain') - ->active(); + ->with('domain:id,domain,is_system') + ->where('is_active', true) + ->where('is_system', false) // keine System-Mailboxen + ->whereHas('domain', fn($d) => $d->where('is_system', false)); // keine Systemdomains - if ($only = trim((string)$this->option('user'))) { - $q->byEmail($only); + if ($onlyUser) { + $q->where('email', $onlyUser); } $users = $q->get(); + + $log->info('mail:update-stats START', [ + 'only' => $onlyUser, + 'users' => $users->count(), + ]); + if ($users->isEmpty()) { - Log::warning('Keine passenden Mailboxen gefunden.'); $this->warn('Keine passenden Mailboxen gefunden.'); + $log->info('mail:update-stats DONE (no users)', ['ms' => (int)((microtime(true) - $started) * 1000)]); return self::SUCCESS; } - foreach ($users as $u) { - // 1) sichere E-Mail ermitteln: roher DB-Wert → Accessor → zusammengesetzt - $raw = (string)($u->getRawOriginal('email') ?? ''); - $email = $raw !== '' ? $raw : ($u->email ?? $u->address ?? ''); + $changed = 0; + $checked = 0; - if (!preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', (string)$email)) { - Log::warning('Ungültige effektive Adresse – skip', [ - 'user_id' => $u->id, 'raw' => $raw, 'computed' => $email - ]); + foreach ($users as $u) { + $checked++; + + // Email robust ermitteln + $raw = (string)($u->getRawOriginal('email') ?? ''); + $email = $raw !== '' ? $raw : ($u->email ?? $u->address ?? null); + + if (!is_string($email) || !preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $email)) { + // nur debug – vermeidet Spam + $log->debug('skip invalid email', ['user_id' => $u->id, 'raw' => $raw, 'computed' => $email]); continue; } [$local, $domain] = explode('@', $email, 2); $maildir = "/var/mail/vhosts/{$domain}/{$local}"; - // 2) Größe (Dateisystem) + // 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(); + if ($f->isFile()) { + $usedBytes += $f->getSize(); + } } } - // 3) Message-Count – prefer doveadm als vmail; Fallback: Files + // Nachrichten zählen $messageCount = $this->countViaDoveadm($email); if ($messageCount === null) { $messageCount = $this->countViaFilesystem($maildir); } - // 4) In Settings (→ Redis + DB-Backup) persistieren - Setting::set("mailbox.{$email}", [ + // Nur bei Änderungen persistieren + loggen + $key = "mailbox.{$email}"; + $prev = Setting::get($key, null) ?: []; + + $new = [ '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)); + if ( + !is_array($prev) || + ($prev['used_bytes'] ?? null) !== $new['used_bytes'] || + ($prev['message_count'] ?? null) !== $new['message_count'] + ) { + Setting::set($key, $new); + $changed++; - Log::info('Mailbox-Stat OK', [ - 'email' => $email, - 'maildir' => $maildir, - 'used_bytes' => $usedBytes, - 'message_count' => $messageCount, - ]); + // kurze, nützliche Info – nur bei Änderung + $this->line(sprintf("%-35s %7.1f MiB %5d msgs", + $email, $usedBytes / 1024 / 1024, $messageCount)); + + $log->info('updated', [ + 'email' => $email, + 'used_bytes' => $new['used_bytes'], + 'message_count' => $new['message_count'], + ]); + } else { + // keine Änderung → kein Info-Log + $log->debug('unchanged', ['email' => $email]); + } } - Log::info('=== mail:update-stats DONE ==='); + $ms = (int)((microtime(true) - $started) * 1000); + $log->info('mail:update-stats DONE', [ + 'checked' => $checked, + 'changed' => $changed, + 'ms' => $ms, + ]); + $this->info('Mailbox-Statistiken aktualisiert.'); return self::SUCCESS; } @@ -95,6 +134,7 @@ class UpdateMailboxStats extends Command $out = []; $rc = 0; exec($cmd, $out, $rc); + if ($rc !== 0) return null; foreach ($out as $line) { @@ -123,6 +163,129 @@ class UpdateMailboxStats extends Command } } +//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 Quota & Nachrichtenzahl (Settings/Redis; DB-frei)'; +// +// public function handle(): int +// { +// Log::info('=== mail:update-stats START ===', ['userOpt' => $this->option('user')]); +// +// $q = MailUser::query() +// ->with('domain:id,domain') +// ->active(); +// +// if ($only = trim((string)$this->option('user'))) { +// $q->byEmail($only); +// } +// +// $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) 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]+$/', (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}"; +// +// // 2) 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(); +// } +// } +// +// // 3) Message-Count – prefer doveadm als vmail; Fallback: Files +// $messageCount = $this->countViaDoveadm($email); +// if ($messageCount === null) { +// $messageCount = $this->countViaFilesystem($maildir); +// } +// +// // 4) In Settings (→ Redis + DB-Backup) persistieren +// 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', [ +// 'email' => $email, +// 'maildir' => $maildir, +// 'used_bytes' => $usedBytes, +// 'message_count' => $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 Illuminate\Console\Command; diff --git a/app/Models/MailUser.php b/app/Models/MailUser.php index 7f39f56..8d13548 100644 --- a/app/Models/MailUser.php +++ b/app/Models/MailUser.php @@ -8,67 +8,96 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MailUser extends Model { 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_system', + 'is_active', + 'can_login', + 'quota_mb', + 'rate_limit_per_hour', + // -> KEINE used_bytes / message_count hier, die cachen wir separat ]; protected $hidden = ['password_hash']; protected $casts = [ - 'is_active' => 'bool', - 'is_system' => 'bool', - 'quota_mb' => 'int', + 'is_system' => 'boolean', + 'is_active' => 'boolean', + 'can_login' => 'boolean', + 'quota_mb' => 'integer', + 'rate_limit_per_hour' => 'integer', 'last_login_at' => 'datetime', + 'stats_refreshed_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', ]; + /* + |-------------------------------------------------------------------------- + | Beziehungen + |-------------------------------------------------------------------------- + */ + public function domain(): BelongsTo { return $this->belongsTo(Domain::class); } - // optional: virtueller Setter + /* + |-------------------------------------------------------------------------- + | Accessors / Helper + |-------------------------------------------------------------------------- + */ + + /** + * Bevorzugt den DB-Wert aus 'email'. + * Fehlt dieser, wird aus localpart + domain.domain gebaut. + * Gibt niemals "@" zurück, sondern null, wenn unbestimmbar. + */ + public function getEmailAttribute($value): ?string + { + if (!empty($value)) { + return $value; // DB-Wert hat Vorrang + } + + $local = (string)($this->attributes['localpart'] ?? ''); + // Domainname – wenn Relation geladen, daraus; sonst nichts (kein teurer Query hier). + $dom = $this->relationLoaded('domain') + ? (string)($this->domain->domain ?? '') + : (string)($this->attributes['domain'] ?? ''); // nur falls bei Joins alias 'domain' selektiert wäre + + if ($local !== '' && $dom !== '') { + return "{$local}@{$dom}"; + } + + return null; + } + + /** + * „Adresse“ als bequemer Fallback (identisch zu email(), fällt aber am Ende auf '@dom' NICHT zurück). + */ + public function getAddressAttribute(): string + { + return (string)($this->getRawOriginal('email') ?: ($this->email ?? '')); + } + + /** + * Optionaler Setter für Passwort über virtuelles Attribut "password". + */ public function setPasswordAttribute(string $plain): void { $this->attributes['password_hash'] = password_hash($plain, PASSWORD_BCRYPT); } - /** - * 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'] ?? ''); + /* + |-------------------------------------------------------------------------- + | Scopes + |-------------------------------------------------------------------------- + */ - 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 - { - $raw = (string)($this->attributes['email'] ?? ''); - if ($raw !== '') return $raw; - - $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); @@ -86,6 +115,87 @@ class MailUser extends Model } +//class MailUser extends Model +//{ +// 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' => 'bool', +// 'quota_mb' => 'int', +// 'last_login_at' => 'datetime', +// ]; +// +// public function domain(): BelongsTo +// { +// return $this->belongsTo(Domain::class); +// } +// +// // optional: virtueller Setter +// public function setPasswordAttribute(string $plain): void +// { +// $this->attributes['password_hash'] = password_hash($plain, PASSWORD_BCRYPT); +// } +// +// /** +// * 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 +// { +// $raw = (string)($this->attributes['email'] ?? ''); +// if ($raw !== '') return $raw; +// +// $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 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'; diff --git a/config/logging.php b/config/logging.php index d7a85c5..196fe06 100644 --- a/config/logging.php +++ b/config/logging.php @@ -54,8 +54,9 @@ return [ 'mailstats' => [ 'driver' => 'daily', 'path' => storage_path('logs/mailstats.log'), - 'level' => 'debug', - 'days' => 14, + 'level' => 'info', + 'days' => 7, + 'bubble' => false, ], 'stack' => [ diff --git a/routes/console.php b/routes/console.php index 54d2b3a..dc9f066 100644 --- a/routes/console.php +++ b/routes/console.php @@ -11,9 +11,6 @@ Artisan::command('inspire', function () { Schedule::job(RunHealthChecks::class)->everytenSeconds()->withoutOverlapping(); //Schedule::command('mailwolt:check-updates')->dailyAt('04:10'); -Schedule::command('mailwolt:check-updates')->everyFiveMinutes(); +Schedule::command('mailwolt:check-updates')->everytwoMinutes(); -Schedule::command('mail:update-stats') - ->everyFiveMinutes() - ->withoutOverlapping() - ->appendOutputTo(storage_path('logs/mail_stats.log')); +Schedule::command('mail:update-stats')->everyFiveMinutes()->withoutOverlapping();