317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
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<IEmailDomain[]> {
|
|
const docs = await EmailDomainDoc.findAll();
|
|
return docs.map((d) => this.docToInterface(d));
|
|
}
|
|
|
|
public async getById(id: string): Promise<IEmailDomain | null> {
|
|
const doc = await EmailDomainDoc.findById(id);
|
|
return doc ? this.docToInterface(doc) : null;
|
|
}
|
|
|
|
public async createEmailDomain(opts: {
|
|
linkedDomainId: string;
|
|
dkimSelector?: string;
|
|
dkimKeySize?: number;
|
|
rotateKeys?: boolean;
|
|
rotationIntervalDays?: number;
|
|
}): Promise<IEmailDomain> {
|
|
// Resolve the linked DNS domain
|
|
const domainDoc = await DomainDoc.findById(opts.linkedDomainId);
|
|
if (!domainDoc) {
|
|
throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
|
|
}
|
|
const domainName = domainDoc.name;
|
|
|
|
// 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.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<void> {
|
|
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<void> {
|
|
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<IEmailDnsRecord[]> {
|
|
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<number> {
|
|
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<IEmailDnsRecord[]> {
|
|
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<TDnsRecordStatus> {
|
|
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<TDnsRecordStatus> {
|
|
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,
|
|
dkim: doc.dkim,
|
|
rateLimits: doc.rateLimits,
|
|
dnsStatus: doc.dnsStatus,
|
|
createdAt: doc.createdAt,
|
|
updatedAt: doc.updatedAt,
|
|
};
|
|
}
|
|
}
|