This commit is contained in:
2025-05-07 20:20:17 +00:00
parent f6377d1973
commit 630e911589
40 changed files with 8745 additions and 333 deletions

View File

@ -5,9 +5,15 @@ import { Email } from './classes.email.js';
import { EmailSendJob, DeliveryStatus } from './classes.emailsendjob.js';
import { DKIMCreator } from './classes.dkimcreator.js';
import { DKIMVerifier } from './classes.dkimverifier.js';
import { SpfVerifier } from './classes.spfverifier.js';
import { DmarcVerifier } from './classes.dmarcverifier.js';
import { SMTPServer, type ISmtpServerOptions } from './classes.smtpserver.js';
import { DNSManager } from './classes.dnsmanager.js';
import { ApiManager } from './classes.apimanager.js';
import { RateLimiter, type IRateLimitConfig } from './classes.ratelimiter.js';
import { ContentScanner } from '../security/classes.contentscanner.js';
import { IPWarmupManager } from '../deliverability/classes.ipwarmupmanager.js';
import { SenderReputationMonitor } from '../deliverability/classes.senderreputationmonitor.js';
import type { SzPlatformService } from '../platformservice.js';
/**
@ -57,6 +63,33 @@ export interface IMtaConfig {
/** Whether to apply per domain (vs globally) */
perDomain?: boolean;
};
/** IP warmup configuration */
warmup?: {
/** Whether IP warmup is enabled */
enabled?: boolean;
/** IP addresses to warm up */
ipAddresses?: string[];
/** Target domains to warm up */
targetDomains?: string[];
/** Allocation policy to use */
allocationPolicy?: string;
/** Fallback percentage for ESP routing during warmup */
fallbackPercentage?: number;
};
/** Reputation monitoring configuration */
reputation?: {
/** Whether reputation monitoring is enabled */
enabled?: boolean;
/** How frequently to update metrics (ms) */
updateFrequency?: number;
/** Alert thresholds */
alertThresholds?: {
/** Minimum acceptable reputation score */
minReputationScore?: number;
/** Maximum acceptable complaint rate */
maxComplaintRate?: number;
};
};
};
/** Security settings */
security?: {
@ -66,10 +99,26 @@ export interface IMtaConfig {
verifyDkim?: boolean;
/** Whether to verify SPF on inbound */
verifySpf?: boolean;
/** Whether to verify DMARC on inbound */
verifyDmarc?: boolean;
/** Whether to enforce DMARC policy */
enforceDmarc?: boolean;
/** Whether to use TLS for outbound when available */
useTls?: boolean;
/** Whether to require valid certificates */
requireValidCerts?: boolean;
/** Log level for email security events */
securityLogLevel?: 'info' | 'warn' | 'error';
/** Whether to check IP reputation for inbound emails */
checkIPReputation?: boolean;
/** Whether to scan content for malicious payloads */
scanContent?: boolean;
/** Action to take when malicious content is detected */
maliciousContentAction?: 'tag' | 'quarantine' | 'reject';
/** Minimum threat score to trigger action */
threatScoreThreshold?: number;
/** Whether to reject connections from high-risk IPs */
rejectHighRiskIPs?: boolean;
};
/** Domains configuration */
domains?: {
@ -121,6 +170,18 @@ interface MtaStats {
expiresAt: Date;
daysUntilExpiry: number;
};
warmupInfo?: {
enabled: boolean;
activeIPs: number;
inWarmupPhase: number;
completedWarmup: number;
};
reputationInfo?: {
enabled: boolean;
monitoredDomains: number;
averageScore: number;
domainsWithIssues: number;
};
}
/**
@ -130,6 +191,11 @@ export class MtaService {
/** Reference to the platform service */
public platformServiceRef: SzPlatformService;
// Get access to the email service and bounce manager
private get emailService() {
return this.platformServiceRef.emailService;
}
/** SMTP server instance */
public server: SMTPServer;
@ -139,6 +205,12 @@ export class MtaService {
/** DKIM verifier for validating incoming emails */
public dkimVerifier: DKIMVerifier;
/** SPF verifier for validating incoming emails */
public spfVerifier: SpfVerifier;
/** DMARC verifier for email authentication policy enforcement */
public dmarcVerifier: DmarcVerifier;
/** DNS manager for handling DNS records */
public dnsManager: DNSManager;
@ -151,17 +223,20 @@ export class MtaService {
/** Email queue processing state */
private queueProcessing = false;
/** Rate limiters for outbound emails */
private rateLimiters: Map<string, {
tokens: number;
lastRefill: number;
}> = new Map();
/** Rate limiter for outbound emails */
private rateLimiter: RateLimiter;
/** IP warmup manager for controlled scaling of new IPs */
private ipWarmupManager: IPWarmupManager;
/** Sender reputation monitor for tracking domain reputation */
private reputationMonitor: SenderReputationMonitor;
/** Certificate cache */
private certificate: Certificate = null;
/** MTA configuration */
private config: IMtaConfig;
public config: IMtaConfig;
/** Stats for monitoring */
private stats: MtaStats;
@ -191,9 +266,46 @@ export class MtaService {
this.dkimVerifier = new DKIMVerifier(this);
this.dnsManager = new DNSManager(this);
this.apiManager = new ApiManager();
// Initialize authentication verifiers
this.spfVerifier = new SpfVerifier(this);
this.dmarcVerifier = new DmarcVerifier(this);
// Initialize SMTP rule engine
this.smtpRuleEngine = new plugins.smartrule.SmartRule<Email>();
// Initialize rate limiter with config
const rateLimitConfig = this.config.outbound?.rateLimit;
this.rateLimiter = new RateLimiter({
maxPerPeriod: rateLimitConfig?.maxPerPeriod || 100,
periodMs: rateLimitConfig?.periodMs || 60000,
perKey: rateLimitConfig?.perDomain || true,
burstTokens: 5 // Allow small bursts
});
// Initialize IP warmup manager
const warmupConfig = this.config.outbound?.warmup;
this.ipWarmupManager = IPWarmupManager.getInstance({
enabled: warmupConfig?.enabled || false,
ipAddresses: warmupConfig?.ipAddresses || [],
targetDomains: warmupConfig?.targetDomains || [],
fallbackPercentage: warmupConfig?.fallbackPercentage || 50
});
// Set active allocation policy if specified
if (warmupConfig?.allocationPolicy) {
this.ipWarmupManager.setActiveAllocationPolicy(warmupConfig.allocationPolicy);
}
// Initialize sender reputation monitor
const reputationConfig = this.config.outbound?.reputation;
this.reputationMonitor = SenderReputationMonitor.getInstance({
enabled: reputationConfig?.enabled || false,
domains: this.config.domains?.local || [],
updateFrequency: reputationConfig?.updateFrequency || 24 * 60 * 60 * 1000,
alertThresholds: reputationConfig?.alertThresholds || {}
});
// Initialize stats
this.stats = {
startTime: new Date(),
@ -234,14 +346,37 @@ export class MtaService {
maxPerPeriod: 100,
periodMs: 60000, // 1 minute
perDomain: true
},
warmup: {
enabled: false,
ipAddresses: [],
targetDomains: [],
allocationPolicy: 'balanced',
fallbackPercentage: 50
},
reputation: {
enabled: false,
updateFrequency: 24 * 60 * 60 * 1000, // Daily
alertThresholds: {
minReputationScore: 70,
maxComplaintRate: 0.1 // 0.1%
}
}
},
security: {
useDkim: true,
verifyDkim: true,
verifySpf: true,
verifyDmarc: true,
enforceDmarc: true,
useTls: true,
requireValidCerts: false
requireValidCerts: false,
securityLogLevel: 'warn',
checkIPReputation: true,
scanContent: true,
maliciousContentAction: 'tag',
threatScoreThreshold: 50,
rejectHighRiskIPs: false
},
domains: {
local: ['lossless.one'],
@ -393,6 +528,14 @@ export class MtaService {
// Update stats
this.stats.queueSize = this.emailQueue.size;
// Record 'sent' event for sender reputation monitoring
if (this.config.outbound?.reputation?.enabled) {
const fromDomain = email.getFromDomain();
if (fromDomain) {
this.reputationMonitor.recordSendEvent(fromDomain, { type: 'sent' });
}
}
console.log(`Email added to queue: ${id}`);
return id;
@ -413,18 +556,62 @@ export class MtaService {
throw new Error('MTA service is not running');
}
// Apply SMTP rule engine decisions
try {
await this.smtpRuleEngine.makeDecision(email);
} catch (err) {
console.error('Error executing SMTP rules:', err);
}
try {
console.log(`Processing incoming email from ${email.from} to ${email.to}`);
// Update stats
this.stats.emailsReceived++;
// Apply SMTP rule engine decisions
try {
await this.smtpRuleEngine.makeDecision(email);
} catch (err) {
console.error('Error executing SMTP rules:', err);
}
// Scan for malicious content if enabled
if (this.config.security?.scanContent !== false) {
const contentScanner = ContentScanner.getInstance();
const scanResult = await contentScanner.scanEmail(email);
// Log the scan result
console.log(`Content scan result for email ${email.getMessageId()}: score=${scanResult.threatScore}, isClean=${scanResult.isClean}`);
// Take action based on the scan result and configuration
if (!scanResult.isClean) {
const threatScoreThreshold = this.config.security?.threatScoreThreshold || 50;
// Check if the threat score exceeds the threshold
if (scanResult.threatScore >= threatScoreThreshold) {
const action = this.config.security?.maliciousContentAction || 'tag';
switch (action) {
case 'reject':
// Reject the email
console.log(`Rejecting email from ${email.from} due to malicious content: ${scanResult.threatType} (score: ${scanResult.threatScore})`);
return false;
case 'quarantine':
// Save to quarantine folder instead of regular processing
await this.saveToQuarantine(email, scanResult);
return true;
case 'tag':
default:
// Tag the email by modifying subject and adding headers
email.subject = `[SUSPICIOUS] ${email.subject}`;
email.addHeader('X-Content-Scanned', 'True');
email.addHeader('X-Threat-Type', scanResult.threatType || 'unknown');
email.addHeader('X-Threat-Score', scanResult.threatScore.toString());
email.addHeader('X-Threat-Details', scanResult.threatDetails || 'Suspicious content detected');
email.mightBeSpam = true;
console.log(`Tagged email from ${email.from} with suspicious content: ${scanResult.threatType} (score: ${scanResult.threatScore})`);
break;
}
}
}
}
// Check if the recipient domain is local
const recipientDomain = email.to[0].split('@')[1];
const isLocalDomain = this.isLocalDomain(recipientDomain);
@ -444,6 +631,55 @@ export class MtaService {
return false;
}
}
/**
* Save a suspicious email to quarantine
* @param email The email to quarantine
* @param scanResult The scan result
*/
private async saveToQuarantine(email: Email, scanResult: any): Promise<void> {
try {
// Create quarantine directory if it doesn't exist
const quarantinePath = plugins.path.join(paths.dataDir, 'emails', 'quarantine');
plugins.smartfile.fs.ensureDirSync(quarantinePath);
// Generate a filename with timestamp and details
const timestamp = Date.now();
const safeFrom = email.from.replace(/[^a-zA-Z0-9]/g, '_');
const filename = `${timestamp}_${safeFrom}_${scanResult.threatScore}.eml`;
// Save the email
const emailContent = email.toRFC822String();
const filePath = plugins.path.join(quarantinePath, filename);
plugins.smartfile.memory.toFsSync(emailContent, filePath);
// Save scan metadata alongside the email
const metadataPath = plugins.path.join(quarantinePath, `${filename}.meta.json`);
const metadata = {
timestamp,
from: email.from,
to: email.to,
subject: email.subject,
messageId: email.getMessageId(),
scanResult: {
threatType: scanResult.threatType,
threatDetails: scanResult.threatDetails,
threatScore: scanResult.threatScore,
scannedElements: scanResult.scannedElements
}
};
plugins.smartfile.memory.toFsSync(
JSON.stringify(metadata, null, 2),
metadataPath
);
console.log(`Email quarantined: ${filePath}`);
} catch (error) {
console.error('Error saving email to quarantine:', error);
}
}
/**
* Check if a domain is local
@ -456,6 +692,14 @@ export class MtaService {
* Save an email to a local mailbox
*/
private async saveToLocalMailbox(email: Email): Promise<void> {
// Check if this is a bounce notification
const isBounceNotification = this.isBounceNotification(email);
if (isBounceNotification) {
await this.processBounceNotification(email);
return;
}
// Simplified implementation - in a real system, this would store to a user's mailbox
const mailboxPath = plugins.path.join(paths.receivedEmailsDir, 'local');
plugins.smartfile.fs.ensureDirSync(mailboxPath);
@ -470,6 +714,77 @@ export class MtaService {
console.log(`Email saved to local mailbox: ${filename}`);
}
/**
* Check if an email is a bounce notification
*/
private isBounceNotification(email: Email): boolean {
// Check subject for bounce-related keywords
const subject = email.subject?.toLowerCase() || '';
if (
subject.includes('mail delivery') ||
subject.includes('delivery failed') ||
subject.includes('undeliverable') ||
subject.includes('delivery status') ||
subject.includes('failure notice') ||
subject.includes('returned mail') ||
subject.includes('delivery problem')
) {
return true;
}
// Check sender address for common bounced email addresses
const from = email.from.toLowerCase();
if (
from.includes('mailer-daemon') ||
from.includes('postmaster') ||
from.includes('mail-delivery') ||
from.includes('bounces')
) {
return true;
}
return false;
}
/**
* Process a bounce notification
*/
private async processBounceNotification(email: Email): Promise<void> {
try {
console.log(`Processing bounce notification from ${email.from}`);
// Convert to Smartmail for bounce processing
const smartmail = await email.toSmartmail();
// If we have a bounce manager available, process it
if (this.emailService?.bounceManager) {
const bounceResult = await this.emailService.bounceManager.processBounceEmail(smartmail);
if (bounceResult) {
console.log(`Processed bounce for recipient: ${bounceResult.recipient}, type: ${bounceResult.bounceType}`);
} else {
console.log('Could not extract bounce information from email');
}
} else {
console.log('Bounce manager not available, saving bounce notification for later processing');
// Save to bounces directory for later processing
const bouncesPath = plugins.path.join(paths.dataDir, 'emails', 'bounces');
plugins.smartfile.fs.ensureDirSync(bouncesPath);
const emailContent = email.toRFC822String();
const filename = `${Date.now()}_bounce.eml`;
plugins.smartfile.memory.toFsSync(
emailContent,
plugins.path.join(bouncesPath, filename)
);
}
} catch (error) {
console.error('Error processing bounce notification:', error);
}
}
/**
* Start processing the email queue
@ -572,6 +887,17 @@ export class MtaService {
this.stats.emailsFailed++;
console.log(`Email ${entry.id} failed permanently: ${entry.error.message}`);
// Record bounce event for reputation monitoring
if (this.config.outbound?.reputation?.enabled) {
const domain = entry.email.getFromDomain();
if (domain) {
this.reputationMonitor.recordSendEvent(domain, {
type: 'bounce',
hardBounce: true
});
}
}
// Remove from queue
this.emailQueue.delete(entry.id);
} else if (status === DeliveryStatus.DEFERRED) {
@ -587,6 +913,17 @@ export class MtaService {
// Remove from queue
this.emailQueue.delete(entry.id);
} else {
// Record soft bounce for reputation monitoring
if (this.config.outbound?.reputation?.enabled) {
const domain = entry.email.getFromDomain();
if (domain) {
this.reputationMonitor.recordSendEvent(domain, {
type: 'bounce',
hardBounce: false
});
}
}
// Schedule retry
const delay = this.calculateRetryDelay(entry.attempts);
entry.nextAttempt = new Date(Date.now() + delay);
@ -602,9 +939,33 @@ export class MtaService {
if (entry.attempts >= this.config.outbound.retries.max) {
entry.status = DeliveryStatus.FAILED;
this.stats.emailsFailed++;
// Record bounce event for reputation monitoring after max retries
if (this.config.outbound?.reputation?.enabled) {
const domain = entry.email.getFromDomain();
if (domain) {
this.reputationMonitor.recordSendEvent(domain, {
type: 'bounce',
hardBounce: true
});
}
}
this.emailQueue.delete(entry.id);
} else {
entry.status = DeliveryStatus.DEFERRED;
// Record soft bounce for reputation monitoring
if (this.config.outbound?.reputation?.enabled) {
const domain = entry.email.getFromDomain();
if (domain) {
this.reputationMonitor.recordSendEvent(domain, {
type: 'bounce',
hardBounce: false
});
}
}
const delay = this.calculateRetryDelay(entry.attempts);
entry.nextAttempt = new Date(Date.now() + delay);
}
@ -635,42 +996,11 @@ export class MtaService {
* Check if an email can be sent under rate limits
*/
private checkRateLimit(email: Email): boolean {
const config = this.config.outbound.rateLimit;
if (!config || !config.maxPerPeriod) {
return true; // No rate limit configured
}
// Get the appropriate domain key
const domainKey = email.getFromDomain();
// Determine which limiter to use
const key = config.perDomain ? email.getFromDomain() : 'global';
// Initialize limiter if needed
if (!this.rateLimiters.has(key)) {
this.rateLimiters.set(key, {
tokens: config.maxPerPeriod,
lastRefill: Date.now()
});
}
const limiter = this.rateLimiters.get(key);
// Refill tokens based on time elapsed
const now = Date.now();
const elapsedMs = now - limiter.lastRefill;
const tokensToAdd = Math.floor(elapsedMs / config.periodMs) * config.maxPerPeriod;
if (tokensToAdd > 0) {
limiter.tokens = Math.min(config.maxPerPeriod, limiter.tokens + tokensToAdd);
limiter.lastRefill = now - (elapsedMs % config.periodMs);
}
// Check if we have tokens available
if (limiter.tokens > 0) {
limiter.tokens--;
return true;
} else {
console.log(`Rate limit exceeded for ${key}`);
return false;
}
// Check if sending is allowed under rate limits
return this.rateLimiter.consume(domainKey);
}
/**
@ -974,10 +1304,24 @@ export class MtaService {
}
}
/**
* Get the IP warmup manager
*/
public getIPWarmupManager(): IPWarmupManager {
return this.ipWarmupManager;
}
/**
* Get the sender reputation monitor
*/
public getReputationMonitor(): SenderReputationMonitor {
return this.reputationMonitor;
}
/**
* Get MTA service statistics
*/
public getStats(): MtaStats {
public getStats(): MtaStats & { rateLimiting?: any } {
// Update queue size
this.stats.queueSize = this.emailQueue.size;
@ -995,6 +1339,80 @@ export class MtaService {
};
}
return { ...this.stats };
// Add rate limiting stats
const statsWithRateLimiting = {
...this.stats,
rateLimiting: {
global: this.rateLimiter.getStats('global')
}
};
// Add warmup information if enabled
if (this.config.outbound?.warmup?.enabled) {
const warmupStatuses = this.ipWarmupManager.getWarmupStatus() as Map<string, any>;
let activeIPs = 0;
let inWarmupPhase = 0;
let completedWarmup = 0;
warmupStatuses.forEach(status => {
activeIPs++;
if (status.isActive) {
if (status.currentStage < this.ipWarmupManager.getStageCount()) {
inWarmupPhase++;
} else {
completedWarmup++;
}
}
});
statsWithRateLimiting.warmupInfo = {
enabled: true,
activeIPs,
inWarmupPhase,
completedWarmup
};
} else {
statsWithRateLimiting.warmupInfo = {
enabled: false,
activeIPs: 0,
inWarmupPhase: 0,
completedWarmup: 0
};
}
// Add reputation metrics if enabled
if (this.config.outbound?.reputation?.enabled) {
const reputationSummary = this.reputationMonitor.getReputationSummary();
// Calculate average reputation score
const avgScore = reputationSummary.length > 0
? reputationSummary.reduce((sum, domain) => sum + domain.score, 0) / reputationSummary.length
: 0;
// Count domains with issues
const domainsWithIssues = reputationSummary.filter(
domain => domain.status === 'poor' || domain.status === 'critical' || domain.listed
).length;
statsWithRateLimiting.reputationInfo = {
enabled: true,
monitoredDomains: reputationSummary.length,
averageScore: avgScore,
domainsWithIssues
};
} else {
statsWithRateLimiting.reputationInfo = {
enabled: false,
monitoredDomains: 0,
averageScore: 0,
domainsWithIssues: 0
};
}
// Clean up old rate limiter buckets to prevent memory leaks
this.rateLimiter.cleanup();
return statsWithRateLimiting;
}
}