diff --git a/README.md b/README.md index 4cd815b..1660ce6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A modern web hosting control panel for WordPress and general PHP hosting. Built with Laravel 12, Filament v5, Livewire 4, and Tailwind CSS v4. -Version: 0.9-rc32 (release candidate) +Version: 0.9-rc33 (release candidate) This is a release candidate. Expect rapid iteration and breaking changes until 1.0. @@ -156,6 +156,7 @@ php artisan test --compact ## Initial Release +- 0.9-rc33: Email Logs unified with Mail Queue; journald fallback; agent response reading hardened. - 0.9-rc32: Server Updates list loads reliably; admin sidebar order aligned; apt update parsing expanded. - 0.9-rc31: File manager navigation uses Livewire actions; parent row excluded from bulk select. - 0.9-rc30: Avoid IncludeOptional in ModSecurity CRS includes. diff --git a/VERSION b/VERSION index 3e0a9af..2f4e6d2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -VERSION=0.9-rc32 +VERSION=0.9-rc33 diff --git a/app/Filament/Admin/Pages/AutomationApi.php b/app/Filament/Admin/Pages/AutomationApi.php index 16b12f5..55e50a5 100644 --- a/app/Filament/Admin/Pages/AutomationApi.php +++ b/app/Filament/Admin/Pages/AutomationApi.php @@ -27,7 +27,7 @@ class AutomationApi extends Page implements HasActions, HasTable protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedKey; - protected static ?int $navigationSort = 16; + protected static ?int $navigationSort = 17; protected static ?string $slug = 'automation-api'; diff --git a/app/Filament/Admin/Pages/DatabaseTuning.php b/app/Filament/Admin/Pages/DatabaseTuning.php index 5e69577..abac809 100644 --- a/app/Filament/Admin/Pages/DatabaseTuning.php +++ b/app/Filament/Admin/Pages/DatabaseTuning.php @@ -26,7 +26,7 @@ class DatabaseTuning extends Page implements HasActions, HasTable protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCircleStack; - protected static ?int $navigationSort = 18; + protected static ?int $navigationSort = 19; protected static ?string $slug = 'database-tuning'; diff --git a/app/Filament/Admin/Pages/EmailLogs.php b/app/Filament/Admin/Pages/EmailLogs.php new file mode 100644 index 0000000..5e029e1 --- /dev/null +++ b/app/Filament/Admin/Pages/EmailLogs.php @@ -0,0 +1,306 @@ +loadLogs(false); + } + + protected function getAgent(): AgentClient + { + return $this->agent ??= new AgentClient; + } + + public function loadLogs(bool $refreshTable = true): void + { + try { + $result = $this->getAgent()->send('email.get_logs', [ + 'limit' => 200, + ]); + + $this->logs = $result['logs'] ?? []; + $this->logsLoaded = true; + } catch (\Exception $e) { + $this->logs = []; + $this->logsLoaded = true; + + Notification::make() + ->title(__('Failed to load email logs')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + + if ($refreshTable) { + $this->resetTable(); + } + } + + public function loadQueue(bool $refreshTable = true): void + { + try { + $result = $this->getAgent()->send('mail.queue_list'); + $this->queueItems = $result['queue'] ?? []; + $this->queueLoaded = true; + } catch (\Exception $e) { + $this->queueItems = []; + $this->queueLoaded = true; + Notification::make() + ->title(__('Failed to load mail queue')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + + if ($refreshTable) { + $this->resetTable(); + } + } + + public function setViewMode(string $mode): void + { + $mode = in_array($mode, ['logs', 'queue'], true) ? $mode : 'logs'; + if ($this->viewMode === $mode) { + return; + } + + $this->viewMode = $mode; + + if ($mode === 'queue') { + $this->loadQueue(false); + } else { + $this->loadLogs(false); + } + + $this->resetTable(); + } + + public function table(Table $table): Table + { + return $table + ->paginated([25, 50, 100]) + ->defaultPaginationPageOption(25) + ->records(function () { + if ($this->viewMode === 'queue') { + if (! $this->queueLoaded) { + $this->loadQueue(false); + } + $records = $this->queueItems; + } else { + if (! $this->logsLoaded) { + $this->loadLogs(false); + } + $records = $this->logs; + } + + return collect($records) + ->mapWithKeys(function (array $record, int $index): array { + $queueId = $record['queue_id'] ?? ''; + $timestamp = (int) ($record['timestamp'] ?? 0); + $keyParts = array_filter([ + $queueId, + $timestamp > 0 ? (string) $timestamp : '', + ], fn (string $part): bool => $part !== ''); + + $key = implode('-', $keyParts); + + return [$key !== '' ? $key : (string) $index => $record]; + }) + ->all(); + }) + ->columns($this->viewMode === 'queue' ? $this->getQueueColumns() : $this->getLogColumns()) + ->recordActions($this->viewMode === 'queue' ? $this->getQueueActions() : []) + ->emptyStateHeading($this->viewMode === 'queue' ? __('Mail queue is empty') : __('No email logs found')) + ->emptyStateDescription($this->viewMode === 'queue' ? __('No deferred messages found.') : __('Mail logs are empty or unavailable.')) + ->headerActions([ + Action::make('viewLogs') + ->label(__('Logs')) + ->color($this->viewMode === 'logs' ? 'primary' : 'gray') + ->action(fn () => $this->setViewMode('logs')), + Action::make('viewQueue') + ->label(__('Queue')) + ->color($this->viewMode === 'queue' ? 'primary' : 'gray') + ->action(fn () => $this->setViewMode('queue')), + Action::make('refresh') + ->label(__('Refresh')) + ->icon('heroicon-o-arrow-path') + ->action(function (): void { + if ($this->viewMode === 'queue') { + $this->loadQueue(); + } else { + $this->loadLogs(); + } + }), + ]); + } + + protected function getLogColumns(): array + { + return [ + TextColumn::make('timestamp') + ->label(__('Time')) + ->formatStateUsing(function (array $record): string { + $timestamp = (int) ($record['timestamp'] ?? 0); + + return $timestamp > 0 ? date('Y-m-d H:i:s', $timestamp) : ''; + }) + ->sortable(), + TextColumn::make('queue_id') + ->label(__('Queue ID')) + ->fontFamily('mono') + ->copyable() + ->toggleable(), + TextColumn::make('component') + ->label(__('Component')) + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('from') + ->label(__('From')) + ->wrap() + ->searchable(), + TextColumn::make('to') + ->label(__('To')) + ->wrap() + ->searchable(), + TextColumn::make('status') + ->label(__('Status')) + ->badge() + ->formatStateUsing(fn (array $record): string => (string) ($record['status'] ?? 'unknown')), + TextColumn::make('relay') + ->label(__('Relay')) + ->toggleable(), + TextColumn::make('message') + ->label(__('Details')) + ->wrap() + ->limit(80) + ->toggleable(isToggledHiddenByDefault: true), + ]; + } + + protected function getQueueColumns(): array + { + return [ + TextColumn::make('id') + ->label(__('Queue ID')) + ->fontFamily('mono') + ->copyable(), + TextColumn::make('arrival') + ->label(__('Arrival')), + TextColumn::make('sender') + ->label(__('Sender')) + ->wrap() + ->searchable(), + TextColumn::make('recipients') + ->label(__('Recipients')) + ->formatStateUsing(function (array $record): string { + $recipients = $record['recipients'] ?? []; + if (empty($recipients)) { + return __('Unknown'); + } + $first = $recipients[0] ?? ''; + $count = count($recipients); + + return $count > 1 ? $first.' +'.($count - 1) : $first; + }) + ->wrap(), + TextColumn::make('size') + ->label(__('Size')) + ->formatStateUsing(fn (array $record): string => $record['size'] ?? ''), + TextColumn::make('status') + ->label(__('Status')) + ->wrap(), + ]; + } + + protected function getQueueActions(): array + { + return [ + Action::make('retry') + ->label(__('Retry')) + ->icon('heroicon-o-arrow-path') + ->color('info') + ->action(function (array $record): void { + try { + $result = $this->getAgent()->send('mail.queue_retry', ['id' => $record['id'] ?? '']); + if ($result['success'] ?? false) { + Notification::make()->title(__('Message retried'))->success()->send(); + $this->loadQueue(); + } else { + throw new \Exception($result['error'] ?? __('Failed to retry message')); + } + } catch (\Exception $e) { + Notification::make()->title(__('Retry failed'))->body($e->getMessage())->danger()->send(); + } + }), + Action::make('delete') + ->label(__('Delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->action(function (array $record): void { + try { + $result = $this->getAgent()->send('mail.queue_delete', ['id' => $record['id'] ?? '']); + if ($result['success'] ?? false) { + Notification::make()->title(__('Message deleted'))->success()->send(); + $this->loadQueue(); + } else { + throw new \Exception($result['error'] ?? __('Failed to delete message')); + } + } catch (\Exception $e) { + Notification::make()->title(__('Delete failed'))->body($e->getMessage())->danger()->send(); + } + }), + ]; + } +} diff --git a/app/Filament/Admin/Pages/EmailQueue.php b/app/Filament/Admin/Pages/EmailQueue.php index 31dd39e..cce7ec9 100644 --- a/app/Filament/Admin/Pages/EmailQueue.php +++ b/app/Filament/Admin/Pages/EmailQueue.php @@ -25,16 +25,20 @@ class EmailQueue extends Page implements HasActions, HasTable protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedQueueList; - protected static ?int $navigationSort = 14; + protected static ?int $navigationSort = null; protected static ?string $slug = 'email-queue'; + protected static bool $shouldRegisterNavigation = false; + protected string $view = 'filament.admin.pages.email-queue'; public array $queueItems = []; protected ?AgentClient $agent = null; + protected bool $queueLoaded = false; + public function getTitle(): string|Htmlable { return __('Email Queue Manager'); @@ -47,7 +51,7 @@ class EmailQueue extends Page implements HasActions, HasTable public function mount(): void { - $this->loadQueue(); + $this->redirect(EmailLogs::getUrl()); } protected function getAgent(): AgentClient @@ -55,13 +59,15 @@ class EmailQueue extends Page implements HasActions, HasTable return $this->agent ??= new AgentClient; } - public function loadQueue(): void + public function loadQueue(bool $refreshTable = true): void { try { $result = $this->getAgent()->send('mail.queue_list'); $this->queueItems = $result['queue'] ?? []; + $this->queueLoaded = true; } catch (\Exception $e) { $this->queueItems = []; + $this->queueLoaded = true; Notification::make() ->title(__('Failed to load mail queue')) ->body($e->getMessage()) @@ -69,13 +75,27 @@ class EmailQueue extends Page implements HasActions, HasTable ->send(); } - $this->resetTable(); + if ($refreshTable) { + $this->resetTable(); + } } public function table(Table $table): Table { return $table - ->records(fn () => $this->queueItems) + ->records(function () { + if (! $this->queueLoaded) { + $this->loadQueue(false); + } + + return collect($this->queueItems) + ->mapWithKeys(function (array $record, int $index): array { + $key = $record['id'] ?? (string) $index; + + return [$key !== '' ? $key : (string) $index => $record]; + }) + ->all(); + }) ->columns([ TextColumn::make('id') ->label(__('Queue ID')) diff --git a/app/Filament/Admin/Pages/ServerUpdates.php b/app/Filament/Admin/Pages/ServerUpdates.php index 8a3ee80..06bdffb 100644 --- a/app/Filament/Admin/Pages/ServerUpdates.php +++ b/app/Filament/Admin/Pages/ServerUpdates.php @@ -25,7 +25,7 @@ class ServerUpdates extends Page implements HasActions, HasTable protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowPathRoundedSquare; - protected static ?int $navigationSort = 15; + protected static ?int $navigationSort = 16; protected static ?string $slug = 'server-updates'; diff --git a/app/Filament/Admin/Pages/Waf.php b/app/Filament/Admin/Pages/Waf.php index bf9e496..8eb8126 100644 --- a/app/Filament/Admin/Pages/Waf.php +++ b/app/Filament/Admin/Pages/Waf.php @@ -24,7 +24,7 @@ class Waf extends Page implements HasForms protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck; - protected static ?int $navigationSort = 19; + protected static ?int $navigationSort = 20; protected static ?string $slug = 'waf'; diff --git a/app/Filament/Admin/Resources/GeoBlockRules/GeoBlockRuleResource.php b/app/Filament/Admin/Resources/GeoBlockRules/GeoBlockRuleResource.php index 10d4672..9d8de05 100644 --- a/app/Filament/Admin/Resources/GeoBlockRules/GeoBlockRuleResource.php +++ b/app/Filament/Admin/Resources/GeoBlockRules/GeoBlockRuleResource.php @@ -22,7 +22,7 @@ class GeoBlockRuleResource extends Resource protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedGlobeAlt; - protected static ?int $navigationSort = 20; + protected static ?int $navigationSort = 21; public static function getNavigationLabel(): string { diff --git a/app/Filament/Admin/Resources/WebhookEndpoints/WebhookEndpointResource.php b/app/Filament/Admin/Resources/WebhookEndpoints/WebhookEndpointResource.php index 70a6ecc..ef62513 100644 --- a/app/Filament/Admin/Resources/WebhookEndpoints/WebhookEndpointResource.php +++ b/app/Filament/Admin/Resources/WebhookEndpoints/WebhookEndpointResource.php @@ -22,7 +22,7 @@ class WebhookEndpointResource extends Resource protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBellAlert; - protected static ?int $navigationSort = 17; + protected static ?int $navigationSort = 18; public static function getNavigationLabel(): string { diff --git a/app/Services/Agent/AgentClient.php b/app/Services/Agent/AgentClient.php index 132b0bc..a22b098 100644 --- a/app/Services/Agent/AgentClient.php +++ b/app/Services/Agent/AgentClient.php @@ -44,11 +44,12 @@ class AgentClient socket_write($socket, $request, strlen($request)); $response = ''; - while ($buf = socket_read($socket, 8192)) { - $response .= $buf; - if (strlen($buf) < 8192) { + while (true) { + $buf = socket_read($socket, 8192); + if ($buf === '' || $buf === false) { break; } + $response .= $buf; } socket_close($socket); diff --git a/bin/jabali-agent b/bin/jabali-agent index 541696a..353a5bb 100755 --- a/bin/jabali-agent +++ b/bin/jabali-agent @@ -10076,14 +10076,18 @@ function emailGetLogs(array $params): array $logs = []; $mailLogFile = '/var/log/mail.log'; - - if (!file_exists($mailLogFile)) { - return ['success' => true, 'logs' => []]; - } - - // Read last N lines of mail log $output = []; - exec("tail -n 1000 " . escapeshellarg($mailLogFile), $output); + + if (file_exists($mailLogFile)) { + // Read last N lines of mail log + exec("tail -n 1000 " . escapeshellarg($mailLogFile), $output); + } else { + // Fallback to journald when mail.log is not present (common on systemd systems) + exec("journalctl -u postfix --no-pager -n 1000 -o short-iso 2>/dev/null", $output, $journalCode); + if ($journalCode !== 0) { + return ['success' => true, 'logs' => []]; + } + } $currentMessage = null; $messageIndex = []; @@ -10099,54 +10103,68 @@ function emailGetLogs(array $params): array $message = null; // Try ISO 8601 format first (modern systemd/journald) - if (preg_match('/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2})?)\s+\S+\s+postfix\/(\w+)\[(\d+)\]:\s+([A-F0-9]+):\s+(.+)$/', $line, $matches)) { + if (preg_match('/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2})?)\s+\S+\s+postfix\/([\w\-\/]+)\[\d+\]:\s+(.+)$/', $line, $matches)) { $timestamp = strtotime($matches[1]); $component = $matches[2]; - $queueId = $matches[4]; - $message = $matches[5]; + $message = $matches[3]; } // Try traditional syslog format - elseif (preg_match('/^(\w+\s+\d+\s+\d+:\d+:\d+)\s+\S+\s+postfix\/(\w+)\[(\d+)\]:\s+([A-F0-9]+):\s+(.+)$/', $line, $matches)) { + elseif (preg_match('/^(\w+\s+\d+\s+\d+:\d+:\d+)\s+\S+\s+postfix\/([\w\-\/]+)\[\d+\]:\s+(.+)$/', $line, $matches)) { $timestamp = strtotime($matches[1] . ' ' . date('Y')); $component = $matches[2]; - $queueId = $matches[4]; - $message = $matches[5]; + $message = $matches[3]; } - if ($timestamp && $queueId && $message) { + if ($timestamp && $component && $message) { + $queueId = null; + $payload = $message; + + if (preg_match('/^([A-F0-9]{5,}):\s+(.+)$/', $message, $idMatch)) { + $queueId = $idMatch[1]; + $payload = $idMatch[2]; + } elseif (preg_match('/^NOQUEUE:\s+(.+)$/', $message, $noQueueMatch)) { + $queueId = 'NOQUEUE'; + $payload = $noQueueMatch[1]; + } else { + continue; + } // Initialize message entry - if (!isset($messageIndex[$queueId])) { - $messageIndex[$queueId] = [ + $messageKey = $queueId . '-' . $timestamp; + if (!isset($messageIndex[$messageKey])) { + $messageIndex[$messageKey] = [ 'timestamp' => $timestamp, 'queue_id' => $queueId, + 'component' => $component, 'from' => null, 'to' => null, 'subject' => null, - 'status' => 'unknown', + 'status' => $queueId === 'NOQUEUE' ? 'reject' : 'unknown', 'message' => '', ]; } // Parse from - if (preg_match('/from=<([^>]*)>/', $message, $fromMatch)) { - $messageIndex[$queueId]['from'] = $fromMatch[1]; + if (preg_match('/from=<([^>]*)>/', $payload, $fromMatch)) { + $messageIndex[$messageKey]['from'] = $fromMatch[1]; } // Parse to - if (preg_match('/to=<([^>]*)>/', $message, $toMatch)) { - $messageIndex[$queueId]['to'] = $toMatch[1]; + if (preg_match('/to=<([^>]*)>/', $payload, $toMatch)) { + $messageIndex[$messageKey]['to'] = $toMatch[1]; } // Parse status - if (preg_match('/status=(\w+)/', $message, $statusMatch)) { - $messageIndex[$queueId]['status'] = $statusMatch[1]; - $messageIndex[$queueId]['message'] = $message; + if (preg_match('/status=(\w+)/', $payload, $statusMatch)) { + $messageIndex[$messageKey]['status'] = $statusMatch[1]; + $messageIndex[$messageKey]['message'] = $payload; + } else { + $messageIndex[$messageKey]['message'] = $payload; } // Parse delay and relay info - if (preg_match('/relay=([^,]+)/', $message, $relayMatch)) { - $messageIndex[$queueId]['relay'] = $relayMatch[1]; + if (preg_match('/relay=([^,]+)/', $payload, $relayMatch)) { + $messageIndex[$messageKey]['relay'] = $relayMatch[1]; } } } diff --git a/resources/views/filament/admin/pages/email-logs.blade.php b/resources/views/filament/admin/pages/email-logs.blade.php new file mode 100644 index 0000000..d166217 --- /dev/null +++ b/resources/views/filament/admin/pages/email-logs.blade.php @@ -0,0 +1,5 @@ + + {{ $this->table }} + + +