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);
 | |
|   }
 | |
| } |