Merge email logs and queue

This commit is contained in:
root
2026-01-29 18:08:38 +02:00
parent be0ec33ecd
commit a3e7da7275
13 changed files with 393 additions and 42 deletions

View File

@@ -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.

View File

@@ -1 +1 @@
VERSION=0.9-rc32
VERSION=0.9-rc33

View File

@@ -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';

View File

@@ -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';

View File

@@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
class EmailLogs extends Page implements HasActions, HasTable
{
use InteractsWithActions;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedInbox;
protected static ?int $navigationSort = 14;
protected static ?string $slug = 'email-logs';
protected string $view = 'filament.admin.pages.email-logs';
public string $viewMode = 'logs';
public array $logs = [];
public array $queueItems = [];
protected ?AgentClient $agent = null;
protected bool $logsLoaded = false;
protected bool $queueLoaded = false;
public function getTitle(): string|Htmlable
{
return __('Email Logs');
}
public static function getNavigationLabel(): string
{
return __('Email Logs');
}
public function mount(): void
{
$this->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();
}
}),
];
}
}

View File

@@ -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();
}
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'))

View File

@@ -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';

View File

@@ -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';

View File

@@ -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
{

View File

@@ -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
{

View File

@@ -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);

View File

@@ -10076,14 +10076,18 @@ function emailGetLogs(array $params): array
$logs = [];
$mailLogFile = '/var/log/mail.log';
$output = [];
if (!file_exists($mailLogFile)) {
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' => []];
}
// Read last N lines of mail log
$output = [];
exec("tail -n 1000 " . escapeshellarg($mailLogFile), $output);
}
$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];
}
}
}

View File

@@ -0,0 +1,5 @@
<x-filament-panels::page>
{{ $this->table }}
<x-filament-actions::modals />
</x-filament-panels::page>