resolveServer(domain: $domain); $name = rtrim($domain->host, '.'); $existing = $this->db($server)->table('domains')->where('name', $name)->first(); if ($existing) { $this->db($server)->table('domains')->where('id', $existing->id)->update(['active' => 1]); Log::info('MailServerProvisioner: Domain already exists, activated', ['domain' => $name, 'server_id' => $server->id]); return $existing->id; } $id = $this->db($server)->table('domains')->insertGetId([ 'name' => $name, 'active' => 1, 'created_at' => now(), ]); Log::info('MailServerProvisioner: Domain provisioned', ['domain' => $name, 'server_id' => $server->id, 'id' => $id]); return $id; } public function provisionMailbox(Mailbox $mailbox, ?string $passwordPlain = null): int { $domain = $mailbox->domain; if (! $domain) { throw new \RuntimeException('Mailbox has no associated domain.'); } $server = $this->resolveServer(domain: $domain); $mailDomainId = $this->provisionDomain($domain); $email = strtolower(trim($mailbox->address)); $localPart = strtolower(trim($mailbox->local_part)); $domainName = rtrim($domain->host, '.'); $maildir = $this->maildirPath($server, $domainName, $localPart); return $this->upsertMailboxRecord($server, $mailDomainId, $email, $localPart, $maildir, $mailbox->quota_mb ?? Mailbox::defaultQuotaMb(), $passwordPlain); } public function provisionAlias(Domain $domain, string $source, string $destination): int { $server = $this->resolveServer(domain: $domain); $mailDomainId = $this->provisionDomain($domain); $existing = $this->db($server)->table('aliases')->where('source', $source)->first(); if ($existing) { $this->db($server)->table('aliases')->where('id', $existing->id)->update([ 'destination' => $destination, 'active' => 1, ]); return $existing->id; } return $this->db($server)->table('aliases')->insertGetId([ 'domain_id' => $mailDomainId, 'source' => $source, 'destination' => $destination, 'active' => 1, 'created_at' => now(), ]); } public function deactivateDomain(Domain $domain): void { $server = $this->resolveServer(domain: $domain); $name = rtrim($domain->host, '.'); $this->db($server)->table('domains')->where('name', $name)->update(['active' => 0]); Log::info('MailServerProvisioner: Domain deactivated', ['domain' => $name, 'server_id' => $server->id]); } public function deactivateMailbox(Mailbox $mailbox): void { $server = $this->resolveServer(mailbox: $mailbox); $email = strtolower(trim($mailbox->address)); $this->db($server)->table('mailboxes')->where('email', $email)->update(['active' => 0]); Log::info('MailServerProvisioner: Mailbox deactivated', ['email' => $email, 'server_id' => $server->id]); } public function deployDkim(Domain $domain, DomainDkimKey $dkim): bool { $server = $this->resolveServer(domain: $domain); $signingServer = $server->mailDatabaseServer(); return $this->deployDkimViaSsh($signingServer, rtrim($domain->host, '.'), $dkim); } public function isConfigured(): bool { if (EmailServer::query()->whereIn('status', [EmailServer::STATUS_ACTIVE, EmailServer::STATUS_FULL])->exists()) { return true; } $password = config('database.connections.mailserver.password'); return is_string($password) && $password !== ''; } public function provisionEmailDomain(EmailDomain $emailDomain): int { $server = $this->resolveServer(emailDomain: $emailDomain); $name = rtrim($emailDomain->domain, '.'); $existing = $this->db($server)->table('domains')->where('name', $name)->first(); if ($existing) { $this->db($server)->table('domains')->where('id', $existing->id)->update(['active' => 1]); return $existing->id; } $id = $this->db($server)->table('domains')->insertGetId([ 'name' => $name, 'active' => 1, 'created_at' => now(), ]); Log::info('MailServerProvisioner: Email domain provisioned', ['domain' => $name, 'server_id' => $server->id]); return $id; } public function provisionStandaloneMailbox(Mailbox $mailbox, ?string $passwordPlain = null): int { $emailDomain = $mailbox->emailDomain; if (! $emailDomain) { throw new \RuntimeException('Standalone mailbox has no associated email domain.'); } $server = $this->resolveServer(emailDomain: $emailDomain); $mailDomainId = $this->provisionEmailDomain($emailDomain); $email = strtolower(trim($mailbox->address)); $localPart = strtolower(trim($mailbox->local_part)); $domainName = rtrim($emailDomain->domain, '.'); $maildir = $this->maildirPath($server, $domainName, $localPart); return $this->upsertMailboxRecord($server, $mailDomainId, $email, $localPart, $maildir, $mailbox->quota_mb ?? Mailbox::defaultQuotaMb(), $passwordPlain); } public function deprovisionEmailDomain(EmailDomain $emailDomain): void { $server = $this->resolveServer(emailDomain: $emailDomain); $name = rtrim($emailDomain->domain, '.'); $domainRecord = $this->db($server)->table('domains')->where('name', $name)->first(); if ($domainRecord) { $this->db($server)->table('mailboxes')->where('domain_id', $domainRecord->id)->update(['active' => 0]); $this->db($server)->table('domains')->where('id', $domainRecord->id)->update(['active' => 0]); } Log::info('MailServerProvisioner: Email domain deprovisioned', ['domain' => $name, 'server_id' => $server->id]); } private function upsertMailboxRecord( EmailServer $server, int $mailDomainId, string $email, string $localPart, string $maildir, int $quotaMb, ?string $passwordPlain, ): int { $existing = $this->db($server)->table('mailboxes')->where('email', $email)->first(); if ($existing) { $update = [ 'active' => 1, 'quota_mb' => $quotaMb, 'maildir' => $maildir, ]; if ($passwordPlain !== null && $passwordPlain !== '') { $update['password_hash'] = $this->hashPassword($passwordPlain); } $this->db($server)->table('mailboxes')->where('id', $existing->id)->update($update); return $existing->id; } if ($passwordPlain === null || $passwordPlain === '') { throw new \RuntimeException("Cannot provision new mailbox {$email} without a password."); } $id = $this->db($server)->table('mailboxes')->insertGetId([ 'domain_id' => $mailDomainId, 'local_part' => $localPart, 'email' => $email, 'password_hash' => $this->hashPassword($passwordPlain), 'quota_mb' => $quotaMb, 'active' => 1, 'maildir' => $maildir, 'created_at' => now(), ]); $this->capacity->refreshServerMetrics($server); return $id; } private function maildirPath(EmailServer $server, string $domainName, string $localPart): string { return "{$domainName}/{$localPart}/"; } private function deployDkimViaSsh(EmailServer $server, string $domainName, DomainDkimKey $dkim): bool { $sshHost = $server->ssh_host ?: $server->ip_address; $sshUser = $server->ssh_user ?: 'root'; $sshKey = $server->ssh_private_key; if (empty($sshHost) || empty($sshKey)) { $sshHost = config('mailinfra.mail_ssh_host'); $sshUser = config('mailinfra.mail_ssh_user', 'root'); $sshKeyPath = config('mailinfra.mail_ssh_key_path'); if (empty($sshHost) || empty($sshKeyPath)) { Log::warning('MailServerProvisioner: SSH not configured, skipping DKIM deployment', ['domain' => $domainName]); return false; } return $this->deployDkimWithKeyPath($sshHost, $sshUser, $sshKeyPath, $domainName, $dkim); } $tmpKey = tempnam(sys_get_temp_dir(), 'ladill-mail-ssh-'); file_put_contents($tmpKey, $sshKey); chmod($tmpKey, 0600); try { return $this->deployDkimWithKeyPath($sshHost, $sshUser, $tmpKey, $domainName, $dkim); } finally { @unlink($tmpKey); } } private function deployDkimWithKeyPath(string $sshHost, string $sshUser, string $sshKeyPath, string $domainName, DomainDkimKey $dkim): bool { $selector = $dkim->selector; $privatePath = $dkim->private_key_path; if (! $privatePath || ! file_exists($privatePath)) { Log::warning('MailServerProvisioner: DKIM private key file not found', ['domain' => $domainName, 'path' => $privatePath]); return false; } $privateKey = file_get_contents($privatePath); $remoteKeyDir = "/etc/opendkim/keys/{$domainName}"; $remoteKeyFile = "{$remoteKeyDir}/{$selector}.private"; $escapedKey = base64_encode($privateKey); $keyTableEntry = "{$selector}._domainkey.{$domainName} {$domainName}:{$selector}:{$remoteKeyFile}"; $signingTableEntry = "*@{$domainName} {$selector}._domainkey.{$domainName}"; $commands = [ "mkdir -p {$remoteKeyDir}", "chown opendkim:opendkim {$remoteKeyDir}", "echo '{$escapedKey}' | base64 -d > {$remoteKeyFile}", "chown opendkim:opendkim {$remoteKeyFile}", "chmod 600 {$remoteKeyFile}", "grep -qF '{$domainName}' /etc/opendkim/key.table || echo '{$keyTableEntry}' >> /etc/opendkim/key.table", "grep -qF '{$domainName}' /etc/opendkim/signing.table || echo '{$signingTableEntry}' >> /etc/opendkim/signing.table", 'systemctl reload opendkim 2>/dev/null || service opendkim restart 2>/dev/null || true', ]; $sshBase = "ssh -i {$sshKeyPath} -o StrictHostKeyChecking=no {$sshUser}@{$sshHost}"; $fullCommand = $sshBase.' "'.implode(' && ', $commands).'"'; $output = []; $exitCode = 0; exec($fullCommand.' 2>&1', $output, $exitCode); if ($exitCode !== 0) { Log::error('MailServerProvisioner: DKIM deployment failed', [ 'domain' => $domainName, 'exit_code' => $exitCode, 'output' => implode("\n", $output), ]); return false; } Log::info('MailServerProvisioner: DKIM deployed', ['domain' => $domainName, 'selector' => $selector]); return true; } private function hashPassword(string $password): string { return '{BLF-CRYPT}'.password_hash($password, PASSWORD_BCRYPT); } private function db(?EmailServer $server = null): ConnectionInterface { if ($server) { return $this->connections->connection($server->mailDatabaseServer()); } return DB::connection('mailserver'); } private function resolveServer( ?EmailServer $server = null, ?EmailDomain $emailDomain = null, ?Domain $domain = null, ?Mailbox $mailbox = null, ): EmailServer { if ($server) { return $server; } if ($mailbox?->emailDomain?->emailServer) { return $mailbox->emailDomain->emailServer; } if ($mailbox?->domain?->emailServer) { return $mailbox->domain->emailServer; } if ($emailDomain) { if ($emailDomain->email_server_id) { return $emailDomain->emailServer; } return $this->capacity->assignServerToEmailDomain($emailDomain); } if ($domain) { if ($domain->email_server_id) { return $domain->emailServer; } return $this->capacity->assignServerToDomain($domain); } $fallback = EmailServer::available()->where('is_default', true)->first() ?? EmailServer::available()->first(); if ($fallback) { return $fallback; } throw new \RuntimeException('No email server configured. Add a primary mail pool in Admin → Email Servers or set MAIL_DB_* in .env.'); } }