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 = 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): 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): 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'); } }