activeTab = $this->normalizeTabName($this->activeTab); if ($this->activeTab === 'spam') { $this->loadSpamSettings(); } } public function updatedActiveTab(): void { $this->activeTab = $this->normalizeTabName($this->activeTab); if ($this->activeTab === 'spam') { $this->loadSpamSettings(); } $this->resetTable(); } protected function normalizeTabName(?string $tab): string { // Handle Filament's tab format "tabname::tab" or just "tabname" $tab = $tab ?? 'mailboxes'; if (str_contains($tab, '::')) { $tab = explode('::', $tab)[0]; } // Map to valid tab names return match ($tab) { 'mailboxes', 'Mailboxes' => 'mailboxes', 'forwarders', 'Forwarders' => 'forwarders', 'autoresponders', 'Autoresponders' => 'autoresponders', 'catchall', 'catch-all', 'Catch-All' => 'catchall', 'logs', 'Logs' => 'logs', 'spam', 'Spam' => 'spam', default => 'mailboxes', }; } protected function getActiveTabIndex(): int { return match ($this->activeTab) { 'mailboxes' => 1, 'forwarders' => 2, 'autoresponders' => 3, 'catchall' => 4, 'logs' => 5, 'spam' => 6, default => 1, }; } protected function getForms(): array { return ['emailForm', 'spamForm']; } public function emailForm(Schema $schema): Schema { return $schema->schema([ Tabs::make(__('Email Sections')) ->contained() ->livewireProperty('activeTab') ->tabs([ 'mailboxes' => Tab::make(__('Mailboxes')) ->icon('heroicon-o-envelope') ->schema([ View::make('filament.jabali.pages.email-tab-table'), ]), 'forwarders' => Tab::make(__('Forwarders')) ->icon('heroicon-o-arrow-right') ->schema([ View::make('filament.jabali.pages.email-tab-table'), ]), 'autoresponders' => Tab::make(__('Autoresponders')) ->icon('heroicon-o-clock') ->schema([ View::make('filament.jabali.pages.email-tab-table'), ]), 'catchall' => Tab::make(__('Catch-All')) ->icon('heroicon-o-inbox-stack') ->schema([ View::make('filament.jabali.pages.email-tab-table'), ]), 'logs' => Tab::make(__('Logs')) ->icon('heroicon-o-document-text') ->schema([ View::make('filament.jabali.pages.email-tab-table'), ]), 'spam' => Tab::make(__('Spam Settings')) ->icon('heroicon-o-shield-check') ->schema([ View::make('filament.jabali.pages.email-tab-spam'), ]), ]), ]); } public function spamForm(Schema $schema): Schema { return $schema ->statePath('spamFormData') ->schema([ Section::make(__('Spam Settings')) ->schema([ Textarea::make('whitelist') ->label(__('Whitelist (one per line)')) ->rows(6) ->placeholder(__("friend@example.com\ntrusted.com")), Textarea::make('blacklist') ->label(__('Blacklist (one per line)')) ->rows(6) ->placeholder(__("spam@example.com\nbad-domain.com")), TextInput::make('score') ->label(__('Spam Score Threshold')) ->numeric() ->default(6.0) ->helperText(__('Lower values are stricter, higher values are more permissive.')), ]) ->columns(2), ]); } public function setTab(string $tab): void { $this->activeTab = $this->normalizeTabName($tab); if ($this->activeTab === 'spam') { $this->loadSpamSettings(); } $this->resetTable(); } public function getAgent(): AgentClient { if ($this->agent === null) { $this->agent = new AgentClient; } return $this->agent; } public function getUsername(): string { return Auth::user()->username; } public function generateSecurePassword(int $length = 16): string { $lowercase = 'abcdefghijklmnopqrstuvwxyz'; $uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; $numbers = '0123456789'; $special = '!@#$%^&*'; // Ensure at least one of each required type $password = $lowercase[random_int(0, strlen($lowercase) - 1)] .$uppercase[random_int(0, strlen($uppercase) - 1)] .$numbers[random_int(0, strlen($numbers) - 1)] .$special[random_int(0, strlen($special) - 1)]; // Fill the rest with random characters from all types $allChars = $lowercase.$uppercase.$numbers.$special; for ($i = strlen($password); $i < $length; $i++) { $password .= $allChars[random_int(0, strlen($allChars) - 1)]; } // Shuffle the password to randomize position of required characters return str_shuffle($password); } protected function loadSpamSettings(): void { $settings = UserSetting::getForUser(Auth::id(), 'spam_settings', [ 'whitelist' => [], 'blacklist' => [], 'score' => 6.0, ]); $this->spamFormData = [ 'whitelist' => implode("\n", $settings['whitelist'] ?? []), 'blacklist' => implode("\n", $settings['blacklist'] ?? []), 'score' => $settings['score'] ?? 6.0, ]; } public function saveSpamSettings(): void { $data = $this->spamForm->getState(); $whitelist = $this->linesToArray($data['whitelist'] ?? ''); $blacklist = $this->linesToArray($data['blacklist'] ?? ''); $score = isset($data['score']) && $data['score'] !== '' ? (float) $data['score'] : null; UserSetting::setForUser(Auth::id(), 'spam_settings', [ 'whitelist' => $whitelist, 'blacklist' => $blacklist, 'score' => $score, ]); $result = $this->getAgent()->rspamdUserSettings($this->getUsername(), $whitelist, $blacklist, $score); if (! ($result['success'] ?? false)) { Notification::make() ->title(__('Failed to update spam settings')) ->body($result['error'] ?? '') ->danger() ->send(); return; } Notification::make() ->title(__('Spam settings updated')) ->success() ->send(); } protected function linesToArray(string $value): array { return collect(preg_split('/\\r\\n|\\r|\\n/', $value)) ->map(fn ($line) => trim((string) $line)) ->filter() ->values() ->toArray(); } public function table(Table $table): Table { return match ($this->activeTab) { 'mailboxes' => $this->mailboxesTable($table), 'forwarders' => $this->forwardersTable($table), 'autoresponders' => $this->autorespondersTable($table), 'catchall' => $this->catchAllTable($table), 'logs' => $this->emailLogsTable($table), 'spam' => $this->mailboxesTable($table), default => $this->mailboxesTable($table), }; } protected function mailboxesTable(Table $table): Table { return $table ->query( Mailbox::query() ->whereHas('emailDomain.domain', fn (Builder $q) => $q->where('user_id', Auth::id())) ->with('emailDomain.domain') ) ->columns([ TextColumn::make('email') ->label(__('Email Address')) ->icon('heroicon-o-envelope') ->iconColor('primary') ->description(fn (Mailbox $record) => $record->name) ->searchable() ->sortable(), TextColumn::make('quota_display') ->label(__('Quota')) ->getStateUsing(fn (Mailbox $record) => $record->quota_used_formatted.' / '.$record->quota_formatted) ->description(fn (Mailbox $record) => $record->quota_percent.'% '.__('used')) ->color(fn (Mailbox $record) => match (true) { $record->quota_percent >= 90 => 'danger', $record->quota_percent >= 80 => 'warning', default => 'gray', }), TextColumn::make('is_active') ->label(__('Status')) ->badge() ->formatStateUsing(fn (bool $state) => $state ? __('Active') : __('Suspended')) ->color(fn (bool $state) => $state ? 'success' : 'danger'), TextColumn::make('last_login_at') ->label(__('Last Login')) ->since() ->placeholder(__('Never')) ->sortable(), ]) ->recordActions([ Action::make('webmail') ->label(__('Webmail')) ->icon('heroicon-o-envelope-open') ->color('success') ->url(fn (Mailbox $record) => route('webmail.sso', $record)) ->openUrlInNewTab(), Action::make('info') ->label(__('Info')) ->icon('heroicon-o-information-circle') ->color('info') ->modalHeading(fn (Mailbox $record) => __('Connection Settings')) ->modalDescription(fn (Mailbox $record) => $record->email) ->modalSubmitAction(false) ->modalCancelActionLabel(__('Close')) ->infolist(function (Mailbox $record): array { $serverHostname = \App\Models\Setting::get('mail_hostname') ?: request()->getHost(); return [ Section::make(__('IMAP Settings')) ->description(__('For receiving email in mail clients')) ->icon('heroicon-o-inbox-arrow-down') ->columns(3) ->schema([ TextEntry::make('imap_server') ->label(__('Server')) ->state($serverHostname) ->copyable(), TextEntry::make('imap_port') ->label(__('Port')) ->state('993') ->copyable(), TextEntry::make('imap_security') ->label(__('Security')) ->state('SSL/TLS') ->badge() ->color('success'), ]), Section::make(__('POP3 Settings')) ->description(__('Alternative for receiving email')) ->icon('heroicon-o-arrow-down-tray') ->columns(3) ->collapsed() ->schema([ TextEntry::make('pop3_server') ->label(__('Server')) ->state($serverHostname) ->copyable(), TextEntry::make('pop3_port') ->label(__('Port')) ->state('995') ->copyable(), TextEntry::make('pop3_security') ->label(__('Security')) ->state('SSL/TLS') ->badge() ->color('success'), ]), Section::make(__('SMTP Settings')) ->description(__('For sending email')) ->icon('heroicon-o-paper-airplane') ->columns(3) ->schema([ TextEntry::make('smtp_server') ->label(__('Server')) ->state($serverHostname) ->copyable(), TextEntry::make('smtp_port') ->label(__('Port')) ->state('587') ->copyable(), TextEntry::make('smtp_security') ->label(__('Security')) ->state('STARTTLS') ->badge() ->color('warning'), ]), Section::make(__('Credentials')) ->description(__('Use your email address and password')) ->icon('heroicon-o-key') ->columns(2) ->schema([ TextEntry::make('username') ->label(__('Username')) ->state($record->email) ->copyable(), TextEntry::make('password_hint') ->label(__('Password')) ->state(__('Your mailbox password')), ]), ]; }), Action::make('password') ->label(__('Password')) ->icon('heroicon-o-key') ->color('warning') ->modalHeading(__('Change Password')) ->modalDescription(fn (Mailbox $record) => $record->email) ->modalIcon('heroicon-o-key') ->modalIconColor('warning') ->modalSubmitActionLabel(__('Change Password')) ->form([ TextInput::make('password') ->label(__('New Password')) ->password() ->revealable() ->required() ->minLength(8) ->rules([ 'regex:/[a-z]/', // lowercase 'regex:/[A-Z]/', // uppercase 'regex:/[0-9]/', // number ]) ->default(fn () => $this->generateSecurePassword()) ->suffixActions([ Action::make('generatePassword') ->icon('heroicon-o-arrow-path') ->tooltip(__('Generate secure password')) ->action(fn ($set) => $set('password', $this->generateSecurePassword())), Action::make('copyPassword') ->icon('heroicon-o-clipboard-document') ->tooltip(__('Copy to clipboard')) ->action(function ($state, $livewire) { if ($state) { $escaped = addslashes($state); $livewire->js("navigator.clipboard.writeText('{$escaped}')"); Notification::make() ->title(__('Copied to clipboard')) ->success() ->duration(2000) ->send(); } }), ]) ->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')), ]) ->action(fn (Mailbox $record, array $data) => $this->changeMailboxPasswordDirect($record, $data['password'])), Action::make('toggle') ->label(fn (Mailbox $record) => $record->is_active ? __('Suspend') : __('Enable')) ->icon(fn (Mailbox $record) => $record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play') ->color('gray') ->action(fn (Mailbox $record) => $this->toggleMailbox($record->id)), Action::make('delete') ->label(__('Delete')) ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() ->modalHeading(__('Delete Mailbox')) ->modalDescription(fn (Mailbox $record) => __("Delete ':email'? All emails will be lost.", ['email' => $record->email])) ->modalIcon('heroicon-o-trash') ->modalIconColor('danger') ->modalSubmitActionLabel(__('Delete Mailbox')) ->form([ Toggle::make('delete_files') ->label(__('Also delete all email files')) ->default(false) ->helperText(__('Warning: This cannot be undone')), ]) ->action(fn (Mailbox $record, array $data) => $this->deleteMailboxDirect($record, $data['delete_files'] ?? false)), ]) ->emptyStateHeading(__('No mailboxes yet')) ->emptyStateDescription(__('Enable email for a domain first, then create a mailbox.')) ->emptyStateIcon('heroicon-o-envelope') ->striped(); } protected function forwardersTable(Table $table): Table { return $table ->query( EmailForwarder::query() ->whereHas('emailDomain.domain', fn (Builder $q) => $q->where('user_id', Auth::id())) ->with('emailDomain.domain') ) ->columns([ TextColumn::make('email') ->label(__('From')) ->icon('heroicon-o-arrow-right') ->iconColor('primary') ->searchable() ->sortable(), TextColumn::make('destinations') ->label(__('Forward To')) ->badge() ->separator(',') ->color('gray'), TextColumn::make('is_active') ->label(__('Status')) ->badge() ->formatStateUsing(fn (bool $state) => $state ? __('Active') : __('Disabled')) ->color(fn (bool $state) => $state ? 'success' : 'danger'), ]) ->recordActions([ Action::make('edit') ->label(__('Edit')) ->icon('heroicon-o-pencil') ->color('info') ->modalHeading(__('Edit Forwarder')) ->modalDescription(fn (EmailForwarder $record) => $record->email) ->modalIcon('heroicon-o-pencil') ->modalIconColor('info') ->modalSubmitActionLabel(__('Save Changes')) ->fillForm(fn (EmailForwarder $record) => [ 'destinations' => implode(', ', $record->destinations ?? []), ]) ->form([ TextInput::make('destinations') ->label(__('Forward To')) ->required() ->helperText(__('Comma-separated email addresses')), ]) ->action(fn (EmailForwarder $record, array $data) => $this->updateForwarderDirect($record, $data['destinations'])), Action::make('toggle') ->label(fn (EmailForwarder $record) => $record->is_active ? __('Disable') : __('Enable')) ->icon(fn (EmailForwarder $record) => $record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play') ->color('gray') ->action(fn (EmailForwarder $record) => $this->toggleForwarder($record->id)), Action::make('delete') ->label(__('Delete')) ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() ->modalHeading(__('Delete Forwarder')) ->modalDescription(fn (EmailForwarder $record) => __("Delete forwarder ':email'?", ['email' => $record->email])) ->modalIcon('heroicon-o-trash') ->modalIconColor('danger') ->modalSubmitActionLabel(__('Delete Forwarder')) ->action(fn (EmailForwarder $record) => $this->deleteForwarderDirect($record)), ]) ->emptyStateHeading(__('No forwarders yet')) ->emptyStateDescription(__('Create a forwarder to redirect emails to another address.')) ->emptyStateIcon('heroicon-o-arrow-right') ->striped(); } protected function autorespondersTable(Table $table): Table { return $table ->query( Autoresponder::query() ->whereHas('mailbox.emailDomain.domain', fn (Builder $q) => $q->where('user_id', Auth::id())) ->with('mailbox.emailDomain.domain') ) ->columns([ TextColumn::make('mailbox.email') ->label(__('Email')) ->icon('heroicon-o-envelope') ->iconColor('primary') ->searchable() ->sortable(), TextColumn::make('subject') ->label(__('Subject')) ->limit(30) ->searchable(), TextColumn::make('status') ->label(__('Status')) ->badge() ->getStateUsing(function (Autoresponder $record): string { if (! $record->is_active) { return __('Disabled'); } if ($record->isCurrentlyActive()) { return __('Active'); } if ($record->start_date && now()->lt($record->start_date)) { return __('Scheduled'); } return __('Expired'); }) ->color(function (Autoresponder $record): string { if (! $record->is_active) { return 'gray'; } if ($record->isCurrentlyActive()) { return 'success'; } if ($record->start_date && now()->lt($record->start_date)) { return 'warning'; } return 'danger'; }), TextColumn::make('start_date') ->label(__('From')) ->date('M d, Y') ->placeholder(__('No start date')), TextColumn::make('end_date') ->label(__('Until')) ->date('M d, Y') ->placeholder(__('No end date')), ]) ->recordActions([ Action::make('edit') ->label(__('Edit')) ->icon('heroicon-o-pencil') ->color('info') ->modalHeading(__('Edit Autoresponder')) ->modalDescription(fn (Autoresponder $record) => $record->mailbox->email) ->modalIcon('heroicon-o-clock') ->modalIconColor('info') ->modalSubmitActionLabel(__('Save Changes')) ->fillForm(fn (Autoresponder $record) => [ 'subject' => $record->subject, 'message' => $record->message, 'start_date' => $record->start_date?->format('Y-m-d'), 'end_date' => $record->end_date?->format('Y-m-d'), 'is_active' => $record->is_active, ]) ->form([ TextInput::make('subject') ->label(__('Subject')) ->required() ->maxLength(255), Textarea::make('message') ->label(__('Message')) ->required() ->rows(5) ->helperText(__('The automatic reply message')), DatePicker::make('start_date') ->label(__('Start Date')) ->helperText(__('Leave empty to start immediately')), DatePicker::make('end_date') ->label(__('End Date')) ->helperText(__('Leave empty for no end date')), Toggle::make('is_active') ->label(__('Active')) ->default(true), ]) ->action(fn (Autoresponder $record, array $data) => $this->updateAutoresponder($record, $data)), Action::make('toggle') ->label(fn (Autoresponder $record) => $record->is_active ? __('Disable') : __('Enable')) ->icon(fn (Autoresponder $record) => $record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play') ->color('gray') ->action(fn (Autoresponder $record) => $this->toggleAutoresponder($record)), Action::make('delete') ->label(__('Delete')) ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() ->modalHeading(__('Delete Autoresponder')) ->modalDescription(fn (Autoresponder $record) => __("Delete autoresponder for ':email'?", ['email' => $record->mailbox->email])) ->modalIcon('heroicon-o-trash') ->modalIconColor('danger') ->modalSubmitActionLabel(__('Delete')) ->action(fn (Autoresponder $record) => $this->deleteAutoresponder($record)), ]) ->emptyStateHeading(__('No autoresponders')) ->emptyStateDescription(__('Set up vacation messages for your mailboxes.')) ->emptyStateIcon('heroicon-o-clock') ->striped(); } protected function catchAllTable(Table $table): Table { return $table ->query( EmailDomain::query() ->whereHas('domain', fn (Builder $q) => $q->where('user_id', Auth::id())) ->with('domain') ) ->columns([ TextColumn::make('domain.domain') ->label(__('Domain')) ->icon('heroicon-o-globe-alt') ->iconColor('primary') ->searchable() ->sortable(), TextColumn::make('catch_all_enabled') ->label(__('Status')) ->badge() ->formatStateUsing(fn (bool $state) => $state ? __('Enabled') : __('Disabled')) ->color(fn (bool $state) => $state ? 'success' : 'gray'), TextColumn::make('catch_all_address') ->label(__('Forward To')) ->placeholder(__('Not configured')) ->icon('heroicon-o-envelope') ->iconColor('info'), ]) ->recordActions([ Action::make('configure') ->label(__('Configure')) ->icon('heroicon-o-cog-6-tooth') ->color('info') ->modalHeading(__('Configure Catch-All')) ->modalDescription(fn (EmailDomain $record) => $record->domain->domain) ->modalIcon('heroicon-o-inbox-stack') ->modalIconColor('info') ->modalSubmitActionLabel(__('Save')) ->fillForm(fn (EmailDomain $record) => [ 'enabled' => $record->catch_all_enabled, 'address' => $record->catch_all_address, ]) ->form([ Toggle::make('enabled') ->label(__('Enable Catch-All')) ->helperText(__('Receive emails sent to any non-existent address on this domain')), Select::make('address') ->label(__('Deliver To')) ->options(function (EmailDomain $record) { return Mailbox::where('email_domain_id', $record->id) ->pluck('local_part') ->mapWithKeys(fn ($local) => [ $local.'@'.$record->domain->domain => $local.'@'.$record->domain->domain, ]) ->toArray(); }) ->searchable() ->helperText(__('Select a mailbox to receive catch-all emails')), ]) ->action(fn (EmailDomain $record, array $data) => $this->updateCatchAll($record, $data)), ]) ->emptyStateHeading(__('No email domains')) ->emptyStateDescription(__('Create a mailbox first to enable email for a domain.')) ->emptyStateIcon('heroicon-o-inbox-stack') ->striped(); } protected function emailLogsTable(Table $table): Table { // Read mail logs (last 100 entries) $logs = $this->getEmailLogs(); return $table ->records(fn () => $logs) ->columns([ TextColumn::make('timestamp') ->label(__('Time')) ->dateTime('M d, H:i:s') ->sortable(), TextColumn::make('status') ->label(__('Status')) ->badge() ->color(fn (array $record) => match ($record['status'] ?? '') { 'sent', 'delivered' => 'success', 'deferred' => 'warning', 'bounced', 'rejected', 'failed' => 'danger', default => 'gray', }), TextColumn::make('from') ->label(__('From')) ->limit(30) ->searchable(), TextColumn::make('to') ->label(__('To')) ->limit(30) ->searchable(), TextColumn::make('subject') ->label(__('Subject')) ->limit(40) ->placeholder(__('(no subject)')), ]) ->recordActions([ Action::make('details') ->label(__('Details')) ->icon('heroicon-o-information-circle') ->color('gray') ->modalHeading(__('Email Details')) ->modalSubmitAction(false) ->modalCancelActionLabel(__('Close')) ->infolist(fn (array $record): array => [ Section::make(__('Message Info')) ->columns(2) ->schema([ TextEntry::make('from') ->label(__('From')) ->state($record['from'] ?? '-') ->copyable(), TextEntry::make('to') ->label(__('To')) ->state($record['to'] ?? '-') ->copyable(), TextEntry::make('subject') ->label(__('Subject')) ->state($record['subject'] ?? '-'), TextEntry::make('timestamp') ->label(__('Time')) ->state(isset($record['timestamp']) ? date('Y-m-d H:i:s', $record['timestamp']) : '-'), ]), Section::make(__('Delivery Status')) ->schema([ TextEntry::make('status') ->label(__('Status')) ->state($record['status'] ?? '-') ->badge() ->color(match ($record['status'] ?? '') { 'sent', 'delivered' => 'success', 'deferred' => 'warning', 'bounced', 'rejected', 'failed' => 'danger', default => 'gray', }), TextEntry::make('message') ->label(__('Message')) ->state($record['message'] ?? '-'), ]), ]), ]) ->emptyStateHeading(__('No email logs')) ->emptyStateDescription(__('Email activity will appear here once emails are sent or received.')) ->emptyStateIcon('heroicon-o-document-text') ->striped() ->defaultSort('timestamp', 'desc'); } protected function getHeaderActions(): array { return [ $this->createMailboxAction(), $this->createForwarderAction(), $this->createAutoresponderAction(), $this->showCredentialsAction(), ]; } protected function showCredentialsAction(): Action { return Action::make('showCredentials') ->label(__('Credentials')) ->hidden() ->modalHeading(__('Mailbox Credentials')) ->modalDescription(__('Save these credentials! The password won\'t be shown again.')) ->modalIcon('heroicon-o-check-circle') ->modalIconColor('success') ->modalSubmitAction(false) ->modalCancelActionLabel(__('Done')) ->infolist([ Section::make(__('Email Address')) ->schema([ TextEntry::make('email') ->hiddenLabel() ->state(fn () => $this->credEmail) ->copyable() ->fontFamily('mono'), ]), Section::make(__('Password')) ->schema([ TextEntry::make('password') ->hiddenLabel() ->state(fn () => $this->credPassword) ->copyable() ->fontFamily('mono'), ]), ]); } protected function getOrCreateEmailDomain(Domain $domain): EmailDomain { $emailDomain = $domain->emailDomain; if (! $emailDomain) { // Enable email for this domain on the server $this->getAgent()->emailEnableDomain($this->getUsername(), $domain->domain); // Create EmailDomain record $emailDomain = EmailDomain::create([ 'domain_id' => $domain->id, 'is_active' => true, ]); $this->syncMailRouting(); // Generate DKIM try { $dkimResult = $this->getAgent()->emailGenerateDkim($this->getUsername(), $domain->domain); if (isset($dkimResult['public_key'])) { $selector = $dkimResult['selector'] ?? 'default'; $publicKey = $dkimResult['public_key']; $emailDomain->update([ 'dkim_selector' => $selector, 'dkim_public_key' => $publicKey, 'dkim_private_key' => $dkimResult['private_key'] ?? null, ]); // Add DKIM record to DNS $dkimRecord = DnsRecord::where('domain_id', $domain->id) ->where('name', "{$selector}._domainkey") ->where('type', 'TXT') ->first(); // Format the DKIM public key (remove headers and newlines) $cleanKey = str_replace([ '-----BEGIN PUBLIC KEY-----', '-----END PUBLIC KEY-----', "\n", "\r", ], '', $publicKey); $dkimContent = "v=DKIM1; k=rsa; p={$cleanKey}"; if (! $dkimRecord) { DnsRecord::create([ 'domain_id' => $domain->id, 'name' => "{$selector}._domainkey", 'type' => 'TXT', 'content' => $dkimContent, 'ttl' => 3600, ]); } else { $dkimRecord->update(['content' => $dkimContent]); } // Regenerate DNS zone to include the new DKIM record $this->regenerateDnsZone($domain); } } catch (Exception $e) { // DKIM generation failed, but email can still work } } return $emailDomain; } protected function syncMailRouting(): void { try { app(MailRoutingSyncService::class)->sync(); } catch (Exception $e) { Notification::make() ->title(__('Mail routing sync failed')) ->body($e->getMessage()) ->warning() ->send(); } } protected function regenerateDnsZone(Domain $domain): void { try { $records = DnsRecord::where('domain_id', $domain->id)->get()->toArray(); $settings = \App\Models\DnsSetting::getAll(); $hostname = gethostname() ?: 'localhost'; $serverIp = trim(shell_exec("hostname -I | awk '{print \$1}'") ?? '') ?: '127.0.0.1'; $serverIpv6 = $settings['default_ipv6'] ?? null; $this->getAgent()->send('dns.sync_zone', [ 'domain' => $domain->domain, 'records' => $records, 'ns1' => $settings['ns1'] ?? "ns1.{$hostname}", 'ns2' => $settings['ns2'] ?? "ns2.{$hostname}", 'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}", 'default_ip' => $settings['default_ip'] ?? $serverIp, 'default_ipv6' => $serverIpv6, 'default_ttl' => $settings['default_ttl'] ?? 3600, ]); } catch (Exception $e) { // Log but don't fail - DNS zone regeneration is not critical } } // Mailbox Actions protected function createMailboxAction(): Action { return Action::make('createMailbox') ->label(__('New Mailbox')) ->icon('heroicon-o-plus-circle') ->color('success') ->visible(fn () => Domain::where('user_id', Auth::id())->exists()) ->modalHeading(__('Create New Mailbox')) ->modalDescription(__('Create an email account for one of your domains')) ->modalIcon('heroicon-o-envelope') ->modalIconColor('success') ->modalSubmitActionLabel(__('Create Mailbox')) ->form([ Select::make('domain_id') ->label(__('Domain')) ->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray()) ->required() ->searchable(), TextInput::make('local_part') ->label(__('Email Address')) ->required() ->regex('/^[a-zA-Z0-9._%+-]+$/') ->maxLength(64) ->helperText(__('The part before the @ symbol')), TextInput::make('name') ->label(__('Display Name')) ->maxLength(255), TextInput::make('password') ->label(__('Password')) ->password() ->revealable() ->required() ->minLength(8) ->rules([ 'regex:/[a-z]/', // lowercase 'regex:/[A-Z]/', // uppercase 'regex:/[0-9]/', // number ]) ->default(fn () => $this->generateSecurePassword()) ->suffixActions([ Action::make('generatePassword') ->icon('heroicon-o-arrow-path') ->tooltip(__('Generate secure password')) ->action(fn ($set) => $set('password', $this->generateSecurePassword())), Action::make('copyPassword') ->icon('heroicon-o-clipboard-document') ->tooltip(__('Copy to clipboard')) ->action(function ($state, $livewire) { if ($state) { $escaped = addslashes($state); $livewire->js("navigator.clipboard.writeText('{$escaped}')"); Notification::make() ->title(__('Copied to clipboard')) ->success() ->duration(2000) ->send(); } }), ]) ->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')), TextInput::make('quota_mb') ->label(__('Quota (MB)')) ->numeric() ->default(1024) ->minValue(100) ->maxValue(10240) ->helperText(__('Storage limit in megabytes')), ]) ->action(function (array $data): void { $limit = Auth::user()?->hostingPackage?->mailboxes_limit; if ($limit && Mailbox::where('user_id', Auth::id())->count() >= $limit) { Notification::make() ->title(__('Mailbox limit reached')) ->body(__('Your hosting package allows up to :limit mailboxes.', ['limit' => $limit])) ->warning() ->send(); return; } $domain = Domain::where('user_id', Auth::id())->find($data['domain_id']); if (! $domain) { Notification::make()->title(__('Domain not found'))->danger()->send(); return; } try { // Get or create EmailDomain (enables email on server if needed) $emailDomain = $this->getOrCreateEmailDomain($domain); $email = $data['local_part'].'@'.$domain->domain; $quotaBytes = (int) $data['quota_mb'] * 1024 * 1024; if (Mailbox::where('email_domain_id', $emailDomain->id)->where('local_part', $data['local_part'])->exists()) { Notification::make()->title(__('Mailbox already exists'))->danger()->send(); return; } $result = $this->getAgent()->mailboxCreate( $this->getUsername(), $email, $data['password'], $quotaBytes ); Mailbox::create([ 'email_domain_id' => $emailDomain->id, 'user_id' => Auth::id(), 'local_part' => $data['local_part'], 'password_hash' => $result['password_hash'] ?? '', 'password_encrypted' => Crypt::encryptString($data['password']), 'maildir_path' => $result['maildir_path'] ?? null, 'system_uid' => $result['uid'] ?? null, 'system_gid' => $result['gid'] ?? null, 'name' => $data['name'], 'quota_bytes' => $quotaBytes, 'is_active' => true, ]); AuditLog::logEmailAction('created', $email, [ 'domain' => $domain->domain, 'quota_bytes' => $quotaBytes, ]); $this->syncMailRouting(); $this->credEmail = $email; $this->credPassword = $data['password']; Notification::make()->title(__('Mailbox created'))->success()->send(); $this->mountAction('showCredentials'); } catch (Exception $e) { Notification::make()->title(__('Error creating mailbox'))->body($e->getMessage())->danger()->send(); } }); } public function changeMailboxPasswordDirect(Mailbox $mailbox, string $password): void { try { $result = $this->getAgent()->mailboxChangePassword( $this->getUsername(), $mailbox->email, $password ); $mailbox->update([ 'password_hash' => $result['password_hash'] ?? '', 'password_encrypted' => Crypt::encryptString($password), ]); $this->credEmail = $mailbox->email; $this->credPassword = $password; AuditLog::logEmailAction('password_changed', $mailbox->email); Notification::make()->title(__('Password changed'))->success()->send(); $this->mountAction('showCredentials'); } catch (Exception $e) { Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); } } public function toggleMailbox(int $mailboxId): void { $mailbox = Mailbox::with('emailDomain.domain')->find($mailboxId); if (! $mailbox) { Notification::make()->title(__('Mailbox not found'))->danger()->send(); return; } try { $newStatus = ! $mailbox->is_active; $this->getAgent()->mailboxToggle($this->getUsername(), $mailbox->email, $newStatus); $mailbox->update(['is_active' => $newStatus]); $this->syncMailRouting(); AuditLog::logEmailAction($newStatus ? 'enabled' : 'disabled', $mailbox->email); Notification::make() ->title($newStatus ? __('Mailbox enabled') : __('Mailbox disabled')) ->success() ->send(); } catch (Exception $e) { Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); } } public function deleteMailboxDirect(Mailbox $mailbox, bool $deleteFiles): void { try { $this->getAgent()->mailboxDelete( $this->getUsername(), $mailbox->email, $deleteFiles, $mailbox->maildir_path ); $mailbox->delete(); $this->syncMailRouting(); AuditLog::logEmailAction('deleted', $mailbox->email, [ 'delete_files' => $deleteFiles, ]); Notification::make()->title(__('Mailbox deleted'))->success()->send(); } catch (Exception $e) { Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); } } // Forwarder Actions protected function createForwarderAction(): Action { return Action::make('createForwarder') ->label(__('New Forwarder')) ->icon('heroicon-o-arrow-right-circle') ->color('info') ->visible(fn () => Domain::where('user_id', Auth::id())->exists()) ->modalHeading(__('Create New Forwarder')) ->modalDescription(__('Redirect emails from one address to another')) ->modalIcon('heroicon-o-arrow-right') ->modalIconColor('info') ->modalSubmitActionLabel(__('Create Forwarder')) ->form([ Select::make('domain_id') ->label(__('Domain')) ->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray()) ->required() ->searchable(), TextInput::make('local_part') ->label(__('Email Address')) ->required() ->regex('/^[a-zA-Z0-9._%+-]+$/') ->maxLength(64) ->helperText(__('The part before the @ symbol')), TextInput::make('destinations') ->label(__('Forward To')) ->required() ->helperText(__('Comma-separated email addresses to forward to')), ]) ->action(function (array $data): void { $domain = Domain::where('user_id', Auth::id())->find($data['domain_id']); if (! $domain) { Notification::make()->title(__('Domain not found'))->danger()->send(); return; } $destinations = array_map('trim', explode(',', $data['destinations'])); $destinations = array_filter($destinations, fn ($d) => filter_var($d, FILTER_VALIDATE_EMAIL)); if (empty($destinations)) { Notification::make()->title(__('Invalid destination emails'))->danger()->send(); return; } try { // Get or create EmailDomain (enables email on server if needed) $emailDomain = $this->getOrCreateEmailDomain($domain); $email = $data['local_part'].'@'.$domain->domain; if (EmailForwarder::where('email_domain_id', $emailDomain->id)->where('local_part', $data['local_part'])->exists()) { Notification::make()->title(__('Forwarder already exists'))->danger()->send(); return; } if (Mailbox::where('email_domain_id', $emailDomain->id)->where('local_part', $data['local_part'])->exists()) { Notification::make()->title(__('A mailbox with this address already exists'))->danger()->send(); return; } $this->getAgent()->send('email.forwarder_create', [ 'username' => $this->getUsername(), 'email' => $email, 'destinations' => $destinations, ]); EmailForwarder::create([ 'email_domain_id' => $emailDomain->id, 'user_id' => Auth::id(), 'local_part' => $data['local_part'], 'destinations' => $destinations, 'is_active' => true, ]); $this->syncMailRouting(); Notification::make()->title(__('Forwarder created'))->success()->send(); } catch (Exception $e) { Notification::make()->title(__('Error creating forwarder'))->body($e->getMessage())->danger()->send(); } }); } public function updateForwarderDirect(EmailForwarder $forwarder, string $destinationsString): void { $destinations = array_map('trim', explode(',', $destinationsString)); $destinations = array_filter($destinations, fn ($d) => filter_var($d, FILTER_VALIDATE_EMAIL)); if (empty($destinations)) { Notification::make()->title(__('Invalid destination emails'))->danger()->send(); return; } try { $this->getAgent()->send('email.forwarder_update', [ 'username' => $this->getUsername(), 'email' => $forwarder->email, 'destinations' => $destinations, ]); $forwarder->update(['destinations' => $destinations]); $this->syncMailRouting(); Notification::make()->title(__('Forwarder updated'))->success()->send(); } catch (Exception $e) { Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); } } public function toggleForwarder(int $forwarderId): void { $forwarder = EmailForwarder::with('emailDomain.domain')->find($forwarderId); if (! $forwarder) { Notification::make()->title(__('Forwarder not found'))->danger()->send(); return; } try { $newStatus = ! $forwarder->is_active; $this->getAgent()->send('email.forwarder_toggle', [ 'username' => $this->getUsername(), 'email' => $forwarder->email, 'active' => $newStatus, ]); $forwarder->update(['is_active' => $newStatus]); $this->syncMailRouting(); Notification::make() ->title($newStatus ? __('Forwarder enabled') : __('Forwarder disabled')) ->success() ->send(); } catch (Exception $e) { Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); } } public function deleteForwarderDirect(EmailForwarder $forwarder): void { try { $this->getAgent()->send('email.forwarder_delete', [ 'username' => $this->getUsername(), 'email' => $forwarder->email, ]); $forwarder->delete(); $this->syncMailRouting(); Notification::make()->title(__('Forwarder deleted'))->success()->send(); } catch (Exception $e) { Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); } } // Autoresponder Actions protected function createAutoresponderAction(): Action { return Action::make('createAutoresponder') ->label(__('New Autoresponder')) ->icon('heroicon-o-clock') ->color('warning') ->visible(fn () => Mailbox::whereHas('emailDomain.domain', fn ($q) => $q->where('user_id', Auth::id()))->exists()) ->modalHeading(__('Create Autoresponder')) ->modalDescription(__('Set up an automatic vacation reply')) ->modalIcon('heroicon-o-clock') ->modalIconColor('warning') ->modalSubmitActionLabel(__('Create')) ->form([ Select::make('mailbox_id') ->label(__('Mailbox')) ->options(fn () => Mailbox::whereHas('emailDomain.domain', fn ($q) => $q->where('user_id', Auth::id())) ->with('emailDomain.domain') ->get() ->mapWithKeys(fn ($m) => [$m->id => $m->email]) ->toArray()) ->required() ->searchable(), TextInput::make('subject') ->label(__('Subject')) ->required() ->default(__('Out of Office')) ->maxLength(255), Textarea::make('message') ->label(__('Message')) ->required() ->rows(5) ->default(__("Thank you for your email. I am currently out of the office and will respond to your message upon my return.\n\nBest regards")) ->helperText(__('The automatic reply message')), DatePicker::make('start_date') ->label(__('Start Date')) ->helperText(__('Leave empty to start immediately')), DatePicker::make('end_date') ->label(__('End Date')) ->helperText(__('Leave empty for no end date')), ]) ->action(function (array $data): void { $mailbox = Mailbox::whereHas('emailDomain.domain', fn ($q) => $q->where('user_id', Auth::id())) ->find($data['mailbox_id']); if (! $mailbox) { Notification::make()->title(__('Mailbox not found'))->danger()->send(); return; } // Check if autoresponder already exists for this mailbox if (Autoresponder::where('mailbox_id', $mailbox->id)->exists()) { Notification::make() ->title(__('Autoresponder already exists')) ->body(__('Edit the existing autoresponder instead.')) ->danger() ->send(); return; } try { // Create autoresponder in database $autoresponder = Autoresponder::create([ 'mailbox_id' => $mailbox->id, 'subject' => $data['subject'], 'message' => $data['message'], 'start_date' => $data['start_date'] ?? null, 'end_date' => $data['end_date'] ?? null, 'is_active' => true, ]); // Configure on mail server via agent $this->getAgent()->send('email.autoresponder_set', [ 'username' => $this->getUsername(), 'email' => $mailbox->email, 'subject' => $data['subject'], 'message' => $data['message'], 'start_date' => $data['start_date'] ?? null, 'end_date' => $data['end_date'] ?? null, 'active' => true, ]); Notification::make()->title(__('Autoresponder created'))->success()->send(); $this->setTab('autoresponders'); } catch (Exception $e) { Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); } }); } public function updateAutoresponder(Autoresponder $autoresponder, array $data): void { try { $autoresponder->update([ 'subject' => $data['subject'], 'message' => $data['message'], 'start_date' => $data['start_date'] ?? null, 'end_date' => $data['end_date'] ?? null, 'is_active' => $data['is_active'] ?? true, ]); // Update on mail server $this->getAgent()->send('email.autoresponder_set', [ 'username' => $this->getUsername(), 'email' => $autoresponder->mailbox->email, 'subject' => $data['subject'], 'message' => $data['message'], 'start_date' => $data['start_date'] ?? null, 'end_date' => $data['end_date'] ?? null, 'active' => $data['is_active'] ?? true, ]); Notification::make()->title(__('Autoresponder updated'))->success()->send(); } catch (Exception $e) { Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); } } public function toggleAutoresponder(Autoresponder $autoresponder): void { try { $newStatus = ! $autoresponder->is_active; $autoresponder->update(['is_active' => $newStatus]); // Update on mail server $this->getAgent()->send('email.autoresponder_toggle', [ 'username' => $this->getUsername(), 'email' => $autoresponder->mailbox->email, 'active' => $newStatus, ]); Notification::make() ->title($newStatus ? __('Autoresponder enabled') : __('Autoresponder disabled')) ->success() ->send(); } catch (Exception $e) { Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); } } public function deleteAutoresponder(Autoresponder $autoresponder): void { try { // Remove from mail server $this->getAgent()->send('email.autoresponder_delete', [ 'username' => $this->getUsername(), 'email' => $autoresponder->mailbox->email, ]); $autoresponder->delete(); Notification::make()->title(__('Autoresponder deleted'))->success()->send(); } catch (Exception $e) { Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); } } // Helper methods for counts public function getMailboxesCount(): int { return Mailbox::whereHas('emailDomain.domain', fn ($q) => $q->where('user_id', Auth::id()))->count(); } public function getForwardersCount(): int { return EmailForwarder::whereHas('emailDomain.domain', fn ($q) => $q->where('user_id', Auth::id()))->count(); } public function getCatchAllCount(): int { return EmailDomain::whereHas('domain', fn ($q) => $q->where('user_id', Auth::id()))->count(); } // Catch-all methods public function updateCatchAll(EmailDomain $emailDomain, array $data): void { try { $enabled = $data['enabled'] ?? false; $address = $data['address'] ?? null; if ($enabled && empty($address)) { Notification::make() ->title(__('Error')) ->body(__('Please select a mailbox to receive catch-all emails')) ->danger() ->send(); return; } // Update in Postfix virtual alias maps $this->getAgent()->send('email.catchall_update', [ 'username' => $this->getUsername(), 'domain' => $emailDomain->domain->domain, 'enabled' => $enabled, 'address' => $address, ]); $emailDomain->update([ 'catch_all_enabled' => $enabled, 'catch_all_address' => $enabled ? $address : null, ]); $this->syncMailRouting(); Notification::make() ->title($enabled ? __('Catch-all enabled') : __('Catch-all disabled')) ->success() ->send(); } catch (Exception $e) { Notification::make()->title(__('Error'))->body($e->getMessage())->danger()->send(); } } // Email Logs public function getEmailLogs(): array { try { $result = $this->getAgent()->send('email.get_logs', [ 'username' => $this->getUsername(), 'limit' => 100, ]); return $result['logs'] ?? []; } catch (Exception $e) { return []; } } // Email Usage Stats public function getEmailUsageStats(): array { $domains = EmailDomain::whereHas('domain', fn ($q) => $q->where('user_id', Auth::id())) ->with(['mailboxes', 'domain']) ->get(); $totalMailboxes = 0; $totalUsed = 0; $totalQuota = 0; foreach ($domains as $domain) { $totalMailboxes += $domain->mailboxes->count(); $totalUsed += $domain->mailboxes->sum('quota_used_bytes'); $totalQuota += $domain->mailboxes->sum('quota_bytes'); } return [ 'domains' => $domains->count(), 'mailboxes' => $totalMailboxes, 'used_bytes' => $totalUsed, 'quota_bytes' => $totalQuota, 'used_formatted' => $this->formatBytes($totalUsed), 'quota_formatted' => $this->formatBytes($totalQuota), 'percent' => $totalQuota > 0 ? round(($totalUsed / $totalQuota) * 100, 1) : 0, ]; } protected function formatBytes(int $bytes): string { if ($bytes < 1024) { return $bytes.' B'; } if ($bytes < 1048576) { return round($bytes / 1024, 1).' KB'; } if ($bytes < 1073741824) { return round($bytes / 1048576, 1).' MB'; } return round($bytes / 1073741824, 1).' GB'; } }