407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
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';
|
|
import { buildEmailDnsRecords } from './email-dns-records.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<void> {
|
|
await this.syncManagedDomainsToRuntime();
|
|
}
|
|
|
|
public async stop(): Promise<void> {}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
subdomain?: 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 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<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();
|
|
await this.syncManagedDomainsToRuntime();
|
|
}
|
|
|
|
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();
|
|
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<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 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}`);
|
|
}
|
|
}
|
|
|
|
return buildEmailDnsRecords({
|
|
domain,
|
|
hostname,
|
|
selector,
|
|
dkimValue,
|
|
statuses: doc.dnsStatus,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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) => 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<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
|
|
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<TDnsRecordStatus> {
|
|
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<TDnsRecordStatus> {
|
|
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<IEmailDomainConfig[]> {
|
|
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<void> {
|
|
if (!this.dcRouter.options?.emailConfig) {
|
|
return;
|
|
}
|
|
|
|
const mergedDomains = new Map<string, IEmailDomainConfig>();
|
|
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 });
|
|
}
|
|
}
|
|
}
|