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; headers?: Map; 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 = 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 { 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 { // 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 { // 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 { // 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 { // 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): void { this.config = { ...this.config, ...config }; this.emit('configUpdated', this.config); } }