Files
jabali-panel/app/Filament/Jabali/Pages/Email.php
2026-01-25 03:08:37 +02:00

1474 lines
62 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Filament\Concerns\HasPageTour;
use App\Models\Autoresponder;
use App\Models\DnsRecord;
use App\Models\Domain;
use App\Models\EmailDomain;
use App\Models\EmailForwarder;
use App\Models\Mailbox;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Livewire\Attributes\Url;
class Email extends Page implements HasActions, HasForms, HasTable
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-envelope';
protected static ?int $navigationSort = 3;
public static function getNavigationLabel(): string
{
return __('Email');
}
protected string $view = 'filament.jabali.pages.email';
#[Url(as: 'tab')]
public ?string $activeTab = 'mailboxes';
public string $credEmail = '';
public string $credPassword = '';
protected ?AgentClient $agent = null;
public function getTitle(): string|Htmlable
{
return __('Email Management');
}
public function mount(): void
{
// Normalize the tab value from URL
$this->activeTab = $this->normalizeTabName($this->activeTab);
}
public function updatedActiveTab(): void
{
$this->activeTab = $this->normalizeTabName($this->activeTab);
$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',
default => 'mailboxes',
};
}
protected function getActiveTabIndex(): int
{
return match ($this->activeTab) {
'mailboxes' => 1,
'forwarders' => 2,
'autoresponders' => 3,
'catchall' => 4,
'logs' => 5,
default => 1,
};
}
protected function getForms(): array
{
return ['emailForm'];
}
public function emailForm(Schema $schema): Schema
{
return $schema->schema([
View::make('filament.jabali.components.email-tabs-nav'),
]);
}
public function setTab(string $tab): void
{
$this->activeTab = $this->normalizeTabName($tab);
$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);
}
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),
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->getTourAction(),
$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,
]);
// 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 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 {
$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,
]);
$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;
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]);
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();
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,
]);
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]);
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]);
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();
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,
]);
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';
}
}