2025-05-08 00:12:36 +00:00
|
|
|
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');
|
|
|
|
}
|
2025-05-08 00:39:43 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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);
|
|
|
|
}
|
2025-05-08 00:12:36 +00:00
|
|
|
}
|