494 lines
33 KiB
JavaScript
494 lines
33 KiB
JavaScript
|
|
import * as plugins from '../../plugins.js';
|
||
|
|
import { EventEmitter } from 'node:events';
|
||
|
|
/**
|
||
|
|
* Email router that evaluates routes and determines actions
|
||
|
|
*/
|
||
|
|
export class EmailRouter extends EventEmitter {
|
||
|
|
routes;
|
||
|
|
patternCache = new Map();
|
||
|
|
storageManager; // StorageManager instance
|
||
|
|
persistChanges;
|
||
|
|
/**
|
||
|
|
* Create a new email router
|
||
|
|
* @param routes Array of email routes
|
||
|
|
* @param options Router options
|
||
|
|
*/
|
||
|
|
constructor(routes, options) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
sortRoutesByPriority(routes) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
getRoutes() {
|
||
|
|
return [...this.routes];
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Update routes
|
||
|
|
* @param routes New routes
|
||
|
|
* @param persist Whether to persist changes (defaults to persistChanges setting)
|
||
|
|
*/
|
||
|
|
async updateRoutes(routes, persist) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async setRoutes(routes, persist) {
|
||
|
|
await this.updateRoutes(routes, persist);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Clear the pattern cache
|
||
|
|
*/
|
||
|
|
clearCache() {
|
||
|
|
this.patternCache.clear();
|
||
|
|
this.emit('cacheCleared');
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Evaluate routes and find the first match
|
||
|
|
* @param context Email context
|
||
|
|
* @returns Matched route or null
|
||
|
|
*/
|
||
|
|
async evaluateRoutes(context) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async matchesRoute(route, context) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
matchesRecipients(email, patterns) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
matchesSenders(email, patterns) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
matchesClientIp(context, patterns) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
matchesHeaders(email, headerPatterns) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
matchesSize(email, sizeRange) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
matchesSubject(email, pattern) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
matchesPattern(str, pattern) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
globToRegExp(pattern) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
ipInCidr(ip, cidr) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
ipToNumber(ip) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
calculateEmailSize(email) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async saveRoutes() {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async loadRoutes(options) {
|
||
|
|
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);
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
async addRoute(route, persist) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
async removeRoute(name, persist) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async updateRoute(name, route, persist) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
getRoute(name) {
|
||
|
|
return this.routes.find(r => r.name === name);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5lbWFpbC5yb3V0ZXIuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL3JvdXRpbmcvY2xhc3Nlcy5lbWFpbC5yb3V0ZXIudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLE9BQU8sTUFBTSxrQkFBa0IsQ0FBQztBQUM1QyxPQUFPLEVBQUUsWUFBWSxFQUFFLE1BQU0sYUFBYSxDQUFDO0FBSTNDOztHQUVHO0FBQ0gsTUFBTSxPQUFPLFdBQVksU0FBUSxZQUFZO0lBQ25DLE1BQU0sQ0FBZ0I7SUFDdEIsWUFBWSxHQUF5QixJQUFJLEdBQUcsRUFBRSxDQUFDO0lBQy9DLGNBQWMsQ0FBTyxDQUFDLDBCQUEwQjtJQUNoRCxjQUFjLENBQVU7SUFFaEM7Ozs7T0FJRztJQUNILFlBQVksTUFBcUIsRUFBRSxPQUdsQztRQUNDLEtBQUssRUFBRSxDQUFDO1FBQ1IsSUFBSSxDQUFDLE1BQU0sR0FBRyxJQUFJLENBQUMsb0JBQW9CLENBQUMsTUFBTSxDQUFDLENBQUM7UUFDaEQsSUFBSSxDQUFDLGNBQWMsR0FBRyxPQUFPLEVBQUUsY0FBYyxDQUFDO1FBQzlDLElBQUksQ0FBQyxjQUFjLEdBQUcsT0FBTyxFQUFFLGNBQWMsSUFBSSxDQUFDLENBQUMsSUFBSSxDQUFDLGNBQWMsQ0FBQztRQUV2RSwrREFBK0Q7UUFDL0QsSUFBSSxJQUFJLENBQUMsY0FBYyxFQUFFLENBQUM7WUFDeEIsSUFBSSxDQUFDLFVBQVUsQ0FBQyxFQUFFLEtBQUssRUFBRSxJQUFJLEVBQUUsQ0FBQyxDQUFDLEtBQUssQ0FBQyxLQUFLLENBQUMsRUFBRTtnQkFDN0MsT0FBTyxDQUFDLEtBQUssQ0FBQyxvQ0FBb0MsS0FBSyxDQUFDLE9BQU8sRUFBRSxDQUFDLENBQUM7WUFDckUsQ0FBQyxDQUFDLENBQUM7UUFDTCxDQUFDO0lBQ0gsQ0FBQztJQUVEOzs7O09BSUc7SUFDSyxvQkFBb0IsQ0FBQyxNQUFxQjtRQUNoRCxPQUFPLENB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
|