Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
Deploy Ladill Hosting / deploy (push) Failing after 17s
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>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
use App\Models\EmailDomain;
|
||||
use App\Models\EmailServer;
|
||||
use App\Models\Mailbox;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EmailServerPoolService
|
||||
{
|
||||
public function poolRoot(EmailServer $server): EmailServer
|
||||
{
|
||||
return $server->poolRoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, EmailServer>
|
||||
*/
|
||||
public function poolMembers(EmailServer $server): Collection
|
||||
{
|
||||
$root = $this->poolRoot($server);
|
||||
|
||||
return EmailServer::query()
|
||||
->where(function ($query) use ($root) {
|
||||
$query->where('id', $root->id)
|
||||
->orWhere('primary_email_server_id', $root->id);
|
||||
})
|
||||
->whereNotIn('status', [EmailServer::STATUS_DECOMMISSIONED])
|
||||
->orderBy('role')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function poolDiskGb(EmailServer $server): int
|
||||
{
|
||||
return (int) $this->poolMembers($server)->sum('disk_gb');
|
||||
}
|
||||
|
||||
public function poolUsedStorageMb(EmailServer $server): int
|
||||
{
|
||||
return (int) $this->poolMembers($server)->sum('used_storage_mb');
|
||||
}
|
||||
|
||||
public function poolAllocatedStorageMb(EmailServer $server): int
|
||||
{
|
||||
$memberIds = $this->poolMembers($server)->pluck('id');
|
||||
|
||||
$fromEmailDomains = EmailDomain::query()
|
||||
->whereIn('email_server_id', $memberIds)
|
||||
->withSum('mailboxes as quota_sum', 'quota_mb')
|
||||
->get()
|
||||
->sum(fn (EmailDomain $d) => (int) ($d->quota_sum ?? 0));
|
||||
|
||||
$fromWebsiteDomains = Mailbox::query()
|
||||
->whereHas('domain', fn ($q) => $q->whereIn('email_server_id', $memberIds))
|
||||
->sum('quota_mb');
|
||||
|
||||
return (int) ($fromEmailDomains + $fromWebsiteDomains);
|
||||
}
|
||||
|
||||
public function poolDomainCount(EmailServer $server): int
|
||||
{
|
||||
$memberIds = $this->poolMembers($server)->pluck('id');
|
||||
|
||||
$standalone = EmailDomain::query()->whereIn('email_server_id', $memberIds)->count();
|
||||
$website = \App\Models\Domain::query()->whereIn('email_server_id', $memberIds)->count();
|
||||
|
||||
return $standalone + $website;
|
||||
}
|
||||
|
||||
public function poolMailboxCount(EmailServer $server): int
|
||||
{
|
||||
$memberIds = $this->poolMembers($server)->pluck('id');
|
||||
|
||||
$standalone = Mailbox::query()
|
||||
->whereHas('emailDomain', fn ($q) => $q->whereIn('email_server_id', $memberIds))
|
||||
->count();
|
||||
|
||||
$website = Mailbox::query()
|
||||
->whereHas('domain', fn ($q) => $q->whereIn('email_server_id', $memberIds))
|
||||
->count();
|
||||
|
||||
return $standalone + $website;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the pool member with the most free physical disk for new mail storage.
|
||||
*/
|
||||
public function findStorageMember(EmailServer $poolRoot): ?EmailServer
|
||||
{
|
||||
$members = $this->poolMembers($poolRoot)
|
||||
->filter(fn (EmailServer $m) => in_array($m->status, [EmailServer::STATUS_ACTIVE, EmailServer::STATUS_FULL], true));
|
||||
|
||||
if ($members->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $members
|
||||
->sortByDesc(function (EmailServer $member) {
|
||||
$disk = (int) ($member->disk_gb ?? 0);
|
||||
$usedGb = $this->memberUsedStorageGb($member);
|
||||
|
||||
return max($disk - $usedGb, 0);
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
public function memberUsedStorageGb(EmailServer $member): float
|
||||
{
|
||||
return ((int) $member->used_storage_mb) / 1024;
|
||||
}
|
||||
|
||||
public function poolPressurePercent(EmailServer $server): float
|
||||
{
|
||||
$poolDisk = $this->poolDiskGb($server);
|
||||
if ($poolDisk <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$usedGb = $this->poolUsedStorageMb($server) / 1024;
|
||||
|
||||
return round(($usedGb / $poolDisk) * 100, 2);
|
||||
}
|
||||
|
||||
public function syncExtensionCredentials(EmailServer $primary): void
|
||||
{
|
||||
if (! $primary->isPrimary()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$primary->extensions()->update([
|
||||
'mail_host' => $primary->mail_host,
|
||||
'db_host' => $primary->db_host,
|
||||
'db_port' => $primary->db_port,
|
||||
'db_database' => $primary->db_database,
|
||||
'db_username' => $primary->db_username,
|
||||
'db_password' => $primary->db_password,
|
||||
]);
|
||||
}
|
||||
|
||||
public function syncExtensionFromPrimary(EmailServer $extension, EmailServer $primary): void
|
||||
{
|
||||
$extension->update([
|
||||
'mail_host' => $primary->mail_host,
|
||||
'role' => EmailServer::ROLE_EXTENSION,
|
||||
'primary_email_server_id' => $primary->id,
|
||||
'db_host' => $primary->db_host,
|
||||
'db_port' => $primary->db_port,
|
||||
'db_database' => $primary->db_database,
|
||||
'db_username' => $primary->db_username,
|
||||
'db_password' => $primary->db_password,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
use App\Models\EmailServer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MailServerNfsService
|
||||
{
|
||||
public function __construct(
|
||||
private MailServerSshExecutor $ssh,
|
||||
private EmailServerPoolService $pool,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Allow pool extension IPs on the primary NFS export.
|
||||
*/
|
||||
public function refreshPrimaryExports(EmailServer $primary): void
|
||||
{
|
||||
$primary = $primary->poolRoot();
|
||||
|
||||
if (! $primary->isPrimary() || ! $this->ssh->canConnect($primary)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$clientIps = $this->pool->poolMembers($primary)
|
||||
->filter(fn (EmailServer $m) => $m->isExtension())
|
||||
->pluck('ip_address')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($clientIps === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$script = resource_path('infrastructure/scripts/nfs-refresh-exports.sh');
|
||||
$replacements = [
|
||||
'__VMAIL_ROOT__' => rtrim((string) ($primary->pool_vmail_root ?: config('infrastructure.mail_stack.vmail_root', '/var/vmail')), '/'),
|
||||
'__NEW_CLIENTS__' => implode(' ', $clientIps),
|
||||
];
|
||||
|
||||
try {
|
||||
$this->ssh->uploadAndRunScript(
|
||||
$primary,
|
||||
'/tmp/ladill-nfs-refresh-exports.sh',
|
||||
$script,
|
||||
$replacements,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to refresh NFS exports on primary mail server', [
|
||||
'primary_id' => $primary->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
use App\Models\Mailbox;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Every mailbox holder gets a Ladill account (users.email = mailbox address).
|
||||
* The Ladill login password matches the mailbox password so holders can sign in
|
||||
* to account.ladill.com with the same credentials they use for webmail/IMAP.
|
||||
*/
|
||||
class MailboxUserProvisioner
|
||||
{
|
||||
public function __construct(private MailboxWebmailVault $vault) {}
|
||||
|
||||
public function ensureForMailbox(Mailbox $mailbox, ?string $password = null): User
|
||||
{
|
||||
$user = $this->ensureForAddress((string) $mailbox->address, (string) $mailbox->display_name, $password);
|
||||
$this->syncHolderPassword($mailbox, $password);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function ensureForAddress(string $address, string $displayName = '', ?string $password = null): User
|
||||
{
|
||||
$email = strtolower(trim($address));
|
||||
if ($email === '' || ! str_contains($email, '@')) {
|
||||
throw new \InvalidArgumentException('Invalid mailbox address.');
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'name' => ($name = trim($displayName) ?: Str::headline(Str::before($email, '@'))),
|
||||
'first_name' => Str::before($name, ' '),
|
||||
'last_name' => trim(Str::after($name, ' ')) ?: '',
|
||||
'password' => $password ?? Str::random(40),
|
||||
];
|
||||
|
||||
$user = User::firstOrCreate(['email' => $email], $attributes);
|
||||
|
||||
if (! $user->wasRecentlyCreated && $password !== null && $password !== '') {
|
||||
$user->password = $password;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
if ($user->email_verified_at === null) {
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the Ladill account password aligned with the mailbox password.
|
||||
* Uses the plaintext when available; otherwise copies the stored bcrypt hash.
|
||||
*/
|
||||
public function syncHolderPassword(Mailbox $mailbox, ?string $plaintextPassword = null): void
|
||||
{
|
||||
$email = strtolower(trim((string) $mailbox->address));
|
||||
if ($email === '' || ! str_contains($email, '@')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($plaintextPassword !== null && $plaintextPassword !== '') {
|
||||
$user->password = $plaintextPassword;
|
||||
$user->save();
|
||||
$this->vault->store($mailbox, $plaintextPassword);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hash = (string) $mailbox->password_hash;
|
||||
if ($hash !== '') {
|
||||
$this->assignPasswordHash($user, $hash);
|
||||
}
|
||||
}
|
||||
|
||||
public function provisionSilently(string $address, string $displayName = '', ?string $password = null): ?User
|
||||
{
|
||||
try {
|
||||
return $this->ensureForAddress($address, $displayName, $password);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function provisionMailboxSilently(Mailbox $mailbox, ?string $password = null): ?User
|
||||
{
|
||||
try {
|
||||
return $this->ensureForMailbox($mailbox, $password);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function assignPasswordHash(User $user, string $passwordHash): void
|
||||
{
|
||||
$user->getConnection()
|
||||
->table($user->getTable())
|
||||
->where($user->getKeyName(), $user->getKey())
|
||||
->update(['password' => $passwordHash]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user