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