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(); /** * Create a new email router * @param routes Array of email routes */ constructor(routes: IEmailRoute[]) { super(); this.routes = this.sortRoutesByPriority(routes); } /** * 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 */ public updateRoutes(routes: IEmailRoute[]): void { this.routes = this.sortRoutesByPriority(routes); this.clearCache(); this.emit('routesUpdated', this.routes); } /** * Set routes (alias for updateRoutes) * @param routes New routes */ public setRoutes(routes: IEmailRoute[]): void { this.updateRoutes(routes); } /** * 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; } }