import * as plugins from '../plugins.js'; import type { IEmailDomainConfig } from '@push.rocks/smartmta'; import { logger } from '../logger.js'; import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js'; import { DomainDoc } from '../db/documents/classes.domain.doc.js'; import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js'; import type { DnsManager } from '../dns/manager.dns.js'; import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js'; /** * EmailDomainManager — orchestrates email domain setup. * * Wires smartmta's DKIMCreator (key generation) with dcrouter's DnsManager * (record creation for dcrouter-hosted and provider-managed zones) to provide * a single entry point for setting up an email domain from A to Z. */ export class EmailDomainManager { private dcRouter: any; // DcRouter — avoids circular import private readonly baseEmailDomains: IEmailDomainConfig[]; constructor(dcRouterRef: any) { this.dcRouter = dcRouterRef; this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[]) .map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig); } private get dnsManager(): DnsManager | undefined { return this.dcRouter.dnsManager; } private get dkimCreator(): any | undefined { return this.dcRouter.emailServer?.dkimCreator; } private get emailHostname(): string { return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost'; } public async start(): Promise { await this.syncManagedDomainsToRuntime(); } public async stop(): Promise {} // --------------------------------------------------------------------------- // CRUD // --------------------------------------------------------------------------- public async getAll(): Promise { const docs = await EmailDomainDoc.findAll(); return docs.map((d) => this.docToInterface(d)); } public async getById(id: string): Promise { const doc = await EmailDomainDoc.findById(id); return doc ? this.docToInterface(doc) : null; } public async createEmailDomain(opts: { linkedDomainId: string; subdomain?: string; dkimSelector?: string; dkimKeySize?: number; rotateKeys?: boolean; rotationIntervalDays?: number; }): Promise { // Resolve the linked DNS domain const domainDoc = await DomainDoc.findById(opts.linkedDomainId); if (!domainDoc) { throw new Error(`DNS domain not found: ${opts.linkedDomainId}`); } const baseDomain = domainDoc.name; const subdomain = opts.subdomain?.trim() || undefined; const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain; // Check for duplicates if (this.isDomainAlreadyConfigured(domainName)) { throw new Error(`Email domain already configured for ${domainName}`); } const existing = await EmailDomainDoc.findByDomain(domainName); if (existing) { throw new Error(`Email domain already exists for ${domainName}`); } const selector = opts.dkimSelector || 'default'; const keySize = opts.dkimKeySize || 2048; const now = new Date().toISOString(); // Generate DKIM keys let publicKey: string | undefined; if (this.dkimCreator) { try { await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize); const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector); // Extract public key from the DNS record value const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/); publicKey = match ? match[1] : undefined; logger.log('info', `DKIM keys generated for ${domainName} (selector: ${selector})`); } catch (err: unknown) { logger.log('warn', `DKIM key generation failed for ${domainName}: ${(err as Error).message}`); } } // Create the document const doc = new EmailDomainDoc(); doc.id = plugins.smartunique.shortId(); doc.domain = domainName.toLowerCase(); doc.linkedDomainId = opts.linkedDomainId; doc.subdomain = subdomain; doc.dkim = { selector, keySize, publicKey, rotateKeys: opts.rotateKeys ?? false, rotationIntervalDays: opts.rotationIntervalDays ?? 90, }; doc.dnsStatus = { mx: 'unchecked', spf: 'unchecked', dkim: 'unchecked', dmarc: 'unchecked', }; doc.createdAt = now; doc.updatedAt = now; await doc.save(); await this.syncManagedDomainsToRuntime(); logger.log('info', `Email domain created: ${domainName}`); return this.docToInterface(doc); } public async updateEmailDomain( id: string, changes: { rotateKeys?: boolean; rotationIntervalDays?: number; rateLimits?: IEmailDomain['rateLimits']; }, ): Promise { const doc = await EmailDomainDoc.findById(id); if (!doc) throw new Error(`Email domain not found: ${id}`); if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys; if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays; if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits; doc.updatedAt = new Date().toISOString(); await doc.save(); await this.syncManagedDomainsToRuntime(); } public async deleteEmailDomain(id: string): Promise { const doc = await EmailDomainDoc.findById(id); if (!doc) throw new Error(`Email domain not found: ${id}`); await doc.delete(); await this.syncManagedDomainsToRuntime(); logger.log('info', `Email domain deleted: ${doc.domain}`); } // --------------------------------------------------------------------------- // DNS record computation // --------------------------------------------------------------------------- /** * Compute the 4 required DNS records for an email domain. */ public async getRequiredDnsRecords(id: string): Promise { const doc = await EmailDomainDoc.findById(id); if (!doc) throw new Error(`Email domain not found: ${id}`); const domain = doc.domain; const selector = doc.dkim.selector; const hostname = this.emailHostname; let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`; if (this.dkimCreator) { try { const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector); dkimValue = dnsRecord.value; } catch (err: unknown) { logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`); } } const records: IEmailDnsRecord[] = [ { type: 'MX', name: domain, value: `10 ${hostname}`, status: doc.dnsStatus.mx, }, { type: 'TXT', name: domain, value: 'v=spf1 a mx ~all', status: doc.dnsStatus.spf, }, { type: 'TXT', name: `${selector}._domainkey.${domain}`, value: dkimValue, status: doc.dnsStatus.dkim, }, { type: 'TXT', name: `_dmarc.${domain}`, value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`, status: doc.dnsStatus.dmarc, }, ]; return records; } // --------------------------------------------------------------------------- // DNS provisioning // --------------------------------------------------------------------------- /** * Auto-create missing DNS records via the linked domain's DNS path. */ public async provisionDnsRecords(id: string): Promise { const doc = await EmailDomainDoc.findById(id); if (!doc) throw new Error(`Email domain not found: ${id}`); if (!this.dnsManager) throw new Error('DnsManager not available'); const requiredRecords = await this.getRequiredDnsRecords(id); const domainId = doc.linkedDomainId; // Get existing DNS records for the linked domain const existingRecords = await DnsRecordDoc.findByDomainId(domainId); let provisioned = 0; for (const required of requiredRecords) { // Check if a matching record already exists const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required)); if (!exists) { try { await this.dnsManager.createRecord({ domainId, name: required.name, type: required.type as any, value: required.value, ttl: 3600, createdBy: 'email-domain-manager', }); provisioned++; logger.log('info', `Provisioned ${required.type} record for ${required.name}`); } catch (err: unknown) { logger.log('warn', `Failed to provision ${required.type} for ${required.name}: ${(err as Error).message}`); } } } // Re-validate after provisioning await this.validateDns(id); return provisioned; } // --------------------------------------------------------------------------- // DNS validation // --------------------------------------------------------------------------- /** * Validate DNS records via live lookups. */ public async validateDns(id: string): Promise { const doc = await EmailDomainDoc.findById(id); if (!doc) throw new Error(`Email domain not found: ${id}`); const domain = doc.domain; const selector = doc.dkim.selector; const resolver = new plugins.dns.promises.Resolver(); // MX check const requiredRecords = await this.getRequiredDnsRecords(id); const mxRecord = requiredRecords.find((record) => record.type === 'MX'); const spfRecord = requiredRecords.find((record) => record.name === domain && record.value.startsWith('v=spf1')); const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`); const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`); doc.dnsStatus.mx = await this.checkMx(resolver, domain, mxRecord?.value); // SPF check doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value); // DKIM check doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value); // DMARC check doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value); doc.dnsStatus.lastCheckedAt = new Date().toISOString(); doc.updatedAt = new Date().toISOString(); await doc.save(); return this.getRequiredDnsRecords(id); } private recordMatchesRequired(record: DnsRecordDoc, required: IEmailDnsRecord): boolean { if (record.type !== required.type || record.name.toLowerCase() !== required.name.toLowerCase()) { return false; } return record.value.trim() === required.value.trim(); } private async checkMx( resolver: plugins.dns.promises.Resolver, domain: string, expectedValue?: string, ): Promise { try { const records = await resolver.resolveMx(domain); if (!records || records.length === 0) { return 'missing'; } if (!expectedValue) { return 'valid'; } const found = records.some((record) => `${record.priority} ${record.exchange}`.trim() === expectedValue.trim()); return found ? 'valid' : 'invalid'; } catch { return 'missing'; } } private async checkTxtRecord( resolver: plugins.dns.promises.Resolver, name: string, expectedValue?: string, ): Promise { try { const records = await resolver.resolveTxt(name); const flat = records.map((r) => r.join('')); if (flat.length === 0) { return 'missing'; } if (!expectedValue) { return 'valid'; } const found = flat.some((record) => record.trim() === expectedValue.trim()); return found ? 'valid' : 'invalid'; } catch { return 'missing'; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- private docToInterface(doc: EmailDomainDoc): IEmailDomain { return { id: doc.id, domain: doc.domain, linkedDomainId: doc.linkedDomainId, subdomain: doc.subdomain, dkim: doc.dkim, rateLimits: doc.rateLimits, dnsStatus: doc.dnsStatus, createdAt: doc.createdAt, updatedAt: doc.updatedAt, }; } private isDomainAlreadyConfigured(domainName: string): boolean { const configuredDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[]) .map((domainConfig) => domainConfig.domain.toLowerCase()); return configuredDomains.includes(domainName.toLowerCase()); } private async buildManagedDomainConfigs(): Promise { const docs = await EmailDomainDoc.findAll(); const managedConfigs: IEmailDomainConfig[] = []; for (const doc of docs) { const linkedDomain = await DomainDoc.findById(doc.linkedDomainId); if (!linkedDomain) { logger.log('warn', `Skipping managed email domain ${doc.domain}: linked domain missing`); continue; } managedConfigs.push({ domain: doc.domain, dnsMode: linkedDomain.source === 'dcrouter' ? 'internal-dns' : 'external-dns', dkim: { selector: doc.dkim.selector, keySize: doc.dkim.keySize, rotateKeys: doc.dkim.rotateKeys, rotationInterval: doc.dkim.rotationIntervalDays, }, rateLimits: doc.rateLimits, }); } return managedConfigs; } private async syncManagedDomainsToRuntime(): Promise { if (!this.dcRouter.options?.emailConfig) { return; } const mergedDomains = new Map(); for (const domainConfig of this.baseEmailDomains) { mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig); } for (const managedConfig of await this.buildManagedDomainConfigs()) { const key = managedConfig.domain.toLowerCase(); if (mergedDomains.has(key)) { logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`); continue; } mergedDomains.set(key, managedConfig); } const domains = Array.from(mergedDomains.values()); this.dcRouter.options.emailConfig.domains = domains; if (this.dcRouter.emailServer) { this.dcRouter.emailServer.updateOptions({ domains }); } } }