import * as plugins from '../plugins.js'; 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 constructor(dcRouterRef: any) { this.dcRouter = dcRouterRef; } 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'; } // --------------------------------------------------------------------------- // 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 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.handleDKIMKeysForDomain(domainName); const dnsRecord = await this.dkimCreator.getDNSRecordForSelector(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(); 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(); } 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(); 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 publicKey = doc.dkim.publicKey || ''; const hostname = this.emailHostname; 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: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`, 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) => { if (required.type === 'MX') { return r.type === 'MX' && r.name.toLowerCase() === required.name.toLowerCase(); } // For TXT records, match by name AND check value prefix (v=spf1, v=DKIM1, v=DMARC1) if (r.type !== 'TXT' || r.name.toLowerCase() !== required.name.toLowerCase()) return false; if (required.value.startsWith('v=spf1')) return r.value.includes('v=spf1'); if (required.value.startsWith('v=DKIM1')) return r.value.includes('v=DKIM1'); if (required.value.startsWith('v=DMARC1')) return r.value.includes('v=DMARC1'); return false; }); 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 doc.dnsStatus.mx = await this.checkMx(resolver, domain); // SPF check doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, 'v=spf1'); // DKIM check doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, 'v=DKIM1'); // DMARC check doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, 'v=DMARC1'); doc.dnsStatus.lastCheckedAt = new Date().toISOString(); doc.updatedAt = new Date().toISOString(); await doc.save(); return this.getRequiredDnsRecords(id); } private async checkMx(resolver: plugins.dns.promises.Resolver, domain: string): Promise { try { const records = await resolver.resolveMx(domain); return records && records.length > 0 ? 'valid' : 'missing'; } catch { return 'missing'; } } private async checkTxtRecord( resolver: plugins.dns.promises.Resolver, name: string, prefix: string, ): Promise { try { const records = await resolver.resolveTxt(name); const flat = records.map((r) => r.join('')); const found = flat.some((r) => r.startsWith(prefix)); return found ? 'valid' : 'missing'; } 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, }; } }