Files
ladill-hosting/app/Services/Mail/MailServerProvisioner.php
T
isaaccladandCursor e251a4cf60
Deploy Ladill Hosting / deploy (push) Failing after 17s
Initial Ladill Hosting app with Gitea deploy pipeline.
Shared web hosting extracted from the platform monolith, with CI deploy
to /var/www/ladill-hosting matching Bird/Domains/Email.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 16:24:20 +00:00

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