initial
This commit is contained in:
563
ts/mail/routing/classes.dns.manager.ts
Normal file
563
ts/mail/routing/classes.dns.manager.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import type { IEmailDomainConfig } from './interfaces.ts';
|
||||
import { logger } from '../../logger.ts';
|
||||
import type { DcRouter } from '../../classes.mailer.ts';
|
||||
import type { StorageManager } from '../../storage/index.ts';
|
||||
|
||||
/**
|
||||
* DNS validation result
|
||||
*/
|
||||
export interface IDnsValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
requiredChanges: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DNS records found for a domain
|
||||
*/
|
||||
interface IDnsRecords {
|
||||
mx?: string[];
|
||||
spf?: string;
|
||||
dkim?: string;
|
||||
dmarc?: string;
|
||||
ns?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages DNS configuration for email domains
|
||||
* Handles both validation and creation of DNS records
|
||||
*/
|
||||
export class DnsManager {
|
||||
private dcRouter: DcRouter;
|
||||
private storageManager: StorageManager;
|
||||
|
||||
constructor(dcRouter: DcRouter) {
|
||||
this.dcRouter = dcRouter;
|
||||
this.storageManager = dcRouter.storageManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all domain configurations
|
||||
*/
|
||||
async validateAllDomains(domainConfigs: IEmailDomainConfig[]): Promise<Map<string, IDnsValidationResult>> {
|
||||
const results = new Map<string, IDnsValidationResult>();
|
||||
|
||||
for (const config of domainConfigs) {
|
||||
const result = await this.validateDomain(config);
|
||||
results.set(config.domain, result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single domain configuration
|
||||
*/
|
||||
async validateDomain(config: IEmailDomainConfig): Promise<IDnsValidationResult> {
|
||||
switch (config.dnsMode) {
|
||||
case 'forward':
|
||||
return this.validateForwardMode(config);
|
||||
case 'internal-dns':
|
||||
return this.validateInternalDnsMode(config);
|
||||
case 'external-dns':
|
||||
return this.validateExternalDnsMode(config);
|
||||
default:
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`Unknown DNS mode: ${config.dnsMode}`],
|
||||
warnings: [],
|
||||
requiredChanges: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate forward mode configuration
|
||||
*/
|
||||
private async validateForwardMode(config: IEmailDomainConfig): Promise<IDnsValidationResult> {
|
||||
const result: IDnsValidationResult = {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
requiredChanges: []
|
||||
};
|
||||
|
||||
// Forward mode doesn't require DNS validation by default
|
||||
if (!config.dns?.forward?.skipDnsValidation) {
|
||||
logger.log('info', `DNS validation skipped for forward mode domain: ${config.domain}`);
|
||||
}
|
||||
|
||||
// DKIM keys are still generated for consistency
|
||||
result.warnings.push(
|
||||
`Domain "${config.domain}" uses forward mode. DKIM keys will be generated but signing only happens if email is processed.`
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate internal DNS mode configuration
|
||||
*/
|
||||
private async validateInternalDnsMode(config: IEmailDomainConfig): Promise<IDnsValidationResult> {
|
||||
const result: IDnsValidationResult = {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
requiredChanges: []
|
||||
};
|
||||
|
||||
// Check if DNS configuration is set up
|
||||
const dnsNsDomains = this.dcRouter.options?.dnsNsDomains;
|
||||
const dnsScopes = this.dcRouter.options?.dnsScopes;
|
||||
|
||||
if (!dnsNsDomains || dnsNsDomains.length === 0) {
|
||||
result.valid = false;
|
||||
result.errors.push(
|
||||
`Domain "${config.domain}" is configured to use internal DNS, but dnsNsDomains is not set in DcRouter configuration.`
|
||||
);
|
||||
console.error(
|
||||
`❌ ERROR: Domain "${config.domain}" is configured to use internal DNS,\n` +
|
||||
' but dnsNsDomains is not set in DcRouter configuration.\n' +
|
||||
' Please configure dnsNsDomains to enable the DNS server.\n' +
|
||||
' Example: dnsNsDomains: ["ns1.myservice.com", "ns2.myservice.com"]'
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!dnsScopes || dnsScopes.length === 0) {
|
||||
result.valid = false;
|
||||
result.errors.push(
|
||||
`Domain "${config.domain}" is configured to use internal DNS, but dnsScopes is not set in DcRouter configuration.`
|
||||
);
|
||||
console.error(
|
||||
`❌ ERROR: Domain "${config.domain}" is configured to use internal DNS,\n` +
|
||||
' but dnsScopes is not set in DcRouter configuration.\n' +
|
||||
' Please configure dnsScopes to define authoritative domains.\n' +
|
||||
' Example: dnsScopes: ["myservice.com", "mail.myservice.com"]'
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if the email domain is in dnsScopes
|
||||
if (!dnsScopes.includes(config.domain)) {
|
||||
result.valid = false;
|
||||
result.errors.push(
|
||||
`Domain "${config.domain}" is configured to use internal DNS, but is not included in dnsScopes.`
|
||||
);
|
||||
console.error(
|
||||
`❌ ERROR: Domain "${config.domain}" is configured to use internal DNS,\n` +
|
||||
` but is not included in dnsScopes: [${dnsScopes.join(', ')}].\n` +
|
||||
' Please add this domain to dnsScopes to enable internal DNS.\n' +
|
||||
` Example: dnsScopes: [..., "${config.domain}"]`
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
const primaryNameserver = dnsNsDomains[0];
|
||||
|
||||
// Check NS delegation
|
||||
try {
|
||||
const nsRecords = await this.resolveNs(config.domain);
|
||||
const delegatedNameservers = dnsNsDomains.filter(ns => nsRecords.includes(ns));
|
||||
const isDelegated = delegatedNameservers.length > 0;
|
||||
|
||||
if (!isDelegated) {
|
||||
result.warnings.push(
|
||||
`NS delegation not found for ${config.domain}. Please add NS records at your registrar.`
|
||||
);
|
||||
dnsNsDomains.forEach(ns => {
|
||||
result.requiredChanges.push(
|
||||
`Add NS record: ${config.domain}. NS ${ns}.`
|
||||
);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`📋 DNS Delegation Required for ${config.domain}:\n` +
|
||||
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||
'Please add these NS records at your domain registrar:\n' +
|
||||
dnsNsDomains.map(ns => ` ${config.domain}. NS ${ns}.`).join('\n') + '\n' +
|
||||
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||
'This delegation is required for internal DNS mode to work.'
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`✅ NS delegation verified: ${config.domain} -> [${delegatedNameservers.join(', ')}]`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
result.warnings.push(
|
||||
`Could not verify NS delegation for ${config.domain}: ${error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate external DNS mode configuration
|
||||
*/
|
||||
private async validateExternalDnsMode(config: IEmailDomainConfig): Promise<IDnsValidationResult> {
|
||||
const result: IDnsValidationResult = {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
requiredChanges: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Get current DNS records
|
||||
const records = await this.checkDnsRecords(config);
|
||||
const requiredRecords = config.dns?.external?.requiredRecords || ['MX', 'SPF', 'DKIM', 'DMARC'];
|
||||
|
||||
// Check MX record
|
||||
if (requiredRecords.includes('MX') && !records.mx?.length) {
|
||||
result.requiredChanges.push(
|
||||
`Add MX record: ${this.getBaseDomain(config.domain)} -> ${config.domain} (priority 10)`
|
||||
);
|
||||
}
|
||||
|
||||
// Check SPF record
|
||||
if (requiredRecords.includes('SPF') && !records.spf) {
|
||||
result.requiredChanges.push(
|
||||
`Add TXT record: ${this.getBaseDomain(config.domain)} -> "v=spf1 a mx ~all"`
|
||||
);
|
||||
}
|
||||
|
||||
// Check DKIM record
|
||||
if (requiredRecords.includes('DKIM') && !records.dkim) {
|
||||
const selector = config.dkim?.selector || 'default';
|
||||
const dkimPublicKey = await this.storageManager.get(`/email/dkim/${config.domain}/public.key`);
|
||||
|
||||
if (dkimPublicKey) {
|
||||
const publicKeyBase64 = dkimPublicKey
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
||||
.replace(/-----END PUBLIC KEY-----/g, '')
|
||||
.replace(/\s/g, '');
|
||||
|
||||
result.requiredChanges.push(
|
||||
`Add TXT record: ${selector}._domainkey.${config.domain} -> "v=DKIM1; k=rsa; p=${publicKeyBase64}"`
|
||||
);
|
||||
} else {
|
||||
result.warnings.push(
|
||||
`DKIM public key not found for ${config.domain}. It will be generated on first use.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check DMARC record
|
||||
if (requiredRecords.includes('DMARC') && !records.dmarc) {
|
||||
result.requiredChanges.push(
|
||||
`Add TXT record: _dmarc.${this.getBaseDomain(config.domain)} -> "v=DMARC1; p=none; rua=mailto:dmarc@${config.domain}"`
|
||||
);
|
||||
}
|
||||
|
||||
// Show setup instructions if needed
|
||||
if (result.requiredChanges.length > 0) {
|
||||
console.log(
|
||||
`📋 DNS Configuration Required for ${config.domain}:\n` +
|
||||
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
||||
result.requiredChanges.map((change, i) => `${i + 1}. ${change}`).join('\n') +
|
||||
'\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
|
||||
);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push(`DNS validation failed: ${error.message}`);
|
||||
result.valid = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check DNS records for a domain
|
||||
*/
|
||||
private async checkDnsRecords(config: IEmailDomainConfig): Promise<IDnsRecords> {
|
||||
const records: IDnsRecords = {};
|
||||
const baseDomain = this.getBaseDomain(config.domain);
|
||||
const selector = config.dkim?.selector || 'default';
|
||||
|
||||
// Use custom DNS servers if specified
|
||||
const resolver = new plugins.dns.promises.Resolver();
|
||||
if (config.dns?.external?.servers?.length) {
|
||||
resolver.setServers(config.dns.external.servers);
|
||||
}
|
||||
|
||||
// Check MX records
|
||||
try {
|
||||
const mxRecords = await resolver.resolveMx(baseDomain);
|
||||
records.mx = mxRecords.map(mx => mx.exchange);
|
||||
} catch (error) {
|
||||
logger.log('debug', `No MX records found for ${baseDomain}`);
|
||||
}
|
||||
|
||||
// Check SPF record
|
||||
try {
|
||||
const txtRecords = await resolver.resolveTxt(baseDomain);
|
||||
const spfRecord = txtRecords.find(records =>
|
||||
records.some(record => record.startsWith('v=spf1'))
|
||||
);
|
||||
if (spfRecord) {
|
||||
records.spf = spfRecord.join('');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('debug', `No SPF record found for ${baseDomain}`);
|
||||
}
|
||||
|
||||
// Check DKIM record
|
||||
try {
|
||||
const dkimRecords = await resolver.resolveTxt(`${selector}._domainkey.${config.domain}`);
|
||||
const dkimRecord = dkimRecords.find(records =>
|
||||
records.some(record => record.includes('v=DKIM1'))
|
||||
);
|
||||
if (dkimRecord) {
|
||||
records.dkim = dkimRecord.join('');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('debug', `No DKIM record found for ${selector}._domainkey.${config.domain}`);
|
||||
}
|
||||
|
||||
// Check DMARC record
|
||||
try {
|
||||
const dmarcRecords = await resolver.resolveTxt(`_dmarc.${baseDomain}`);
|
||||
const dmarcRecord = dmarcRecords.find(records =>
|
||||
records.some(record => record.startsWith('v=DMARC1'))
|
||||
);
|
||||
if (dmarcRecord) {
|
||||
records.dmarc = dmarcRecord.join('');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('debug', `No DMARC record found for _dmarc.${baseDomain}`);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve NS records for a domain
|
||||
*/
|
||||
private async resolveNs(domain: string): Promise<string[]> {
|
||||
try {
|
||||
const resolver = new plugins.dns.promises.Resolver();
|
||||
const nsRecords = await resolver.resolveNs(domain);
|
||||
return nsRecords;
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to resolve NS records for ${domain}: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base domain from email domain (e.g., mail.example.com -> example.com)
|
||||
*/
|
||||
private getBaseDomain(domain: string): string {
|
||||
const parts = domain.split('.');
|
||||
if (parts.length <= 2) {
|
||||
return domain;
|
||||
}
|
||||
|
||||
// For subdomains like mail.example.com, return example.com
|
||||
// But preserve domain structure for longer TLDs like .co.uk
|
||||
if (parts[parts.length - 2].length <= 3 && parts[parts.length - 1].length === 2) {
|
||||
// Likely a country code TLD like .co.uk
|
||||
return parts.slice(-3).join('.');
|
||||
}
|
||||
|
||||
return parts.slice(-2).join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all DNS records are created for configured domains
|
||||
* This is the main entry point for DNS record management
|
||||
*/
|
||||
async ensureDnsRecords(domainConfigs: IEmailDomainConfig[], dkimCreator?: any): Promise<void> {
|
||||
logger.log('info', `Ensuring DNS records for ${domainConfigs.length} domains`);
|
||||
|
||||
// First, validate all domains
|
||||
const validationResults = await this.validateAllDomains(domainConfigs);
|
||||
|
||||
// Then create records for internal-dns domains
|
||||
const internalDnsDomains = domainConfigs.filter(config => config.dnsMode === 'internal-dns');
|
||||
if (internalDnsDomains.length > 0) {
|
||||
await this.createInternalDnsRecords(internalDnsDomains);
|
||||
|
||||
// Create DKIM records if DKIMCreator is provided
|
||||
if (dkimCreator) {
|
||||
await this.createDkimRecords(domainConfigs, dkimCreator);
|
||||
}
|
||||
}
|
||||
|
||||
// Log validation results for external-dns domains
|
||||
for (const [domain, result] of validationResults) {
|
||||
const config = domainConfigs.find(c => c.domain === domain);
|
||||
if (config?.dnsMode === 'external-dns' && result.requiredChanges.length > 0) {
|
||||
logger.log('warn', `External DNS configuration required for ${domain}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create DNS records for internal-dns mode domains
|
||||
*/
|
||||
private async createInternalDnsRecords(domainConfigs: IEmailDomainConfig[]): Promise<void> {
|
||||
// Check if DNS server is available
|
||||
if (!this.dcRouter.dnsServer) {
|
||||
logger.log('warn', 'DNS server not available, skipping internal DNS record creation');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', `Creating DNS records for ${domainConfigs.length} internal-dns domains`);
|
||||
|
||||
for (const domainConfig of domainConfigs) {
|
||||
const domain = domainConfig.domain;
|
||||
const ttl = domainConfig.dns?.internal?.ttl || 3600;
|
||||
const mxPriority = domainConfig.dns?.internal?.mxPriority || 10;
|
||||
|
||||
try {
|
||||
// 1. Register MX record - points to the email domain itself
|
||||
this.dcRouter.dnsServer.registerHandler(
|
||||
domain,
|
||||
['MX'],
|
||||
() => ({
|
||||
name: domain,
|
||||
type: 'MX',
|
||||
class: 'IN',
|
||||
ttl: ttl,
|
||||
data: {
|
||||
priority: mxPriority,
|
||||
exchange: domain
|
||||
}
|
||||
})
|
||||
);
|
||||
logger.log('info', `MX record registered for ${domain} -> ${domain} (priority ${mxPriority})`);
|
||||
|
||||
// Store MX record in StorageManager
|
||||
await this.storageManager.set(
|
||||
`/email/dns/${domain}/mx`,
|
||||
JSON.stringify({
|
||||
type: 'MX',
|
||||
priority: mxPriority,
|
||||
exchange: domain,
|
||||
ttl: ttl
|
||||
})
|
||||
);
|
||||
|
||||
// 2. Register SPF record - allows the domain to send emails
|
||||
const spfRecord = `v=spf1 a mx ~all`;
|
||||
this.dcRouter.dnsServer.registerHandler(
|
||||
domain,
|
||||
['TXT'],
|
||||
() => ({
|
||||
name: domain,
|
||||
type: 'TXT',
|
||||
class: 'IN',
|
||||
ttl: ttl,
|
||||
data: spfRecord
|
||||
})
|
||||
);
|
||||
logger.log('info', `SPF record registered for ${domain}: "${spfRecord}"`);
|
||||
|
||||
// Store SPF record in StorageManager
|
||||
await this.storageManager.set(
|
||||
`/email/dns/${domain}/spf`,
|
||||
JSON.stringify({
|
||||
type: 'TXT',
|
||||
data: spfRecord,
|
||||
ttl: ttl
|
||||
})
|
||||
);
|
||||
|
||||
// 3. Register DMARC record - policy for handling email authentication
|
||||
const dmarcRecord = `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`;
|
||||
this.dcRouter.dnsServer.registerHandler(
|
||||
`_dmarc.${domain}`,
|
||||
['TXT'],
|
||||
() => ({
|
||||
name: `_dmarc.${domain}`,
|
||||
type: 'TXT',
|
||||
class: 'IN',
|
||||
ttl: ttl,
|
||||
data: dmarcRecord
|
||||
})
|
||||
);
|
||||
logger.log('info', `DMARC record registered for _dmarc.${domain}: "${dmarcRecord}"`);
|
||||
|
||||
// Store DMARC record in StorageManager
|
||||
await this.storageManager.set(
|
||||
`/email/dns/${domain}/dmarc`,
|
||||
JSON.stringify({
|
||||
type: 'TXT',
|
||||
name: `_dmarc.${domain}`,
|
||||
data: dmarcRecord,
|
||||
ttl: ttl
|
||||
})
|
||||
);
|
||||
|
||||
// Log summary of DNS records created
|
||||
logger.log('info', `✅ DNS records created for ${domain}:
|
||||
- MX: ${domain} (priority ${mxPriority})
|
||||
- SPF: ${spfRecord}
|
||||
- DMARC: ${dmarcRecord}
|
||||
- DKIM: Will be created when keys are generated`);
|
||||
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to create DNS records for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create DKIM DNS records for all domains
|
||||
*/
|
||||
private async createDkimRecords(domainConfigs: IEmailDomainConfig[], dkimCreator: any): Promise<void> {
|
||||
for (const domainConfig of domainConfigs) {
|
||||
const domain = domainConfig.domain;
|
||||
const selector = domainConfig.dkim?.selector || 'default';
|
||||
|
||||
try {
|
||||
// Get DKIM DNS record from DKIMCreator
|
||||
const dnsRecord = await dkimCreator.getDNSRecordForDomain(domain);
|
||||
|
||||
// For internal-dns domains, register the DNS handler
|
||||
if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) {
|
||||
const ttl = domainConfig.dns?.internal?.ttl || 3600;
|
||||
|
||||
this.dcRouter.dnsServer.registerHandler(
|
||||
`${selector}._domainkey.${domain}`,
|
||||
['TXT'],
|
||||
() => ({
|
||||
name: `${selector}._domainkey.${domain}`,
|
||||
type: 'TXT',
|
||||
class: 'IN',
|
||||
ttl: ttl,
|
||||
data: dnsRecord.value
|
||||
})
|
||||
);
|
||||
|
||||
logger.log('info', `DKIM DNS record registered for ${selector}._domainkey.${domain}`);
|
||||
|
||||
// Store DKIM record in StorageManager
|
||||
await this.storageManager.set(
|
||||
`/email/dns/${domain}/dkim`,
|
||||
JSON.stringify({
|
||||
type: 'TXT',
|
||||
name: `${selector}._domainkey.${domain}`,
|
||||
data: dnsRecord.value,
|
||||
ttl: ttl
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// For external-dns domains, just log what should be configured
|
||||
if (domainConfig.dnsMode === 'external-dns') {
|
||||
logger.log('info', `DKIM record for external DNS: ${dnsRecord.name} -> "${dnsRecord.value}"`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.log('warn', `Could not create DKIM DNS record for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
559
ts/mail/routing/classes.dnsmanager.ts
Normal file
559
ts/mail/routing/classes.dnsmanager.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import * as paths from '../../paths.ts';
|
||||
import { DKIMCreator } from '../security/classes.dkimcreator.ts';
|
||||
|
||||
/**
|
||||
* Interface for DNS record information
|
||||
*/
|
||||
export interface IDnsRecord {
|
||||
name: string;
|
||||
type: string;
|
||||
value: string;
|
||||
ttl?: number;
|
||||
dnsSecEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for DNS lookup options
|
||||
*/
|
||||
export interface IDnsLookupOptions {
|
||||
/** Cache time to live in milliseconds, 0 to disable caching */
|
||||
cacheTtl?: number;
|
||||
/** Timeout for DNS queries in milliseconds */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for DNS verification result
|
||||
*/
|
||||
export interface IDnsVerificationResult {
|
||||
record: string;
|
||||
found: boolean;
|
||||
valid: boolean;
|
||||
value?: string;
|
||||
expectedValue?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager for DNS-related operations, including record lookups, verification, and generation
|
||||
*/
|
||||
export class DNSManager {
|
||||
public dkimCreator: DKIMCreator;
|
||||
private cache: Map<string, { data: any; expires: number }> = new Map();
|
||||
private defaultOptions: IDnsLookupOptions = {
|
||||
cacheTtl: 300000, // 5 minutes
|
||||
timeout: 5000 // 5 seconds
|
||||
};
|
||||
|
||||
constructor(dkimCreatorArg: DKIMCreator, options?: IDnsLookupOptions) {
|
||||
this.dkimCreator = dkimCreatorArg;
|
||||
|
||||
if (options) {
|
||||
this.defaultOptions = {
|
||||
...this.defaultOptions,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure the DNS records directory exists
|
||||
plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup MX records for a domain
|
||||
* @param domain Domain to look up
|
||||
* @param options Lookup options
|
||||
* @returns Array of MX records sorted by priority
|
||||
*/
|
||||
public async lookupMx(domain: string, options?: IDnsLookupOptions): Promise<plugins.dns.MxRecord[]> {
|
||||
const lookupOptions = { ...this.defaultOptions, ...options };
|
||||
const cacheKey = `mx:${domain}`;
|
||||
|
||||
// Check cache first
|
||||
const cached = this.getFromCache<plugins.dns.MxRecord[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await this.dnsResolveMx(domain, lookupOptions.timeout);
|
||||
|
||||
// Sort by priority
|
||||
records.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
// Cache the result
|
||||
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
|
||||
|
||||
return records;
|
||||
} catch (error) {
|
||||
console.error(`Error looking up MX records for ${domain}:`, error);
|
||||
throw new Error(`Failed to lookup MX records for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup TXT records for a domain
|
||||
* @param domain Domain to look up
|
||||
* @param options Lookup options
|
||||
* @returns Array of TXT records
|
||||
*/
|
||||
public async lookupTxt(domain: string, options?: IDnsLookupOptions): Promise<string[][]> {
|
||||
const lookupOptions = { ...this.defaultOptions, ...options };
|
||||
const cacheKey = `txt:${domain}`;
|
||||
|
||||
// Check cache first
|
||||
const cached = this.getFromCache<string[][]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await this.dnsResolveTxt(domain, lookupOptions.timeout);
|
||||
|
||||
// Cache the result
|
||||
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
|
||||
|
||||
return records;
|
||||
} catch (error) {
|
||||
console.error(`Error looking up TXT records for ${domain}:`, error);
|
||||
throw new Error(`Failed to lookup TXT records for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find specific TXT record by subdomain and prefix
|
||||
* @param domain Base domain
|
||||
* @param subdomain Subdomain prefix (e.g., "dkim._domainkey")
|
||||
* @param prefix Record prefix to match (e.g., "v=DKIM1")
|
||||
* @param options Lookup options
|
||||
* @returns Matching TXT record or null if not found
|
||||
*/
|
||||
public async findTxtRecord(
|
||||
domain: string,
|
||||
subdomain: string = '',
|
||||
prefix: string = '',
|
||||
options?: IDnsLookupOptions
|
||||
): Promise<string | null> {
|
||||
const fullDomain = subdomain ? `${subdomain}.${domain}` : domain;
|
||||
|
||||
try {
|
||||
const records = await this.lookupTxt(fullDomain, options);
|
||||
|
||||
for (const recordArray of records) {
|
||||
// TXT records can be split into chunks, join them
|
||||
const record = recordArray.join('');
|
||||
|
||||
if (!prefix || record.startsWith(prefix)) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Domain might not exist or no TXT records
|
||||
console.log(`No matching TXT record found for ${fullDomain} with prefix ${prefix}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if a domain has a valid SPF record
|
||||
* @param domain Domain to verify
|
||||
* @returns Verification result
|
||||
*/
|
||||
public async verifySpfRecord(domain: string): Promise<IDnsVerificationResult> {
|
||||
const result: IDnsVerificationResult = {
|
||||
record: 'SPF',
|
||||
found: false,
|
||||
valid: false
|
||||
};
|
||||
|
||||
try {
|
||||
const spfRecord = await this.findTxtRecord(domain, '', 'v=spf1');
|
||||
|
||||
if (spfRecord) {
|
||||
result.found = true;
|
||||
result.value = spfRecord;
|
||||
|
||||
// Basic validation - check if it contains all, include, ip4, ip6, or mx mechanisms
|
||||
const isValid = /v=spf1\s+([-~?+]?(all|include:|ip4:|ip6:|mx|a|exists:))/.test(spfRecord);
|
||||
result.valid = isValid;
|
||||
|
||||
if (!isValid) {
|
||||
result.error = 'SPF record format is invalid';
|
||||
}
|
||||
} else {
|
||||
result.error = 'No SPF record found';
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = `Error verifying SPF: ${error.message}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if a domain has a valid DKIM record
|
||||
* @param domain Domain to verify
|
||||
* @param selector DKIM selector (usually "mta" in our case)
|
||||
* @returns Verification result
|
||||
*/
|
||||
public async verifyDkimRecord(domain: string, selector: string = 'mta'): Promise<IDnsVerificationResult> {
|
||||
const result: IDnsVerificationResult = {
|
||||
record: 'DKIM',
|
||||
found: false,
|
||||
valid: false
|
||||
};
|
||||
|
||||
try {
|
||||
const dkimSelector = `${selector}._domainkey`;
|
||||
const dkimRecord = await this.findTxtRecord(domain, dkimSelector, 'v=DKIM1');
|
||||
|
||||
if (dkimRecord) {
|
||||
result.found = true;
|
||||
result.value = dkimRecord;
|
||||
|
||||
// Basic validation - check for required fields
|
||||
const hasP = dkimRecord.includes('p=');
|
||||
result.valid = dkimRecord.includes('v=DKIM1') && hasP;
|
||||
|
||||
if (!result.valid) {
|
||||
result.error = 'DKIM record is missing required fields';
|
||||
} else if (dkimRecord.includes('p=') && !dkimRecord.match(/p=[a-zA-Z0-9+/]+/)) {
|
||||
result.valid = false;
|
||||
result.error = 'DKIM record has invalid public key format';
|
||||
}
|
||||
} else {
|
||||
result.error = `No DKIM record found for selector ${selector}`;
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = `Error verifying DKIM: ${error.message}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if a domain has a valid DMARC record
|
||||
* @param domain Domain to verify
|
||||
* @returns Verification result
|
||||
*/
|
||||
public async verifyDmarcRecord(domain: string): Promise<IDnsVerificationResult> {
|
||||
const result: IDnsVerificationResult = {
|
||||
record: 'DMARC',
|
||||
found: false,
|
||||
valid: false
|
||||
};
|
||||
|
||||
try {
|
||||
const dmarcDomain = `_dmarc.${domain}`;
|
||||
const dmarcRecord = await this.findTxtRecord(dmarcDomain, '', 'v=DMARC1');
|
||||
|
||||
if (dmarcRecord) {
|
||||
result.found = true;
|
||||
result.value = dmarcRecord;
|
||||
|
||||
// Basic validation - check for required fields
|
||||
const hasPolicy = dmarcRecord.includes('p=');
|
||||
result.valid = dmarcRecord.includes('v=DMARC1') && hasPolicy;
|
||||
|
||||
if (!result.valid) {
|
||||
result.error = 'DMARC record is missing required fields';
|
||||
}
|
||||
} else {
|
||||
result.error = 'No DMARC record found';
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = `Error verifying DMARC: ${error.message}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all email authentication records (SPF, DKIM, DMARC) for a domain
|
||||
* @param domain Domain to check
|
||||
* @param dkimSelector DKIM selector
|
||||
* @returns Object with verification results for each record type
|
||||
*/
|
||||
public async verifyEmailAuthRecords(domain: string, dkimSelector: string = 'mta'): Promise<{
|
||||
spf: IDnsVerificationResult;
|
||||
dkim: IDnsVerificationResult;
|
||||
dmarc: IDnsVerificationResult;
|
||||
}> {
|
||||
const [spf, dkim, dmarc] = await Promise.all([
|
||||
this.verifySpfRecord(domain),
|
||||
this.verifyDkimRecord(domain, dkimSelector),
|
||||
this.verifyDmarcRecord(domain)
|
||||
]);
|
||||
|
||||
return { spf, dkim, dmarc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a recommended SPF record for a domain
|
||||
* @param domain Domain name
|
||||
* @param options Configuration options for the SPF record
|
||||
* @returns Generated SPF record
|
||||
*/
|
||||
public generateSpfRecord(domain: string, options: {
|
||||
includeMx?: boolean;
|
||||
includeA?: boolean;
|
||||
includeIps?: string[];
|
||||
includeSpf?: string[];
|
||||
policy?: 'none' | 'neutral' | 'softfail' | 'fail' | 'reject';
|
||||
} = {}): IDnsRecord {
|
||||
const {
|
||||
includeMx = true,
|
||||
includeA = true,
|
||||
includeIps = [],
|
||||
includeSpf = [],
|
||||
policy = 'softfail'
|
||||
} = options;
|
||||
|
||||
let value = 'v=spf1';
|
||||
|
||||
if (includeMx) {
|
||||
value += ' mx';
|
||||
}
|
||||
|
||||
if (includeA) {
|
||||
value += ' a';
|
||||
}
|
||||
|
||||
// Add IP addresses
|
||||
for (const ip of includeIps) {
|
||||
if (ip.includes(':')) {
|
||||
value += ` ip6:${ip}`;
|
||||
} else {
|
||||
value += ` ip4:${ip}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add includes
|
||||
for (const include of includeSpf) {
|
||||
value += ` include:${include}`;
|
||||
}
|
||||
|
||||
// Add policy
|
||||
const policyMap = {
|
||||
'none': '?all',
|
||||
'neutral': '~all',
|
||||
'softfail': '~all',
|
||||
'fail': '-all',
|
||||
'reject': '-all'
|
||||
};
|
||||
|
||||
value += ` ${policyMap[policy]}`;
|
||||
|
||||
return {
|
||||
name: domain,
|
||||
type: 'TXT',
|
||||
value: value
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a recommended DMARC record for a domain
|
||||
* @param domain Domain name
|
||||
* @param options Configuration options for the DMARC record
|
||||
* @returns Generated DMARC record
|
||||
*/
|
||||
public generateDmarcRecord(domain: string, options: {
|
||||
policy?: 'none' | 'quarantine' | 'reject';
|
||||
subdomainPolicy?: 'none' | 'quarantine' | 'reject';
|
||||
pct?: number;
|
||||
rua?: string;
|
||||
ruf?: string;
|
||||
daysInterval?: number;
|
||||
} = {}): IDnsRecord {
|
||||
const {
|
||||
policy = 'none',
|
||||
subdomainPolicy,
|
||||
pct = 100,
|
||||
rua,
|
||||
ruf,
|
||||
daysInterval = 1
|
||||
} = options;
|
||||
|
||||
let value = 'v=DMARC1; p=' + policy;
|
||||
|
||||
if (subdomainPolicy) {
|
||||
value += `; sp=${subdomainPolicy}`;
|
||||
}
|
||||
|
||||
if (pct !== 100) {
|
||||
value += `; pct=${pct}`;
|
||||
}
|
||||
|
||||
if (rua) {
|
||||
value += `; rua=mailto:${rua}`;
|
||||
}
|
||||
|
||||
if (ruf) {
|
||||
value += `; ruf=mailto:${ruf}`;
|
||||
}
|
||||
|
||||
if (daysInterval !== 1) {
|
||||
value += `; ri=${daysInterval * 86400}`;
|
||||
}
|
||||
|
||||
// Add reporting format and ADKIM/ASPF alignment
|
||||
value += '; fo=1; adkim=r; aspf=r';
|
||||
|
||||
return {
|
||||
name: `_dmarc.${domain}`,
|
||||
type: 'TXT',
|
||||
value: value
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save DNS record recommendations to a file
|
||||
* @param domain Domain name
|
||||
* @param records DNS records to save
|
||||
*/
|
||||
public async saveDnsRecommendations(domain: string, records: IDnsRecord[]): Promise<void> {
|
||||
try {
|
||||
const filePath = plugins.path.join(paths.dnsRecordsDir, `${domain}.recommendations.tson`);
|
||||
plugins.smartfile.memory.toFsSync(JSON.stringify(records, null, 2), filePath);
|
||||
console.log(`DNS recommendations for ${domain} saved to ${filePath}`);
|
||||
} catch (error) {
|
||||
console.error(`Error saving DNS recommendations for ${domain}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache key value
|
||||
* @param key Cache key
|
||||
* @returns Cached value or undefined if not found or expired
|
||||
*/
|
||||
private getFromCache<T>(key: string): T | undefined {
|
||||
const cached = this.cache.get(key);
|
||||
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return cached.data as T;
|
||||
}
|
||||
|
||||
// Remove expired entry
|
||||
if (cached) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache key value
|
||||
* @param key Cache key
|
||||
* @param data Data to cache
|
||||
* @param ttl TTL in milliseconds
|
||||
*/
|
||||
private setInCache<T>(key: string, data: T, ttl: number = this.defaultOptions.cacheTtl): void {
|
||||
if (ttl <= 0) return; // Don't cache if TTL is disabled
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
expires: Date.now() + ttl
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the DNS cache
|
||||
* @param key Optional specific key to clear, or all cache if not provided
|
||||
*/
|
||||
public clearCache(key?: string): void {
|
||||
if (key) {
|
||||
this.cache.delete(key);
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise-based wrapper for dns.resolveMx
|
||||
* @param domain Domain to resolve
|
||||
* @param timeout Timeout in milliseconds
|
||||
* @returns Promise resolving to MX records
|
||||
*/
|
||||
private dnsResolveMx(domain: string, timeout: number = 5000): Promise<plugins.dns.MxRecord[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(`DNS MX lookup timeout for ${domain}`));
|
||||
}, timeout);
|
||||
|
||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(addresses);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise-based wrapper for dns.resolveTxt
|
||||
* @param domain Domain to resolve
|
||||
* @param timeout Timeout in milliseconds
|
||||
* @returns Promise resolving to TXT records
|
||||
*/
|
||||
private dnsResolveTxt(domain: string, timeout: number = 5000): Promise<string[][]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(`DNS TXT lookup timeout for ${domain}`));
|
||||
}, timeout);
|
||||
|
||||
plugins.dns.resolveTxt(domain, (err, records) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(records);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all recommended DNS records for proper email authentication
|
||||
* @param domain Domain to generate records for
|
||||
* @returns Array of recommended DNS records
|
||||
*/
|
||||
public async generateAllRecommendedRecords(domain: string): Promise<IDnsRecord[]> {
|
||||
const records: IDnsRecord[] = [];
|
||||
|
||||
// Get DKIM record (already created by DKIMCreator)
|
||||
try {
|
||||
// Call the DKIM creator directly
|
||||
const dkimRecord = await this.dkimCreator.getDNSRecordForDomain(domain);
|
||||
records.push(dkimRecord);
|
||||
} catch (error) {
|
||||
console.error(`Error getting DKIM record for ${domain}:`, error);
|
||||
}
|
||||
|
||||
// Generate SPF record
|
||||
const spfRecord = this.generateSpfRecord(domain, {
|
||||
includeMx: true,
|
||||
includeA: true,
|
||||
policy: 'softfail'
|
||||
});
|
||||
records.push(spfRecord);
|
||||
|
||||
// Generate DMARC record
|
||||
const dmarcRecord = this.generateDmarcRecord(domain, {
|
||||
policy: 'none', // Start with monitoring mode
|
||||
rua: `dmarc@${domain}` // Replace with appropriate report address
|
||||
});
|
||||
records.push(dmarcRecord);
|
||||
|
||||
// Save recommendations
|
||||
await this.saveDnsRecommendations(domain, records);
|
||||
|
||||
return records;
|
||||
}
|
||||
}
|
||||
139
ts/mail/routing/classes.domain.registry.ts
Normal file
139
ts/mail/routing/classes.domain.registry.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { IEmailDomainConfig } from './interfaces.ts';
|
||||
import { logger } from '../../logger.ts';
|
||||
|
||||
/**
|
||||
* Registry for email domain configurations
|
||||
* Provides fast lookups and validation for domains
|
||||
*/
|
||||
export class DomainRegistry {
|
||||
private domains: Map<string, IEmailDomainConfig> = new Map();
|
||||
private defaults: IEmailDomainConfig['dkim'] & {
|
||||
dnsMode?: 'forward' | 'internal-dns' | 'external-dns';
|
||||
rateLimits?: IEmailDomainConfig['rateLimits'];
|
||||
};
|
||||
|
||||
constructor(
|
||||
domainConfigs: IEmailDomainConfig[],
|
||||
defaults?: {
|
||||
dnsMode?: 'forward' | 'internal-dns' | 'external-dns';
|
||||
dkim?: IEmailDomainConfig['dkim'];
|
||||
rateLimits?: IEmailDomainConfig['rateLimits'];
|
||||
}
|
||||
) {
|
||||
// Set defaults
|
||||
this.defaults = {
|
||||
dnsMode: defaults?.dnsMode || 'external-dns',
|
||||
...this.getDefaultDkimConfig(),
|
||||
...defaults?.dkim,
|
||||
rateLimits: defaults?.rateLimits
|
||||
};
|
||||
|
||||
// Process and store domain configurations
|
||||
for (const config of domainConfigs) {
|
||||
const processedConfig = this.applyDefaults(config);
|
||||
this.domains.set(config.domain.toLowerCase(), processedConfig);
|
||||
logger.log('info', `Registered domain: ${config.domain} with DNS mode: ${processedConfig.dnsMode}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default DKIM configuration
|
||||
*/
|
||||
private getDefaultDkimConfig(): IEmailDomainConfig['dkim'] {
|
||||
return {
|
||||
selector: 'default',
|
||||
keySize: 2048,
|
||||
rotateKeys: false,
|
||||
rotationInterval: 90
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply defaults to a domain configuration
|
||||
*/
|
||||
private applyDefaults(config: IEmailDomainConfig): IEmailDomainConfig {
|
||||
return {
|
||||
...config,
|
||||
dnsMode: config.dnsMode || this.defaults.dnsMode!,
|
||||
dkim: {
|
||||
...this.getDefaultDkimConfig(),
|
||||
...this.defaults,
|
||||
...config.dkim
|
||||
},
|
||||
rateLimits: {
|
||||
...this.defaults.rateLimits,
|
||||
...config.rateLimits,
|
||||
outbound: {
|
||||
...this.defaults.rateLimits?.outbound,
|
||||
...config.rateLimits?.outbound
|
||||
},
|
||||
inbound: {
|
||||
...this.defaults.rateLimits?.inbound,
|
||||
...config.rateLimits?.inbound
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is registered
|
||||
*/
|
||||
isDomainRegistered(domain: string): boolean {
|
||||
return this.domains.has(domain.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an email address belongs to a registered domain
|
||||
*/
|
||||
isEmailRegistered(email: string): boolean {
|
||||
const domain = this.extractDomain(email);
|
||||
if (!domain) return false;
|
||||
return this.isDomainRegistered(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domain configuration
|
||||
*/
|
||||
getDomainConfig(domain: string): IEmailDomainConfig | undefined {
|
||||
return this.domains.get(domain.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domain configuration for an email address
|
||||
*/
|
||||
getEmailDomainConfig(email: string): IEmailDomainConfig | undefined {
|
||||
const domain = this.extractDomain(email);
|
||||
if (!domain) return undefined;
|
||||
return this.getDomainConfig(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from email address
|
||||
*/
|
||||
private extractDomain(email: string): string | null {
|
||||
const parts = email.toLowerCase().split('@');
|
||||
if (parts.length !== 2) return null;
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered domains
|
||||
*/
|
||||
getAllDomains(): string[] {
|
||||
return Array.from(this.domains.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all domain configurations
|
||||
*/
|
||||
getAllConfigs(): IEmailDomainConfig[] {
|
||||
return Array.from(this.domains.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domains by DNS mode
|
||||
*/
|
||||
getDomainsByMode(mode: 'forward' | 'internal-dns' | 'external-dns'): IEmailDomainConfig[] {
|
||||
return Array.from(this.domains.values()).filter(config => config.dnsMode === mode);
|
||||
}
|
||||
}
|
||||
82
ts/mail/routing/classes.email.config.ts
Normal file
82
ts/mail/routing/classes.email.config.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { EmailProcessingMode } from '../delivery/interfaces.ts';
|
||||
|
||||
// Re-export EmailProcessingMode type
|
||||
export type { EmailProcessingMode };
|
||||
|
||||
|
||||
/**
|
||||
* Domain rule interface for pattern-based routing
|
||||
*/
|
||||
export interface IDomainRule {
|
||||
// Domain pattern (e.g., "*@example.com", "*@*.example.net")
|
||||
pattern: string;
|
||||
|
||||
// Handling mode for this pattern
|
||||
mode: EmailProcessingMode;
|
||||
|
||||
// Forward mode configuration
|
||||
target?: {
|
||||
server: string;
|
||||
port?: number;
|
||||
useTls?: boolean;
|
||||
authentication?: {
|
||||
user?: string;
|
||||
pass?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// MTA mode configuration
|
||||
mtaOptions?: IMtaOptions;
|
||||
|
||||
// Process mode configuration
|
||||
contentScanning?: boolean;
|
||||
scanners?: IContentScanner[];
|
||||
transformations?: ITransformation[];
|
||||
|
||||
// Rate limits for this domain
|
||||
rateLimits?: {
|
||||
maxMessagesPerMinute?: number;
|
||||
maxRecipientsPerMessage?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MTA options interface
|
||||
*/
|
||||
export interface IMtaOptions {
|
||||
domain?: string;
|
||||
allowLocalDelivery?: boolean;
|
||||
localDeliveryPath?: string;
|
||||
dkimSign?: boolean;
|
||||
dkimOptions?: {
|
||||
domainName: string;
|
||||
keySelector: string;
|
||||
privateKey?: string;
|
||||
};
|
||||
smtpBanner?: string;
|
||||
maxConnections?: number;
|
||||
connTimeout?: number;
|
||||
spoolDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content scanner interface
|
||||
*/
|
||||
export interface IContentScanner {
|
||||
type: 'spam' | 'virus' | 'attachment';
|
||||
threshold?: number;
|
||||
action: 'tag' | 'reject';
|
||||
blockedExtensions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformation interface
|
||||
*/
|
||||
export interface ITransformation {
|
||||
type: string;
|
||||
header?: string;
|
||||
value?: string;
|
||||
domains?: string[];
|
||||
append?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
575
ts/mail/routing/classes.email.router.ts
Normal file
575
ts/mail/routing/classes.email.router.ts
Normal file
@@ -0,0 +1,575 @@
|
||||
import * as plugins from '../../plugins.ts';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { IEmailRoute, IEmailMatch, IEmailAction, IEmailContext } from './interfaces.ts';
|
||||
import type { Email } from '../core/classes.email.ts';
|
||||
|
||||
/**
|
||||
* Email router that evaluates routes and determines actions
|
||||
*/
|
||||
export class EmailRouter extends EventEmitter {
|
||||
private routes: IEmailRoute[];
|
||||
private patternCache: Map<string, boolean> = new Map();
|
||||
private storageManager?: any; // StorageManager instance
|
||||
private persistChanges: boolean;
|
||||
|
||||
/**
|
||||
* Create a new email router
|
||||
* @param routes Array of email routes
|
||||
* @param options Router options
|
||||
*/
|
||||
constructor(routes: IEmailRoute[], options?: {
|
||||
storageManager?: any;
|
||||
persistChanges?: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.routes = this.sortRoutesByPriority(routes);
|
||||
this.storageManager = options?.storageManager;
|
||||
this.persistChanges = options?.persistChanges ?? !!this.storageManager;
|
||||
|
||||
// If storage manager is provided, try to load persisted routes
|
||||
if (this.storageManager) {
|
||||
this.loadRoutes({ merge: true }).catch(error => {
|
||||
console.error(`Failed to load persisted routes: ${error.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort routes by priority (higher priority first)
|
||||
* @param routes Routes to sort
|
||||
* @returns Sorted routes
|
||||
*/
|
||||
private sortRoutesByPriority(routes: IEmailRoute[]): IEmailRoute[] {
|
||||
return [...routes].sort((a, b) => {
|
||||
const priorityA = a.priority ?? 0;
|
||||
const priorityB = b.priority ?? 0;
|
||||
return priorityB - priorityA; // Higher priority first
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configured routes
|
||||
* @returns Array of routes
|
||||
*/
|
||||
public getRoutes(): IEmailRoute[] {
|
||||
return [...this.routes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update routes
|
||||
* @param routes New routes
|
||||
* @param persist Whether to persist changes (defaults to persistChanges setting)
|
||||
*/
|
||||
public async updateRoutes(routes: IEmailRoute[], persist?: boolean): Promise<void> {
|
||||
this.routes = this.sortRoutesByPriority(routes);
|
||||
this.clearCache();
|
||||
this.emit('routesUpdated', this.routes);
|
||||
|
||||
// Persist if requested or if persistChanges is enabled
|
||||
if (persist ?? this.persistChanges) {
|
||||
await this.saveRoutes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set routes (alias for updateRoutes)
|
||||
* @param routes New routes
|
||||
* @param persist Whether to persist changes
|
||||
*/
|
||||
public async setRoutes(routes: IEmailRoute[], persist?: boolean): Promise<void> {
|
||||
await this.updateRoutes(routes, persist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the pattern cache
|
||||
*/
|
||||
public clearCache(): void {
|
||||
this.patternCache.clear();
|
||||
this.emit('cacheCleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate routes and find the first match
|
||||
* @param context Email context
|
||||
* @returns Matched route or null
|
||||
*/
|
||||
public async evaluateRoutes(context: IEmailContext): Promise<IEmailRoute | null> {
|
||||
for (const route of this.routes) {
|
||||
if (await this.matchesRoute(route, context)) {
|
||||
this.emit('routeMatched', route, context);
|
||||
return route;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route matches the context
|
||||
* @param route Route to check
|
||||
* @param context Email context
|
||||
* @returns True if route matches
|
||||
*/
|
||||
private async matchesRoute(route: IEmailRoute, context: IEmailContext): Promise<boolean> {
|
||||
const match = route.match;
|
||||
|
||||
// Check recipients
|
||||
if (match.recipients && !this.matchesRecipients(context.email, match.recipients)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check senders
|
||||
if (match.senders && !this.matchesSenders(context.email, match.senders)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check client IP
|
||||
if (match.clientIp && !this.matchesClientIp(context, match.clientIp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
if (match.authenticated !== undefined &&
|
||||
context.session.authenticated !== match.authenticated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check headers
|
||||
if (match.headers && !this.matchesHeaders(context.email, match.headers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check size
|
||||
if (match.sizeRange && !this.matchesSize(context.email, match.sizeRange)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check subject
|
||||
if (match.subject && !this.matchesSubject(context.email, match.subject)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check attachments
|
||||
if (match.hasAttachments !== undefined &&
|
||||
(context.email.attachments.length > 0) !== match.hasAttachments) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email recipients match patterns
|
||||
* @param email Email to check
|
||||
* @param patterns Patterns to match
|
||||
* @returns True if any recipient matches
|
||||
*/
|
||||
private matchesRecipients(email: Email, patterns: string | string[]): boolean {
|
||||
const patternArray = Array.isArray(patterns) ? patterns : [patterns];
|
||||
const recipients = email.getAllRecipients();
|
||||
|
||||
for (const recipient of recipients) {
|
||||
for (const pattern of patternArray) {
|
||||
if (this.matchesPattern(recipient, pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email sender matches patterns
|
||||
* @param email Email to check
|
||||
* @param patterns Patterns to match
|
||||
* @returns True if sender matches
|
||||
*/
|
||||
private matchesSenders(email: Email, patterns: string | string[]): boolean {
|
||||
const patternArray = Array.isArray(patterns) ? patterns : [patterns];
|
||||
const sender = email.from;
|
||||
|
||||
for (const pattern of patternArray) {
|
||||
if (this.matchesPattern(sender, pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client IP matches patterns
|
||||
* @param context Email context
|
||||
* @param patterns IP patterns to match
|
||||
* @returns True if IP matches
|
||||
*/
|
||||
private matchesClientIp(context: IEmailContext, patterns: string | string[]): boolean {
|
||||
const patternArray = Array.isArray(patterns) ? patterns : [patterns];
|
||||
const clientIp = context.session.remoteAddress;
|
||||
|
||||
if (!clientIp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const pattern of patternArray) {
|
||||
// Check for CIDR notation
|
||||
if (pattern.includes('/')) {
|
||||
if (this.ipInCidr(clientIp, pattern)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Exact match
|
||||
if (clientIp === pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email headers match patterns
|
||||
* @param email Email to check
|
||||
* @param headerPatterns Header patterns to match
|
||||
* @returns True if headers match
|
||||
*/
|
||||
private matchesHeaders(email: Email, headerPatterns: Record<string, string | RegExp>): boolean {
|
||||
for (const [header, pattern] of Object.entries(headerPatterns)) {
|
||||
const value = email.headers[header];
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pattern instanceof RegExp) {
|
||||
if (!pattern.test(value)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (value !== pattern) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email size matches range
|
||||
* @param email Email to check
|
||||
* @param sizeRange Size range to match
|
||||
* @returns True if size is in range
|
||||
*/
|
||||
private matchesSize(email: Email, sizeRange: { min?: number; max?: number }): boolean {
|
||||
// Calculate approximate email size
|
||||
const size = this.calculateEmailSize(email);
|
||||
|
||||
if (sizeRange.min !== undefined && size < sizeRange.min) {
|
||||
return false;
|
||||
}
|
||||
if (sizeRange.max !== undefined && size > sizeRange.max) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email subject matches pattern
|
||||
* @param email Email to check
|
||||
* @param pattern Pattern to match
|
||||
* @returns True if subject matches
|
||||
*/
|
||||
private matchesSubject(email: Email, pattern: string | RegExp): boolean {
|
||||
const subject = email.subject || '';
|
||||
|
||||
if (pattern instanceof RegExp) {
|
||||
return pattern.test(subject);
|
||||
} else {
|
||||
return this.matchesPattern(subject, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string matches a glob pattern
|
||||
* @param str String to check
|
||||
* @param pattern Glob pattern
|
||||
* @returns True if matches
|
||||
*/
|
||||
private matchesPattern(str: string, pattern: string): boolean {
|
||||
// Check cache
|
||||
const cacheKey = `${str}:${pattern}`;
|
||||
const cached = this.patternCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Convert glob to regex
|
||||
const regexPattern = this.globToRegExp(pattern);
|
||||
const matches = regexPattern.test(str);
|
||||
|
||||
// Cache result
|
||||
this.patternCache.set(cacheKey, matches);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert glob pattern to RegExp
|
||||
* @param pattern Glob pattern
|
||||
* @returns Regular expression
|
||||
*/
|
||||
private globToRegExp(pattern: string): RegExp {
|
||||
// Escape special regex characters except * and ?
|
||||
let regexString = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.');
|
||||
|
||||
return new RegExp(`^${regexString}$`, 'i');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IP is in CIDR range
|
||||
* @param ip IP address to check
|
||||
* @param cidr CIDR notation (e.g., '192.168.0.0/16')
|
||||
* @returns True if IP is in range
|
||||
*/
|
||||
private ipInCidr(ip: string, cidr: string): boolean {
|
||||
try {
|
||||
const [range, bits] = cidr.split('/');
|
||||
const mask = parseInt(bits, 10);
|
||||
|
||||
// Convert IPs to numbers
|
||||
const ipNum = this.ipToNumber(ip);
|
||||
const rangeNum = this.ipToNumber(range);
|
||||
|
||||
// Calculate mask
|
||||
const maskBits = 0xffffffff << (32 - mask);
|
||||
|
||||
// Check if in range
|
||||
return (ipNum & maskBits) === (rangeNum & maskBits);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert IP address to number
|
||||
* @param ip IP address
|
||||
* @returns Number representation
|
||||
*/
|
||||
private ipToNumber(ip: string): number {
|
||||
const parts = ip.split('.');
|
||||
return parts.reduce((acc, part, index) => {
|
||||
return acc + (parseInt(part, 10) << (8 * (3 - index)));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate approximate email size in bytes
|
||||
* @param email Email to measure
|
||||
* @returns Size in bytes
|
||||
*/
|
||||
private calculateEmailSize(email: Email): number {
|
||||
let size = 0;
|
||||
|
||||
// Headers
|
||||
for (const [key, value] of Object.entries(email.headers)) {
|
||||
size += key.length + value.length + 4; // ": " + "\r\n"
|
||||
}
|
||||
|
||||
// Body
|
||||
size += (email.text || '').length;
|
||||
size += (email.html || '').length;
|
||||
|
||||
// Attachments
|
||||
for (const attachment of email.attachments) {
|
||||
if (attachment.content) {
|
||||
size += attachment.content.length;
|
||||
}
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current routes to storage
|
||||
*/
|
||||
public async saveRoutes(): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
this.emit('persistenceWarning', 'Cannot save routes: StorageManager not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate all routes before saving
|
||||
for (const route of this.routes) {
|
||||
if (!route.name || !route.match || !route.action) {
|
||||
throw new Error(`Invalid route: ${JSON.stringify(route)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const routesData = JSON.stringify(this.routes, null, 2);
|
||||
await this.storageManager.set('/email/routes/config.tson', routesData);
|
||||
|
||||
this.emit('routesPersisted', this.routes.length);
|
||||
} catch (error) {
|
||||
console.error(`Failed to save routes: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load routes from storage
|
||||
* @param options Load options
|
||||
*/
|
||||
public async loadRoutes(options?: {
|
||||
merge?: boolean; // Merge with existing routes
|
||||
replace?: boolean; // Replace existing routes
|
||||
}): Promise<IEmailRoute[]> {
|
||||
if (!this.storageManager) {
|
||||
this.emit('persistenceWarning', 'Cannot load routes: StorageManager not configured');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const routesData = await this.storageManager.get('/email/routes/config.tson');
|
||||
|
||||
if (!routesData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const loadedRoutes = JSON.parse(routesData) as IEmailRoute[];
|
||||
|
||||
// Validate loaded routes
|
||||
for (const route of loadedRoutes) {
|
||||
if (!route.name || !route.match || !route.action) {
|
||||
console.warn(`Skipping invalid route: ${JSON.stringify(route)}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.replace) {
|
||||
// Replace all routes
|
||||
this.routes = this.sortRoutesByPriority(loadedRoutes);
|
||||
} else if (options?.merge) {
|
||||
// Merge with existing routes (loaded routes take precedence)
|
||||
const routeMap = new Map<string, IEmailRoute>();
|
||||
|
||||
// Add existing routes
|
||||
for (const route of this.routes) {
|
||||
routeMap.set(route.name, route);
|
||||
}
|
||||
|
||||
// Override with loaded routes
|
||||
for (const route of loadedRoutes) {
|
||||
routeMap.set(route.name, route);
|
||||
}
|
||||
|
||||
this.routes = this.sortRoutesByPriority(Array.from(routeMap.values()));
|
||||
}
|
||||
|
||||
this.clearCache();
|
||||
this.emit('routesLoaded', loadedRoutes.length);
|
||||
|
||||
return loadedRoutes;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load routes: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a route
|
||||
* @param route Route to add
|
||||
* @param persist Whether to persist changes
|
||||
*/
|
||||
public async addRoute(route: IEmailRoute, persist?: boolean): Promise<void> {
|
||||
// Validate route
|
||||
if (!route.name || !route.match || !route.action) {
|
||||
throw new Error('Invalid route: missing required fields');
|
||||
}
|
||||
|
||||
// Check if route already exists
|
||||
const existingIndex = this.routes.findIndex(r => r.name === route.name);
|
||||
if (existingIndex >= 0) {
|
||||
throw new Error(`Route '${route.name}' already exists`);
|
||||
}
|
||||
|
||||
// Add route
|
||||
this.routes.push(route);
|
||||
this.routes = this.sortRoutesByPriority(this.routes);
|
||||
this.clearCache();
|
||||
|
||||
this.emit('routeAdded', route);
|
||||
this.emit('routesUpdated', this.routes);
|
||||
|
||||
// Persist if requested
|
||||
if (persist ?? this.persistChanges) {
|
||||
await this.saveRoutes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a route by name
|
||||
* @param name Route name
|
||||
* @param persist Whether to persist changes
|
||||
*/
|
||||
public async removeRoute(name: string, persist?: boolean): Promise<void> {
|
||||
const index = this.routes.findIndex(r => r.name === name);
|
||||
|
||||
if (index < 0) {
|
||||
throw new Error(`Route '${name}' not found`);
|
||||
}
|
||||
|
||||
const removedRoute = this.routes.splice(index, 1)[0];
|
||||
this.clearCache();
|
||||
|
||||
this.emit('routeRemoved', removedRoute);
|
||||
this.emit('routesUpdated', this.routes);
|
||||
|
||||
// Persist if requested
|
||||
if (persist ?? this.persistChanges) {
|
||||
await this.saveRoutes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a route
|
||||
* @param name Route name
|
||||
* @param route Updated route data
|
||||
* @param persist Whether to persist changes
|
||||
*/
|
||||
public async updateRoute(name: string, route: IEmailRoute, persist?: boolean): Promise<void> {
|
||||
// Validate route
|
||||
if (!route.name || !route.match || !route.action) {
|
||||
throw new Error('Invalid route: missing required fields');
|
||||
}
|
||||
|
||||
const index = this.routes.findIndex(r => r.name === name);
|
||||
|
||||
if (index < 0) {
|
||||
throw new Error(`Route '${name}' not found`);
|
||||
}
|
||||
|
||||
// Update route
|
||||
this.routes[index] = route;
|
||||
this.routes = this.sortRoutesByPriority(this.routes);
|
||||
this.clearCache();
|
||||
|
||||
this.emit('routeUpdated', route);
|
||||
this.emit('routesUpdated', this.routes);
|
||||
|
||||
// Persist if requested
|
||||
if (persist ?? this.persistChanges) {
|
||||
await this.saveRoutes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a route by name
|
||||
* @param name Route name
|
||||
* @returns Route or undefined
|
||||
*/
|
||||
public getRoute(name: string): IEmailRoute | undefined {
|
||||
return this.routes.find(r => r.name === name);
|
||||
}
|
||||
}
|
||||
1873
ts/mail/routing/classes.unified.email.server.ts
Normal file
1873
ts/mail/routing/classes.unified.email.server.ts
Normal file
File diff suppressed because it is too large
Load Diff
6
ts/mail/routing/index.ts
Normal file
6
ts/mail/routing/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Email routing components
|
||||
export * from './classes.email.router.ts';
|
||||
export * from './classes.unified.email.server.ts';
|
||||
export * from './classes.dns.manager.ts';
|
||||
export * from './interfaces.ts';
|
||||
export * from './classes.domain.registry.ts';
|
||||
202
ts/mail/routing/interfaces.ts
Normal file
202
ts/mail/routing/interfaces.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { Email } from '../core/classes.email.ts';
|
||||
import type { IExtendedSmtpSession } from './classes.unified.email.server.ts';
|
||||
|
||||
/**
|
||||
* Route configuration for email routing
|
||||
*/
|
||||
export interface IEmailRoute {
|
||||
/** Route identifier */
|
||||
name: string;
|
||||
/** Order of evaluation (higher priority evaluated first, default: 0) */
|
||||
priority?: number;
|
||||
/** Conditions to match */
|
||||
match: IEmailMatch;
|
||||
/** Action to take when matched */
|
||||
action: IEmailAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match criteria for email routing
|
||||
*/
|
||||
export interface IEmailMatch {
|
||||
/** Email patterns to match recipients: "*@example.com", "admin@*" */
|
||||
recipients?: string | string[];
|
||||
/** Email patterns to match senders */
|
||||
senders?: string | string[];
|
||||
/** IP addresses or CIDR ranges to match */
|
||||
clientIp?: string | string[];
|
||||
/** Require authentication status */
|
||||
authenticated?: boolean;
|
||||
|
||||
// Optional advanced matching
|
||||
/** Headers to match */
|
||||
headers?: Record<string, string | RegExp>;
|
||||
/** Message size range */
|
||||
sizeRange?: { min?: number; max?: number };
|
||||
/** Subject line patterns */
|
||||
subject?: string | RegExp;
|
||||
/** Has attachments */
|
||||
hasAttachments?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to take when route matches
|
||||
*/
|
||||
export interface IEmailAction {
|
||||
/** Type of action to perform */
|
||||
type: 'forward' | 'deliver' | 'reject' | 'process';
|
||||
|
||||
/** Forward action configuration */
|
||||
forward?: {
|
||||
/** Target host to forward to */
|
||||
host: string;
|
||||
/** Target port (default: 25) */
|
||||
port?: number;
|
||||
/** Authentication credentials */
|
||||
auth?: {
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
/** Preserve original headers */
|
||||
preserveHeaders?: boolean;
|
||||
/** Additional headers to add */
|
||||
addHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
/** Reject action configuration */
|
||||
reject?: {
|
||||
/** SMTP response code */
|
||||
code: number;
|
||||
/** SMTP response message */
|
||||
message: string;
|
||||
};
|
||||
|
||||
/** Process action configuration */
|
||||
process?: {
|
||||
/** Enable content scanning */
|
||||
scan?: boolean;
|
||||
/** Enable DKIM signing */
|
||||
dkim?: boolean;
|
||||
/** Delivery queue priority */
|
||||
queue?: 'normal' | 'priority' | 'bulk';
|
||||
};
|
||||
|
||||
/** Options for various action types */
|
||||
options?: {
|
||||
/** MTA specific options */
|
||||
mtaOptions?: {
|
||||
domain?: string;
|
||||
allowLocalDelivery?: boolean;
|
||||
localDeliveryPath?: string;
|
||||
dkimSign?: boolean;
|
||||
dkimOptions?: {
|
||||
domainName: string;
|
||||
keySelector: string;
|
||||
privateKey?: string;
|
||||
};
|
||||
smtpBanner?: string;
|
||||
maxConnections?: number;
|
||||
connTimeout?: number;
|
||||
spoolDir?: string;
|
||||
};
|
||||
/** Content scanning configuration */
|
||||
contentScanning?: boolean;
|
||||
scanners?: Array<{
|
||||
type: 'spam' | 'virus' | 'attachment';
|
||||
threshold?: number;
|
||||
action: 'tag' | 'reject';
|
||||
blockedExtensions?: string[];
|
||||
}>;
|
||||
/** Email transformations */
|
||||
transformations?: Array<{
|
||||
type: string;
|
||||
header?: string;
|
||||
value?: string;
|
||||
domains?: string[];
|
||||
append?: boolean;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** Delivery options (applies to forward/process/deliver) */
|
||||
delivery?: {
|
||||
/** Rate limit (messages per minute) */
|
||||
rateLimit?: number;
|
||||
/** Number of retry attempts */
|
||||
retries?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for route evaluation
|
||||
*/
|
||||
export interface IEmailContext {
|
||||
/** The email being routed */
|
||||
email: Email;
|
||||
/** The SMTP session */
|
||||
session: IExtendedSmtpSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email domain configuration
|
||||
*/
|
||||
export interface IEmailDomainConfig {
|
||||
/** Domain name */
|
||||
domain: string;
|
||||
|
||||
/** DNS handling mode */
|
||||
dnsMode: 'forward' | 'internal-dns' | 'external-dns';
|
||||
|
||||
/** DNS configuration based on mode */
|
||||
dns?: {
|
||||
/** For 'forward' mode */
|
||||
forward?: {
|
||||
/** Skip DNS validation (default: false) */
|
||||
skipDnsValidation?: boolean;
|
||||
/** Target server's expected domain */
|
||||
targetDomain?: string;
|
||||
};
|
||||
|
||||
/** For 'internal-dns' mode */
|
||||
internal?: {
|
||||
/** TTL for DNS records in seconds (default: 3600) */
|
||||
ttl?: number;
|
||||
/** MX record priority (default: 10) */
|
||||
mxPriority?: number;
|
||||
};
|
||||
|
||||
/** For 'external-dns' mode */
|
||||
external?: {
|
||||
/** Custom DNS servers (default: system DNS) */
|
||||
servers?: string[];
|
||||
/** Which records to validate (default: ['MX', 'SPF', 'DKIM', 'DMARC']) */
|
||||
requiredRecords?: ('MX' | 'SPF' | 'DKIM' | 'DMARC')[];
|
||||
};
|
||||
};
|
||||
|
||||
/** Per-domain DKIM settings (DKIM always enabled) */
|
||||
dkim?: {
|
||||
/** DKIM selector (default: 'default') */
|
||||
selector?: string;
|
||||
/** Key size in bits (default: 2048) */
|
||||
keySize?: number;
|
||||
/** Automatically rotate keys (default: false) */
|
||||
rotateKeys?: boolean;
|
||||
/** Days between key rotations (default: 90) */
|
||||
rotationInterval?: number;
|
||||
};
|
||||
|
||||
/** Per-domain rate limits */
|
||||
rateLimits?: {
|
||||
outbound?: {
|
||||
messagesPerMinute?: number;
|
||||
messagesPerHour?: number;
|
||||
messagesPerDay?: number;
|
||||
};
|
||||
inbound?: {
|
||||
messagesPerMinute?: number;
|
||||
connectionsPerIp?: number;
|
||||
recipientsPerMessage?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user