This commit is contained in:
2025-05-08 01:13:54 +00:00
parent 63781ab1bd
commit fe2069c48e
47 changed files with 295 additions and 1620 deletions

View File

@ -0,0 +1,559 @@
import * as plugins from '../../plugins.js';
import * as paths from '../../paths.js';
import type { MtaService } from '../delivery/classes.mta.js';
/**
* 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 mtaRef: MtaService;
private cache: Map<string, { data: any; expires: number }> = new Map();
private defaultOptions: IDnsLookupOptions = {
cacheTtl: 300000, // 5 minutes
timeout: 5000 // 5 seconds
};
constructor(mtaRefArg: MtaService, options?: IDnsLookupOptions) {
this.mtaRef = mtaRefArg;
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.json`);
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 {
// Now using the public method
const dkimRecord = await this.mtaRef.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;
}
}

View File

@ -0,0 +1,369 @@
import * as plugins from '../../plugins.js';
import { EventEmitter } from 'node:events';
import { type IDomainRule, type EmailProcessingMode } from './classes.email.config.js';
/**
* Options for the domain-based router
*/
export interface IDomainRouterOptions {
// Domain rules with glob pattern matching
domainRules: IDomainRule[];
// Default handling for unmatched domains
defaultMode: EmailProcessingMode;
defaultServer?: string;
defaultPort?: number;
defaultTls?: boolean;
// Pattern matching options
caseSensitive?: boolean;
priorityOrder?: 'most-specific' | 'first-match';
// Cache settings for pattern matching
enableCache?: boolean;
cacheSize?: number;
}
/**
* Result of a pattern match operation
*/
export interface IPatternMatchResult {
rule: IDomainRule;
exactMatch: boolean;
wildcardMatch: boolean;
specificity: number; // Higher is more specific
}
/**
* A pattern matching and routing class for email domains
*/
export class DomainRouter extends EventEmitter {
private options: IDomainRouterOptions;
private patternCache: Map<string, IDomainRule | null> = new Map();
/**
* Create a new domain router
* @param options Router options
*/
constructor(options: IDomainRouterOptions) {
super();
this.options = {
// Default options
caseSensitive: false,
priorityOrder: 'most-specific',
enableCache: true,
cacheSize: 1000,
...options
};
}
/**
* Match an email address against defined rules
* @param email Email address to match
* @returns The matching rule or null if no match
*/
public matchRule(email: string): IDomainRule | null {
// Check cache first if enabled
if (this.options.enableCache && this.patternCache.has(email)) {
return this.patternCache.get(email) || null;
}
// Normalize email if case-insensitive
const normalizedEmail = this.options.caseSensitive ? email : email.toLowerCase();
// Get all matching rules
const matches = this.getAllMatchingRules(normalizedEmail);
if (matches.length === 0) {
// Cache the result (null) if caching is enabled
if (this.options.enableCache) {
this.addToCache(email, null);
}
return null;
}
// Sort by specificity or order
let matchedRule: IDomainRule;
if (this.options.priorityOrder === 'most-specific') {
// Sort by specificity (most specific first)
const sortedMatches = matches.sort((a, b) => {
const aSpecificity = this.calculateSpecificity(a.pattern);
const bSpecificity = this.calculateSpecificity(b.pattern);
return bSpecificity - aSpecificity;
});
matchedRule = sortedMatches[0];
} else {
// First match in the list
matchedRule = matches[0];
}
// Cache the result if caching is enabled
if (this.options.enableCache) {
this.addToCache(email, matchedRule);
}
return matchedRule;
}
/**
* Calculate pattern specificity
* Higher is more specific
* @param pattern Pattern to calculate specificity for
*/
private calculateSpecificity(pattern: string): number {
let specificity = 0;
// Exact match is most specific
if (!pattern.includes('*')) {
return 100;
}
// Count characters that aren't wildcards
specificity += pattern.replace(/\*/g, '').length;
// Position of wildcards affects specificity
if (pattern.startsWith('*@')) {
// Wildcard in local part
specificity += 10;
} else if (pattern.includes('@*')) {
// Wildcard in domain part
specificity += 20;
}
return specificity;
}
/**
* Check if email matches a specific pattern
* @param email Email address to check
* @param pattern Pattern to check against
* @returns True if matching, false otherwise
*/
public matchesPattern(email: string, pattern: string): boolean {
// Normalize if case-insensitive
const normalizedEmail = this.options.caseSensitive ? email : email.toLowerCase();
const normalizedPattern = this.options.caseSensitive ? pattern : pattern.toLowerCase();
// Exact match
if (normalizedEmail === normalizedPattern) {
return true;
}
// Convert glob pattern to regex
const regexPattern = this.globToRegExp(normalizedPattern);
return regexPattern.test(normalizedEmail);
}
/**
* Convert a glob pattern to a regular expression
* @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}$`);
}
/**
* Get all rules that match an email address
* @param email Email address to match
* @returns Array of matching rules
*/
public getAllMatchingRules(email: string): IDomainRule[] {
return this.options.domainRules.filter(rule => this.matchesPattern(email, rule.pattern));
}
/**
* Add a new routing rule
* @param rule Domain rule to add
*/
public addRule(rule: IDomainRule): void {
// Validate the rule
this.validateRule(rule);
// Add the rule
this.options.domainRules.push(rule);
// Clear cache since rules have changed
this.clearCache();
// Emit event
this.emit('ruleAdded', rule);
}
/**
* Validate a domain rule
* @param rule Rule to validate
*/
private validateRule(rule: IDomainRule): void {
// Pattern is required
if (!rule.pattern) {
throw new Error('Domain rule pattern is required');
}
// Mode is required
if (!rule.mode) {
throw new Error('Domain rule mode is required');
}
// Forward mode requires target
if (rule.mode === 'forward' && !rule.target) {
throw new Error('Forward mode requires target configuration');
}
// Forward mode target requires server
if (rule.mode === 'forward' && rule.target && !rule.target.server) {
throw new Error('Forward mode target requires server');
}
}
/**
* Update an existing rule
* @param pattern Pattern to update
* @param updates Updates to apply
* @returns True if rule was found and updated, false otherwise
*/
public updateRule(pattern: string, updates: Partial<IDomainRule>): boolean {
const ruleIndex = this.options.domainRules.findIndex(r => r.pattern === pattern);
if (ruleIndex === -1) {
return false;
}
// Get current rule
const currentRule = this.options.domainRules[ruleIndex];
// Create updated rule
const updatedRule: IDomainRule = {
...currentRule,
...updates
};
// Validate the updated rule
this.validateRule(updatedRule);
// Update the rule
this.options.domainRules[ruleIndex] = updatedRule;
// Clear cache since rules have changed
this.clearCache();
// Emit event
this.emit('ruleUpdated', updatedRule);
return true;
}
/**
* Remove a rule
* @param pattern Pattern to remove
* @returns True if rule was found and removed, false otherwise
*/
public removeRule(pattern: string): boolean {
const initialLength = this.options.domainRules.length;
this.options.domainRules = this.options.domainRules.filter(r => r.pattern !== pattern);
const removed = initialLength > this.options.domainRules.length;
if (removed) {
// Clear cache since rules have changed
this.clearCache();
// Emit event
this.emit('ruleRemoved', pattern);
}
return removed;
}
/**
* Get rule by pattern
* @param pattern Pattern to find
* @returns Rule with matching pattern or null if not found
*/
public getRule(pattern: string): IDomainRule | null {
return this.options.domainRules.find(r => r.pattern === pattern) || null;
}
/**
* Get all rules
* @returns Array of all domain rules
*/
public getRules(): IDomainRule[] {
return [...this.options.domainRules];
}
/**
* Update options
* @param options New options
*/
public updateOptions(options: Partial<IDomainRouterOptions>): void {
this.options = {
...this.options,
...options
};
// Clear cache if cache settings changed
if ('enableCache' in options || 'cacheSize' in options) {
this.clearCache();
}
// Emit event
this.emit('optionsUpdated', this.options);
}
/**
* Add an item to the pattern cache
* @param email Email address
* @param rule Matching rule or null
*/
private addToCache(email: string, rule: IDomainRule | null): void {
// If cache is disabled, do nothing
if (!this.options.enableCache) {
return;
}
// Add to cache
this.patternCache.set(email, rule);
// Check if cache size exceeds limit
if (this.patternCache.size > (this.options.cacheSize || 1000)) {
// Remove oldest entry (first in the Map)
const firstKey = this.patternCache.keys().next().value;
this.patternCache.delete(firstKey);
}
}
/**
* Clear pattern matching cache
*/
public clearCache(): void {
this.patternCache.clear();
this.emit('cacheCleared');
}
/**
* Update all domain rules at once
* @param rules New set of domain rules to replace existing ones
*/
public updateRules(rules: IDomainRule[]): void {
// Validate all rules
rules.forEach(rule => this.validateRule(rule));
// Replace all rules
this.options.domainRules = [...rules];
// Clear cache since rules have changed
this.clearCache();
// Emit event
this.emit('rulesUpdated', rules);
}
}

View File

@ -0,0 +1,129 @@
import * as plugins from '../../plugins.js';
/**
* Email processing modes
*/
export type EmailProcessingMode = 'forward' | 'mta' | 'process';
/**
* Consolidated email configuration interface
*/
export interface IEmailConfig {
// Email server settings
ports: number[];
hostname: string;
maxMessageSize?: number;
// TLS configuration for email server
tls?: {
certPath?: string;
keyPath?: string;
caPath?: string;
minVersion?: string;
};
// Authentication for inbound connections
auth?: {
required?: boolean;
methods?: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
users?: Array<{username: string, password: string}>;
};
// Default routing for unmatched domains
defaultMode: EmailProcessingMode;
defaultServer?: string;
defaultPort?: number;
defaultTls?: boolean;
// Domain rules with glob pattern support
domainRules: IDomainRule[];
// Queue configuration for all email processing
queue?: {
storageType?: 'memory' | 'disk';
persistentPath?: string;
maxRetries?: number;
baseRetryDelay?: number;
maxRetryDelay?: number;
};
// Advanced MTA settings
mtaGlobalOptions?: IMtaOptions;
}
/**
* 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;
}

View File

@ -0,0 +1,991 @@
import * as plugins from '../../plugins.js';
import * as paths from '../../paths.js';
import { EventEmitter } from 'events';
import { logger } from '../../logger.js';
import {
SecurityLogger,
SecurityLogLevel,
SecurityEventType
} from '../../security/index.js';
import { DomainRouter } from './classes.domain.router.js';
import type {
IEmailConfig,
EmailProcessingMode,
IDomainRule
} from './classes.email.config.js';
import { Email } from '../core/classes.email.js';
import * as net from 'node:net';
import * as tls from 'node:tls';
import * as stream from 'node:stream';
import { SMTPServer as MtaSmtpServer } from '../delivery/classes.smtpserver.js';
/**
* Options for the unified email server
*/
export interface IUnifiedEmailServerOptions {
// Base server options
ports: number[];
hostname: string;
banner?: string;
// Authentication options
auth?: {
required?: boolean;
methods?: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
users?: Array<{username: string, password: string}>;
};
// TLS options
tls?: {
certPath?: string;
keyPath?: string;
caPath?: string;
minVersion?: string;
ciphers?: string;
};
// Limits
maxMessageSize?: number;
maxClients?: number;
maxConnections?: number;
// Connection options
connectionTimeout?: number;
socketTimeout?: number;
// Domain rules
domainRules: IDomainRule[];
// Default handling for unmatched domains
defaultMode: EmailProcessingMode;
defaultServer?: string;
defaultPort?: number;
defaultTls?: boolean;
}
/**
* Interface describing SMTP session data
*/
export interface ISmtpSession {
id: string;
remoteAddress: string;
clientHostname: string;
secure: boolean;
authenticated: boolean;
user?: {
username: string;
[key: string]: any;
};
envelope: {
mailFrom: {
address: string;
args: any;
};
rcptTo: Array<{
address: string;
args: any;
}>;
};
processingMode?: EmailProcessingMode;
matchedRule?: IDomainRule;
}
/**
* Authentication data for SMTP
*/
export interface IAuthData {
method: string;
username: string;
password: string;
}
/**
* Server statistics
*/
export interface IServerStats {
startTime: Date;
connections: {
current: number;
total: number;
};
messages: {
processed: number;
delivered: number;
failed: number;
};
processingTime: {
avg: number;
max: number;
min: number;
};
}
/**
* Unified email server that handles all email traffic with pattern-based routing
*/
export class UnifiedEmailServer extends EventEmitter {
private options: IUnifiedEmailServerOptions;
private domainRouter: DomainRouter;
private servers: MtaSmtpServer[] = [];
private stats: IServerStats;
private processingTimes: number[] = [];
constructor(options: IUnifiedEmailServerOptions) {
super();
// Set default options
this.options = {
...options,
banner: options.banner || `${options.hostname} ESMTP UnifiedEmailServer`,
maxMessageSize: options.maxMessageSize || 10 * 1024 * 1024, // 10MB
maxClients: options.maxClients || 100,
maxConnections: options.maxConnections || 1000,
connectionTimeout: options.connectionTimeout || 60000, // 1 minute
socketTimeout: options.socketTimeout || 60000 // 1 minute
};
// Initialize domain router for pattern matching
this.domainRouter = new DomainRouter({
domainRules: options.domainRules,
defaultMode: options.defaultMode,
defaultServer: options.defaultServer,
defaultPort: options.defaultPort,
defaultTls: options.defaultTls,
enableCache: true,
cacheSize: 1000
});
// Initialize statistics
this.stats = {
startTime: new Date(),
connections: {
current: 0,
total: 0
},
messages: {
processed: 0,
delivered: 0,
failed: 0
},
processingTime: {
avg: 0,
max: 0,
min: 0
}
};
// We'll create the SMTP servers during the start() method
}
/**
* Start the unified email server
*/
public async start(): Promise<void> {
logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`);
try {
// Ensure we have the necessary TLS options
const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath;
// Prepare the certificate and key if available
let key: string | undefined;
let cert: string | undefined;
if (hasTlsConfig) {
try {
key = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
cert = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8');
logger.log('info', 'TLS certificates loaded successfully');
} catch (error) {
logger.log('warn', `Failed to load TLS certificates: ${error.message}`);
}
}
// Create a SMTP server for each port
for (const port of this.options.ports as number[]) {
// Create a reference object to hold the MTA service during setup
const mtaRef = {
config: {
smtp: {
hostname: this.options.hostname
},
security: {
checkIPReputation: false,
verifyDkim: true,
verifySpf: true,
verifyDmarc: true
}
},
// These will be implemented in the real integration:
dkimVerifier: {
verify: async () => ({ isValid: true, domain: '' })
},
spfVerifier: {
verifyAndApply: async () => true
},
dmarcVerifier: {
verify: async () => ({}),
applyPolicy: () => true
},
processIncomingEmail: async (email: Email) => {
// This is where we'll process the email based on domain routing
const to = email.to[0]; // Email.to is an array, take the first recipient
const rule = this.domainRouter.matchRule(to);
const mode = rule?.mode || this.options.defaultMode;
// Process based on the mode
await this.processEmailByMode(email, {
id: 'session-' + Math.random().toString(36).substring(2),
remoteAddress: '127.0.0.1',
clientHostname: '',
secure: false,
authenticated: false,
envelope: {
mailFrom: { address: email.from, args: {} },
rcptTo: email.to.map(recipient => ({ address: recipient, args: {} }))
},
processingMode: mode,
matchedRule: rule
}, mode);
return true;
}
};
// Create server options
const serverOptions = {
port,
hostname: this.options.hostname,
key,
cert
};
// Create and start the SMTP server
const smtpServer = new MtaSmtpServer(mtaRef as any, serverOptions);
this.servers.push(smtpServer);
// Start the server
await new Promise<void>((resolve, reject) => {
try {
smtpServer.start();
logger.log('info', `UnifiedEmailServer listening on port ${port}`);
// Set up event handlers
(smtpServer as any).server.on('error', (err: Error) => {
logger.log('error', `SMTP server error on port ${port}: ${err.message}`);
this.emit('error', err);
});
resolve();
} catch (err) {
if ((err as any).code === 'EADDRINUSE') {
logger.log('error', `Port ${port} is already in use`);
reject(new Error(`Port ${port} is already in use`));
} else {
logger.log('error', `Error starting server on port ${port}: ${err.message}`);
reject(err);
}
}
});
}
logger.log('info', 'UnifiedEmailServer started successfully');
this.emit('started');
} catch (error) {
logger.log('error', `Failed to start UnifiedEmailServer: ${error.message}`);
throw error;
}
}
/**
* Stop the unified email server
*/
public async stop(): Promise<void> {
logger.log('info', 'Stopping UnifiedEmailServer');
try {
// Stop all SMTP servers
for (const server of this.servers) {
server.stop();
}
// Clear the servers array
this.servers = [];
logger.log('info', 'UnifiedEmailServer stopped successfully');
this.emit('stopped');
} catch (error) {
logger.log('error', `Error stopping UnifiedEmailServer: ${error.message}`);
throw error;
}
}
/**
* Handle new SMTP connection (stub implementation)
*/
private onConnect(session: ISmtpSession, callback: (err?: Error) => void): void {
logger.log('info', `New connection from ${session.remoteAddress}`);
// Update connection statistics
this.stats.connections.current++;
this.stats.connections.total++;
// Log connection event
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
type: SecurityEventType.CONNECTION,
message: 'New SMTP connection established',
ipAddress: session.remoteAddress,
details: {
sessionId: session.id,
secure: session.secure
}
});
// Optional IP reputation check would go here
// Continue with the connection
callback();
}
/**
* Handle authentication (stub implementation)
*/
private onAuth(auth: IAuthData, session: ISmtpSession, callback: (err?: Error, user?: any) => void): void {
if (!this.options.auth || !this.options.auth.users || this.options.auth.users.length === 0) {
// No authentication configured, reject
const error = new Error('Authentication not supported');
logger.log('warn', `Authentication attempt when not configured: ${auth.username}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.WARN,
type: SecurityEventType.AUTHENTICATION,
message: 'Authentication attempt when not configured',
ipAddress: session.remoteAddress,
details: {
username: auth.username,
method: auth.method,
sessionId: session.id
},
success: false
});
return callback(error);
}
// Find matching user
const user = this.options.auth.users.find(u => u.username === auth.username && u.password === auth.password);
if (user) {
logger.log('info', `User ${auth.username} authenticated successfully`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
type: SecurityEventType.AUTHENTICATION,
message: 'SMTP authentication successful',
ipAddress: session.remoteAddress,
details: {
username: auth.username,
method: auth.method,
sessionId: session.id
},
success: true
});
return callback(null, { username: user.username });
} else {
const error = new Error('Invalid username or password');
logger.log('warn', `Failed authentication for ${auth.username}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.WARN,
type: SecurityEventType.AUTHENTICATION,
message: 'SMTP authentication failed',
ipAddress: session.remoteAddress,
details: {
username: auth.username,
method: auth.method,
sessionId: session.id
},
success: false
});
return callback(error);
}
}
/**
* Handle MAIL FROM command (stub implementation)
*/
private onMailFrom(address: {address: string}, session: ISmtpSession, callback: (err?: Error) => void): void {
logger.log('info', `MAIL FROM: ${address.address}`);
// Validate the email address
if (!this.isValidEmail(address.address)) {
const error = new Error('Invalid sender address');
logger.log('warn', `Invalid sender address: ${address.address}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.WARN,
type: SecurityEventType.EMAIL_VALIDATION,
message: 'Invalid sender email format',
ipAddress: session.remoteAddress,
details: {
address: address.address,
sessionId: session.id
},
success: false
});
return callback(error);
}
// Authentication check if required
if (this.options.auth?.required && !session.authenticated) {
const error = new Error('Authentication required');
logger.log('warn', `Unauthenticated sender rejected: ${address.address}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.WARN,
type: SecurityEventType.AUTHENTICATION,
message: 'Unauthenticated sender rejected',
ipAddress: session.remoteAddress,
details: {
address: address.address,
sessionId: session.id
},
success: false
});
return callback(error);
}
// Continue processing
callback();
}
/**
* Handle RCPT TO command (stub implementation)
*/
private onRcptTo(address: {address: string}, session: ISmtpSession, callback: (err?: Error) => void): void {
logger.log('info', `RCPT TO: ${address.address}`);
// Validate the email address
if (!this.isValidEmail(address.address)) {
const error = new Error('Invalid recipient address');
logger.log('warn', `Invalid recipient address: ${address.address}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.WARN,
type: SecurityEventType.EMAIL_VALIDATION,
message: 'Invalid recipient email format',
ipAddress: session.remoteAddress,
details: {
address: address.address,
sessionId: session.id
},
success: false
});
return callback(error);
}
// Pattern match the recipient to determine processing mode
const rule = this.domainRouter.matchRule(address.address);
if (rule) {
// Store the matched rule and processing mode in the session
session.matchedRule = rule;
session.processingMode = rule.mode;
logger.log('info', `Email ${address.address} matched rule: ${rule.pattern}, mode: ${rule.mode}`);
} else {
// Use default mode
session.processingMode = this.options.defaultMode;
logger.log('info', `Email ${address.address} using default mode: ${this.options.defaultMode}`);
}
// Continue processing
callback();
}
/**
* Handle incoming email data (stub implementation)
*/
private onData(stream: stream.Readable, session: ISmtpSession, callback: (err?: Error) => void): void {
logger.log('info', `Processing email data for session ${session.id}`);
const startTime = Date.now();
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
stream.on('end', async () => {
try {
const data = Buffer.concat(chunks);
const mode = session.processingMode || this.options.defaultMode;
// Determine processing mode based on matched rule
const processedEmail = await this.processEmailByMode(data, session, mode);
// Update statistics
this.stats.messages.processed++;
this.stats.messages.delivered++;
// Calculate processing time
const processingTime = Date.now() - startTime;
this.processingTimes.push(processingTime);
this.updateProcessingTimeStats();
// Emit event for delivery queue
this.emit('emailProcessed', processedEmail, mode, session.matchedRule);
logger.log('info', `Email processed successfully in ${processingTime}ms, mode: ${mode}`);
callback();
} catch (error) {
logger.log('error', `Error processing email: ${error.message}`);
// Update statistics
this.stats.messages.processed++;
this.stats.messages.failed++;
// Calculate processing time for failed attempts too
const processingTime = Date.now() - startTime;
this.processingTimes.push(processingTime);
this.updateProcessingTimeStats();
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.EMAIL_PROCESSING,
message: 'Email processing failed',
ipAddress: session.remoteAddress,
details: {
error: error.message,
sessionId: session.id,
mode: session.processingMode,
processingTime
},
success: false
});
callback(error);
}
});
stream.on('error', (err) => {
logger.log('error', `Stream error: ${err.message}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.EMAIL_PROCESSING,
message: 'Email stream error',
ipAddress: session.remoteAddress,
details: {
error: err.message,
sessionId: session.id
},
success: false
});
callback(err);
});
}
/**
* Update processing time statistics
*/
private updateProcessingTimeStats(): void {
if (this.processingTimes.length === 0) return;
// Keep only the last 1000 processing times
if (this.processingTimes.length > 1000) {
this.processingTimes = this.processingTimes.slice(-1000);
}
// Calculate stats
const sum = this.processingTimes.reduce((acc, time) => acc + time, 0);
const avg = sum / this.processingTimes.length;
const max = Math.max(...this.processingTimes);
const min = Math.min(...this.processingTimes);
this.stats.processingTime = { avg, max, min };
}
/**
* Process email based on the determined mode
*/
private async processEmailByMode(emailData: Email | Buffer, session: ISmtpSession, mode: EmailProcessingMode): Promise<Email> {
// Convert Buffer to Email if needed
let email: Email;
if (Buffer.isBuffer(emailData)) {
// Parse the email data buffer into an Email object
try {
const parsed = await plugins.mailparser.simpleParser(emailData);
email = new Email({
from: parsed.from?.value[0]?.address || session.envelope.mailFrom.address,
to: session.envelope.rcptTo[0]?.address || '',
subject: parsed.subject || '',
text: parsed.text || '',
html: parsed.html || undefined,
attachments: parsed.attachments?.map(att => ({
filename: att.filename || '',
content: att.content,
contentType: att.contentType
})) || []
});
} catch (error) {
logger.log('error', `Error parsing email data: ${error.message}`);
throw new Error(`Error parsing email data: ${error.message}`);
}
} else {
email = emailData;
}
// Process based on mode
switch (mode) {
case 'forward':
await this.handleForwardMode(email, session);
break;
case 'mta':
await this.handleMtaMode(email, session);
break;
case 'process':
await this.handleProcessMode(email, session);
break;
default:
throw new Error(`Unknown processing mode: ${mode}`);
}
// Return the processed email
return email;
}
/**
* Handle email in forward mode (SMTP proxy)
*/
private async handleForwardMode(email: Email, session: ISmtpSession): Promise<void> {
logger.log('info', `Handling email in forward mode for session ${session.id}`);
// Get target server information
const rule = session.matchedRule;
const targetServer = rule?.target?.server || this.options.defaultServer;
const targetPort = rule?.target?.port || this.options.defaultPort || 25;
const useTls = rule?.target?.useTls ?? this.options.defaultTls ?? false;
if (!targetServer) {
throw new Error('No target server configured for forward mode');
}
logger.log('info', `Forwarding email to ${targetServer}:${targetPort}, TLS: ${useTls}`);
try {
// Create a simple SMTP client connection to the target server
const client = new net.Socket();
await new Promise<void>((resolve, reject) => {
// Connect to the target server
client.connect({
host: targetServer,
port: targetPort
});
client.on('data', (data) => {
const response = data.toString().trim();
logger.log('debug', `SMTP response: ${response}`);
// Handle SMTP response codes
if (response.startsWith('2')) {
// Success response
resolve();
} else if (response.startsWith('5')) {
// Permanent error
reject(new Error(`SMTP error: ${response}`));
}
});
client.on('error', (err) => {
logger.log('error', `SMTP client error: ${err.message}`);
reject(err);
});
// SMTP client commands would go here in a full implementation
// For now, just finish the connection
client.end();
resolve();
});
logger.log('info', `Email forwarded successfully to ${targetServer}:${targetPort}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
type: SecurityEventType.EMAIL_FORWARDING,
message: 'Email forwarded',
ipAddress: session.remoteAddress,
details: {
sessionId: session.id,
targetServer,
targetPort,
useTls,
ruleName: rule?.pattern || 'default',
subject: email.subject
},
success: true
});
} catch (error) {
logger.log('error', `Failed to forward email: ${error.message}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.EMAIL_FORWARDING,
message: 'Email forwarding failed',
ipAddress: session.remoteAddress,
details: {
sessionId: session.id,
targetServer,
targetPort,
useTls,
ruleName: rule?.pattern || 'default',
error: error.message
},
success: false
});
throw error;
}
}
/**
* Handle email in MTA mode (programmatic processing)
*/
private async handleMtaMode(email: Email, session: ISmtpSession): Promise<void> {
logger.log('info', `Handling email in MTA mode for session ${session.id}`);
try {
// Apply MTA rule options if provided
if (session.matchedRule?.mtaOptions) {
const options = session.matchedRule.mtaOptions;
// Apply DKIM signing if enabled
if (options.dkimSign && options.dkimOptions) {
// Sign the email with DKIM
logger.log('info', `Signing email with DKIM for domain ${options.dkimOptions.domainName}`);
// In a full implementation, this would use the DKIM signing library
}
}
// Get email content for logging/processing
const subject = email.subject;
const recipients = email.getAllRecipients().join(', ');
logger.log('info', `Email processed by MTA: ${subject} to ${recipients}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
type: SecurityEventType.EMAIL_PROCESSING,
message: 'Email processed by MTA',
ipAddress: session.remoteAddress,
details: {
sessionId: session.id,
ruleName: session.matchedRule?.pattern || 'default',
subject,
recipients
},
success: true
});
} catch (error) {
logger.log('error', `Failed to process email in MTA mode: ${error.message}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.EMAIL_PROCESSING,
message: 'MTA processing failed',
ipAddress: session.remoteAddress,
details: {
sessionId: session.id,
ruleName: session.matchedRule?.pattern || 'default',
error: error.message
},
success: false
});
throw error;
}
}
/**
* Handle email in process mode (store-and-forward with scanning)
*/
private async handleProcessMode(email: Email, session: ISmtpSession): Promise<void> {
logger.log('info', `Handling email in process mode for session ${session.id}`);
try {
const rule = session.matchedRule;
// Apply content scanning if enabled
if (rule?.contentScanning && rule.scanners && rule.scanners.length > 0) {
logger.log('info', 'Performing content scanning');
// Apply each scanner
for (const scanner of rule.scanners) {
switch (scanner.type) {
case 'spam':
logger.log('info', 'Scanning for spam content');
// Implement spam scanning
break;
case 'virus':
logger.log('info', 'Scanning for virus content');
// Implement virus scanning
break;
case 'attachment':
logger.log('info', 'Scanning attachments');
// Check for blocked extensions
if (scanner.blockedExtensions && scanner.blockedExtensions.length > 0) {
for (const attachment of email.attachments) {
const ext = this.getFileExtension(attachment.filename);
if (scanner.blockedExtensions.includes(ext)) {
if (scanner.action === 'reject') {
throw new Error(`Blocked attachment type: ${ext}`);
} else { // tag
email.addHeader('X-Attachment-Warning', `Potentially unsafe attachment: ${attachment.filename}`);
}
}
}
}
break;
}
}
}
// Apply transformations if defined
if (rule?.transformations && rule.transformations.length > 0) {
logger.log('info', 'Applying email transformations');
for (const transform of rule.transformations) {
switch (transform.type) {
case 'addHeader':
if (transform.header && transform.value) {
email.addHeader(transform.header, transform.value);
}
break;
}
}
}
logger.log('info', `Email successfully processed in store-and-forward mode`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
type: SecurityEventType.EMAIL_PROCESSING,
message: 'Email processed and queued',
ipAddress: session.remoteAddress,
details: {
sessionId: session.id,
ruleName: rule?.pattern || 'default',
contentScanning: rule?.contentScanning || false,
subject: email.subject
},
success: true
});
} catch (error) {
logger.log('error', `Failed to process email: ${error.message}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.EMAIL_PROCESSING,
message: 'Email processing failed',
ipAddress: session.remoteAddress,
details: {
sessionId: session.id,
ruleName: session.matchedRule?.pattern || 'default',
error: error.message
},
success: false
});
throw error;
}
}
/**
* Get file extension from filename
*/
private getFileExtension(filename: string): string {
return filename.substring(filename.lastIndexOf('.')).toLowerCase();
}
/**
* Handle server errors
*/
private onError(err: Error): void {
logger.log('error', `Server error: ${err.message}`);
this.emit('error', err);
}
/**
* Handle server close
*/
private onClose(): void {
logger.log('info', 'Server closed');
this.emit('close');
// Update statistics
this.stats.connections.current = 0;
}
/**
* Update server configuration
*/
public updateOptions(options: Partial<IUnifiedEmailServerOptions>): void {
// Stop the server if changing ports
const portsChanged = options.ports &&
(!this.options.ports ||
JSON.stringify(options.ports) !== JSON.stringify(this.options.ports));
if (portsChanged) {
this.stop().then(() => {
this.options = { ...this.options, ...options };
this.start();
});
} else {
// Update options without restart
this.options = { ...this.options, ...options };
// Update domain router if rules changed
if (options.domainRules) {
this.domainRouter.updateRules(options.domainRules);
}
}
}
/**
* Update domain rules
*/
public updateDomainRules(rules: IDomainRule[]): void {
this.options.domainRules = rules;
this.domainRouter.updateRules(rules);
}
/**
* Get server statistics
*/
public getStats(): IServerStats {
return { ...this.stats };
}
/**
* Validate email address format
*/
private isValidEmail(email: string): boolean {
// Basic validation - a more comprehensive validation could be used
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}

5
ts/mail/routing/index.ts Normal file
View File

@ -0,0 +1,5 @@
// Email routing components
export * from './classes.domain.router.js';
export * from './classes.email.config.js';
export * from './classes.unified.email.server.js';
export * from './classes.dnsmanager.js';