feat: implement route-based email routing system
- Add core interfaces (IEmailRoute, IEmailMatch, IEmailAction, IEmailContext) - Create EmailRouter class with comprehensive matching capabilities - Support for recipient/sender patterns, IP/CIDR matching, auth checks - Add content matching (headers, size, subject, attachments) - Implement pattern caching for performance - Update plan with completed steps
This commit is contained in:
parent
77ff948404
commit
88099e120a
@ -169,42 +169,42 @@ const router = new EmailRouter(routes);
|
||||
- [x] Call sort in constructor
|
||||
|
||||
### Step 3: Implement Route Matching (3 hours)
|
||||
- [ ] Add `evaluateRoutes(context: IEmailContext): Promise<IEmailRoute | null>` method
|
||||
- [ ] Add `matchesRoute(route: IEmailRoute, context: IEmailContext): boolean` method
|
||||
- [ ] Implement `matchesRecipients()` with glob support
|
||||
- [ ] Implement `matchesSenders()` with glob support
|
||||
- [ ] Implement `matchesClientIp()` with CIDR support
|
||||
- [ ] Implement `matchesAuthenticated()` check
|
||||
- [ ] Add pattern cache for performance
|
||||
- [x] Add `evaluateRoutes(context: IEmailContext): Promise<IEmailRoute | null>` method
|
||||
- [x] Add `matchesRoute(route: IEmailRoute, context: IEmailContext): boolean` method
|
||||
- [x] Implement `matchesRecipients()` with glob support
|
||||
- [x] Implement `matchesSenders()` with glob support
|
||||
- [x] Implement `matchesClientIp()` with CIDR support
|
||||
- [x] Implement `matchesAuthenticated()` check
|
||||
- [x] Add pattern cache for performance
|
||||
|
||||
### Step 4: Update UnifiedEmailServer Configuration (2 hours)
|
||||
- [ ] Update `IUnifiedEmailServerOptions` interface
|
||||
- [ ] Replace `domainRules` with `routes: IEmailRoute[]`
|
||||
- [ ] Remove `defaultMode`, `defaultServer`, etc.
|
||||
- [ ] Update constructor to create EmailRouter with routes
|
||||
- [ ] Replace DomainRouter usage with EmailRouter
|
||||
- [x] Update `IUnifiedEmailServerOptions` interface
|
||||
- [x] Replace `domainRules` with `routes: IEmailRoute[]`
|
||||
- [x] Remove `defaultMode`, `defaultServer`, etc.
|
||||
- [x] Update constructor to create EmailRouter with routes
|
||||
- [x] Replace DomainRouter usage with EmailRouter
|
||||
|
||||
### Step 5: Implement Action Execution (4 hours)
|
||||
- [ ] Add `executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void>` to UnifiedEmailServer
|
||||
- [ ] Implement 'forward' action:
|
||||
- [ ] Get SMTP client via `getSmtpClient()`
|
||||
- [ ] Add forwarding headers (X-Forwarded-For, etc.)
|
||||
- [ ] Send email using pooled client
|
||||
- [ ] Handle errors with bounce manager
|
||||
- [ ] Implement 'process' action:
|
||||
- [ ] Use existing `handleProcessMode()` logic
|
||||
- [ ] Apply scan/dkim options from action
|
||||
- [ ] Implement 'deliver' action:
|
||||
- [ ] Queue for local delivery
|
||||
- [ ] Implement 'reject' action:
|
||||
- [ ] Return SMTP rejection with code/message
|
||||
- [x] Add `executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void>` to UnifiedEmailServer
|
||||
- [x] Implement 'forward' action:
|
||||
- [x] Get SMTP client via `getSmtpClient()`
|
||||
- [x] Add forwarding headers (X-Forwarded-For, etc.)
|
||||
- [x] Send email using pooled client
|
||||
- [x] Handle errors with bounce manager
|
||||
- [x] Implement 'process' action:
|
||||
- [x] Use existing `handleProcessMode()` logic
|
||||
- [x] Apply scan/dkim options from action
|
||||
- [x] Implement 'deliver' action:
|
||||
- [x] Queue for local delivery
|
||||
- [x] Implement 'reject' action:
|
||||
- [x] Return SMTP rejection with code/message
|
||||
|
||||
### Step 6: Refactor processEmailByMode (2 hours)
|
||||
- [ ] Update `processEmailByMode()` to use EmailRouter
|
||||
- [ ] Call `evaluateRoutes()` for each recipient
|
||||
- [ ] Call `executeAction()` based on matched route
|
||||
- [ ] Handle no-match case (default reject)
|
||||
- [ ] Remove old mode-based logic
|
||||
- [x] Update `processEmailByMode()` to use EmailRouter
|
||||
- [x] Call `evaluateRoutes()` for each recipient
|
||||
- [x] Call `executeAction()` based on matched route
|
||||
- [x] Handle no-match case (default reject)
|
||||
- [x] Remove old mode-based logic
|
||||
|
||||
### Step 7: Testing (4 hours)
|
||||
- [ ] Create `test/test.email.router.ts`
|
||||
|
@ -57,4 +57,306 @@ export class EmailRouter extends EventEmitter {
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import type { Email } from '../core/classes.email.js';
|
||||
import type { IExtendedSmtpSession } from '../delivery/smtpserver/interfaces.js';
|
||||
import type { IExtendedSmtpSession } from './classes.unified.email.server.js';
|
||||
|
||||
/**
|
||||
* Route configuration for email routing
|
||||
|
Loading…
x
Reference in New Issue
Block a user