495 lines
14 KiB
TypeScript
495 lines
14 KiB
TypeScript
|
import * as plugins from '../plugins.js';
|
||
|
import type { ISmtpConfig, IContentScannerConfig, ITransformationConfig } from './classes.smtp.config.js';
|
||
|
import type { ISmtpSession } from './classes.smtp.server.js';
|
||
|
import { EventEmitter } from 'node:events';
|
||
|
|
||
|
// Create standalone types to avoid interface compatibility issues
|
||
|
interface AddressObject {
|
||
|
address?: string;
|
||
|
name?: string;
|
||
|
[key: string]: any;
|
||
|
}
|
||
|
|
||
|
interface ExtendedAddressObject {
|
||
|
value: AddressObject | AddressObject[];
|
||
|
[key: string]: any;
|
||
|
}
|
||
|
|
||
|
// Don't extend ParsedMail directly to avoid type compatibility issues
|
||
|
interface ExtendedParsedMail {
|
||
|
// Basic properties from ParsedMail
|
||
|
subject?: string;
|
||
|
text?: string;
|
||
|
textAsHtml?: string;
|
||
|
html?: string;
|
||
|
attachments?: Array<any>;
|
||
|
headers?: Map<string, any>;
|
||
|
headerLines?: Array<{key: string; line: string}>;
|
||
|
messageId?: string;
|
||
|
date?: Date;
|
||
|
|
||
|
// Extended address objects
|
||
|
from?: ExtendedAddressObject;
|
||
|
to?: ExtendedAddressObject;
|
||
|
cc?: ExtendedAddressObject;
|
||
|
bcc?: ExtendedAddressObject;
|
||
|
|
||
|
// Add any other properties we need
|
||
|
[key: string]: any;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Email metadata extracted from parsed mail
|
||
|
*/
|
||
|
export interface IEmailMetadata {
|
||
|
id: string;
|
||
|
from: string;
|
||
|
fromDomain: string;
|
||
|
to: string[];
|
||
|
toDomains: string[];
|
||
|
subject?: string;
|
||
|
size: number;
|
||
|
hasAttachments: boolean;
|
||
|
receivedAt: Date;
|
||
|
clientIp: string;
|
||
|
authenticated: boolean;
|
||
|
authUser?: string;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Content scanning result
|
||
|
*/
|
||
|
export interface IScanResult {
|
||
|
id: string;
|
||
|
spamScore?: number;
|
||
|
hasVirus?: boolean;
|
||
|
blockedAttachments?: string[];
|
||
|
action: 'accept' | 'tag' | 'reject';
|
||
|
reason?: string;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Routing decision for an email
|
||
|
*/
|
||
|
export interface IRoutingDecision {
|
||
|
id: string;
|
||
|
targetServer: string;
|
||
|
port: number;
|
||
|
useTls: boolean;
|
||
|
authentication?: {
|
||
|
user?: string;
|
||
|
pass?: string;
|
||
|
};
|
||
|
headers?: Array<{
|
||
|
name: string;
|
||
|
value: string;
|
||
|
append?: boolean;
|
||
|
}>;
|
||
|
signDkim?: boolean;
|
||
|
dkimOptions?: {
|
||
|
domainName: string;
|
||
|
keySelector: string;
|
||
|
privateKey: string;
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Complete processing result
|
||
|
*/
|
||
|
export interface IProcessingResult {
|
||
|
id: string;
|
||
|
metadata: IEmailMetadata;
|
||
|
scanResult: IScanResult;
|
||
|
routing: IRoutingDecision;
|
||
|
modifiedMessage?: ExtendedParsedMail;
|
||
|
originalMessage: ExtendedParsedMail;
|
||
|
rawData: string;
|
||
|
action: 'queue' | 'reject';
|
||
|
session: ISmtpSession;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Email Processor handles email processing pipeline
|
||
|
*/
|
||
|
export class EmailProcessor extends EventEmitter {
|
||
|
private config: ISmtpConfig;
|
||
|
private processingQueue: Map<string, IProcessingResult> = new Map();
|
||
|
|
||
|
/**
|
||
|
* Create a new email processor
|
||
|
* @param config SMTP configuration
|
||
|
*/
|
||
|
constructor(config: ISmtpConfig) {
|
||
|
super();
|
||
|
this.config = config;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Process an email message
|
||
|
* @param message Parsed email message
|
||
|
* @param rawData Raw email data
|
||
|
* @param session SMTP session
|
||
|
*/
|
||
|
public async processEmail(
|
||
|
message: ExtendedParsedMail,
|
||
|
rawData: string,
|
||
|
session: ISmtpSession
|
||
|
): Promise<IProcessingResult> {
|
||
|
try {
|
||
|
// Generate ID for this processing task
|
||
|
const id = plugins.uuid.v4();
|
||
|
|
||
|
// Extract metadata
|
||
|
const metadata = await this.extractMetadata(message, session, id);
|
||
|
|
||
|
// Scan content if enabled
|
||
|
const scanResult = await this.scanContent(message, metadata);
|
||
|
|
||
|
// If content scanning rejects the message, return early
|
||
|
if (scanResult.action === 'reject') {
|
||
|
const result: IProcessingResult = {
|
||
|
id,
|
||
|
metadata,
|
||
|
scanResult,
|
||
|
routing: {
|
||
|
id,
|
||
|
targetServer: '',
|
||
|
port: 0,
|
||
|
useTls: false
|
||
|
},
|
||
|
originalMessage: message,
|
||
|
rawData,
|
||
|
action: 'reject',
|
||
|
session
|
||
|
};
|
||
|
|
||
|
this.emit('rejected', result);
|
||
|
return result;
|
||
|
}
|
||
|
|
||
|
// Determine routing
|
||
|
const routing = await this.determineRouting(message, metadata);
|
||
|
|
||
|
// Apply transformations
|
||
|
const modifiedMessage = await this.applyTransformations(message, routing, scanResult);
|
||
|
|
||
|
// Create processing result
|
||
|
const result: IProcessingResult = {
|
||
|
id,
|
||
|
metadata,
|
||
|
scanResult,
|
||
|
routing,
|
||
|
modifiedMessage,
|
||
|
originalMessage: message,
|
||
|
rawData,
|
||
|
action: 'queue',
|
||
|
session
|
||
|
};
|
||
|
|
||
|
// Add to processing queue
|
||
|
this.processingQueue.set(id, result);
|
||
|
|
||
|
// Emit processed event
|
||
|
this.emit('processed', result);
|
||
|
|
||
|
return result;
|
||
|
} catch (error) {
|
||
|
console.error('Error processing email:', error);
|
||
|
throw error;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Extract metadata from email message
|
||
|
* @param message Parsed email
|
||
|
* @param session SMTP session
|
||
|
* @param id Processing ID
|
||
|
*/
|
||
|
private async extractMetadata(
|
||
|
message: ExtendedParsedMail,
|
||
|
session: ISmtpSession,
|
||
|
id: string
|
||
|
): Promise<IEmailMetadata> {
|
||
|
// Extract sender information
|
||
|
let from = '';
|
||
|
if (message.from && message.from.value) {
|
||
|
const fromValue = message.from.value;
|
||
|
if (Array.isArray(fromValue)) {
|
||
|
from = fromValue[0]?.address || '';
|
||
|
} else if (typeof fromValue === 'object' && 'address' in fromValue && fromValue.address) {
|
||
|
from = fromValue.address;
|
||
|
}
|
||
|
}
|
||
|
const fromDomain = from.split('@')[1] || '';
|
||
|
|
||
|
// Extract recipient information
|
||
|
let to: string[] = [];
|
||
|
if (message.to && message.to.value) {
|
||
|
const toValue = message.to.value;
|
||
|
if (Array.isArray(toValue)) {
|
||
|
to = toValue
|
||
|
.map(addr => (addr && 'address' in addr) ? addr.address || '' : '')
|
||
|
.filter(Boolean);
|
||
|
} else if (typeof toValue === 'object' && 'address' in toValue && toValue.address) {
|
||
|
to = [toValue.address];
|
||
|
}
|
||
|
}
|
||
|
const toDomains = to.map(addr => addr.split('@')[1] || '');
|
||
|
|
||
|
// Create metadata
|
||
|
return {
|
||
|
id,
|
||
|
from,
|
||
|
fromDomain,
|
||
|
to,
|
||
|
toDomains,
|
||
|
subject: message.subject,
|
||
|
size: Buffer.byteLength(message.html || message.textAsHtml || message.text || ''),
|
||
|
hasAttachments: message.attachments?.length > 0,
|
||
|
receivedAt: new Date(),
|
||
|
clientIp: session.remoteAddress,
|
||
|
authenticated: !!session.user,
|
||
|
authUser: session.user?.username
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Scan email content
|
||
|
* @param message Parsed email
|
||
|
* @param metadata Email metadata
|
||
|
*/
|
||
|
private async scanContent(
|
||
|
message: ExtendedParsedMail,
|
||
|
metadata: IEmailMetadata
|
||
|
): Promise<IScanResult> {
|
||
|
// Skip if content scanning is disabled
|
||
|
if (!this.config.contentScanning || !this.config.scanners?.length) {
|
||
|
return {
|
||
|
id: metadata.id,
|
||
|
action: 'accept'
|
||
|
};
|
||
|
}
|
||
|
|
||
|
// Default result
|
||
|
const result: IScanResult = {
|
||
|
id: metadata.id,
|
||
|
action: 'accept'
|
||
|
};
|
||
|
|
||
|
// Placeholder for scanning results
|
||
|
let spamFound = false;
|
||
|
let virusFound = false;
|
||
|
const blockedAttachments: string[] = [];
|
||
|
|
||
|
// Apply each scanner
|
||
|
for (const scanner of this.config.scanners) {
|
||
|
switch (scanner.type) {
|
||
|
case 'spam':
|
||
|
// Placeholder for spam scanning
|
||
|
// In a real implementation, we would use a spam scanning library
|
||
|
const spamScore = Math.random() * 10; // Fake score between 0-10
|
||
|
result.spamScore = spamScore;
|
||
|
|
||
|
if (scanner.threshold && spamScore > scanner.threshold) {
|
||
|
spamFound = true;
|
||
|
if (scanner.action === 'reject') {
|
||
|
result.action = 'reject';
|
||
|
result.reason = `Spam score ${spamScore} exceeds threshold ${scanner.threshold}`;
|
||
|
} else if (scanner.action === 'tag') {
|
||
|
result.action = 'tag';
|
||
|
}
|
||
|
}
|
||
|
break;
|
||
|
|
||
|
case 'virus':
|
||
|
// Placeholder for virus scanning
|
||
|
// In a real implementation, we would use a virus scanning library
|
||
|
const hasVirus = false; // Fake result
|
||
|
result.hasVirus = hasVirus;
|
||
|
|
||
|
if (hasVirus) {
|
||
|
virusFound = true;
|
||
|
if (scanner.action === 'reject') {
|
||
|
result.action = 'reject';
|
||
|
result.reason = 'Message contains virus';
|
||
|
} else if (scanner.action === 'tag') {
|
||
|
result.action = 'tag';
|
||
|
}
|
||
|
}
|
||
|
break;
|
||
|
|
||
|
case 'attachment':
|
||
|
// Check attachments against blocked extensions
|
||
|
if (scanner.blockedExtensions && message.attachments?.length) {
|
||
|
for (const attachment of message.attachments) {
|
||
|
const filename = attachment.filename || '';
|
||
|
const extension = filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
||
|
|
||
|
if (scanner.blockedExtensions.includes(extension)) {
|
||
|
blockedAttachments.push(filename);
|
||
|
|
||
|
if (scanner.action === 'reject') {
|
||
|
result.action = 'reject';
|
||
|
result.reason = `Blocked attachment type: ${extension}`;
|
||
|
} else if (scanner.action === 'tag') {
|
||
|
result.action = 'tag';
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
// Set blocked attachments in result if any
|
||
|
if (blockedAttachments.length) {
|
||
|
result.blockedAttachments = blockedAttachments;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return result;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determine routing for an email
|
||
|
* @param message Parsed email
|
||
|
* @param metadata Email metadata
|
||
|
*/
|
||
|
private async determineRouting(
|
||
|
message: ExtendedParsedMail,
|
||
|
metadata: IEmailMetadata
|
||
|
): Promise<IRoutingDecision> {
|
||
|
// Start with the default routing
|
||
|
const defaultRouting: IRoutingDecision = {
|
||
|
id: metadata.id,
|
||
|
targetServer: this.config.defaultServer,
|
||
|
port: this.config.defaultPort || 25,
|
||
|
useTls: this.config.useTls || false
|
||
|
};
|
||
|
|
||
|
// If no domain configs, use default routing
|
||
|
if (!this.config.domainConfigs?.length) {
|
||
|
return defaultRouting;
|
||
|
}
|
||
|
|
||
|
// Try to find matching domain config based on recipient domains
|
||
|
for (const domain of metadata.toDomains) {
|
||
|
for (const domainConfig of this.config.domainConfigs) {
|
||
|
// Check if domain matches any of the configured domains
|
||
|
if (domainConfig.domains.some(configDomain => this.domainMatches(domain, configDomain))) {
|
||
|
// Create routing from domain config
|
||
|
const routing: IRoutingDecision = {
|
||
|
id: metadata.id,
|
||
|
targetServer: domainConfig.targetIPs[0], // Use first target IP
|
||
|
port: domainConfig.port || 25,
|
||
|
useTls: domainConfig.useTls || false
|
||
|
};
|
||
|
|
||
|
// Add authentication if specified
|
||
|
if (domainConfig.authentication) {
|
||
|
routing.authentication = domainConfig.authentication;
|
||
|
}
|
||
|
|
||
|
// Add header modifications if specified
|
||
|
if (domainConfig.addHeaders && domainConfig.headerInfo?.length) {
|
||
|
routing.headers = domainConfig.headerInfo.map(h => ({
|
||
|
name: h.name,
|
||
|
value: h.value,
|
||
|
append: false
|
||
|
}));
|
||
|
}
|
||
|
|
||
|
// Add DKIM signing if specified
|
||
|
if (domainConfig.signDkim && domainConfig.dkimOptions) {
|
||
|
routing.signDkim = true;
|
||
|
routing.dkimOptions = domainConfig.dkimOptions;
|
||
|
}
|
||
|
|
||
|
return routing;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// No match found, use default routing
|
||
|
return defaultRouting;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Apply transformations to the email
|
||
|
* @param message Original parsed email
|
||
|
* @param routing Routing decision
|
||
|
* @param scanResult Scan result
|
||
|
*/
|
||
|
private async applyTransformations(
|
||
|
message: ExtendedParsedMail,
|
||
|
routing: IRoutingDecision,
|
||
|
scanResult: IScanResult
|
||
|
): Promise<ExtendedParsedMail> {
|
||
|
// Skip if no transformations configured
|
||
|
if (!this.config.transformations?.length) {
|
||
|
return message;
|
||
|
}
|
||
|
|
||
|
// Clone the message for modifications
|
||
|
// Note: In a real implementation, we would need to properly clone the message
|
||
|
const modifiedMessage = { ...message };
|
||
|
|
||
|
// Apply each transformation
|
||
|
for (const transformation of this.config.transformations) {
|
||
|
switch (transformation.type) {
|
||
|
case 'addHeader':
|
||
|
// Add a header to the message
|
||
|
if (transformation.header && transformation.value) {
|
||
|
// In a real implementation, we would modify the raw message headers
|
||
|
console.log(`Adding header ${transformation.header}: ${transformation.value}`);
|
||
|
}
|
||
|
break;
|
||
|
|
||
|
case 'dkimSign':
|
||
|
// Sign the message with DKIM
|
||
|
if (routing.signDkim && routing.dkimOptions) {
|
||
|
// In a real implementation, we would use mailauth.dkimSign
|
||
|
console.log(`Signing message with DKIM for domain ${routing.dkimOptions.domainName}`);
|
||
|
}
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return modifiedMessage;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Check if a domain matches a pattern (including wildcards)
|
||
|
* @param domain Domain to check
|
||
|
* @param pattern Pattern to match against
|
||
|
*/
|
||
|
private domainMatches(domain: string, pattern: string): boolean {
|
||
|
domain = domain.toLowerCase();
|
||
|
pattern = pattern.toLowerCase();
|
||
|
|
||
|
// Exact match
|
||
|
if (domain === pattern) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
// Wildcard match (*.example.com)
|
||
|
if (pattern.startsWith('*.')) {
|
||
|
const suffix = pattern.slice(2);
|
||
|
return domain.endsWith(suffix) && domain.length > suffix.length;
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Update processor configuration
|
||
|
* @param config New configuration
|
||
|
*/
|
||
|
public updateConfig(config: Partial<ISmtpConfig>): void {
|
||
|
this.config = {
|
||
|
...this.config,
|
||
|
...config
|
||
|
};
|
||
|
|
||
|
this.emit('configUpdated', this.config);
|
||
|
}
|
||
|
}
|