VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
370 lines
13 KiB
PHP
370 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Mail;
|
|
|
|
use App\Models\Domain;
|
|
use App\Models\DomainDkimKey;
|
|
use App\Models\EmailDomain;
|
|
use App\Models\EmailServer;
|
|
use App\Models\Mailbox;
|
|
use Illuminate\Database\ConnectionInterface;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class MailServerProvisioner
|
|
{
|
|
public function __construct(
|
|
private MailServerConnectionRegistry $connections,
|
|
private MailServerCapacityService $capacity,
|
|
) {}
|
|
|
|
public function provisionDomain(Domain $domain): int
|
|
{
|
|
$server = $this->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.');
|
|
}
|
|
}
|