update
This commit is contained in:
562
ts/mail/delivery/classes.connector.mta.ts
Normal file
562
ts/mail/delivery/classes.connector.mta.ts
Normal file
@ -0,0 +1,562 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { EmailService } from '../services/classes.emailservice.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
// Import MTA classes
|
||||
import { MtaService } from './classes.mta.js';
|
||||
import { Email as MtaEmail } from '../core/classes.email.js';
|
||||
|
||||
// Import Email types
|
||||
export interface IEmailOptions {
|
||||
from: string;
|
||||
to: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
attachments?: IAttachment[];
|
||||
headers?: { [key: string]: string };
|
||||
}
|
||||
|
||||
// Reuse the DeliveryStatus from the email send job
|
||||
export enum DeliveryStatus {
|
||||
PENDING = 'pending',
|
||||
PROCESSING = 'processing',
|
||||
DELIVERED = 'delivered',
|
||||
DEFERRED = 'deferred',
|
||||
FAILED = 'failed'
|
||||
}
|
||||
|
||||
// Reuse the IAttachment interface
|
||||
export interface IAttachment {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
contentId?: string;
|
||||
encoding?: string;
|
||||
}
|
||||
|
||||
export class MtaConnector {
|
||||
public emailRef: EmailService;
|
||||
private mtaService: MtaService;
|
||||
|
||||
constructor(emailRefArg: EmailService, mtaService?: MtaService) {
|
||||
this.emailRef = emailRefArg;
|
||||
this.mtaService = mtaService || this.emailRef.mtaService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using the MTA service
|
||||
* @param smartmail The email to send
|
||||
* @param toAddresses Recipients (comma-separated or array)
|
||||
* @param options Additional options
|
||||
*/
|
||||
public async sendEmail(
|
||||
smartmail: plugins.smartmail.Smartmail<any>,
|
||||
toAddresses: string | string[],
|
||||
options: any = {}
|
||||
): Promise<string> {
|
||||
// Check if recipients are on the suppression list
|
||||
const recipients = Array.isArray(toAddresses)
|
||||
? toAddresses
|
||||
: toAddresses.split(',').map(addr => addr.trim());
|
||||
|
||||
// Filter out suppressed recipients
|
||||
const validRecipients = [];
|
||||
const suppressedRecipients = [];
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (this.emailRef.bounceManager.isEmailSuppressed(recipient)) {
|
||||
suppressedRecipients.push(recipient);
|
||||
} else {
|
||||
validRecipients.push(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
// Log suppressed recipients
|
||||
if (suppressedRecipients.length > 0) {
|
||||
logger.log('warn', `Skipping ${suppressedRecipients.length} suppressed recipients`, {
|
||||
suppressedRecipients
|
||||
});
|
||||
}
|
||||
|
||||
// If all recipients are suppressed, throw error
|
||||
if (validRecipients.length === 0) {
|
||||
throw new Error('All recipients are on the suppression list');
|
||||
}
|
||||
|
||||
// Continue with valid recipients
|
||||
try {
|
||||
// Use filtered recipients - already an array, no need for toArray
|
||||
|
||||
// Add recipients to smartmail if they're not already added
|
||||
if (!smartmail.options.to || smartmail.options.to.length === 0) {
|
||||
for (const recipient of validRecipients) {
|
||||
smartmail.addRecipient(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle options
|
||||
const emailOptions: Record<string, any> = { ...options };
|
||||
|
||||
// Check if we should use MIME format
|
||||
const useMimeFormat = options.useMimeFormat ?? true;
|
||||
|
||||
if (useMimeFormat) {
|
||||
// Use smartmail's MIME conversion for improved handling
|
||||
try {
|
||||
// Convert to MIME format
|
||||
const mimeEmail = await smartmail.toMimeFormat(smartmail.options.creationObjectRef);
|
||||
|
||||
// Parse the MIME email to create an MTA Email
|
||||
return this.sendMimeEmail(mimeEmail, validRecipients);
|
||||
} catch (mimeError) {
|
||||
logger.log('warn', `Failed to use MIME format, falling back to direct conversion: ${mimeError.message}`);
|
||||
// Fall back to direct conversion
|
||||
return this.sendDirectEmail(smartmail, validRecipients);
|
||||
}
|
||||
} else {
|
||||
// Use direct conversion
|
||||
return this.sendDirectEmail(smartmail, validRecipients);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to send email via MTA: ${error.message}`, {
|
||||
eventType: 'emailError',
|
||||
provider: 'mta',
|
||||
error: error.message
|
||||
});
|
||||
|
||||
// Check if this is a bounce-related error
|
||||
if (error.message.includes('550') || // Rejected
|
||||
error.message.includes('551') || // User not local
|
||||
error.message.includes('552') || // Mailbox full
|
||||
error.message.includes('553') || // Bad mailbox name
|
||||
error.message.includes('554') || // Transaction failed
|
||||
error.message.includes('does not exist') ||
|
||||
error.message.includes('unknown user') ||
|
||||
error.message.includes('invalid recipient')) {
|
||||
|
||||
// Process as a bounce
|
||||
for (const recipient of validRecipients) {
|
||||
await this.emailRef.bounceManager.processSmtpFailure(
|
||||
recipient,
|
||||
error.message,
|
||||
{
|
||||
sender: smartmail.options.from,
|
||||
statusCode: error.message.match(/\b([45]\d{2})\b/) ? error.message.match(/\b([45]\d{2})\b/)[1] : undefined
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a MIME-formatted email
|
||||
* @param mimeEmail The MIME-formatted email content
|
||||
* @param recipients The email recipients
|
||||
*/
|
||||
private async sendMimeEmail(mimeEmail: string, recipients: string[]): Promise<string> {
|
||||
try {
|
||||
// Parse the MIME email
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(mimeEmail);
|
||||
|
||||
// Extract necessary information for MTA Email
|
||||
const mtaEmail = new MtaEmail({
|
||||
from: parsedEmail.from?.text || '',
|
||||
to: recipients,
|
||||
subject: parsedEmail.subject || '',
|
||||
text: parsedEmail.text || '',
|
||||
html: parsedEmail.html || undefined,
|
||||
attachments: parsedEmail.attachments?.map(attachment => ({
|
||||
filename: attachment.filename || 'attachment',
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType || 'application/octet-stream',
|
||||
contentId: attachment.contentId
|
||||
})) || [],
|
||||
headers: Object.fromEntries([...parsedEmail.headers].map(([key, value]) => [key, String(value)]))
|
||||
});
|
||||
|
||||
// Send using MTA
|
||||
const emailId = await this.mtaService.send(mtaEmail);
|
||||
|
||||
logger.log('info', `MIME email sent via MTA to ${recipients.join(', ')}`, {
|
||||
eventType: 'sentEmail',
|
||||
provider: 'mta',
|
||||
emailId,
|
||||
to: recipients
|
||||
});
|
||||
|
||||
return emailId;
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to send MIME email: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using direct conversion (fallback method)
|
||||
* @param smartmail The Smartmail instance
|
||||
* @param recipients The email recipients
|
||||
*/
|
||||
private async sendDirectEmail(
|
||||
smartmail: plugins.smartmail.Smartmail<any>,
|
||||
recipients: string[]
|
||||
): Promise<string> {
|
||||
// Map SmartMail attachments to MTA attachments with improved content type handling
|
||||
const attachments: IAttachment[] = smartmail.attachments.map(attachment => {
|
||||
// Try to determine content type from file extension if not explicitly set
|
||||
let contentType = (attachment as any)?.contentType;
|
||||
|
||||
if (!contentType) {
|
||||
const extension = attachment.parsedPath.ext.toLowerCase();
|
||||
contentType = this.getContentTypeFromExtension(extension);
|
||||
}
|
||||
|
||||
return {
|
||||
filename: attachment.parsedPath.base,
|
||||
content: Buffer.from(attachment.contentBuffer),
|
||||
contentType: contentType || 'application/octet-stream',
|
||||
// Add content ID for inline images if available
|
||||
contentId: (attachment as any)?.contentId
|
||||
};
|
||||
});
|
||||
|
||||
// Create MTA Email
|
||||
const mtaEmail = new MtaEmail({
|
||||
from: smartmail.options.from,
|
||||
to: recipients,
|
||||
subject: smartmail.getSubject(),
|
||||
text: smartmail.getBody(false), // Plain text version
|
||||
html: smartmail.getBody(true), // HTML version
|
||||
attachments
|
||||
});
|
||||
|
||||
// Prepare arrays for CC and BCC recipients
|
||||
let ccRecipients: string[] = [];
|
||||
let bccRecipients: string[] = [];
|
||||
|
||||
// Add CC recipients if present
|
||||
if (smartmail.options.cc?.length > 0) {
|
||||
// Handle CC recipients - smartmail options may contain email objects
|
||||
ccRecipients = smartmail.options.cc.map(r => {
|
||||
if (typeof r === 'string') return r;
|
||||
return typeof (r as any).address === 'string' ? (r as any).address :
|
||||
typeof (r as any).email === 'string' ? (r as any).email : '';
|
||||
});
|
||||
mtaEmail.cc = ccRecipients;
|
||||
}
|
||||
|
||||
// Add BCC recipients if present
|
||||
if (smartmail.options.bcc?.length > 0) {
|
||||
// Handle BCC recipients - smartmail options may contain email objects
|
||||
bccRecipients = smartmail.options.bcc.map(r => {
|
||||
if (typeof r === 'string') return r;
|
||||
return typeof (r as any).address === 'string' ? (r as any).address :
|
||||
typeof (r as any).email === 'string' ? (r as any).email : '';
|
||||
});
|
||||
mtaEmail.bcc = bccRecipients;
|
||||
}
|
||||
|
||||
// Send using MTA
|
||||
const emailId = await this.mtaService.send(mtaEmail);
|
||||
|
||||
logger.log('info', `Email sent via MTA to ${recipients.join(', ')}`, {
|
||||
eventType: 'sentEmail',
|
||||
provider: 'mta',
|
||||
emailId,
|
||||
to: recipients
|
||||
});
|
||||
|
||||
return emailId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type from file extension
|
||||
* @param extension The file extension (with or without dot)
|
||||
* @returns The content type or undefined if unknown
|
||||
*/
|
||||
private getContentTypeFromExtension(extension: string): string | undefined {
|
||||
// Remove dot if present
|
||||
const ext = extension.startsWith('.') ? extension.substring(1) : extension;
|
||||
|
||||
// Common content types
|
||||
const contentTypes: Record<string, string> = {
|
||||
'pdf': 'application/pdf',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'svg': 'image/svg+xml',
|
||||
'webp': 'image/webp',
|
||||
'txt': 'text/plain',
|
||||
'html': 'text/html',
|
||||
'csv': 'text/csv',
|
||||
'json': 'application/json',
|
||||
'xml': 'application/xml',
|
||||
'zip': 'application/zip',
|
||||
'doc': 'application/msword',
|
||||
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'xls': 'application/vnd.ms-excel',
|
||||
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'ppt': 'application/vnd.ms-powerpoint',
|
||||
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
};
|
||||
|
||||
return contentTypes[ext.toLowerCase()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve and process an incoming email
|
||||
* For MTA, this would handle an email already received by the SMTP server
|
||||
* @param emailData The raw email data or identifier
|
||||
* @param options Additional processing options
|
||||
*/
|
||||
public async receiveEmail(
|
||||
emailData: string,
|
||||
options: {
|
||||
preserveHeaders?: boolean;
|
||||
includeRawData?: boolean;
|
||||
validateSender?: boolean;
|
||||
} = {}
|
||||
): Promise<plugins.smartmail.Smartmail<any>> {
|
||||
try {
|
||||
// In a real implementation, this would retrieve an email from the MTA storage
|
||||
// For now, we can use a simplified approach:
|
||||
|
||||
// Parse the email (assuming emailData is a raw email or a file path)
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
|
||||
|
||||
// Extract sender information
|
||||
const sender = parsedEmail.from?.text || '';
|
||||
let senderName = '';
|
||||
let senderEmail = sender;
|
||||
|
||||
// Try to extract name and email from "Name <email>" format
|
||||
const senderMatch = sender.match(/(.*?)\s*<([^>]+)>/);
|
||||
if (senderMatch) {
|
||||
senderName = senderMatch[1].trim();
|
||||
senderEmail = senderMatch[2].trim();
|
||||
}
|
||||
|
||||
// Extract recipients
|
||||
const recipients = [];
|
||||
if (parsedEmail.to) {
|
||||
// Extract recipients safely
|
||||
try {
|
||||
// Handle AddressObject or AddressObject[]
|
||||
if (parsedEmail.to && typeof parsedEmail.to === 'object' && 'value' in parsedEmail.to) {
|
||||
const addressList = Array.isArray(parsedEmail.to.value)
|
||||
? parsedEmail.to.value
|
||||
: [parsedEmail.to.value];
|
||||
|
||||
for (const addr of addressList) {
|
||||
if (addr && typeof addr === 'object' && 'address' in addr) {
|
||||
recipients.push({
|
||||
name: addr.name || '',
|
||||
email: addr.address || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, try to extract as string
|
||||
let toStr = '';
|
||||
if (parsedEmail.to && typeof parsedEmail.to === 'object' && 'text' in parsedEmail.to) {
|
||||
toStr = String(parsedEmail.to.text || '');
|
||||
}
|
||||
if (toStr) {
|
||||
recipients.push({
|
||||
name: '',
|
||||
email: toStr
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a more comprehensive creation object reference
|
||||
const creationObjectRef: Record<string, any> = {
|
||||
sender: {
|
||||
name: senderName,
|
||||
email: senderEmail
|
||||
},
|
||||
recipients: recipients,
|
||||
subject: parsedEmail.subject || '',
|
||||
date: parsedEmail.date || new Date(),
|
||||
messageId: parsedEmail.messageId || '',
|
||||
inReplyTo: parsedEmail.inReplyTo || null,
|
||||
references: parsedEmail.references || []
|
||||
};
|
||||
|
||||
// Include headers if requested
|
||||
if (options.preserveHeaders) {
|
||||
creationObjectRef.headers = parsedEmail.headers;
|
||||
}
|
||||
|
||||
// Include raw data if requested
|
||||
if (options.includeRawData) {
|
||||
creationObjectRef.rawData = emailData;
|
||||
}
|
||||
|
||||
// Create a Smartmail from the parsed email
|
||||
const smartmail = new plugins.smartmail.Smartmail({
|
||||
from: senderEmail,
|
||||
subject: parsedEmail.subject || '',
|
||||
body: parsedEmail.html || parsedEmail.text || '',
|
||||
creationObjectRef
|
||||
});
|
||||
|
||||
// Add recipients
|
||||
if (recipients.length > 0) {
|
||||
for (const recipient of recipients) {
|
||||
smartmail.addRecipient(recipient.email);
|
||||
}
|
||||
}
|
||||
|
||||
// Add CC recipients if present
|
||||
if (parsedEmail.cc) {
|
||||
try {
|
||||
// Extract CC recipients safely
|
||||
if (parsedEmail.cc && typeof parsedEmail.cc === 'object' && 'value' in parsedEmail.cc) {
|
||||
const ccList = Array.isArray(parsedEmail.cc.value)
|
||||
? parsedEmail.cc.value
|
||||
: [parsedEmail.cc.value];
|
||||
|
||||
for (const addr of ccList) {
|
||||
if (addr && typeof addr === 'object' && 'address' in addr) {
|
||||
smartmail.addRecipient(addr.address, 'cc');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, try to extract as string
|
||||
let ccStr = '';
|
||||
if (parsedEmail.cc && typeof parsedEmail.cc === 'object' && 'text' in parsedEmail.cc) {
|
||||
ccStr = String(parsedEmail.cc.text || '');
|
||||
}
|
||||
if (ccStr) {
|
||||
smartmail.addRecipient(ccStr, 'cc');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add BCC recipients if present (usually not in received emails, but just in case)
|
||||
if (parsedEmail.bcc) {
|
||||
try {
|
||||
// Extract BCC recipients safely
|
||||
if (parsedEmail.bcc && typeof parsedEmail.bcc === 'object' && 'value' in parsedEmail.bcc) {
|
||||
const bccList = Array.isArray(parsedEmail.bcc.value)
|
||||
? parsedEmail.bcc.value
|
||||
: [parsedEmail.bcc.value];
|
||||
|
||||
for (const addr of bccList) {
|
||||
if (addr && typeof addr === 'object' && 'address' in addr) {
|
||||
smartmail.addRecipient(addr.address, 'bcc');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, try to extract as string
|
||||
let bccStr = '';
|
||||
if (parsedEmail.bcc && typeof parsedEmail.bcc === 'object' && 'text' in parsedEmail.bcc) {
|
||||
bccStr = String(parsedEmail.bcc.text || '');
|
||||
}
|
||||
if (bccStr) {
|
||||
smartmail.addRecipient(bccStr, 'bcc');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add attachments if present
|
||||
if (parsedEmail.attachments && parsedEmail.attachments.length > 0) {
|
||||
for (const attachment of parsedEmail.attachments) {
|
||||
// Create smartfile with proper constructor options
|
||||
const file = new plugins.smartfile.SmartFile({
|
||||
path: attachment.filename || 'attachment',
|
||||
contentBuffer: attachment.content,
|
||||
base: ''
|
||||
});
|
||||
|
||||
// Set content type and content ID for proper MIME handling
|
||||
if (attachment.contentType) {
|
||||
(file as any).contentType = attachment.contentType;
|
||||
}
|
||||
|
||||
if (attachment.contentId) {
|
||||
(file as any).contentId = attachment.contentId;
|
||||
}
|
||||
|
||||
smartmail.addAttachment(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate sender if requested
|
||||
if (options.validateSender && this.emailRef.emailValidator) {
|
||||
try {
|
||||
const validationResult = await this.emailRef.emailValidator.validate(senderEmail, {
|
||||
checkSyntaxOnly: true // Use syntax-only for performance
|
||||
});
|
||||
|
||||
// Add validation info to the creation object
|
||||
creationObjectRef.senderValidation = validationResult;
|
||||
} catch (validationError) {
|
||||
logger.log('warn', `Sender validation error: ${validationError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return smartmail;
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to receive email via MTA: ${error.message}`, {
|
||||
eventType: 'emailError',
|
||||
provider: 'mta',
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the status of a sent email
|
||||
* @param emailId The email ID to check
|
||||
*/
|
||||
public async checkEmailStatus(emailId: string): Promise<{
|
||||
status: string;
|
||||
details?: any;
|
||||
}> {
|
||||
try {
|
||||
const status = this.mtaService.getEmailStatus(emailId);
|
||||
|
||||
if (!status) {
|
||||
return {
|
||||
status: 'unknown',
|
||||
details: { message: 'Email not found' }
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: status.status,
|
||||
details: {
|
||||
attempts: status.attempts,
|
||||
lastAttempt: status.lastAttempt,
|
||||
nextAttempt: status.nextAttempt,
|
||||
error: status.error?.message
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to check email status: ${error.message}`, {
|
||||
eventType: 'emailError',
|
||||
provider: 'mta',
|
||||
emailId,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
details: { message: error.message }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
638
ts/mail/delivery/classes.delivery.queue.ts
Normal file
638
ts/mail/delivery/classes.delivery.queue.ts
Normal file
@ -0,0 +1,638 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { logger } from '../../logger.js';
|
||||
import { type EmailProcessingMode, type IDomainRule } from '../routing/classes.email.config.js';
|
||||
|
||||
/**
|
||||
* Queue item status
|
||||
*/
|
||||
export type QueueItemStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
||||
|
||||
/**
|
||||
* Queue item interface
|
||||
*/
|
||||
export interface IQueueItem {
|
||||
id: string;
|
||||
processingMode: EmailProcessingMode;
|
||||
processingResult: any;
|
||||
rule: IDomainRule;
|
||||
status: QueueItemStatus;
|
||||
attempts: number;
|
||||
nextAttempt: Date;
|
||||
lastError?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deliveredAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue options interface
|
||||
*/
|
||||
export interface IQueueOptions {
|
||||
// Storage options
|
||||
storageType?: 'memory' | 'disk';
|
||||
persistentPath?: string;
|
||||
|
||||
// Queue behavior
|
||||
checkInterval?: number;
|
||||
maxQueueSize?: number;
|
||||
maxPerDestination?: number;
|
||||
|
||||
// Delivery attempts
|
||||
maxRetries?: number;
|
||||
baseRetryDelay?: number;
|
||||
maxRetryDelay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue statistics interface
|
||||
*/
|
||||
export interface IQueueStats {
|
||||
queueSize: number;
|
||||
status: {
|
||||
pending: number;
|
||||
processing: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
deferred: number;
|
||||
};
|
||||
modes: {
|
||||
forward: number;
|
||||
mta: number;
|
||||
process: number;
|
||||
};
|
||||
oldestItem?: Date;
|
||||
newestItem?: Date;
|
||||
averageAttempts: number;
|
||||
totalProcessed: number;
|
||||
processingActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unified queue for all email modes
|
||||
*/
|
||||
export class UnifiedDeliveryQueue extends EventEmitter {
|
||||
private options: Required<IQueueOptions>;
|
||||
private queue: Map<string, IQueueItem> = new Map();
|
||||
private checkTimer?: NodeJS.Timeout;
|
||||
private stats: IQueueStats;
|
||||
private processing: boolean = false;
|
||||
private totalProcessed: number = 0;
|
||||
|
||||
/**
|
||||
* Create a new unified delivery queue
|
||||
* @param options Queue options
|
||||
*/
|
||||
constructor(options: IQueueOptions) {
|
||||
super();
|
||||
|
||||
// Set default options
|
||||
this.options = {
|
||||
storageType: options.storageType || 'memory',
|
||||
persistentPath: options.persistentPath || path.join(process.cwd(), 'email-queue'),
|
||||
checkInterval: options.checkInterval || 30000, // 30 seconds
|
||||
maxQueueSize: options.maxQueueSize || 10000,
|
||||
maxPerDestination: options.maxPerDestination || 100,
|
||||
maxRetries: options.maxRetries || 5,
|
||||
baseRetryDelay: options.baseRetryDelay || 60000, // 1 minute
|
||||
maxRetryDelay: options.maxRetryDelay || 3600000 // 1 hour
|
||||
};
|
||||
|
||||
// Initialize statistics
|
||||
this.stats = {
|
||||
queueSize: 0,
|
||||
status: {
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
deferred: 0
|
||||
},
|
||||
modes: {
|
||||
forward: 0,
|
||||
mta: 0,
|
||||
process: 0
|
||||
},
|
||||
averageAttempts: 0,
|
||||
totalProcessed: 0,
|
||||
processingActive: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the queue
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
logger.log('info', 'Initializing UnifiedDeliveryQueue');
|
||||
|
||||
try {
|
||||
// Create persistent storage directory if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
if (!fs.existsSync(this.options.persistentPath)) {
|
||||
fs.mkdirSync(this.options.persistentPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Load existing items from disk
|
||||
await this.loadFromDisk();
|
||||
}
|
||||
|
||||
// Start the queue processing timer
|
||||
this.startProcessing();
|
||||
|
||||
// Emit initialized event
|
||||
this.emit('initialized');
|
||||
logger.log('info', 'UnifiedDeliveryQueue initialized successfully');
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to initialize queue: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start queue processing
|
||||
*/
|
||||
private startProcessing(): void {
|
||||
if (this.checkTimer) {
|
||||
clearInterval(this.checkTimer);
|
||||
}
|
||||
|
||||
this.checkTimer = setInterval(() => this.processQueue(), this.options.checkInterval);
|
||||
this.processing = true;
|
||||
this.stats.processingActive = true;
|
||||
this.emit('processingStarted');
|
||||
logger.log('info', 'Queue processing started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop queue processing
|
||||
*/
|
||||
private stopProcessing(): void {
|
||||
if (this.checkTimer) {
|
||||
clearInterval(this.checkTimer);
|
||||
this.checkTimer = undefined;
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
this.stats.processingActive = false;
|
||||
this.emit('processingStopped');
|
||||
logger.log('info', 'Queue processing stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for items that need to be processed
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
try {
|
||||
const now = new Date();
|
||||
let readyItems: IQueueItem[] = [];
|
||||
|
||||
// Find items ready for processing
|
||||
for (const item of this.queue.values()) {
|
||||
if (item.status === 'pending' || (item.status === 'deferred' && item.nextAttempt <= now)) {
|
||||
readyItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (readyItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by oldest first
|
||||
readyItems.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
|
||||
// Emit event for ready items
|
||||
this.emit('itemsReady', readyItems);
|
||||
logger.log('info', `Found ${readyItems.length} items ready for processing`);
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
} catch (error) {
|
||||
logger.log('error', `Error processing queue: ${error.message}`);
|
||||
this.emit('error', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to the queue
|
||||
* @param processingResult Processing result to queue
|
||||
* @param mode Processing mode
|
||||
* @param rule Domain rule
|
||||
*/
|
||||
public async enqueue(processingResult: any, mode: EmailProcessingMode, rule: IDomainRule): Promise<string> {
|
||||
// Check if queue is full
|
||||
if (this.queue.size >= this.options.maxQueueSize) {
|
||||
throw new Error('Queue is full');
|
||||
}
|
||||
|
||||
// Generate a unique ID
|
||||
const id = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
||||
|
||||
// Create queue item
|
||||
const item: IQueueItem = {
|
||||
id,
|
||||
processingMode: mode,
|
||||
processingResult,
|
||||
rule,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
nextAttempt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
// Add to queue
|
||||
this.queue.set(id, item);
|
||||
|
||||
// Persist to disk if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
await this.persistItem(item);
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
// Emit event
|
||||
this.emit('itemEnqueued', item);
|
||||
logger.log('info', `Item enqueued with ID ${id}, mode: ${mode}`);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an item from the queue
|
||||
* @param id Item ID
|
||||
*/
|
||||
public getItem(id: string): IQueueItem | undefined {
|
||||
return this.queue.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an item as being processed
|
||||
* @param id Item ID
|
||||
*/
|
||||
public async markProcessing(id: string): Promise<boolean> {
|
||||
const item = this.queue.get(id);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update status
|
||||
item.status = 'processing';
|
||||
item.attempts++;
|
||||
item.updatedAt = new Date();
|
||||
|
||||
// Persist changes if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
await this.persistItem(item);
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
// Emit event
|
||||
this.emit('itemProcessing', item);
|
||||
logger.log('info', `Item ${id} marked as processing, attempt ${item.attempts}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an item as delivered
|
||||
* @param id Item ID
|
||||
*/
|
||||
public async markDelivered(id: string): Promise<boolean> {
|
||||
const item = this.queue.get(id);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update status
|
||||
item.status = 'delivered';
|
||||
item.updatedAt = new Date();
|
||||
item.deliveredAt = new Date();
|
||||
|
||||
// Persist changes if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
await this.persistItem(item);
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.totalProcessed++;
|
||||
this.updateStats();
|
||||
|
||||
// Emit event
|
||||
this.emit('itemDelivered', item);
|
||||
logger.log('info', `Item ${id} marked as delivered after ${item.attempts} attempts`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an item as failed
|
||||
* @param id Item ID
|
||||
* @param error Error message
|
||||
*/
|
||||
public async markFailed(id: string, error: string): Promise<boolean> {
|
||||
const item = this.queue.get(id);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine if we should retry
|
||||
if (item.attempts < this.options.maxRetries) {
|
||||
// Calculate next retry time with exponential backoff
|
||||
const delay = Math.min(
|
||||
this.options.baseRetryDelay * Math.pow(2, item.attempts - 1),
|
||||
this.options.maxRetryDelay
|
||||
);
|
||||
|
||||
// Update status
|
||||
item.status = 'deferred';
|
||||
item.lastError = error;
|
||||
item.nextAttempt = new Date(Date.now() + delay);
|
||||
item.updatedAt = new Date();
|
||||
|
||||
// Persist changes if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
await this.persistItem(item);
|
||||
}
|
||||
|
||||
// Emit event
|
||||
this.emit('itemDeferred', item);
|
||||
logger.log('info', `Item ${id} deferred for ${delay}ms, attempt ${item.attempts}, error: ${error}`);
|
||||
} else {
|
||||
// Mark as permanently failed
|
||||
item.status = 'failed';
|
||||
item.lastError = error;
|
||||
item.updatedAt = new Date();
|
||||
|
||||
// Persist changes if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
await this.persistItem(item);
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.totalProcessed++;
|
||||
|
||||
// Emit event
|
||||
this.emit('itemFailed', item);
|
||||
logger.log('warn', `Item ${id} permanently failed after ${item.attempts} attempts, error: ${error}`);
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from the queue
|
||||
* @param id Item ID
|
||||
*/
|
||||
public async removeItem(id: string): Promise<boolean> {
|
||||
const item = this.queue.get(id);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove from queue
|
||||
this.queue.delete(id);
|
||||
|
||||
// Remove from disk if using disk storage
|
||||
if (this.options.storageType === 'disk') {
|
||||
await this.removeItemFromDisk(id);
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
// Emit event
|
||||
this.emit('itemRemoved', item);
|
||||
logger.log('info', `Item ${id} removed from queue`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an item to disk
|
||||
* @param item Item to persist
|
||||
*/
|
||||
private async persistItem(item: IQueueItem): Promise<void> {
|
||||
try {
|
||||
const filePath = path.join(this.options.persistentPath, `${item.id}.json`);
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(item, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to persist item ${item.id}: ${error.message}`);
|
||||
this.emit('error', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from disk
|
||||
* @param id Item ID
|
||||
*/
|
||||
private async removeItemFromDisk(id: string): Promise<void> {
|
||||
try {
|
||||
const filePath = path.join(this.options.persistentPath, `${id}.json`);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to remove item ${id} from disk: ${error.message}`);
|
||||
this.emit('error', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load queue items from disk
|
||||
*/
|
||||
private async loadFromDisk(): Promise<void> {
|
||||
try {
|
||||
// Check if directory exists
|
||||
if (!fs.existsSync(this.options.persistentPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all JSON files
|
||||
const files = fs.readdirSync(this.options.persistentPath).filter(file => file.endsWith('.json'));
|
||||
|
||||
// Load each file
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(this.options.persistentPath, file);
|
||||
const data = await fs.promises.readFile(filePath, 'utf8');
|
||||
const item = JSON.parse(data) as IQueueItem;
|
||||
|
||||
// Convert date strings to Date objects
|
||||
item.createdAt = new Date(item.createdAt);
|
||||
item.updatedAt = new Date(item.updatedAt);
|
||||
item.nextAttempt = new Date(item.nextAttempt);
|
||||
if (item.deliveredAt) {
|
||||
item.deliveredAt = new Date(item.deliveredAt);
|
||||
}
|
||||
|
||||
// Add to queue
|
||||
this.queue.set(item.id, item);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load item from ${file}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
logger.log('info', `Loaded ${this.queue.size} items from disk`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load items from disk: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update queue statistics
|
||||
*/
|
||||
private updateStats(): void {
|
||||
// Reset counters
|
||||
this.stats.queueSize = this.queue.size;
|
||||
this.stats.status = {
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
deferred: 0
|
||||
};
|
||||
this.stats.modes = {
|
||||
forward: 0,
|
||||
mta: 0,
|
||||
process: 0
|
||||
};
|
||||
|
||||
let totalAttempts = 0;
|
||||
let oldestTime = Date.now();
|
||||
let newestTime = 0;
|
||||
|
||||
// Count by status and mode
|
||||
for (const item of this.queue.values()) {
|
||||
// Count by status
|
||||
this.stats.status[item.status]++;
|
||||
|
||||
// Count by mode
|
||||
this.stats.modes[item.processingMode]++;
|
||||
|
||||
// Track total attempts
|
||||
totalAttempts += item.attempts;
|
||||
|
||||
// Track oldest and newest
|
||||
const itemTime = item.createdAt.getTime();
|
||||
if (itemTime < oldestTime) {
|
||||
oldestTime = itemTime;
|
||||
}
|
||||
if (itemTime > newestTime) {
|
||||
newestTime = itemTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average attempts
|
||||
this.stats.averageAttempts = this.queue.size > 0 ? totalAttempts / this.queue.size : 0;
|
||||
|
||||
// Set oldest and newest
|
||||
this.stats.oldestItem = this.queue.size > 0 ? new Date(oldestTime) : undefined;
|
||||
this.stats.newestItem = this.queue.size > 0 ? new Date(newestTime) : undefined;
|
||||
|
||||
// Set total processed
|
||||
this.stats.totalProcessed = this.totalProcessed;
|
||||
|
||||
// Set processing active
|
||||
this.stats.processingActive = this.processing;
|
||||
|
||||
// Emit statistics event
|
||||
this.emit('statsUpdated', this.stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*/
|
||||
public getStats(): IQueueStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause queue processing
|
||||
*/
|
||||
public pause(): void {
|
||||
if (this.processing) {
|
||||
this.stopProcessing();
|
||||
logger.log('info', 'Queue processing paused');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume queue processing
|
||||
*/
|
||||
public resume(): void {
|
||||
if (!this.processing) {
|
||||
this.startProcessing();
|
||||
logger.log('info', 'Queue processing resumed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old delivered and failed items
|
||||
* @param maxAge Maximum age in milliseconds (default: 7 days)
|
||||
*/
|
||||
public async cleanupOldItems(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise<number> {
|
||||
const cutoff = new Date(Date.now() - maxAge);
|
||||
let removedCount = 0;
|
||||
|
||||
// Find old items
|
||||
for (const item of this.queue.values()) {
|
||||
if (['delivered', 'failed'].includes(item.status) && item.updatedAt < cutoff) {
|
||||
// Remove item
|
||||
await this.removeItem(item.id);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('info', `Cleaned up ${removedCount} old items`);
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the queue
|
||||
*/
|
||||
public async shutdown(): Promise<void> {
|
||||
logger.log('info', 'Shutting down UnifiedDeliveryQueue');
|
||||
|
||||
// Stop processing
|
||||
this.stopProcessing();
|
||||
|
||||
// If using disk storage, make sure all items are persisted
|
||||
if (this.options.storageType === 'disk') {
|
||||
const pendingWrites: Promise<void>[] = [];
|
||||
|
||||
for (const item of this.queue.values()) {
|
||||
pendingWrites.push(this.persistItem(item));
|
||||
}
|
||||
|
||||
// Wait for all writes to complete
|
||||
await Promise.all(pendingWrites);
|
||||
}
|
||||
|
||||
// Clear the queue (memory only)
|
||||
this.queue.clear();
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
// Emit shutdown event
|
||||
this.emit('shutdown');
|
||||
logger.log('info', 'UnifiedDeliveryQueue shut down successfully');
|
||||
}
|
||||
}
|
935
ts/mail/delivery/classes.delivery.system.ts
Normal file
935
ts/mail/delivery/classes.delivery.system.ts
Normal file
@ -0,0 +1,935 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import * as net from 'node:net';
|
||||
import * as tls from 'node:tls';
|
||||
import { logger } from '../../logger.js';
|
||||
import {
|
||||
SecurityLogger,
|
||||
SecurityLogLevel,
|
||||
SecurityEventType
|
||||
} from '../../security/index.js';
|
||||
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js';
|
||||
import type { Email } from '../core/classes.email.js';
|
||||
import type { IDomainRule } from '../routing/classes.email.config.js';
|
||||
|
||||
/**
|
||||
* Delivery handler interface
|
||||
*/
|
||||
export interface IDeliveryHandler {
|
||||
deliver(item: IQueueItem): Promise<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery options
|
||||
*/
|
||||
export interface IMultiModeDeliveryOptions {
|
||||
// Connection options
|
||||
connectionPoolSize?: number;
|
||||
socketTimeout?: number;
|
||||
|
||||
// Delivery behavior
|
||||
concurrentDeliveries?: number;
|
||||
sendTimeout?: number;
|
||||
|
||||
// TLS options
|
||||
verifyCertificates?: boolean;
|
||||
tlsMinVersion?: string;
|
||||
|
||||
// Mode-specific handlers
|
||||
forwardHandler?: IDeliveryHandler;
|
||||
mtaHandler?: IDeliveryHandler;
|
||||
processHandler?: IDeliveryHandler;
|
||||
|
||||
// Rate limiting
|
||||
globalRateLimit?: number;
|
||||
perPatternRateLimit?: Record<string, number>;
|
||||
|
||||
// Event hooks
|
||||
onDeliveryStart?: (item: IQueueItem) => Promise<void>;
|
||||
onDeliverySuccess?: (item: IQueueItem, result: any) => Promise<void>;
|
||||
onDeliveryFailed?: (item: IQueueItem, error: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery system statistics
|
||||
*/
|
||||
export interface IDeliveryStats {
|
||||
activeDeliveries: number;
|
||||
totalSuccessful: number;
|
||||
totalFailed: number;
|
||||
avgDeliveryTime: number;
|
||||
byMode: {
|
||||
forward: {
|
||||
successful: number;
|
||||
failed: number;
|
||||
};
|
||||
mta: {
|
||||
successful: number;
|
||||
failed: number;
|
||||
};
|
||||
process: {
|
||||
successful: number;
|
||||
failed: number;
|
||||
};
|
||||
};
|
||||
rateLimiting: {
|
||||
currentRate: number;
|
||||
globalLimit: number;
|
||||
throttled: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles delivery for all email processing modes
|
||||
*/
|
||||
export class MultiModeDeliverySystem extends EventEmitter {
|
||||
private queue: UnifiedDeliveryQueue;
|
||||
private options: Required<IMultiModeDeliveryOptions>;
|
||||
private stats: IDeliveryStats;
|
||||
private deliveryTimes: number[] = [];
|
||||
private activeDeliveries: Set<string> = new Set();
|
||||
private running: boolean = false;
|
||||
private throttled: boolean = false;
|
||||
private rateLimitLastCheck: number = Date.now();
|
||||
private rateLimitCounter: number = 0;
|
||||
|
||||
/**
|
||||
* Create a new multi-mode delivery system
|
||||
* @param queue Unified delivery queue
|
||||
* @param options Delivery options
|
||||
*/
|
||||
constructor(queue: UnifiedDeliveryQueue, options: IMultiModeDeliveryOptions) {
|
||||
super();
|
||||
|
||||
this.queue = queue;
|
||||
|
||||
// Set default options
|
||||
this.options = {
|
||||
connectionPoolSize: options.connectionPoolSize || 10,
|
||||
socketTimeout: options.socketTimeout || 30000, // 30 seconds
|
||||
concurrentDeliveries: options.concurrentDeliveries || 10,
|
||||
sendTimeout: options.sendTimeout || 60000, // 1 minute
|
||||
verifyCertificates: options.verifyCertificates !== false, // Default to true
|
||||
tlsMinVersion: options.tlsMinVersion || 'TLSv1.2',
|
||||
forwardHandler: options.forwardHandler || {
|
||||
deliver: this.handleForwardDelivery.bind(this)
|
||||
},
|
||||
mtaHandler: options.mtaHandler || {
|
||||
deliver: this.handleMtaDelivery.bind(this)
|
||||
},
|
||||
processHandler: options.processHandler || {
|
||||
deliver: this.handleProcessDelivery.bind(this)
|
||||
},
|
||||
globalRateLimit: options.globalRateLimit || 100, // 100 emails per minute
|
||||
perPatternRateLimit: options.perPatternRateLimit || {},
|
||||
onDeliveryStart: options.onDeliveryStart || (async () => {}),
|
||||
onDeliverySuccess: options.onDeliverySuccess || (async () => {}),
|
||||
onDeliveryFailed: options.onDeliveryFailed || (async () => {})
|
||||
};
|
||||
|
||||
// Initialize statistics
|
||||
this.stats = {
|
||||
activeDeliveries: 0,
|
||||
totalSuccessful: 0,
|
||||
totalFailed: 0,
|
||||
avgDeliveryTime: 0,
|
||||
byMode: {
|
||||
forward: {
|
||||
successful: 0,
|
||||
failed: 0
|
||||
},
|
||||
mta: {
|
||||
successful: 0,
|
||||
failed: 0
|
||||
},
|
||||
process: {
|
||||
successful: 0,
|
||||
failed: 0
|
||||
}
|
||||
},
|
||||
rateLimiting: {
|
||||
currentRate: 0,
|
||||
globalLimit: this.options.globalRateLimit,
|
||||
throttled: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Set up event listeners
|
||||
this.queue.on('itemsReady', this.processItems.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the delivery system
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
logger.log('info', 'Starting MultiModeDeliverySystem');
|
||||
|
||||
if (this.running) {
|
||||
logger.log('warn', 'MultiModeDeliverySystem is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
|
||||
// Emit started event
|
||||
this.emit('started');
|
||||
logger.log('info', 'MultiModeDeliverySystem started successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the delivery system
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
logger.log('info', 'Stopping MultiModeDeliverySystem');
|
||||
|
||||
if (!this.running) {
|
||||
logger.log('warn', 'MultiModeDeliverySystem is already stopped');
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = false;
|
||||
|
||||
// Wait for active deliveries to complete
|
||||
if (this.activeDeliveries.size > 0) {
|
||||
logger.log('info', `Waiting for ${this.activeDeliveries.size} active deliveries to complete`);
|
||||
|
||||
// Wait for a maximum of 30 seconds
|
||||
await new Promise<void>(resolve => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (this.activeDeliveries.size === 0) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Force resolve after 30 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
// Emit stopped event
|
||||
this.emit('stopped');
|
||||
logger.log('info', 'MultiModeDeliverySystem stopped successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process ready items from the queue
|
||||
* @param items Queue items ready for processing
|
||||
*/
|
||||
private async processItems(items: IQueueItem[]): Promise<void> {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're already at max concurrent deliveries
|
||||
if (this.activeDeliveries.size >= this.options.concurrentDeliveries) {
|
||||
logger.log('debug', `Already at max concurrent deliveries (${this.activeDeliveries.size})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check rate limiting
|
||||
if (this.checkRateLimit()) {
|
||||
logger.log('debug', 'Rate limit exceeded, throttling deliveries');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate how many more deliveries we can start
|
||||
const availableSlots = this.options.concurrentDeliveries - this.activeDeliveries.size;
|
||||
const itemsToProcess = items.slice(0, availableSlots);
|
||||
|
||||
if (itemsToProcess.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', `Processing ${itemsToProcess.length} items for delivery`);
|
||||
|
||||
// Process each item
|
||||
for (const item of itemsToProcess) {
|
||||
// Mark as processing
|
||||
await this.queue.markProcessing(item.id);
|
||||
|
||||
// Add to active deliveries
|
||||
this.activeDeliveries.add(item.id);
|
||||
this.stats.activeDeliveries = this.activeDeliveries.size;
|
||||
|
||||
// Deliver asynchronously
|
||||
this.deliverItem(item).catch(err => {
|
||||
logger.log('error', `Unhandled error in delivery: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.emit('statsUpdated', this.stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver an item from the queue
|
||||
* @param item Queue item to deliver
|
||||
*/
|
||||
private async deliverItem(item: IQueueItem): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Call delivery start hook
|
||||
await this.options.onDeliveryStart(item);
|
||||
|
||||
// Emit delivery start event
|
||||
this.emit('deliveryStart', item);
|
||||
logger.log('info', `Starting delivery of item ${item.id}, mode: ${item.processingMode}`);
|
||||
|
||||
// Choose the appropriate handler based on mode
|
||||
let result: any;
|
||||
|
||||
switch (item.processingMode) {
|
||||
case 'forward':
|
||||
result = await this.options.forwardHandler.deliver(item);
|
||||
break;
|
||||
|
||||
case 'mta':
|
||||
result = await this.options.mtaHandler.deliver(item);
|
||||
break;
|
||||
|
||||
case 'process':
|
||||
result = await this.options.processHandler.deliver(item);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown processing mode: ${item.processingMode}`);
|
||||
}
|
||||
|
||||
// Mark as delivered
|
||||
await this.queue.markDelivered(item.id);
|
||||
|
||||
// Update statistics
|
||||
this.stats.totalSuccessful++;
|
||||
this.stats.byMode[item.processingMode].successful++;
|
||||
|
||||
// Calculate delivery time
|
||||
const deliveryTime = Date.now() - startTime;
|
||||
this.deliveryTimes.push(deliveryTime);
|
||||
this.updateDeliveryTimeStats();
|
||||
|
||||
// Call delivery success hook
|
||||
await this.options.onDeliverySuccess(item, result);
|
||||
|
||||
// Emit delivery success event
|
||||
this.emit('deliverySuccess', item, result);
|
||||
logger.log('info', `Item ${item.id} delivered successfully in ${deliveryTime}ms`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.EMAIL_DELIVERY,
|
||||
message: 'Email delivery successful',
|
||||
details: {
|
||||
itemId: item.id,
|
||||
mode: item.processingMode,
|
||||
pattern: item.rule.pattern,
|
||||
deliveryTime
|
||||
},
|
||||
success: true
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Calculate delivery attempt time even for failures
|
||||
const deliveryTime = Date.now() - startTime;
|
||||
|
||||
// Mark as failed
|
||||
await this.queue.markFailed(item.id, error.message);
|
||||
|
||||
// Update statistics
|
||||
this.stats.totalFailed++;
|
||||
this.stats.byMode[item.processingMode].failed++;
|
||||
|
||||
// Call delivery failed hook
|
||||
await this.options.onDeliveryFailed(item, error.message);
|
||||
|
||||
// Emit delivery failed event
|
||||
this.emit('deliveryFailed', item, error);
|
||||
logger.log('error', `Item ${item.id} delivery failed: ${error.message}`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.EMAIL_DELIVERY,
|
||||
message: 'Email delivery failed',
|
||||
details: {
|
||||
itemId: item.id,
|
||||
mode: item.processingMode,
|
||||
pattern: item.rule.pattern,
|
||||
error: error.message,
|
||||
deliveryTime
|
||||
},
|
||||
success: false
|
||||
});
|
||||
} finally {
|
||||
// Remove from active deliveries
|
||||
this.activeDeliveries.delete(item.id);
|
||||
this.stats.activeDeliveries = this.activeDeliveries.size;
|
||||
|
||||
// Update statistics
|
||||
this.emit('statsUpdated', this.stats);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default handler for forward mode delivery
|
||||
* @param item Queue item
|
||||
*/
|
||||
private async handleForwardDelivery(item: IQueueItem): Promise<any> {
|
||||
logger.log('info', `Forward delivery for item ${item.id}`);
|
||||
|
||||
const email = item.processingResult as Email;
|
||||
const rule = item.rule;
|
||||
|
||||
// Get target server information
|
||||
const targetServer = rule.target?.server;
|
||||
const targetPort = rule.target?.port || 25;
|
||||
const useTls = rule.target?.useTls ?? false;
|
||||
|
||||
if (!targetServer) {
|
||||
throw new Error('No target server configured for forward mode');
|
||||
}
|
||||
|
||||
logger.log('info', `Forwarding email to ${targetServer}:${targetPort}, TLS: ${useTls}`);
|
||||
|
||||
// Create a socket connection to the target server
|
||||
const socket = new net.Socket();
|
||||
|
||||
// Set timeout
|
||||
socket.setTimeout(this.options.socketTimeout);
|
||||
|
||||
try {
|
||||
// Connect to the target server
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// Handle connection events
|
||||
socket.on('connect', () => {
|
||||
logger.log('debug', `Connected to ${targetServer}:${targetPort}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
reject(new Error(`Connection timeout to ${targetServer}:${targetPort}`));
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
reject(new Error(`Connection error to ${targetServer}:${targetPort}: ${err.message}`));
|
||||
});
|
||||
|
||||
// Connect to the server
|
||||
socket.connect({
|
||||
host: targetServer,
|
||||
port: targetPort
|
||||
});
|
||||
});
|
||||
|
||||
// Send EHLO
|
||||
await this.smtpCommand(socket, `EHLO ${rule.mtaOptions?.domain || 'localhost'}`);
|
||||
|
||||
// Start TLS if required
|
||||
if (useTls) {
|
||||
await this.smtpCommand(socket, 'STARTTLS');
|
||||
|
||||
// Upgrade to TLS
|
||||
const tlsSocket = await this.upgradeTls(socket, targetServer);
|
||||
|
||||
// Send EHLO again after STARTTLS
|
||||
await this.smtpCommand(tlsSocket, `EHLO ${rule.mtaOptions?.domain || 'localhost'}`);
|
||||
|
||||
// Use tlsSocket for remaining commands
|
||||
return this.completeSMTPExchange(tlsSocket, email, rule);
|
||||
}
|
||||
|
||||
// Complete the SMTP exchange
|
||||
return this.completeSMTPExchange(socket, email, rule);
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Failed to forward email: ${error.message}`);
|
||||
|
||||
// Close the connection
|
||||
socket.destroy();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the SMTP exchange after connection and initial setup
|
||||
* @param socket Network socket
|
||||
* @param email Email to send
|
||||
* @param rule Domain rule
|
||||
*/
|
||||
private async completeSMTPExchange(socket: net.Socket | tls.TLSSocket, email: Email, rule: IDomainRule): Promise<any> {
|
||||
try {
|
||||
// Authenticate if credentials provided
|
||||
if (rule.target?.authentication?.user && rule.target?.authentication?.pass) {
|
||||
// Send AUTH LOGIN
|
||||
await this.smtpCommand(socket, 'AUTH LOGIN');
|
||||
|
||||
// Send username (base64)
|
||||
const username = Buffer.from(rule.target.authentication.user).toString('base64');
|
||||
await this.smtpCommand(socket, username);
|
||||
|
||||
// Send password (base64)
|
||||
const password = Buffer.from(rule.target.authentication.pass).toString('base64');
|
||||
await this.smtpCommand(socket, password);
|
||||
}
|
||||
|
||||
// Send MAIL FROM
|
||||
await this.smtpCommand(socket, `MAIL FROM:<${email.from}>`);
|
||||
|
||||
// Send RCPT TO for each recipient
|
||||
for (const recipient of email.getAllRecipients()) {
|
||||
await this.smtpCommand(socket, `RCPT TO:<${recipient}>`);
|
||||
}
|
||||
|
||||
// Send DATA
|
||||
await this.smtpCommand(socket, 'DATA');
|
||||
|
||||
// Send email content (simplified)
|
||||
const emailContent = await this.getFormattedEmail(email);
|
||||
await this.smtpData(socket, emailContent);
|
||||
|
||||
// Send QUIT
|
||||
await this.smtpCommand(socket, 'QUIT');
|
||||
|
||||
// Close the connection
|
||||
socket.end();
|
||||
|
||||
logger.log('info', `Email forwarded successfully to ${rule.target?.server}:${rule.target?.port || 25}`);
|
||||
|
||||
return {
|
||||
targetServer: rule.target?.server,
|
||||
targetPort: rule.target?.port || 25,
|
||||
recipients: email.getAllRecipients().length
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Failed to forward email: ${error.message}`);
|
||||
|
||||
// Close the connection
|
||||
socket.destroy();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default handler for MTA mode delivery
|
||||
* @param item Queue item
|
||||
*/
|
||||
private async handleMtaDelivery(item: IQueueItem): Promise<any> {
|
||||
logger.log('info', `MTA delivery for item ${item.id}`);
|
||||
|
||||
const email = item.processingResult as Email;
|
||||
const rule = item.rule;
|
||||
|
||||
try {
|
||||
// In a full implementation, this would use the MTA service
|
||||
// For now, we'll simulate a successful delivery
|
||||
|
||||
logger.log('info', `Email processed by MTA: ${email.subject} to ${email.getAllRecipients().join(', ')}`);
|
||||
|
||||
// Apply MTA rule options if provided
|
||||
if (rule.mtaOptions) {
|
||||
const options = rule.mtaOptions;
|
||||
|
||||
// Apply DKIM signing if enabled
|
||||
if (options.dkimSign && options.dkimOptions) {
|
||||
// Sign the email with DKIM
|
||||
logger.log('info', `Signing email with DKIM for domain ${options.dkimOptions.domainName}`);
|
||||
|
||||
// In a full implementation, this would use the DKIM signing library
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate successful delivery
|
||||
return {
|
||||
recipients: email.getAllRecipients().length,
|
||||
subject: email.subject,
|
||||
dkimSigned: !!rule.mtaOptions?.dkimSign
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Failed to process email in MTA mode: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default handler for process mode delivery
|
||||
* @param item Queue item
|
||||
*/
|
||||
private async handleProcessDelivery(item: IQueueItem): Promise<any> {
|
||||
logger.log('info', `Process delivery for item ${item.id}`);
|
||||
|
||||
const email = item.processingResult as Email;
|
||||
const rule = item.rule;
|
||||
|
||||
try {
|
||||
// Apply content scanning if enabled
|
||||
if (rule.contentScanning && rule.scanners && rule.scanners.length > 0) {
|
||||
logger.log('info', 'Performing content scanning');
|
||||
|
||||
// Apply each scanner
|
||||
for (const scanner of rule.scanners) {
|
||||
switch (scanner.type) {
|
||||
case 'spam':
|
||||
logger.log('info', 'Scanning for spam content');
|
||||
// Implement spam scanning
|
||||
break;
|
||||
|
||||
case 'virus':
|
||||
logger.log('info', 'Scanning for virus content');
|
||||
// Implement virus scanning
|
||||
break;
|
||||
|
||||
case 'attachment':
|
||||
logger.log('info', 'Scanning attachments');
|
||||
|
||||
// Check for blocked extensions
|
||||
if (scanner.blockedExtensions && scanner.blockedExtensions.length > 0) {
|
||||
for (const attachment of email.attachments) {
|
||||
const ext = this.getFileExtension(attachment.filename);
|
||||
if (scanner.blockedExtensions.includes(ext)) {
|
||||
if (scanner.action === 'reject') {
|
||||
throw new Error(`Blocked attachment type: ${ext}`);
|
||||
} else { // tag
|
||||
email.addHeader('X-Attachment-Warning', `Potentially unsafe attachment: ${attachment.filename}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply transformations if defined
|
||||
if (rule.transformations && rule.transformations.length > 0) {
|
||||
logger.log('info', 'Applying email transformations');
|
||||
|
||||
for (const transform of rule.transformations) {
|
||||
switch (transform.type) {
|
||||
case 'addHeader':
|
||||
if (transform.header && transform.value) {
|
||||
email.addHeader(transform.header, transform.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('info', `Email successfully processed in store-and-forward mode`);
|
||||
|
||||
// Simulate successful delivery
|
||||
return {
|
||||
recipients: email.getAllRecipients().length,
|
||||
subject: email.subject,
|
||||
scanned: !!rule.contentScanning,
|
||||
transformed: !!(rule.transformations && rule.transformations.length > 0)
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Failed to process email: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from filename
|
||||
*/
|
||||
private getFileExtension(filename: string): string {
|
||||
return filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format email for SMTP transmission
|
||||
* @param email Email to format
|
||||
*/
|
||||
private async getFormattedEmail(email: Email): Promise<string> {
|
||||
// This is a simplified implementation
|
||||
// In a full implementation, this would use proper MIME formatting
|
||||
|
||||
let content = '';
|
||||
|
||||
// Add headers
|
||||
content += `From: ${email.from}\r\n`;
|
||||
content += `To: ${email.to.join(', ')}\r\n`;
|
||||
content += `Subject: ${email.subject}\r\n`;
|
||||
|
||||
// Add additional headers
|
||||
for (const [name, value] of Object.entries(email.headers || {})) {
|
||||
content += `${name}: ${value}\r\n`;
|
||||
}
|
||||
|
||||
// Add content type for multipart
|
||||
if (email.attachments && email.attachments.length > 0) {
|
||||
const boundary = `----_=_NextPart_${Math.random().toString(36).substr(2)}`;
|
||||
content += `MIME-Version: 1.0\r\n`;
|
||||
content += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
|
||||
content += `\r\n`;
|
||||
|
||||
// Add text part
|
||||
content += `--${boundary}\r\n`;
|
||||
content += `Content-Type: text/plain; charset="UTF-8"\r\n`;
|
||||
content += `\r\n`;
|
||||
content += `${email.text}\r\n`;
|
||||
|
||||
// Add HTML part if present
|
||||
if (email.html) {
|
||||
content += `--${boundary}\r\n`;
|
||||
content += `Content-Type: text/html; charset="UTF-8"\r\n`;
|
||||
content += `\r\n`;
|
||||
content += `${email.html}\r\n`;
|
||||
}
|
||||
|
||||
// Add attachments
|
||||
for (const attachment of email.attachments) {
|
||||
content += `--${boundary}\r\n`;
|
||||
content += `Content-Type: ${attachment.contentType || 'application/octet-stream'}; name="${attachment.filename}"\r\n`;
|
||||
content += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
|
||||
content += `Content-Transfer-Encoding: base64\r\n`;
|
||||
content += `\r\n`;
|
||||
|
||||
// Add base64 encoded content
|
||||
const base64Content = attachment.content.toString('base64');
|
||||
|
||||
// Split into lines of 76 characters
|
||||
for (let i = 0; i < base64Content.length; i += 76) {
|
||||
content += base64Content.substring(i, i + 76) + '\r\n';
|
||||
}
|
||||
}
|
||||
|
||||
// End boundary
|
||||
content += `--${boundary}--\r\n`;
|
||||
} else {
|
||||
// Simple email with just text
|
||||
content += `Content-Type: text/plain; charset="UTF-8"\r\n`;
|
||||
content += `\r\n`;
|
||||
content += `${email.text}\r\n`;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMTP command and wait for response
|
||||
* @param socket Socket connection
|
||||
* @param command SMTP command to send
|
||||
*/
|
||||
private async smtpCommand(socket: net.Socket, command: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const onData = (data: Buffer) => {
|
||||
const response = data.toString().trim();
|
||||
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
// Check response code
|
||||
if (response.charAt(0) === '2' || response.charAt(0) === '3') {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`SMTP error: ${response}`));
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const onTimeout = () => {
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
reject(new Error('SMTP command timeout'));
|
||||
};
|
||||
|
||||
// Set up listeners
|
||||
socket.once('data', onData);
|
||||
socket.once('error', onError);
|
||||
socket.once('timeout', onTimeout);
|
||||
|
||||
// Send command
|
||||
socket.write(command + '\r\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMTP DATA command with content
|
||||
* @param socket Socket connection
|
||||
* @param data Email content to send
|
||||
*/
|
||||
private async smtpData(socket: net.Socket, data: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const onData = (responseData: Buffer) => {
|
||||
const response = responseData.toString().trim();
|
||||
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
// Check response code
|
||||
if (response.charAt(0) === '2') {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`SMTP error: ${response}`));
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const onTimeout = () => {
|
||||
// Clean up listeners
|
||||
socket.removeListener('data', onData);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
|
||||
reject(new Error('SMTP data timeout'));
|
||||
};
|
||||
|
||||
// Set up listeners
|
||||
socket.once('data', onData);
|
||||
socket.once('error', onError);
|
||||
socket.once('timeout', onTimeout);
|
||||
|
||||
// Send data and end with CRLF.CRLF
|
||||
socket.write(data + '\r\n.\r\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade socket to TLS
|
||||
* @param socket Socket connection
|
||||
* @param hostname Target hostname for TLS
|
||||
*/
|
||||
private async upgradeTls(socket: net.Socket, hostname: string): Promise<tls.TLSSocket> {
|
||||
return new Promise<tls.TLSSocket>((resolve, reject) => {
|
||||
const tlsOptions: tls.ConnectionOptions = {
|
||||
socket,
|
||||
servername: hostname,
|
||||
rejectUnauthorized: this.options.verifyCertificates,
|
||||
minVersion: this.options.tlsMinVersion as tls.SecureVersion
|
||||
};
|
||||
|
||||
const tlsSocket = tls.connect(tlsOptions);
|
||||
|
||||
tlsSocket.once('secureConnect', () => {
|
||||
resolve(tlsSocket);
|
||||
});
|
||||
|
||||
tlsSocket.once('error', (err) => {
|
||||
reject(new Error(`TLS error: ${err.message}`));
|
||||
});
|
||||
|
||||
tlsSocket.setTimeout(this.options.socketTimeout);
|
||||
|
||||
tlsSocket.once('timeout', () => {
|
||||
reject(new Error('TLS connection timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update delivery time statistics
|
||||
*/
|
||||
private updateDeliveryTimeStats(): void {
|
||||
if (this.deliveryTimes.length === 0) return;
|
||||
|
||||
// Keep only the last 1000 delivery times
|
||||
if (this.deliveryTimes.length > 1000) {
|
||||
this.deliveryTimes = this.deliveryTimes.slice(-1000);
|
||||
}
|
||||
|
||||
// Calculate average
|
||||
const sum = this.deliveryTimes.reduce((acc, time) => acc + time, 0);
|
||||
this.stats.avgDeliveryTime = sum / this.deliveryTimes.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rate limit is exceeded
|
||||
* @returns True if rate limited, false otherwise
|
||||
*/
|
||||
private checkRateLimit(): boolean {
|
||||
const now = Date.now();
|
||||
const elapsed = now - this.rateLimitLastCheck;
|
||||
|
||||
// Reset counter if more than a minute has passed
|
||||
if (elapsed >= 60000) {
|
||||
this.rateLimitLastCheck = now;
|
||||
this.rateLimitCounter = 0;
|
||||
this.throttled = false;
|
||||
this.stats.rateLimiting.currentRate = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we're already throttled
|
||||
if (this.throttled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
this.rateLimitCounter++;
|
||||
|
||||
// Calculate current rate (emails per minute)
|
||||
const rate = (this.rateLimitCounter / elapsed) * 60000;
|
||||
this.stats.rateLimiting.currentRate = rate;
|
||||
|
||||
// Check if rate limit is exceeded
|
||||
if (rate > this.options.globalRateLimit) {
|
||||
this.throttled = true;
|
||||
this.stats.rateLimiting.throttled++;
|
||||
|
||||
// Schedule throttle reset
|
||||
const resetDelay = 60000 - elapsed;
|
||||
setTimeout(() => {
|
||||
this.throttled = false;
|
||||
this.rateLimitLastCheck = Date.now();
|
||||
this.rateLimitCounter = 0;
|
||||
this.stats.rateLimiting.currentRate = 0;
|
||||
}, resetDelay);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update delivery options
|
||||
* @param options New options
|
||||
*/
|
||||
public updateOptions(options: Partial<IMultiModeDeliveryOptions>): void {
|
||||
this.options = {
|
||||
...this.options,
|
||||
...options
|
||||
};
|
||||
|
||||
// Update rate limit statistics
|
||||
if (options.globalRateLimit) {
|
||||
this.stats.rateLimiting.globalLimit = options.globalRateLimit;
|
||||
}
|
||||
|
||||
logger.log('info', 'MultiModeDeliverySystem options updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get delivery statistics
|
||||
*/
|
||||
public getStats(): IDeliveryStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
}
|
702
ts/mail/delivery/classes.emailsendjob.ts
Normal file
702
ts/mail/delivery/classes.emailsendjob.ts
Normal file
@ -0,0 +1,702 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as paths from '../../paths.js';
|
||||
import { Email } from '../core/classes.email.js';
|
||||
import { EmailSignJob } from './classes.emailsignjob.js';
|
||||
import type { MtaService } from './classes.mta.js';
|
||||
|
||||
// Configuration options for email sending
|
||||
export interface IEmailSendOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number; // in milliseconds
|
||||
connectionTimeout?: number; // in milliseconds
|
||||
tlsOptions?: plugins.tls.ConnectionOptions;
|
||||
debugMode?: boolean;
|
||||
}
|
||||
|
||||
// Email delivery status
|
||||
export enum DeliveryStatus {
|
||||
PENDING = 'pending',
|
||||
SENDING = 'sending',
|
||||
DELIVERED = 'delivered',
|
||||
FAILED = 'failed',
|
||||
DEFERRED = 'deferred' // Temporary failure, will retry
|
||||
}
|
||||
|
||||
// Detailed information about delivery attempts
|
||||
export interface DeliveryInfo {
|
||||
status: DeliveryStatus;
|
||||
attempts: number;
|
||||
error?: Error;
|
||||
lastAttempt?: Date;
|
||||
nextAttempt?: Date;
|
||||
mxServer?: string;
|
||||
deliveryTime?: Date;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export class EmailSendJob {
|
||||
mtaRef: MtaService;
|
||||
private email: Email;
|
||||
private socket: plugins.net.Socket | plugins.tls.TLSSocket = null;
|
||||
private mxServers: string[] = [];
|
||||
private currentMxIndex = 0;
|
||||
private options: IEmailSendOptions;
|
||||
public deliveryInfo: DeliveryInfo;
|
||||
|
||||
constructor(mtaRef: MtaService, emailArg: Email, options: IEmailSendOptions = {}) {
|
||||
this.email = emailArg;
|
||||
this.mtaRef = mtaRef;
|
||||
|
||||
// Set default options
|
||||
this.options = {
|
||||
maxRetries: options.maxRetries || 3,
|
||||
retryDelay: options.retryDelay || 300000, // 5 minutes
|
||||
connectionTimeout: options.connectionTimeout || 30000, // 30 seconds
|
||||
tlsOptions: options.tlsOptions || { rejectUnauthorized: true },
|
||||
debugMode: options.debugMode || false
|
||||
};
|
||||
|
||||
// Initialize delivery info
|
||||
this.deliveryInfo = {
|
||||
status: DeliveryStatus.PENDING,
|
||||
attempts: 0,
|
||||
logs: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the email with retry logic
|
||||
*/
|
||||
async send(): Promise<DeliveryStatus> {
|
||||
try {
|
||||
// Check if the email is valid before attempting to send
|
||||
this.validateEmail();
|
||||
|
||||
// Resolve MX records for the recipient domain
|
||||
await this.resolveMxRecords();
|
||||
|
||||
// Try to send the email
|
||||
return await this.attemptDelivery();
|
||||
} catch (error) {
|
||||
this.log(`Critical error in send process: ${error.message}`);
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
this.deliveryInfo.error = error;
|
||||
|
||||
// Save failed email for potential future retry or analysis
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the email before sending
|
||||
*/
|
||||
private validateEmail(): void {
|
||||
if (!this.email.to || this.email.to.length === 0) {
|
||||
throw new Error('No recipients specified');
|
||||
}
|
||||
|
||||
if (!this.email.from) {
|
||||
throw new Error('No sender specified');
|
||||
}
|
||||
|
||||
const fromDomain = this.email.getFromDomain();
|
||||
if (!fromDomain) {
|
||||
throw new Error('Invalid sender domain');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve MX records for the recipient domain
|
||||
*/
|
||||
private async resolveMxRecords(): Promise<void> {
|
||||
const domain = this.email.getPrimaryRecipient()?.split('@')[1];
|
||||
if (!domain) {
|
||||
throw new Error('Invalid recipient domain');
|
||||
}
|
||||
|
||||
this.log(`Resolving MX records for domain: ${domain}`);
|
||||
try {
|
||||
const addresses = await this.resolveMx(domain);
|
||||
|
||||
// Sort by priority (lowest number = highest priority)
|
||||
addresses.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
this.mxServers = addresses.map(mx => mx.exchange);
|
||||
this.log(`Found ${this.mxServers.length} MX servers: ${this.mxServers.join(', ')}`);
|
||||
|
||||
if (this.mxServers.length === 0) {
|
||||
throw new Error(`No MX records found for domain: ${domain}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Failed to resolve MX records: ${error.message}`);
|
||||
throw new Error(`MX lookup failed for ${domain}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to deliver the email with retries
|
||||
*/
|
||||
private async attemptDelivery(): Promise<DeliveryStatus> {
|
||||
while (this.deliveryInfo.attempts < this.options.maxRetries) {
|
||||
this.deliveryInfo.attempts++;
|
||||
this.deliveryInfo.lastAttempt = new Date();
|
||||
this.deliveryInfo.status = DeliveryStatus.SENDING;
|
||||
|
||||
try {
|
||||
this.log(`Delivery attempt ${this.deliveryInfo.attempts} of ${this.options.maxRetries}`);
|
||||
|
||||
// Try each MX server in order of priority
|
||||
while (this.currentMxIndex < this.mxServers.length) {
|
||||
const currentMx = this.mxServers[this.currentMxIndex];
|
||||
this.deliveryInfo.mxServer = currentMx;
|
||||
|
||||
try {
|
||||
this.log(`Attempting delivery to MX server: ${currentMx}`);
|
||||
await this.connectAndSend(currentMx);
|
||||
|
||||
// If we get here, email was sent successfully
|
||||
this.deliveryInfo.status = DeliveryStatus.DELIVERED;
|
||||
this.deliveryInfo.deliveryTime = new Date();
|
||||
this.log(`Email delivered successfully to ${currentMx}`);
|
||||
|
||||
// Record delivery for sender reputation monitoring
|
||||
this.recordDeliveryEvent('delivered');
|
||||
|
||||
// Save successful email record
|
||||
await this.saveSuccess();
|
||||
return DeliveryStatus.DELIVERED;
|
||||
} catch (error) {
|
||||
this.log(`Error with MX ${currentMx}: ${error.message}`);
|
||||
|
||||
// Clean up socket if it exists
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
// Try the next MX server
|
||||
this.currentMxIndex++;
|
||||
|
||||
// If this is a permanent failure, don't try other MX servers
|
||||
if (this.isPermanentFailure(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we've tried all MX servers without success, throw an error
|
||||
throw new Error('All MX servers failed');
|
||||
} catch (error) {
|
||||
// Check if this is a permanent failure
|
||||
if (this.isPermanentFailure(error)) {
|
||||
this.log(`Permanent failure: ${error.message}`);
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
this.deliveryInfo.error = error;
|
||||
|
||||
// Save failed email for analysis
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
|
||||
// This is a temporary failure, we can retry
|
||||
this.log(`Temporary failure: ${error.message}`);
|
||||
|
||||
// If this is the last attempt, mark as failed
|
||||
if (this.deliveryInfo.attempts >= this.options.maxRetries) {
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
this.deliveryInfo.error = error;
|
||||
|
||||
// Save failed email for analysis
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
|
||||
// Schedule the next retry
|
||||
const nextRetryTime = new Date(Date.now() + this.options.retryDelay);
|
||||
this.deliveryInfo.status = DeliveryStatus.DEFERRED;
|
||||
this.deliveryInfo.nextAttempt = nextRetryTime;
|
||||
this.log(`Will retry at ${nextRetryTime.toISOString()}`);
|
||||
|
||||
// Wait before retrying
|
||||
await this.delay(this.options.retryDelay);
|
||||
|
||||
// Reset MX server index for the next attempt
|
||||
this.currentMxIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
||||
await this.saveFailed();
|
||||
return DeliveryStatus.FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a specific MX server and send the email
|
||||
*/
|
||||
private async connectAndSend(mxServer: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let commandTimeout: NodeJS.Timeout;
|
||||
|
||||
// Function to clear timeouts and remove listeners
|
||||
const cleanup = () => {
|
||||
clearTimeout(commandTimeout);
|
||||
if (this.socket) {
|
||||
this.socket.removeAllListeners();
|
||||
}
|
||||
};
|
||||
|
||||
// Function to set a timeout for each command
|
||||
const setCommandTimeout = () => {
|
||||
clearTimeout(commandTimeout);
|
||||
commandTimeout = setTimeout(() => {
|
||||
this.log('Connection timed out');
|
||||
cleanup();
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
reject(new Error('Connection timed out'));
|
||||
}, this.options.connectionTimeout);
|
||||
};
|
||||
|
||||
// Connect to the MX server
|
||||
this.log(`Connecting to ${mxServer}:25`);
|
||||
setCommandTimeout();
|
||||
|
||||
// Check if IP warmup is enabled and get an IP to use
|
||||
let localAddress: string | undefined = undefined;
|
||||
if (this.mtaRef.config.outbound?.warmup?.enabled) {
|
||||
const warmupManager = this.mtaRef.getIPWarmupManager();
|
||||
if (warmupManager) {
|
||||
const fromDomain = this.email.getFromDomain();
|
||||
const bestIP = warmupManager.getBestIPForSending({
|
||||
from: this.email.from,
|
||||
to: this.email.getAllRecipients(),
|
||||
domain: fromDomain,
|
||||
isTransactional: this.email.priority === 'high'
|
||||
});
|
||||
|
||||
if (bestIP) {
|
||||
this.log(`Using warmed-up IP ${bestIP} for sending`);
|
||||
localAddress = bestIP;
|
||||
|
||||
// Record the send for warm-up tracking
|
||||
warmupManager.recordSend(bestIP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connect with specified local address if available
|
||||
this.socket = plugins.net.connect({
|
||||
port: 25,
|
||||
host: mxServer,
|
||||
localAddress
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
this.log(`Socket error: ${err.message}`);
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Set up the command sequence
|
||||
this.socket.once('data', async (data) => {
|
||||
try {
|
||||
const greeting = data.toString();
|
||||
this.log(`Server greeting: ${greeting.trim()}`);
|
||||
|
||||
if (!greeting.startsWith('220')) {
|
||||
throw new Error(`Unexpected server greeting: ${greeting}`);
|
||||
}
|
||||
|
||||
// EHLO command
|
||||
const fromDomain = this.email.getFromDomain();
|
||||
await this.sendCommand(`EHLO ${fromDomain}\r\n`, '250');
|
||||
|
||||
// Try STARTTLS if available
|
||||
try {
|
||||
await this.sendCommand('STARTTLS\r\n', '220');
|
||||
this.upgradeToTLS(mxServer, fromDomain);
|
||||
// The TLS handshake and subsequent commands will continue in the upgradeToTLS method
|
||||
// resolve will be called from there if successful
|
||||
} catch (error) {
|
||||
this.log(`STARTTLS failed or not supported: ${error.message}`);
|
||||
this.log('Continuing with unencrypted connection');
|
||||
|
||||
// Continue with unencrypted connection
|
||||
await this.sendEmailCommands();
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade the connection to TLS
|
||||
*/
|
||||
private upgradeToTLS(mxServer: string, fromDomain: string): void {
|
||||
this.log('Starting TLS handshake');
|
||||
|
||||
const tlsOptions = {
|
||||
...this.options.tlsOptions,
|
||||
socket: this.socket,
|
||||
servername: mxServer
|
||||
};
|
||||
|
||||
// Create TLS socket
|
||||
this.socket = plugins.tls.connect(tlsOptions);
|
||||
|
||||
// Handle TLS connection
|
||||
this.socket.once('secureConnect', async () => {
|
||||
try {
|
||||
this.log('TLS connection established');
|
||||
|
||||
// Send EHLO again over TLS
|
||||
await this.sendCommand(`EHLO ${fromDomain}\r\n`, '250');
|
||||
|
||||
// Send the email
|
||||
await this.sendEmailCommands();
|
||||
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
} catch (error) {
|
||||
this.log(`Error in TLS session: ${error.message}`);
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
this.log(`TLS error: ${err.message}`);
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMTP commands to deliver the email
|
||||
*/
|
||||
private async sendEmailCommands(): Promise<void> {
|
||||
// MAIL FROM command
|
||||
await this.sendCommand(`MAIL FROM:<${this.email.from}>\r\n`, '250');
|
||||
|
||||
// RCPT TO command for each recipient
|
||||
for (const recipient of this.email.getAllRecipients()) {
|
||||
await this.sendCommand(`RCPT TO:<${recipient}>\r\n`, '250');
|
||||
}
|
||||
|
||||
// DATA command
|
||||
await this.sendCommand('DATA\r\n', '354');
|
||||
|
||||
// Create the email message with DKIM signature
|
||||
const message = await this.createEmailMessage();
|
||||
|
||||
// Send the message content
|
||||
await this.sendCommand(message);
|
||||
await this.sendCommand('\r\n.\r\n', '250');
|
||||
|
||||
// QUIT command
|
||||
await this.sendCommand('QUIT\r\n', '221');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the full email message with headers and DKIM signature
|
||||
*/
|
||||
private async createEmailMessage(): Promise<string> {
|
||||
this.log('Preparing email message');
|
||||
|
||||
const messageId = `<${plugins.uuid.v4()}@${this.email.getFromDomain()}>`;
|
||||
const boundary = '----=_NextPart_' + plugins.uuid.v4();
|
||||
|
||||
// Prepare headers
|
||||
const headers = {
|
||||
'Message-ID': messageId,
|
||||
'From': this.email.from,
|
||||
'To': this.email.to.join(', '),
|
||||
'Subject': this.email.subject,
|
||||
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
|
||||
'Date': new Date().toUTCString()
|
||||
};
|
||||
|
||||
// Add CC header if present
|
||||
if (this.email.cc && this.email.cc.length > 0) {
|
||||
headers['Cc'] = this.email.cc.join(', ');
|
||||
}
|
||||
|
||||
// Add custom headers
|
||||
for (const [key, value] of Object.entries(this.email.headers || {})) {
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
// Add priority header if not normal
|
||||
if (this.email.priority && this.email.priority !== 'normal') {
|
||||
const priorityValue = this.email.priority === 'high' ? '1' : '5';
|
||||
headers['X-Priority'] = priorityValue;
|
||||
}
|
||||
|
||||
// Create body
|
||||
let body = '';
|
||||
|
||||
// Text part
|
||||
body += `--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${this.email.text}\r\n`;
|
||||
|
||||
// HTML part if present
|
||||
if (this.email.html) {
|
||||
body += `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${this.email.html}\r\n`;
|
||||
}
|
||||
|
||||
// Attachments
|
||||
for (const attachment of this.email.attachments) {
|
||||
body += `--${boundary}\r\nContent-Type: ${attachment.contentType}; name="${attachment.filename}"\r\n`;
|
||||
body += 'Content-Transfer-Encoding: base64\r\n';
|
||||
body += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
|
||||
|
||||
// Add Content-ID for inline attachments if present
|
||||
if (attachment.contentId) {
|
||||
body += `Content-ID: <${attachment.contentId}>\r\n`;
|
||||
}
|
||||
|
||||
body += '\r\n';
|
||||
body += attachment.content.toString('base64') + '\r\n';
|
||||
}
|
||||
|
||||
// End of message
|
||||
body += `--${boundary}--\r\n`;
|
||||
|
||||
// Create DKIM signature
|
||||
const dkimSigner = new EmailSignJob(this.mtaRef, {
|
||||
domain: this.email.getFromDomain(),
|
||||
selector: 'mta',
|
||||
headers: headers,
|
||||
body: body,
|
||||
});
|
||||
|
||||
// Build the message with headers
|
||||
let headerString = '';
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
headerString += `${key}: ${value}\r\n`;
|
||||
}
|
||||
let message = headerString + '\r\n' + body;
|
||||
|
||||
// Add DKIM signature header
|
||||
let signatureHeader = await dkimSigner.getSignatureHeader(message);
|
||||
message = `${signatureHeader}${message}`;
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an event for sender reputation monitoring
|
||||
* @param eventType Type of event
|
||||
* @param isHardBounce Whether the event is a hard bounce (for bounce events)
|
||||
*/
|
||||
private recordDeliveryEvent(
|
||||
eventType: 'sent' | 'delivered' | 'bounce' | 'complaint',
|
||||
isHardBounce: boolean = false
|
||||
): void {
|
||||
try {
|
||||
// Check if reputation monitoring is enabled
|
||||
if (!this.mtaRef.config.outbound?.reputation?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reputationMonitor = this.mtaRef.getReputationMonitor();
|
||||
if (!reputationMonitor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get domain from sender
|
||||
const domain = this.email.getFromDomain();
|
||||
if (!domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine receiving domain for complaint tracking
|
||||
let receivingDomain = null;
|
||||
if (eventType === 'complaint' && this.email.to.length > 0) {
|
||||
const recipient = this.email.to[0];
|
||||
const parts = recipient.split('@');
|
||||
if (parts.length === 2) {
|
||||
receivingDomain = parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Record the event
|
||||
reputationMonitor.recordSendEvent(domain, {
|
||||
type: eventType,
|
||||
count: 1,
|
||||
hardBounce: isHardBounce,
|
||||
receivingDomain
|
||||
});
|
||||
} catch (error) {
|
||||
this.log(`Error recording delivery event: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to the SMTP server and wait for the expected response
|
||||
*/
|
||||
private sendCommand(command: string, expectedResponseCode?: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket) {
|
||||
return reject(new Error('Socket not connected'));
|
||||
}
|
||||
|
||||
// Debug log for commands (except DATA which can be large)
|
||||
if (this.options.debugMode && !command.startsWith('--')) {
|
||||
const logCommand = command.length > 100
|
||||
? command.substring(0, 97) + '...'
|
||||
: command;
|
||||
this.log(`Sending: ${logCommand.replace(/\r\n/g, '<CRLF>')}`);
|
||||
}
|
||||
|
||||
this.socket.write(command, (error) => {
|
||||
if (error) {
|
||||
this.log(`Write error: ${error.message}`);
|
||||
return reject(error);
|
||||
}
|
||||
|
||||
// If no response is expected, resolve immediately
|
||||
if (!expectedResponseCode) {
|
||||
return resolve('');
|
||||
}
|
||||
|
||||
// Set a timeout for the response
|
||||
const responseTimeout = setTimeout(() => {
|
||||
this.log('Response timeout');
|
||||
reject(new Error('Response timeout'));
|
||||
}, this.options.connectionTimeout);
|
||||
|
||||
// Wait for the response
|
||||
this.socket.once('data', (data) => {
|
||||
clearTimeout(responseTimeout);
|
||||
const response = data.toString();
|
||||
|
||||
if (this.options.debugMode) {
|
||||
this.log(`Received: ${response.trim()}`);
|
||||
}
|
||||
|
||||
if (response.startsWith(expectedResponseCode)) {
|
||||
resolve(response);
|
||||
} else {
|
||||
const error = new Error(`Unexpected server response: ${response.trim()}`);
|
||||
this.log(error.message);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an error represents a permanent failure
|
||||
*/
|
||||
private isPermanentFailure(error: Error): boolean {
|
||||
if (!error || !error.message) return false;
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
// Check for permanent SMTP error codes (5xx)
|
||||
if (message.match(/^5\d\d/)) return true;
|
||||
|
||||
// Check for specific permanent failure messages
|
||||
const permanentFailurePatterns = [
|
||||
'no such user',
|
||||
'user unknown',
|
||||
'domain not found',
|
||||
'invalid domain',
|
||||
'rejected',
|
||||
'denied',
|
||||
'prohibited',
|
||||
'authentication required',
|
||||
'authentication failed',
|
||||
'unauthorized'
|
||||
];
|
||||
|
||||
return permanentFailurePatterns.some(pattern => message.includes(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve MX records for a domain
|
||||
*/
|
||||
private resolveMx(domain: string): Promise<plugins.dns.MxRecord[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(addresses);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a log entry
|
||||
*/
|
||||
private log(message: string): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = `[${timestamp}] ${message}`;
|
||||
this.deliveryInfo.logs.push(logEntry);
|
||||
|
||||
if (this.options.debugMode) {
|
||||
console.log(`EmailSendJob: ${logEntry}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a successful email for record keeping
|
||||
*/
|
||||
private async saveSuccess(): Promise<void> {
|
||||
try {
|
||||
plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir);
|
||||
const emailContent = await this.createEmailMessage();
|
||||
const fileName = `${Date.now()}_success_${this.email.getPrimaryRecipient()}.eml`;
|
||||
plugins.smartfile.memory.toFsSync(emailContent, plugins.path.join(paths.sentEmailsDir, fileName));
|
||||
|
||||
// Save delivery info
|
||||
const infoFileName = `${Date.now()}_success_${this.email.getPrimaryRecipient()}.json`;
|
||||
plugins.smartfile.memory.toFsSync(
|
||||
JSON.stringify(this.deliveryInfo, null, 2),
|
||||
plugins.path.join(paths.sentEmailsDir, infoFileName)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error saving successful email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a failed email for potential retry
|
||||
*/
|
||||
private async saveFailed(): Promise<void> {
|
||||
try {
|
||||
plugins.smartfile.fs.ensureDirSync(paths.failedEmailsDir);
|
||||
const emailContent = await this.createEmailMessage();
|
||||
const fileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.eml`;
|
||||
plugins.smartfile.memory.toFsSync(emailContent, plugins.path.join(paths.failedEmailsDir, fileName));
|
||||
|
||||
// Save delivery info
|
||||
const infoFileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.json`;
|
||||
plugins.smartfile.memory.toFsSync(
|
||||
JSON.stringify(this.deliveryInfo, null, 2),
|
||||
plugins.path.join(paths.failedEmailsDir, infoFileName)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error saving failed email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple delay function
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
69
ts/mail/delivery/classes.emailsignjob.ts
Normal file
69
ts/mail/delivery/classes.emailsignjob.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { MtaService } from './classes.mta.js';
|
||||
|
||||
interface Headers {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface IEmailSignJobOptions {
|
||||
domain: string;
|
||||
selector: string;
|
||||
headers: Headers;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export class EmailSignJob {
|
||||
mtaRef: MtaService;
|
||||
jobOptions: IEmailSignJobOptions;
|
||||
|
||||
constructor(mtaRefArg: MtaService, options: IEmailSignJobOptions) {
|
||||
this.mtaRef = mtaRefArg;
|
||||
this.jobOptions = options;
|
||||
}
|
||||
|
||||
async loadPrivateKey(): Promise<string> {
|
||||
return plugins.fs.promises.readFile(
|
||||
(await this.mtaRef.dkimCreator.getKeyPathsForDomain(this.jobOptions.domain)).privateKeyPath,
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
public async getSignatureHeader(emailMessage: string): Promise<string> {
|
||||
const signResult = await plugins.dkimSign(emailMessage, {
|
||||
// Optional, default canonicalization, default is "relaxed/relaxed"
|
||||
canonicalization: 'relaxed/relaxed', // c=
|
||||
|
||||
// Optional, default signing and hashing algorithm
|
||||
// Mostly useful when you want to use rsa-sha1, otherwise no need to set
|
||||
algorithm: 'rsa-sha256',
|
||||
|
||||
// Optional, default is current time
|
||||
signTime: new Date(), // t=
|
||||
|
||||
// Keys for one or more signatures
|
||||
// Different signatures can use different algorithms (mostly useful when
|
||||
// you want to sign a message both with RSA and Ed25519)
|
||||
signatureData: [
|
||||
{
|
||||
signingDomain: this.jobOptions.domain, // d=
|
||||
selector: this.jobOptions.selector, // s=
|
||||
// supported key types: RSA, Ed25519
|
||||
privateKey: await this.loadPrivateKey(), // k=
|
||||
|
||||
// Optional algorithm, default is derived from the key.
|
||||
// Overrides whatever was set in parent object
|
||||
algorithm: 'rsa-sha256',
|
||||
|
||||
// Optional signature specifc canonicalization, overrides whatever was set in parent object
|
||||
canonicalization: 'relaxed/relaxed', // c=
|
||||
|
||||
// Maximum number of canonicalized body bytes to sign (eg. the "l=" tag).
|
||||
// Do not use though. This is available only for compatibility testing.
|
||||
// maxBodyLength: 12345
|
||||
},
|
||||
],
|
||||
});
|
||||
const signature = signResult.signatures;
|
||||
return signature;
|
||||
}
|
||||
}
|
1422
ts/mail/delivery/classes.mta.ts
Normal file
1422
ts/mail/delivery/classes.mta.ts
Normal file
File diff suppressed because it is too large
Load Diff
281
ts/mail/delivery/classes.ratelimiter.ts
Normal file
281
ts/mail/delivery/classes.ratelimiter.ts
Normal file
@ -0,0 +1,281 @@
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
/**
|
||||
* Configuration options for rate limiter
|
||||
*/
|
||||
export interface IRateLimitConfig {
|
||||
/** Maximum tokens per period */
|
||||
maxPerPeriod: number;
|
||||
|
||||
/** Time period in milliseconds */
|
||||
periodMs: number;
|
||||
|
||||
/** Whether to apply per domain/key (vs globally) */
|
||||
perKey: boolean;
|
||||
|
||||
/** Initial token count (defaults to max) */
|
||||
initialTokens?: number;
|
||||
|
||||
/** Grace tokens to allow occasional bursts */
|
||||
burstTokens?: number;
|
||||
|
||||
/** Apply global limit in addition to per-key limits */
|
||||
useGlobalLimit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token bucket for an individual key
|
||||
*/
|
||||
interface TokenBucket {
|
||||
/** Current number of tokens */
|
||||
tokens: number;
|
||||
|
||||
/** Last time tokens were refilled */
|
||||
lastRefill: number;
|
||||
|
||||
/** Total allowed requests */
|
||||
allowed: number;
|
||||
|
||||
/** Total denied requests */
|
||||
denied: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiter using token bucket algorithm
|
||||
* Provides more sophisticated rate limiting with burst handling
|
||||
*/
|
||||
export class RateLimiter {
|
||||
/** Rate limit configuration */
|
||||
private config: IRateLimitConfig;
|
||||
|
||||
/** Token buckets per key */
|
||||
private buckets: Map<string, TokenBucket> = new Map();
|
||||
|
||||
/** Global bucket for non-keyed rate limiting */
|
||||
private globalBucket: TokenBucket;
|
||||
|
||||
/**
|
||||
* Create a new rate limiter
|
||||
* @param config Rate limiter configuration
|
||||
*/
|
||||
constructor(config: IRateLimitConfig) {
|
||||
// Set defaults
|
||||
this.config = {
|
||||
maxPerPeriod: config.maxPerPeriod,
|
||||
periodMs: config.periodMs,
|
||||
perKey: config.perKey ?? true,
|
||||
initialTokens: config.initialTokens ?? config.maxPerPeriod,
|
||||
burstTokens: config.burstTokens ?? 0,
|
||||
useGlobalLimit: config.useGlobalLimit ?? false
|
||||
};
|
||||
|
||||
// Initialize global bucket
|
||||
this.globalBucket = {
|
||||
tokens: this.config.initialTokens,
|
||||
lastRefill: Date.now(),
|
||||
allowed: 0,
|
||||
denied: 0
|
||||
};
|
||||
|
||||
// Log initialization
|
||||
logger.log('info', `Rate limiter initialized: ${this.config.maxPerPeriod} per ${this.config.periodMs}ms${this.config.perKey ? ' per key' : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is allowed under rate limits
|
||||
* @param key Key to check rate limit for (e.g. domain, user, IP)
|
||||
* @param cost Token cost (defaults to 1)
|
||||
* @returns Whether the request is allowed
|
||||
*/
|
||||
public isAllowed(key: string = 'global', cost: number = 1): boolean {
|
||||
// If using global bucket directly, just check that
|
||||
if (key === 'global' || !this.config.perKey) {
|
||||
return this.checkBucket(this.globalBucket, cost);
|
||||
}
|
||||
|
||||
// Get the key-specific bucket
|
||||
const bucket = this.getBucket(key);
|
||||
|
||||
// If we also need to check global limit
|
||||
if (this.config.useGlobalLimit) {
|
||||
// Both key bucket and global bucket must have tokens
|
||||
return this.checkBucket(bucket, cost) && this.checkBucket(this.globalBucket, cost);
|
||||
} else {
|
||||
// Only need to check the key-specific bucket
|
||||
return this.checkBucket(bucket, cost);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a bucket has enough tokens and consume them
|
||||
* @param bucket The token bucket to check
|
||||
* @param cost Token cost
|
||||
* @returns Whether tokens were consumed
|
||||
*/
|
||||
private checkBucket(bucket: TokenBucket, cost: number): boolean {
|
||||
// Refill tokens based on elapsed time
|
||||
this.refillBucket(bucket);
|
||||
|
||||
// Check if we have enough tokens
|
||||
if (bucket.tokens >= cost) {
|
||||
// Use tokens
|
||||
bucket.tokens -= cost;
|
||||
bucket.allowed++;
|
||||
return true;
|
||||
} else {
|
||||
// Rate limit exceeded
|
||||
bucket.denied++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume tokens for a request (if available)
|
||||
* @param key Key to consume tokens for
|
||||
* @param cost Token cost (defaults to 1)
|
||||
* @returns Whether tokens were consumed
|
||||
*/
|
||||
public consume(key: string = 'global', cost: number = 1): boolean {
|
||||
const isAllowed = this.isAllowed(key, cost);
|
||||
return isAllowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the remaining tokens for a key
|
||||
* @param key Key to check
|
||||
* @returns Number of remaining tokens
|
||||
*/
|
||||
public getRemainingTokens(key: string = 'global'): number {
|
||||
const bucket = this.getBucket(key);
|
||||
this.refillBucket(bucket);
|
||||
return bucket.tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats for a specific key
|
||||
* @param key Key to get stats for
|
||||
* @returns Rate limit statistics
|
||||
*/
|
||||
public getStats(key: string = 'global'): {
|
||||
remaining: number;
|
||||
limit: number;
|
||||
resetIn: number;
|
||||
allowed: number;
|
||||
denied: number;
|
||||
} {
|
||||
const bucket = this.getBucket(key);
|
||||
this.refillBucket(bucket);
|
||||
|
||||
// Calculate time until next token
|
||||
const resetIn = bucket.tokens < this.config.maxPerPeriod ?
|
||||
Math.ceil(this.config.periodMs / this.config.maxPerPeriod) :
|
||||
0;
|
||||
|
||||
return {
|
||||
remaining: bucket.tokens,
|
||||
limit: this.config.maxPerPeriod,
|
||||
resetIn,
|
||||
allowed: bucket.allowed,
|
||||
denied: bucket.denied
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a token bucket for a key
|
||||
* @param key The rate limit key
|
||||
* @returns Token bucket
|
||||
*/
|
||||
private getBucket(key: string): TokenBucket {
|
||||
if (!this.config.perKey || key === 'global') {
|
||||
return this.globalBucket;
|
||||
}
|
||||
|
||||
if (!this.buckets.has(key)) {
|
||||
// Create new bucket
|
||||
this.buckets.set(key, {
|
||||
tokens: this.config.initialTokens,
|
||||
lastRefill: Date.now(),
|
||||
allowed: 0,
|
||||
denied: 0
|
||||
});
|
||||
}
|
||||
|
||||
return this.buckets.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refill tokens in a bucket based on elapsed time
|
||||
* @param bucket Token bucket to refill
|
||||
*/
|
||||
private refillBucket(bucket: TokenBucket): void {
|
||||
const now = Date.now();
|
||||
const elapsedMs = now - bucket.lastRefill;
|
||||
|
||||
// Calculate how many tokens to add
|
||||
const rate = this.config.maxPerPeriod / this.config.periodMs;
|
||||
const tokensToAdd = elapsedMs * rate;
|
||||
|
||||
if (tokensToAdd >= 0.1) { // Allow for partial token refills
|
||||
// Add tokens, but don't exceed the normal maximum (without burst)
|
||||
// This ensures burst tokens are only used for bursts and don't refill
|
||||
const normalMax = this.config.maxPerPeriod;
|
||||
bucket.tokens = Math.min(
|
||||
// Don't exceed max + burst
|
||||
this.config.maxPerPeriod + (this.config.burstTokens || 0),
|
||||
// Don't exceed normal max when refilling
|
||||
Math.min(normalMax, bucket.tokens + tokensToAdd)
|
||||
);
|
||||
|
||||
// Update last refill time
|
||||
bucket.lastRefill = now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rate limits for a specific key
|
||||
* @param key Key to reset
|
||||
*/
|
||||
public reset(key: string = 'global'): void {
|
||||
if (key === 'global' || !this.config.perKey) {
|
||||
this.globalBucket.tokens = this.config.initialTokens;
|
||||
this.globalBucket.lastRefill = Date.now();
|
||||
} else if (this.buckets.has(key)) {
|
||||
const bucket = this.buckets.get(key);
|
||||
bucket.tokens = this.config.initialTokens;
|
||||
bucket.lastRefill = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all rate limiters
|
||||
*/
|
||||
public resetAll(): void {
|
||||
this.globalBucket.tokens = this.config.initialTokens;
|
||||
this.globalBucket.lastRefill = Date.now();
|
||||
|
||||
for (const bucket of this.buckets.values()) {
|
||||
bucket.tokens = this.config.initialTokens;
|
||||
bucket.lastRefill = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old buckets to prevent memory leaks
|
||||
* @param maxAge Maximum age in milliseconds
|
||||
*/
|
||||
public cleanup(maxAge: number = 24 * 60 * 60 * 1000): void {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
|
||||
for (const [key, bucket] of this.buckets.entries()) {
|
||||
if (now - bucket.lastRefill > maxAge) {
|
||||
this.buckets.delete(key);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
logger.log('debug', `Cleaned up ${removed} stale rate limit buckets`);
|
||||
}
|
||||
}
|
||||
}
|
806
ts/mail/delivery/classes.smtpserver.ts
Normal file
806
ts/mail/delivery/classes.smtpserver.ts
Normal file
@ -0,0 +1,806 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as paths from '../../paths.js';
|
||||
import { Email } from '../core/classes.email.js';
|
||||
import type { MtaService } from './classes.mta.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import {
|
||||
SecurityLogger,
|
||||
SecurityLogLevel,
|
||||
SecurityEventType,
|
||||
IPReputationChecker,
|
||||
ReputationThreshold
|
||||
} from '../../security/index.js';
|
||||
|
||||
export interface ISmtpServerOptions {
|
||||
port: number;
|
||||
key: string;
|
||||
cert: string;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
// SMTP Session States
|
||||
enum SmtpState {
|
||||
GREETING,
|
||||
AFTER_EHLO,
|
||||
MAIL_FROM,
|
||||
RCPT_TO,
|
||||
DATA,
|
||||
DATA_RECEIVING,
|
||||
FINISHED
|
||||
}
|
||||
|
||||
// Structure to store session information
|
||||
interface SmtpSession {
|
||||
state: SmtpState;
|
||||
clientHostname: string;
|
||||
mailFrom: string;
|
||||
rcptTo: string[];
|
||||
emailData: string;
|
||||
useTLS: boolean;
|
||||
connectionEnded: boolean;
|
||||
}
|
||||
|
||||
export class SMTPServer {
|
||||
public mtaRef: MtaService;
|
||||
private smtpServerOptions: ISmtpServerOptions;
|
||||
private server: plugins.net.Server;
|
||||
private sessions: Map<plugins.net.Socket | plugins.tls.TLSSocket, SmtpSession>;
|
||||
private hostname: string;
|
||||
|
||||
constructor(mtaRefArg: MtaService, optionsArg: ISmtpServerOptions) {
|
||||
console.log('SMTPServer instance is being created...');
|
||||
|
||||
this.mtaRef = mtaRefArg;
|
||||
this.smtpServerOptions = optionsArg;
|
||||
this.sessions = new Map();
|
||||
this.hostname = optionsArg.hostname || 'mta.lossless.one';
|
||||
|
||||
this.server = plugins.net.createServer((socket) => {
|
||||
this.handleNewConnection(socket);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleNewConnection(socket: plugins.net.Socket): Promise<void> {
|
||||
const clientIp = socket.remoteAddress;
|
||||
const clientPort = socket.remotePort;
|
||||
console.log(`New connection from ${clientIp}:${clientPort}`);
|
||||
|
||||
// Initialize a new session
|
||||
this.sessions.set(socket, {
|
||||
state: SmtpState.GREETING,
|
||||
clientHostname: '',
|
||||
mailFrom: '',
|
||||
rcptTo: [],
|
||||
emailData: '',
|
||||
useTLS: false,
|
||||
connectionEnded: false
|
||||
});
|
||||
|
||||
// Check IP reputation
|
||||
try {
|
||||
if (this.mtaRef.config.security?.checkIPReputation !== false && clientIp) {
|
||||
const reputationChecker = IPReputationChecker.getInstance();
|
||||
const reputation = await reputationChecker.checkReputation(clientIp);
|
||||
|
||||
// Log the reputation check
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: reputation.score < ReputationThreshold.HIGH_RISK
|
||||
? SecurityLogLevel.WARN
|
||||
: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.IP_REPUTATION,
|
||||
message: `IP reputation checked for new SMTP connection: score=${reputation.score}`,
|
||||
ipAddress: clientIp,
|
||||
details: {
|
||||
clientPort,
|
||||
score: reputation.score,
|
||||
isSpam: reputation.isSpam,
|
||||
isProxy: reputation.isProxy,
|
||||
isTor: reputation.isTor,
|
||||
isVPN: reputation.isVPN,
|
||||
country: reputation.country,
|
||||
blacklists: reputation.blacklists,
|
||||
socketId: socket.remotePort.toString() + socket.remoteFamily
|
||||
}
|
||||
});
|
||||
|
||||
// Handle high-risk IPs - add delay or reject based on score
|
||||
if (reputation.score < ReputationThreshold.HIGH_RISK) {
|
||||
// For high-risk connections, add an artificial delay to slow down potential spam
|
||||
const delayMs = Math.min(5000, Math.max(1000, (ReputationThreshold.HIGH_RISK - reputation.score) * 100));
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
|
||||
if (reputation.score < 5) {
|
||||
// Very high risk - can optionally reject the connection
|
||||
if (this.mtaRef.config.security?.rejectHighRiskIPs) {
|
||||
this.sendResponse(socket, `554 Transaction failed - IP is on spam blocklist`);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Error checking IP reputation: ${error.message}`, {
|
||||
ip: clientIp,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Log the connection as a security event
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.CONNECTION,
|
||||
message: `New SMTP connection established`,
|
||||
ipAddress: clientIp,
|
||||
details: {
|
||||
clientPort,
|
||||
socketId: socket.remotePort.toString() + socket.remoteFamily
|
||||
}
|
||||
});
|
||||
|
||||
// Send greeting
|
||||
this.sendResponse(socket, `220 ${this.hostname} ESMTP Service Ready`);
|
||||
|
||||
socket.on('data', (data) => {
|
||||
this.processData(socket, data);
|
||||
});
|
||||
|
||||
socket.on('end', () => {
|
||||
const clientIp = socket.remoteAddress;
|
||||
const clientPort = socket.remotePort;
|
||||
console.log(`Connection ended from ${clientIp}:${clientPort}`);
|
||||
|
||||
const session = this.sessions.get(socket);
|
||||
if (session) {
|
||||
session.connectionEnded = true;
|
||||
|
||||
// Log connection end as security event
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.CONNECTION,
|
||||
message: `SMTP connection ended normally`,
|
||||
ipAddress: clientIp,
|
||||
details: {
|
||||
clientPort,
|
||||
state: SmtpState[session.state],
|
||||
from: session.mailFrom || 'not set'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
const clientIp = socket.remoteAddress;
|
||||
const clientPort = socket.remotePort;
|
||||
console.error(`Socket error: ${err.message}`);
|
||||
|
||||
// Log connection error as security event
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.CONNECTION,
|
||||
message: `SMTP connection error`,
|
||||
ipAddress: clientIp,
|
||||
details: {
|
||||
clientPort,
|
||||
error: err.message,
|
||||
errorCode: (err as any).code,
|
||||
from: this.sessions.get(socket)?.mailFrom || 'not set'
|
||||
}
|
||||
});
|
||||
|
||||
this.sessions.delete(socket);
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
const clientIp = socket.remoteAddress;
|
||||
const clientPort = socket.remotePort;
|
||||
console.log(`Connection closed from ${clientIp}:${clientPort}`);
|
||||
|
||||
// Log connection closure as security event
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.CONNECTION,
|
||||
message: `SMTP connection closed`,
|
||||
ipAddress: clientIp,
|
||||
details: {
|
||||
clientPort,
|
||||
sessionEnded: this.sessions.get(socket)?.connectionEnded || false
|
||||
}
|
||||
});
|
||||
|
||||
this.sessions.delete(socket);
|
||||
});
|
||||
}
|
||||
|
||||
private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
|
||||
try {
|
||||
socket.write(`${response}\r\n`);
|
||||
console.log(`→ ${response}`);
|
||||
} catch (error) {
|
||||
console.error(`Error sending response: ${error.message}`);
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private processData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: Buffer): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) {
|
||||
console.error('No session found for socket. Closing connection.');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're in DATA_RECEIVING state, handle differently
|
||||
if (session.state === SmtpState.DATA_RECEIVING) {
|
||||
// Call async method but don't return the promise
|
||||
this.processEmailData(socket, data.toString()).catch(err => {
|
||||
console.error(`Error processing email data: ${err.message}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process normal SMTP commands
|
||||
const lines = data.toString().split('\r\n').filter(line => line.length > 0);
|
||||
for (const line of lines) {
|
||||
console.log(`← ${line}`);
|
||||
this.processCommand(socket, line);
|
||||
}
|
||||
}
|
||||
|
||||
private processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session || session.connectionEnded) return;
|
||||
|
||||
const [command, ...args] = commandLine.split(' ');
|
||||
const upperCommand = command.toUpperCase();
|
||||
|
||||
switch (upperCommand) {
|
||||
case 'EHLO':
|
||||
case 'HELO':
|
||||
this.handleEhlo(socket, args.join(' '));
|
||||
break;
|
||||
case 'STARTTLS':
|
||||
this.handleStartTls(socket);
|
||||
break;
|
||||
case 'MAIL':
|
||||
this.handleMailFrom(socket, args.join(' '));
|
||||
break;
|
||||
case 'RCPT':
|
||||
this.handleRcptTo(socket, args.join(' '));
|
||||
break;
|
||||
case 'DATA':
|
||||
this.handleData(socket);
|
||||
break;
|
||||
case 'RSET':
|
||||
this.handleRset(socket);
|
||||
break;
|
||||
case 'QUIT':
|
||||
this.handleQuit(socket);
|
||||
break;
|
||||
case 'NOOP':
|
||||
this.sendResponse(socket, '250 OK');
|
||||
break;
|
||||
default:
|
||||
this.sendResponse(socket, '502 Command not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
private handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
if (!clientHostname) {
|
||||
this.sendResponse(socket, '501 Syntax error in parameters or arguments');
|
||||
return;
|
||||
}
|
||||
|
||||
session.clientHostname = clientHostname;
|
||||
session.state = SmtpState.AFTER_EHLO;
|
||||
|
||||
// List available extensions
|
||||
this.sendResponse(socket, `250-${this.hostname} Hello ${clientHostname}`);
|
||||
this.sendResponse(socket, '250-SIZE 10485760'); // 10MB max
|
||||
this.sendResponse(socket, '250-8BITMIME');
|
||||
|
||||
// Only offer STARTTLS if we haven't already established it
|
||||
if (!session.useTLS) {
|
||||
this.sendResponse(socket, '250-STARTTLS');
|
||||
}
|
||||
|
||||
this.sendResponse(socket, '250 HELP');
|
||||
}
|
||||
|
||||
private handleStartTls(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
if (session.state !== SmtpState.AFTER_EHLO) {
|
||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.useTLS) {
|
||||
this.sendResponse(socket, '503 TLS already active');
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendResponse(socket, '220 Ready to start TLS');
|
||||
this.startTLS(socket);
|
||||
}
|
||||
|
||||
private handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
if (session.state !== SmtpState.AFTER_EHLO) {
|
||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract email from MAIL FROM:<user@example.com>
|
||||
const emailMatch = args.match(/FROM:<([^>]*)>/i);
|
||||
if (!emailMatch) {
|
||||
this.sendResponse(socket, '501 Syntax error in parameters or arguments');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = emailMatch[1];
|
||||
if (!this.isValidEmail(email)) {
|
||||
this.sendResponse(socket, '501 Invalid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
session.mailFrom = email;
|
||||
session.state = SmtpState.MAIL_FROM;
|
||||
this.sendResponse(socket, '250 OK');
|
||||
}
|
||||
|
||||
private handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
if (session.state !== SmtpState.MAIL_FROM && session.state !== SmtpState.RCPT_TO) {
|
||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract email from RCPT TO:<user@example.com>
|
||||
const emailMatch = args.match(/TO:<([^>]*)>/i);
|
||||
if (!emailMatch) {
|
||||
this.sendResponse(socket, '501 Syntax error in parameters or arguments');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = emailMatch[1];
|
||||
if (!this.isValidEmail(email)) {
|
||||
this.sendResponse(socket, '501 Invalid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
session.rcptTo.push(email);
|
||||
session.state = SmtpState.RCPT_TO;
|
||||
this.sendResponse(socket, '250 OK');
|
||||
}
|
||||
|
||||
private handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
if (session.state !== SmtpState.RCPT_TO) {
|
||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
||||
return;
|
||||
}
|
||||
|
||||
session.state = SmtpState.DATA_RECEIVING;
|
||||
session.emailData = '';
|
||||
this.sendResponse(socket, '354 End data with <CR><LF>.<CR><LF>');
|
||||
}
|
||||
|
||||
private handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
// Reset the session data but keep connection information
|
||||
session.state = SmtpState.AFTER_EHLO;
|
||||
session.mailFrom = '';
|
||||
session.rcptTo = [];
|
||||
session.emailData = '';
|
||||
|
||||
this.sendResponse(socket, '250 OK');
|
||||
}
|
||||
|
||||
private handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
this.sendResponse(socket, '221 Goodbye');
|
||||
|
||||
// If we have collected email data, try to parse it before closing
|
||||
if (session.state === SmtpState.FINISHED && session.emailData.length > 0) {
|
||||
this.parseEmail(socket);
|
||||
}
|
||||
|
||||
socket.end();
|
||||
this.sessions.delete(socket);
|
||||
}
|
||||
|
||||
private async processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void> {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
// Check for end of data marker
|
||||
if (data.endsWith('\r\n.\r\n')) {
|
||||
// Remove the end of data marker
|
||||
const emailData = data.slice(0, -5);
|
||||
session.emailData += emailData;
|
||||
session.state = SmtpState.FINISHED;
|
||||
|
||||
// Save and process the email
|
||||
this.saveEmail(socket);
|
||||
this.sendResponse(socket, '250 OK: Message accepted for delivery');
|
||||
} else {
|
||||
// Accumulate the data
|
||||
session.emailData += data;
|
||||
}
|
||||
}
|
||||
|
||||
private saveEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
// Ensure the directory exists
|
||||
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
|
||||
|
||||
// Write the email to disk
|
||||
plugins.smartfile.memory.toFsSync(
|
||||
session.emailData,
|
||||
plugins.path.join(paths.receivedEmailsDir, `${Date.now()}.eml`)
|
||||
);
|
||||
|
||||
// Parse the email
|
||||
this.parseEmail(socket);
|
||||
} catch (error) {
|
||||
console.error('Error saving email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async parseEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<void> {
|
||||
const session = this.sessions.get(socket);
|
||||
if (!session || !session.emailData) {
|
||||
console.error('No email data found for session.');
|
||||
return;
|
||||
}
|
||||
|
||||
let mightBeSpam = false;
|
||||
// Prepare headers for DKIM verification results
|
||||
const customHeaders: Record<string, string> = {};
|
||||
|
||||
// Authentication results
|
||||
let dkimResult = { domain: '', result: false };
|
||||
let spfResult = { domain: '', result: false };
|
||||
|
||||
// Check security configuration
|
||||
const securityConfig = this.mtaRef.config.security || {};
|
||||
|
||||
// 1. Verify DKIM signature if enabled
|
||||
if (securityConfig.verifyDkim !== false) {
|
||||
try {
|
||||
const verificationResult = await this.mtaRef.dkimVerifier.verify(session.emailData, {
|
||||
useCache: true,
|
||||
returnDetails: false
|
||||
});
|
||||
|
||||
dkimResult.result = verificationResult.isValid;
|
||||
dkimResult.domain = verificationResult.domain || '';
|
||||
|
||||
if (!verificationResult.isValid) {
|
||||
logger.log('warn', `DKIM verification failed for incoming email: ${verificationResult.errorMessage || 'Unknown error'}`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification failed for incoming email`,
|
||||
domain: verificationResult.domain || session.mailFrom.split('@')[1],
|
||||
details: {
|
||||
error: verificationResult.errorMessage || 'Unknown error',
|
||||
status: verificationResult.status,
|
||||
selector: verificationResult.selector,
|
||||
senderIP: socket.remoteAddress
|
||||
},
|
||||
ipAddress: socket.remoteAddress,
|
||||
success: false
|
||||
});
|
||||
} else {
|
||||
logger.log('info', `DKIM verification passed for incoming email from domain ${verificationResult.domain}`);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.DKIM,
|
||||
message: `DKIM verification passed for incoming email`,
|
||||
domain: verificationResult.domain,
|
||||
details: {
|
||||
selector: verificationResult.selector,
|
||||
status: verificationResult.status,
|
||||
senderIP: socket.remoteAddress
|
||||
},
|
||||
ipAddress: socket.remoteAddress,
|
||||
success: true
|
||||
});
|
||||
}
|
||||
|
||||
// Store verification results in headers
|
||||
if (verificationResult.domain) {
|
||||
customHeaders['X-DKIM-Domain'] = verificationResult.domain;
|
||||
}
|
||||
|
||||
customHeaders['X-DKIM-Status'] = verificationResult.status || 'unknown';
|
||||
customHeaders['X-DKIM-Result'] = verificationResult.isValid ? 'pass' : 'fail';
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to verify DKIM signature: ${error.message}`);
|
||||
customHeaders['X-DKIM-Status'] = 'error';
|
||||
customHeaders['X-DKIM-Result'] = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Verify SPF if enabled
|
||||
if (securityConfig.verifySpf !== false) {
|
||||
try {
|
||||
// Get the client IP and hostname
|
||||
const clientIp = socket.remoteAddress || '127.0.0.1';
|
||||
const clientHostname = session.clientHostname || 'localhost';
|
||||
|
||||
// Parse the email to get envelope from
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(session.emailData);
|
||||
|
||||
// Create a temporary Email object for SPF verification
|
||||
const tempEmail = new Email({
|
||||
from: parsedEmail.from?.value[0].address || session.mailFrom,
|
||||
to: session.rcptTo[0],
|
||||
subject: "Temporary Email for SPF Verification",
|
||||
text: "This is a temporary email for SPF verification"
|
||||
});
|
||||
|
||||
// Set envelope from for SPF verification
|
||||
tempEmail.setEnvelopeFrom(session.mailFrom);
|
||||
|
||||
// Verify SPF
|
||||
const spfVerified = await this.mtaRef.spfVerifier.verifyAndApply(
|
||||
tempEmail,
|
||||
clientIp,
|
||||
clientHostname
|
||||
);
|
||||
|
||||
// Update SPF result
|
||||
spfResult.result = spfVerified;
|
||||
spfResult.domain = session.mailFrom.split('@')[1] || '';
|
||||
|
||||
// Copy SPF headers from the temp email
|
||||
if (tempEmail.headers['Received-SPF']) {
|
||||
customHeaders['Received-SPF'] = tempEmail.headers['Received-SPF'];
|
||||
}
|
||||
|
||||
// Set spam flag if SPF fails badly
|
||||
if (tempEmail.mightBeSpam) {
|
||||
mightBeSpam = true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to verify SPF: ${error.message}`);
|
||||
customHeaders['Received-SPF'] = `error (${error.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Verify DMARC if enabled
|
||||
if (securityConfig.verifyDmarc !== false) {
|
||||
try {
|
||||
// Parse the email again
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(session.emailData);
|
||||
|
||||
// Create a temporary Email object for DMARC verification
|
||||
const tempEmail = new Email({
|
||||
from: parsedEmail.from?.value[0].address || session.mailFrom,
|
||||
to: session.rcptTo[0],
|
||||
subject: "Temporary Email for DMARC Verification",
|
||||
text: "This is a temporary email for DMARC verification"
|
||||
});
|
||||
|
||||
// Verify DMARC
|
||||
const dmarcResult = await this.mtaRef.dmarcVerifier.verify(
|
||||
tempEmail,
|
||||
spfResult,
|
||||
dkimResult
|
||||
);
|
||||
|
||||
// Apply DMARC policy
|
||||
const dmarcPassed = this.mtaRef.dmarcVerifier.applyPolicy(tempEmail, dmarcResult);
|
||||
|
||||
// Add DMARC result to headers
|
||||
if (tempEmail.headers['X-DMARC-Result']) {
|
||||
customHeaders['X-DMARC-Result'] = tempEmail.headers['X-DMARC-Result'];
|
||||
}
|
||||
|
||||
// Add Authentication-Results header combining all authentication results
|
||||
customHeaders['Authentication-Results'] = `${this.mtaRef.config.smtp.hostname}; ` +
|
||||
`spf=${spfResult.result ? 'pass' : 'fail'} smtp.mailfrom=${session.mailFrom}; ` +
|
||||
`dkim=${dkimResult.result ? 'pass' : 'fail'} header.d=${dkimResult.domain || 'unknown'}; ` +
|
||||
`dmarc=${dmarcPassed ? 'pass' : 'fail'} header.from=${tempEmail.getFromDomain()}`;
|
||||
|
||||
// Set spam flag if DMARC fails
|
||||
if (tempEmail.mightBeSpam) {
|
||||
mightBeSpam = true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to verify DMARC: ${error.message}`);
|
||||
customHeaders['X-DMARC-Result'] = `error (${error.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(session.emailData);
|
||||
|
||||
const email = new Email({
|
||||
from: parsedEmail.from?.value[0].address || session.mailFrom,
|
||||
to: session.rcptTo[0], // Use the first recipient
|
||||
headers: customHeaders, // Add our custom headers with DKIM verification results
|
||||
subject: parsedEmail.subject || '',
|
||||
text: parsedEmail.html || parsedEmail.text || '',
|
||||
attachments: parsedEmail.attachments?.map((attachment) => ({
|
||||
filename: attachment.filename || '',
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType,
|
||||
})) || [],
|
||||
mightBeSpam: mightBeSpam,
|
||||
});
|
||||
|
||||
console.log('Email received and parsed:', {
|
||||
from: email.from,
|
||||
to: email.to,
|
||||
subject: email.subject,
|
||||
attachments: email.attachments.length,
|
||||
mightBeSpam: email.mightBeSpam
|
||||
});
|
||||
|
||||
// Enhanced security logging for received email
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: mightBeSpam ? SecurityLogLevel.WARN : SecurityLogLevel.INFO,
|
||||
type: mightBeSpam ? SecurityEventType.SPAM : SecurityEventType.EMAIL_VALIDATION,
|
||||
message: `Email received and ${mightBeSpam ? 'flagged as potential spam' : 'validated successfully'}`,
|
||||
domain: email.from.split('@')[1],
|
||||
ipAddress: socket.remoteAddress,
|
||||
details: {
|
||||
from: email.from,
|
||||
subject: email.subject,
|
||||
recipientCount: email.getAllRecipients().length,
|
||||
attachmentCount: email.attachments.length,
|
||||
hasAttachments: email.hasAttachments(),
|
||||
dkimStatus: customHeaders['X-DKIM-Result'] || 'unknown'
|
||||
},
|
||||
success: !mightBeSpam
|
||||
});
|
||||
|
||||
// Process or forward the email via MTA service
|
||||
try {
|
||||
await this.mtaRef.processIncomingEmail(email);
|
||||
} catch (err) {
|
||||
console.error('Error in MTA processing of incoming email:', err);
|
||||
|
||||
// Log processing errors
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.EMAIL_VALIDATION,
|
||||
message: `Error processing incoming email`,
|
||||
domain: email.from.split('@')[1],
|
||||
ipAddress: socket.remoteAddress,
|
||||
details: {
|
||||
error: err.message,
|
||||
from: email.from,
|
||||
stack: err.stack
|
||||
},
|
||||
success: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing email:', error);
|
||||
|
||||
// Log parsing errors
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.EMAIL_VALIDATION,
|
||||
message: `Error parsing incoming email`,
|
||||
ipAddress: socket.remoteAddress,
|
||||
details: {
|
||||
error: error.message,
|
||||
sender: session.mailFrom,
|
||||
stack: error.stack
|
||||
},
|
||||
success: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private startTLS(socket: plugins.net.Socket): void {
|
||||
try {
|
||||
const secureContext = plugins.tls.createSecureContext({
|
||||
key: this.smtpServerOptions.key,
|
||||
cert: this.smtpServerOptions.cert,
|
||||
});
|
||||
|
||||
const tlsSocket = new plugins.tls.TLSSocket(socket, {
|
||||
secureContext: secureContext,
|
||||
isServer: true,
|
||||
server: this.server
|
||||
});
|
||||
|
||||
const originalSession = this.sessions.get(socket);
|
||||
if (!originalSession) {
|
||||
console.error('No session found when upgrading to TLS');
|
||||
return;
|
||||
}
|
||||
|
||||
// Transfer the session data to the new TLS socket
|
||||
this.sessions.set(tlsSocket, {
|
||||
...originalSession,
|
||||
useTLS: true,
|
||||
state: SmtpState.GREETING // Reset state to require a new EHLO
|
||||
});
|
||||
|
||||
this.sessions.delete(socket);
|
||||
|
||||
tlsSocket.on('secure', () => {
|
||||
console.log('TLS negotiation successful');
|
||||
});
|
||||
|
||||
tlsSocket.on('data', (data: Buffer) => {
|
||||
this.processData(tlsSocket, data);
|
||||
});
|
||||
|
||||
tlsSocket.on('end', () => {
|
||||
console.log('TLS socket ended');
|
||||
const session = this.sessions.get(tlsSocket);
|
||||
if (session) {
|
||||
session.connectionEnded = true;
|
||||
}
|
||||
});
|
||||
|
||||
tlsSocket.on('error', (err) => {
|
||||
console.error('TLS socket error:', err);
|
||||
this.sessions.delete(tlsSocket);
|
||||
tlsSocket.destroy();
|
||||
});
|
||||
|
||||
tlsSocket.on('close', () => {
|
||||
console.log('TLS socket closed');
|
||||
this.sessions.delete(tlsSocket);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error upgrading connection to TLS:', error);
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private isValidEmail(email: string): boolean {
|
||||
// Basic email validation - more comprehensive validation could be implemented
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.server.listen(this.smtpServerOptions.port, () => {
|
||||
console.log(`SMTP Server is now running on port ${this.smtpServerOptions.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.server.getConnections((err, count) => {
|
||||
if (err) throw err;
|
||||
console.log('Number of active connections: ', count);
|
||||
});
|
||||
|
||||
this.server.close(() => {
|
||||
console.log('SMTP Server is now stopped');
|
||||
});
|
||||
}
|
||||
}
|
897
ts/mail/delivery/classes.unified.rate.limiter.ts
Normal file
897
ts/mail/delivery/classes.unified.rate.limiter.ts
Normal file
@ -0,0 +1,897 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { logger } from '../../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||||
|
||||
/**
|
||||
* Interface for rate limit configuration
|
||||
*/
|
||||
export interface IRateLimitConfig {
|
||||
maxMessagesPerMinute?: number;
|
||||
maxRecipientsPerMessage?: number;
|
||||
maxConnectionsPerIP?: number;
|
||||
maxErrorsPerIP?: number;
|
||||
maxAuthFailuresPerIP?: number;
|
||||
blockDuration?: number; // in milliseconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for hierarchical rate limits
|
||||
*/
|
||||
export interface IHierarchicalRateLimits {
|
||||
// Global rate limits (applied to all traffic)
|
||||
global: IRateLimitConfig;
|
||||
|
||||
// Pattern-specific rate limits (applied to matching patterns)
|
||||
patterns?: Record<string, IRateLimitConfig>;
|
||||
|
||||
// IP-specific rate limits (applied to specific IPs)
|
||||
ips?: Record<string, IRateLimitConfig>;
|
||||
|
||||
// Temporary blocks list and their expiry times
|
||||
blocks?: Record<string, number>; // IP to expiry timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* Counter interface for rate limiting
|
||||
*/
|
||||
interface ILimitCounter {
|
||||
count: number;
|
||||
lastReset: number;
|
||||
recipients: number;
|
||||
errors: number;
|
||||
authFailures: number;
|
||||
connections: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiter statistics
|
||||
*/
|
||||
export interface IRateLimiterStats {
|
||||
activeCounters: number;
|
||||
totalBlocked: number;
|
||||
currentlyBlocked: number;
|
||||
byPattern: Record<string, {
|
||||
messagesPerMinute: number;
|
||||
totalMessages: number;
|
||||
totalBlocked: number;
|
||||
}>;
|
||||
byIp: Record<string, {
|
||||
messagesPerMinute: number;
|
||||
totalMessages: number;
|
||||
totalBlocked: number;
|
||||
connections: number;
|
||||
errors: number;
|
||||
authFailures: number;
|
||||
blocked: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a rate limit check
|
||||
*/
|
||||
export interface IRateLimitResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
limit?: number;
|
||||
current?: number;
|
||||
resetIn?: number; // milliseconds until reset
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified rate limiter for all email processing modes
|
||||
*/
|
||||
export class UnifiedRateLimiter extends EventEmitter {
|
||||
private config: IHierarchicalRateLimits;
|
||||
private counters: Map<string, ILimitCounter> = new Map();
|
||||
private patternCounters: Map<string, ILimitCounter> = new Map();
|
||||
private ipCounters: Map<string, ILimitCounter> = new Map();
|
||||
private cleanupInterval?: NodeJS.Timeout;
|
||||
private stats: IRateLimiterStats;
|
||||
|
||||
/**
|
||||
* Create a new unified rate limiter
|
||||
* @param config Rate limit configuration
|
||||
*/
|
||||
constructor(config: IHierarchicalRateLimits) {
|
||||
super();
|
||||
|
||||
// Set default configuration
|
||||
this.config = {
|
||||
global: {
|
||||
maxMessagesPerMinute: config.global.maxMessagesPerMinute || 100,
|
||||
maxRecipientsPerMessage: config.global.maxRecipientsPerMessage || 100,
|
||||
maxConnectionsPerIP: config.global.maxConnectionsPerIP || 20,
|
||||
maxErrorsPerIP: config.global.maxErrorsPerIP || 10,
|
||||
maxAuthFailuresPerIP: config.global.maxAuthFailuresPerIP || 5,
|
||||
blockDuration: config.global.blockDuration || 3600000 // 1 hour
|
||||
},
|
||||
patterns: config.patterns || {},
|
||||
ips: config.ips || {},
|
||||
blocks: config.blocks || {}
|
||||
};
|
||||
|
||||
// Initialize statistics
|
||||
this.stats = {
|
||||
activeCounters: 0,
|
||||
totalBlocked: 0,
|
||||
currentlyBlocked: 0,
|
||||
byPattern: {},
|
||||
byIp: {}
|
||||
};
|
||||
|
||||
// Start cleanup interval
|
||||
this.startCleanupInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cleanup interval
|
||||
*/
|
||||
private startCleanupInterval(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
// Run cleanup every minute
|
||||
this.cleanupInterval = setInterval(() => this.cleanup(), 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cleanup interval
|
||||
*/
|
||||
public stop(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired counters and blocks
|
||||
*/
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Clean up expired blocks
|
||||
if (this.config.blocks) {
|
||||
for (const [ip, expiry] of Object.entries(this.config.blocks)) {
|
||||
if (expiry <= now) {
|
||||
delete this.config.blocks[ip];
|
||||
logger.log('info', `Rate limit block expired for IP ${ip}`);
|
||||
|
||||
// Update statistics
|
||||
if (this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip].blocked = false;
|
||||
}
|
||||
this.stats.currentlyBlocked--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old counters (older than 10 minutes)
|
||||
const cutoff = now - 600000;
|
||||
|
||||
// Clean global counters
|
||||
for (const [key, counter] of this.counters.entries()) {
|
||||
if (counter.lastReset < cutoff) {
|
||||
this.counters.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean pattern counters
|
||||
for (const [key, counter] of this.patternCounters.entries()) {
|
||||
if (counter.lastReset < cutoff) {
|
||||
this.patternCounters.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean IP counters
|
||||
for (const [key, counter] of this.ipCounters.entries()) {
|
||||
if (counter.lastReset < cutoff) {
|
||||
this.ipCounters.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is allowed by rate limits
|
||||
* @param email Email address
|
||||
* @param ip IP address
|
||||
* @param recipients Number of recipients
|
||||
* @param pattern Matched pattern
|
||||
* @returns Result of rate limit check
|
||||
*/
|
||||
public checkMessageLimit(email: string, ip: string, recipients: number, pattern?: string): IRateLimitResult {
|
||||
// Check if IP is blocked
|
||||
if (this.isIpBlocked(ip)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'IP is blocked',
|
||||
resetIn: this.getBlockReleaseTime(ip)
|
||||
};
|
||||
}
|
||||
|
||||
// Check global message rate limit
|
||||
const globalResult = this.checkGlobalMessageLimit(email);
|
||||
if (!globalResult.allowed) {
|
||||
return globalResult;
|
||||
}
|
||||
|
||||
// Check pattern-specific limit if pattern is provided
|
||||
if (pattern) {
|
||||
const patternResult = this.checkPatternMessageLimit(pattern);
|
||||
if (!patternResult.allowed) {
|
||||
return patternResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Check IP-specific limit
|
||||
const ipResult = this.checkIpMessageLimit(ip);
|
||||
if (!ipResult.allowed) {
|
||||
return ipResult;
|
||||
}
|
||||
|
||||
// Check recipient limit
|
||||
const recipientResult = this.checkRecipientLimit(email, recipients, pattern);
|
||||
if (!recipientResult.allowed) {
|
||||
return recipientResult;
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check global message rate limit
|
||||
* @param email Email address
|
||||
*/
|
||||
private checkGlobalMessageLimit(email: string): IRateLimitResult {
|
||||
const now = Date.now();
|
||||
const limit = this.config.global.maxMessagesPerMinute!;
|
||||
|
||||
if (!limit) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Get or create counter
|
||||
const key = 'global';
|
||||
let counter = this.counters.get(key);
|
||||
|
||||
if (!counter) {
|
||||
counter = {
|
||||
count: 0,
|
||||
lastReset: now,
|
||||
recipients: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
connections: 0
|
||||
};
|
||||
this.counters.set(key, counter);
|
||||
}
|
||||
|
||||
// Check if counter needs to be reset
|
||||
if (now - counter.lastReset >= 60000) {
|
||||
counter.count = 0;
|
||||
counter.lastReset = now;
|
||||
}
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (counter.count >= limit) {
|
||||
// Calculate reset time
|
||||
const resetIn = 60000 - (now - counter.lastReset);
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Global message rate limit exceeded',
|
||||
limit,
|
||||
current: counter.count,
|
||||
resetIn
|
||||
};
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
counter.count++;
|
||||
|
||||
// Update statistics
|
||||
this.updateStats();
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check pattern-specific message rate limit
|
||||
* @param pattern Pattern to check
|
||||
*/
|
||||
private checkPatternMessageLimit(pattern: string): IRateLimitResult {
|
||||
const now = Date.now();
|
||||
|
||||
// Get pattern-specific limit or use global
|
||||
const patternConfig = this.config.patterns?.[pattern];
|
||||
const limit = patternConfig?.maxMessagesPerMinute || this.config.global.maxMessagesPerMinute!;
|
||||
|
||||
if (!limit) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Get or create counter
|
||||
let counter = this.patternCounters.get(pattern);
|
||||
|
||||
if (!counter) {
|
||||
counter = {
|
||||
count: 0,
|
||||
lastReset: now,
|
||||
recipients: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
connections: 0
|
||||
};
|
||||
this.patternCounters.set(pattern, counter);
|
||||
|
||||
// Initialize pattern stats if needed
|
||||
if (!this.stats.byPattern[pattern]) {
|
||||
this.stats.byPattern[pattern] = {
|
||||
messagesPerMinute: 0,
|
||||
totalMessages: 0,
|
||||
totalBlocked: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if counter needs to be reset
|
||||
if (now - counter.lastReset >= 60000) {
|
||||
counter.count = 0;
|
||||
counter.lastReset = now;
|
||||
}
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (counter.count >= limit) {
|
||||
// Calculate reset time
|
||||
const resetIn = 60000 - (now - counter.lastReset);
|
||||
|
||||
// Update statistics
|
||||
this.stats.byPattern[pattern].totalBlocked++;
|
||||
this.stats.totalBlocked++;
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Pattern "${pattern}" message rate limit exceeded`,
|
||||
limit,
|
||||
current: counter.count,
|
||||
resetIn
|
||||
};
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
counter.count++;
|
||||
|
||||
// Update statistics
|
||||
this.stats.byPattern[pattern].messagesPerMinute = counter.count;
|
||||
this.stats.byPattern[pattern].totalMessages++;
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check IP-specific message rate limit
|
||||
* @param ip IP address
|
||||
*/
|
||||
private checkIpMessageLimit(ip: string): IRateLimitResult {
|
||||
const now = Date.now();
|
||||
|
||||
// Get IP-specific limit or use global
|
||||
const ipConfig = this.config.ips?.[ip];
|
||||
const limit = ipConfig?.maxMessagesPerMinute || this.config.global.maxMessagesPerMinute!;
|
||||
|
||||
if (!limit) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Get or create counter
|
||||
let counter = this.ipCounters.get(ip);
|
||||
|
||||
if (!counter) {
|
||||
counter = {
|
||||
count: 0,
|
||||
lastReset: now,
|
||||
recipients: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
connections: 0
|
||||
};
|
||||
this.ipCounters.set(ip, counter);
|
||||
|
||||
// Initialize IP stats if needed
|
||||
if (!this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip] = {
|
||||
messagesPerMinute: 0,
|
||||
totalMessages: 0,
|
||||
totalBlocked: 0,
|
||||
connections: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
blocked: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if counter needs to be reset
|
||||
if (now - counter.lastReset >= 60000) {
|
||||
counter.count = 0;
|
||||
counter.lastReset = now;
|
||||
}
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (counter.count >= limit) {
|
||||
// Calculate reset time
|
||||
const resetIn = 60000 - (now - counter.lastReset);
|
||||
|
||||
// Update statistics
|
||||
this.stats.byIp[ip].totalBlocked++;
|
||||
this.stats.totalBlocked++;
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `IP ${ip} message rate limit exceeded`,
|
||||
limit,
|
||||
current: counter.count,
|
||||
resetIn
|
||||
};
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
counter.count++;
|
||||
|
||||
// Update statistics
|
||||
this.stats.byIp[ip].messagesPerMinute = counter.count;
|
||||
this.stats.byIp[ip].totalMessages++;
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check recipient limit
|
||||
* @param email Email address
|
||||
* @param recipients Number of recipients
|
||||
* @param pattern Matched pattern
|
||||
*/
|
||||
private checkRecipientLimit(email: string, recipients: number, pattern?: string): IRateLimitResult {
|
||||
// Get pattern-specific limit if available
|
||||
let limit = this.config.global.maxRecipientsPerMessage!;
|
||||
|
||||
if (pattern && this.config.patterns?.[pattern]?.maxRecipientsPerMessage) {
|
||||
limit = this.config.patterns[pattern].maxRecipientsPerMessage!;
|
||||
}
|
||||
|
||||
if (!limit) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (recipients > limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Recipient limit exceeded',
|
||||
limit,
|
||||
current: recipients
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a connection from an IP
|
||||
* @param ip IP address
|
||||
* @returns Result of rate limit check
|
||||
*/
|
||||
public recordConnection(ip: string): IRateLimitResult {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if IP is blocked
|
||||
if (this.isIpBlocked(ip)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'IP is blocked',
|
||||
resetIn: this.getBlockReleaseTime(ip)
|
||||
};
|
||||
}
|
||||
|
||||
// Get IP-specific limit or use global
|
||||
const ipConfig = this.config.ips?.[ip];
|
||||
const limit = ipConfig?.maxConnectionsPerIP || this.config.global.maxConnectionsPerIP!;
|
||||
|
||||
if (!limit) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Get or create counter
|
||||
let counter = this.ipCounters.get(ip);
|
||||
|
||||
if (!counter) {
|
||||
counter = {
|
||||
count: 0,
|
||||
lastReset: now,
|
||||
recipients: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
connections: 0
|
||||
};
|
||||
this.ipCounters.set(ip, counter);
|
||||
|
||||
// Initialize IP stats if needed
|
||||
if (!this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip] = {
|
||||
messagesPerMinute: 0,
|
||||
totalMessages: 0,
|
||||
totalBlocked: 0,
|
||||
connections: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
blocked: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if counter needs to be reset
|
||||
if (now - counter.lastReset >= 60000) {
|
||||
counter.connections = 0;
|
||||
counter.lastReset = now;
|
||||
}
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (counter.connections >= limit) {
|
||||
// Calculate reset time
|
||||
const resetIn = 60000 - (now - counter.lastReset);
|
||||
|
||||
// Update statistics
|
||||
this.stats.byIp[ip].totalBlocked++;
|
||||
this.stats.totalBlocked++;
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `IP ${ip} connection rate limit exceeded`,
|
||||
limit,
|
||||
current: counter.connections,
|
||||
resetIn
|
||||
};
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
counter.connections++;
|
||||
|
||||
// Update statistics
|
||||
this.stats.byIp[ip].connections = counter.connections;
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an error from an IP
|
||||
* @param ip IP address
|
||||
* @returns True if IP should be blocked
|
||||
*/
|
||||
public recordError(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
// Get IP-specific limit or use global
|
||||
const ipConfig = this.config.ips?.[ip];
|
||||
const limit = ipConfig?.maxErrorsPerIP || this.config.global.maxErrorsPerIP!;
|
||||
|
||||
if (!limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get or create counter
|
||||
let counter = this.ipCounters.get(ip);
|
||||
|
||||
if (!counter) {
|
||||
counter = {
|
||||
count: 0,
|
||||
lastReset: now,
|
||||
recipients: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
connections: 0
|
||||
};
|
||||
this.ipCounters.set(ip, counter);
|
||||
|
||||
// Initialize IP stats if needed
|
||||
if (!this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip] = {
|
||||
messagesPerMinute: 0,
|
||||
totalMessages: 0,
|
||||
totalBlocked: 0,
|
||||
connections: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
blocked: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if counter needs to be reset
|
||||
if (now - counter.lastReset >= 60000) {
|
||||
counter.errors = 0;
|
||||
counter.lastReset = now;
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
counter.errors++;
|
||||
|
||||
// Update statistics
|
||||
this.stats.byIp[ip].errors = counter.errors;
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (counter.errors >= limit) {
|
||||
// Block the IP
|
||||
this.blockIp(ip);
|
||||
|
||||
logger.log('warn', `IP ${ip} blocked due to excessive errors (${counter.errors}/${limit})`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.RATE_LIMITING,
|
||||
message: 'IP blocked due to excessive errors',
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
errors: counter.errors,
|
||||
limit
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an authentication failure from an IP
|
||||
* @param ip IP address
|
||||
* @returns True if IP should be blocked
|
||||
*/
|
||||
public recordAuthFailure(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
// Get IP-specific limit or use global
|
||||
const ipConfig = this.config.ips?.[ip];
|
||||
const limit = ipConfig?.maxAuthFailuresPerIP || this.config.global.maxAuthFailuresPerIP!;
|
||||
|
||||
if (!limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get or create counter
|
||||
let counter = this.ipCounters.get(ip);
|
||||
|
||||
if (!counter) {
|
||||
counter = {
|
||||
count: 0,
|
||||
lastReset: now,
|
||||
recipients: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
connections: 0
|
||||
};
|
||||
this.ipCounters.set(ip, counter);
|
||||
|
||||
// Initialize IP stats if needed
|
||||
if (!this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip] = {
|
||||
messagesPerMinute: 0,
|
||||
totalMessages: 0,
|
||||
totalBlocked: 0,
|
||||
connections: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
blocked: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if counter needs to be reset
|
||||
if (now - counter.lastReset >= 60000) {
|
||||
counter.authFailures = 0;
|
||||
counter.lastReset = now;
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
counter.authFailures++;
|
||||
|
||||
// Update statistics
|
||||
this.stats.byIp[ip].authFailures = counter.authFailures;
|
||||
|
||||
// Check if limit is exceeded
|
||||
if (counter.authFailures >= limit) {
|
||||
// Block the IP
|
||||
this.blockIp(ip);
|
||||
|
||||
logger.log('warn', `IP ${ip} blocked due to excessive authentication failures (${counter.authFailures}/${limit})`);
|
||||
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.AUTHENTICATION,
|
||||
message: 'IP blocked due to excessive authentication failures',
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
authFailures: counter.authFailures,
|
||||
limit
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block an IP address
|
||||
* @param ip IP address to block
|
||||
* @param duration Override the default block duration (milliseconds)
|
||||
*/
|
||||
public blockIp(ip: string, duration?: number): void {
|
||||
if (!this.config.blocks) {
|
||||
this.config.blocks = {};
|
||||
}
|
||||
|
||||
// Set block expiry time
|
||||
const expiry = Date.now() + (duration || this.config.global.blockDuration || 3600000);
|
||||
this.config.blocks[ip] = expiry;
|
||||
|
||||
// Update statistics
|
||||
if (!this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip] = {
|
||||
messagesPerMinute: 0,
|
||||
totalMessages: 0,
|
||||
totalBlocked: 0,
|
||||
connections: 0,
|
||||
errors: 0,
|
||||
authFailures: 0,
|
||||
blocked: false
|
||||
};
|
||||
}
|
||||
this.stats.byIp[ip].blocked = true;
|
||||
this.stats.currentlyBlocked++;
|
||||
|
||||
// Emit event
|
||||
this.emit('ipBlocked', {
|
||||
ip,
|
||||
expiry,
|
||||
duration: duration || this.config.global.blockDuration
|
||||
});
|
||||
|
||||
logger.log('warn', `IP ${ip} blocked until ${new Date(expiry).toISOString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock an IP address
|
||||
* @param ip IP address to unblock
|
||||
*/
|
||||
public unblockIp(ip: string): void {
|
||||
if (!this.config.blocks) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove block
|
||||
delete this.config.blocks[ip];
|
||||
|
||||
// Update statistics
|
||||
if (this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip].blocked = false;
|
||||
this.stats.currentlyBlocked--;
|
||||
}
|
||||
|
||||
// Emit event
|
||||
this.emit('ipUnblocked', { ip });
|
||||
|
||||
logger.log('info', `IP ${ip} unblocked`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is blocked
|
||||
* @param ip IP address to check
|
||||
*/
|
||||
public isIpBlocked(ip: string): boolean {
|
||||
if (!this.config.blocks) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if IP is in blocks
|
||||
if (!(ip in this.config.blocks)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if block has expired
|
||||
const expiry = this.config.blocks[ip];
|
||||
if (expiry <= Date.now()) {
|
||||
// Remove expired block
|
||||
delete this.config.blocks[ip];
|
||||
|
||||
// Update statistics
|
||||
if (this.stats.byIp[ip]) {
|
||||
this.stats.byIp[ip].blocked = false;
|
||||
this.stats.currentlyBlocked--;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the time until a block is released
|
||||
* @param ip IP address
|
||||
* @returns Milliseconds until release or 0 if not blocked
|
||||
*/
|
||||
public getBlockReleaseTime(ip: string): number {
|
||||
if (!this.config.blocks || !(ip in this.config.blocks)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const expiry = this.config.blocks[ip];
|
||||
const now = Date.now();
|
||||
|
||||
return expiry > now ? expiry - now : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rate limiter statistics
|
||||
*/
|
||||
private updateStats(): void {
|
||||
// Update active counters count
|
||||
this.stats.activeCounters = this.counters.size + this.patternCounters.size + this.ipCounters.size;
|
||||
|
||||
// Emit statistics update
|
||||
this.emit('statsUpdated', this.stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limiter statistics
|
||||
*/
|
||||
public getStats(): IRateLimiterStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rate limiter configuration
|
||||
* @param config New configuration
|
||||
*/
|
||||
public updateConfig(config: Partial<IHierarchicalRateLimits>): void {
|
||||
if (config.global) {
|
||||
this.config.global = {
|
||||
...this.config.global,
|
||||
...config.global
|
||||
};
|
||||
}
|
||||
|
||||
if (config.patterns) {
|
||||
this.config.patterns = {
|
||||
...this.config.patterns,
|
||||
...config.patterns
|
||||
};
|
||||
}
|
||||
|
||||
if (config.ips) {
|
||||
this.config.ips = {
|
||||
...this.config.ips,
|
||||
...config.ips
|
||||
};
|
||||
}
|
||||
|
||||
logger.log('info', 'Rate limiter configuration updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration for debugging
|
||||
*/
|
||||
public getConfig(): IHierarchicalRateLimits {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
18
ts/mail/delivery/index.ts
Normal file
18
ts/mail/delivery/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
// Email delivery components
|
||||
export * from './classes.mta.js';
|
||||
export * from './classes.smtpserver.js';
|
||||
export * from './classes.emailsignjob.js';
|
||||
export * from './classes.delivery.queue.js';
|
||||
export * from './classes.delivery.system.js';
|
||||
|
||||
// Handle exports with naming conflicts
|
||||
export { EmailSendJob } from './classes.emailsendjob.js';
|
||||
export { DeliveryStatus } from './classes.connector.mta.js';
|
||||
export { MtaConnector } from './classes.connector.mta.js';
|
||||
|
||||
// Rate limiter exports - fix naming conflict
|
||||
export { RateLimiter } from './classes.ratelimiter.js';
|
||||
export type { IRateLimitConfig } from './classes.ratelimiter.js';
|
||||
|
||||
// Unified rate limiter
|
||||
export * from './classes.unified.rate.limiter.js';
|
Reference in New Issue
Block a user