import * as plugins from '../../plugins.js'; import { EventEmitter } from 'node:events'; import type { IEmailRoute, IEmailMatch, IEmailAction, IEmailContext } from './interfaces.js'; import type { Email } from '../core/classes.email.js'; /** * Email router that evaluates routes and determines actions */ export class EmailRouter extends EventEmitter { private routes: IEmailRoute[]; private patternCache: Map = 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 { 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 { 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 { 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 { 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): 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 { 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.json', 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 { if (!this.storageManager) { this.emit('persistenceWarning', 'Cannot load routes: StorageManager not configured'); return []; } try { const routesData = await this.storageManager.get('/email/routes/config.json'); 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(); // 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 { // 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 { 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 { // 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); } }