Files
dcrouter/ts/mail/routing/classes.email.router.ts

575 lines
16 KiB
TypeScript

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<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.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<IEmailRoute[]> {
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<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);
}
}