update
This commit is contained in:
902
ts/email/classes.bouncemanager.ts
Normal file
902
ts/email/classes.bouncemanager.ts
Normal file
@ -0,0 +1,902 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../security/index.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
/**
|
||||
* Bounce types for categorizing the reasons for bounces
|
||||
*/
|
||||
export enum BounceType {
|
||||
// Hard bounces (permanent failures)
|
||||
INVALID_RECIPIENT = 'invalid_recipient',
|
||||
DOMAIN_NOT_FOUND = 'domain_not_found',
|
||||
MAILBOX_FULL = 'mailbox_full',
|
||||
MAILBOX_INACTIVE = 'mailbox_inactive',
|
||||
BLOCKED = 'blocked',
|
||||
SPAM_RELATED = 'spam_related',
|
||||
POLICY_RELATED = 'policy_related',
|
||||
|
||||
// Soft bounces (temporary failures)
|
||||
SERVER_UNAVAILABLE = 'server_unavailable',
|
||||
TEMPORARY_FAILURE = 'temporary_failure',
|
||||
QUOTA_EXCEEDED = 'quota_exceeded',
|
||||
NETWORK_ERROR = 'network_error',
|
||||
TIMEOUT = 'timeout',
|
||||
|
||||
// Special cases
|
||||
AUTO_RESPONSE = 'auto_response',
|
||||
CHALLENGE_RESPONSE = 'challenge_response',
|
||||
UNKNOWN = 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard vs soft bounce classification
|
||||
*/
|
||||
export enum BounceCategory {
|
||||
HARD = 'hard',
|
||||
SOFT = 'soft',
|
||||
AUTO_RESPONSE = 'auto_response',
|
||||
UNKNOWN = 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounce data structure
|
||||
*/
|
||||
export interface BounceRecord {
|
||||
id: string;
|
||||
originalEmailId?: string;
|
||||
recipient: string;
|
||||
sender: string;
|
||||
domain: string;
|
||||
subject?: string;
|
||||
bounceType: BounceType;
|
||||
bounceCategory: BounceCategory;
|
||||
timestamp: number;
|
||||
smtpResponse?: string;
|
||||
diagnosticCode?: string;
|
||||
statusCode?: string;
|
||||
headers?: Record<string, string>;
|
||||
processed: boolean;
|
||||
retryCount?: number;
|
||||
nextRetryTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email bounce patterns to identify bounce types in SMTP responses and bounce messages
|
||||
*/
|
||||
const BOUNCE_PATTERNS = {
|
||||
// Hard bounce patterns
|
||||
[BounceType.INVALID_RECIPIENT]: [
|
||||
/no such user/i,
|
||||
/user unknown/i,
|
||||
/does not exist/i,
|
||||
/invalid recipient/i,
|
||||
/unknown recipient/i,
|
||||
/no mailbox/i,
|
||||
/user not found/i,
|
||||
/recipient address rejected/i,
|
||||
/550 5\.1\.1/i
|
||||
],
|
||||
[BounceType.DOMAIN_NOT_FOUND]: [
|
||||
/domain not found/i,
|
||||
/unknown domain/i,
|
||||
/no such domain/i,
|
||||
/host not found/i,
|
||||
/domain invalid/i,
|
||||
/550 5\.1\.2/i
|
||||
],
|
||||
[BounceType.MAILBOX_FULL]: [
|
||||
/mailbox full/i,
|
||||
/over quota/i,
|
||||
/quota exceeded/i,
|
||||
/552 5\.2\.2/i
|
||||
],
|
||||
[BounceType.MAILBOX_INACTIVE]: [
|
||||
/mailbox disabled/i,
|
||||
/mailbox inactive/i,
|
||||
/account disabled/i,
|
||||
/mailbox not active/i,
|
||||
/account suspended/i
|
||||
],
|
||||
[BounceType.BLOCKED]: [
|
||||
/blocked/i,
|
||||
/rejected/i,
|
||||
/denied/i,
|
||||
/blacklisted/i,
|
||||
/prohibited/i,
|
||||
/refused/i,
|
||||
/550 5\.7\./i
|
||||
],
|
||||
[BounceType.SPAM_RELATED]: [
|
||||
/spam/i,
|
||||
/bulk mail/i,
|
||||
/content rejected/i,
|
||||
/message rejected/i,
|
||||
/550 5\.7\.1/i
|
||||
],
|
||||
|
||||
// Soft bounce patterns
|
||||
[BounceType.SERVER_UNAVAILABLE]: [
|
||||
/server unavailable/i,
|
||||
/service unavailable/i,
|
||||
/try again later/i,
|
||||
/try later/i,
|
||||
/451 4\.3\./i,
|
||||
/421 4\.3\./i
|
||||
],
|
||||
[BounceType.TEMPORARY_FAILURE]: [
|
||||
/temporary failure/i,
|
||||
/temporary error/i,
|
||||
/temporary problem/i,
|
||||
/try again/i,
|
||||
/451 4\./i
|
||||
],
|
||||
[BounceType.QUOTA_EXCEEDED]: [
|
||||
/quota temporarily exceeded/i,
|
||||
/mailbox temporarily full/i,
|
||||
/452 4\.2\.2/i
|
||||
],
|
||||
[BounceType.NETWORK_ERROR]: [
|
||||
/network error/i,
|
||||
/connection error/i,
|
||||
/connection timed out/i,
|
||||
/routing error/i,
|
||||
/421 4\.4\./i
|
||||
],
|
||||
[BounceType.TIMEOUT]: [
|
||||
/timed out/i,
|
||||
/timeout/i,
|
||||
/450 4\.4\.2/i
|
||||
],
|
||||
|
||||
// Auto-responses
|
||||
[BounceType.AUTO_RESPONSE]: [
|
||||
/auto[- ]reply/i,
|
||||
/auto[- ]response/i,
|
||||
/vacation/i,
|
||||
/out of office/i,
|
||||
/away from office/i,
|
||||
/on vacation/i,
|
||||
/automatic reply/i
|
||||
],
|
||||
[BounceType.CHALLENGE_RESPONSE]: [
|
||||
/challenge[- ]response/i,
|
||||
/verify your email/i,
|
||||
/confirm your email/i,
|
||||
/email verification/i
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Retry strategy configuration for soft bounces
|
||||
*/
|
||||
interface RetryStrategy {
|
||||
maxRetries: number;
|
||||
initialDelay: number; // milliseconds
|
||||
maxDelay: number; // milliseconds
|
||||
backoffFactor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager for handling email bounces
|
||||
*/
|
||||
export class BounceManager {
|
||||
// Retry strategy with exponential backoff
|
||||
private retryStrategy: RetryStrategy = {
|
||||
maxRetries: 5,
|
||||
initialDelay: 15 * 60 * 1000, // 15 minutes
|
||||
maxDelay: 24 * 60 * 60 * 1000, // 24 hours
|
||||
backoffFactor: 2
|
||||
};
|
||||
|
||||
// Store of bounced emails
|
||||
private bounceStore: BounceRecord[] = [];
|
||||
|
||||
// Cache of recently bounced email addresses to avoid sending to known bad addresses
|
||||
private bounceCache: LRUCache<string, {
|
||||
lastBounce: number;
|
||||
count: number;
|
||||
type: BounceType;
|
||||
category: BounceCategory;
|
||||
}>;
|
||||
|
||||
// Suppression list for addresses that should not receive emails
|
||||
private suppressionList: Map<string, {
|
||||
reason: string;
|
||||
timestamp: number;
|
||||
expiresAt?: number; // undefined means permanent
|
||||
}> = new Map();
|
||||
|
||||
constructor(options?: {
|
||||
retryStrategy?: Partial<RetryStrategy>;
|
||||
maxCacheSize?: number;
|
||||
cacheTTL?: number;
|
||||
}) {
|
||||
// Set retry strategy with defaults
|
||||
if (options?.retryStrategy) {
|
||||
this.retryStrategy = {
|
||||
...this.retryStrategy,
|
||||
...options.retryStrategy
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize bounce cache with LRU (least recently used) caching
|
||||
this.bounceCache = new LRUCache<string, any>({
|
||||
max: options?.maxCacheSize || 10000,
|
||||
ttl: options?.cacheTTL || 30 * 24 * 60 * 60 * 1000, // 30 days default
|
||||
});
|
||||
|
||||
// Load suppression list from storage
|
||||
this.loadSuppressionList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a bounce notification
|
||||
* @param bounceData Bounce data to process
|
||||
* @returns Processed bounce record
|
||||
*/
|
||||
public async processBounce(bounceData: Partial<BounceRecord>): Promise<BounceRecord> {
|
||||
try {
|
||||
// Add required fields if missing
|
||||
const bounce: BounceRecord = {
|
||||
id: bounceData.id || plugins.uuid.v4(),
|
||||
recipient: bounceData.recipient,
|
||||
sender: bounceData.sender,
|
||||
domain: bounceData.domain || bounceData.recipient.split('@')[1],
|
||||
subject: bounceData.subject,
|
||||
bounceType: bounceData.bounceType || BounceType.UNKNOWN,
|
||||
bounceCategory: bounceData.bounceCategory || BounceCategory.UNKNOWN,
|
||||
timestamp: bounceData.timestamp || Date.now(),
|
||||
smtpResponse: bounceData.smtpResponse,
|
||||
diagnosticCode: bounceData.diagnosticCode,
|
||||
statusCode: bounceData.statusCode,
|
||||
headers: bounceData.headers,
|
||||
processed: false,
|
||||
originalEmailId: bounceData.originalEmailId,
|
||||
retryCount: bounceData.retryCount || 0,
|
||||
nextRetryTime: bounceData.nextRetryTime
|
||||
};
|
||||
|
||||
// Determine bounce type and category if not provided
|
||||
if (!bounceData.bounceType || bounceData.bounceType === BounceType.UNKNOWN) {
|
||||
const bounceInfo = this.detectBounceType(
|
||||
bounce.smtpResponse || '',
|
||||
bounce.diagnosticCode || '',
|
||||
bounce.statusCode || ''
|
||||
);
|
||||
|
||||
bounce.bounceType = bounceInfo.type;
|
||||
bounce.bounceCategory = bounceInfo.category;
|
||||
}
|
||||
|
||||
// Process the bounce based on category
|
||||
switch (bounce.bounceCategory) {
|
||||
case BounceCategory.HARD:
|
||||
// Handle hard bounce - add to suppression list
|
||||
await this.handleHardBounce(bounce);
|
||||
break;
|
||||
|
||||
case BounceCategory.SOFT:
|
||||
// Handle soft bounce - schedule retry if eligible
|
||||
await this.handleSoftBounce(bounce);
|
||||
break;
|
||||
|
||||
case BounceCategory.AUTO_RESPONSE:
|
||||
// Handle auto-response - typically no action needed
|
||||
logger.log('info', `Auto-response detected for ${bounce.recipient}`);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown bounce type - log for investigation
|
||||
logger.log('warn', `Unknown bounce type for ${bounce.recipient}`, {
|
||||
bounceType: bounce.bounceType,
|
||||
smtpResponse: bounce.smtpResponse
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Store the bounce record
|
||||
bounce.processed = true;
|
||||
this.bounceStore.push(bounce);
|
||||
|
||||
// Update the bounce cache
|
||||
this.updateBounceCache(bounce);
|
||||
|
||||
// Log the bounce
|
||||
logger.log(
|
||||
bounce.bounceCategory === BounceCategory.HARD ? 'warn' : 'info',
|
||||
`Email bounce processed: ${bounce.bounceCategory} bounce for ${bounce.recipient}`,
|
||||
{
|
||||
bounceType: bounce.bounceType,
|
||||
domain: bounce.domain,
|
||||
category: bounce.bounceCategory
|
||||
}
|
||||
);
|
||||
|
||||
// Enhanced security logging
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: bounce.bounceCategory === BounceCategory.HARD
|
||||
? SecurityLogLevel.WARN
|
||||
: SecurityLogLevel.INFO,
|
||||
type: SecurityEventType.EMAIL_VALIDATION,
|
||||
message: `Email bounce detected: ${bounce.bounceCategory} bounce for recipient`,
|
||||
domain: bounce.domain,
|
||||
details: {
|
||||
recipient: bounce.recipient,
|
||||
bounceType: bounce.bounceType,
|
||||
smtpResponse: bounce.smtpResponse,
|
||||
diagnosticCode: bounce.diagnosticCode,
|
||||
statusCode: bounce.statusCode
|
||||
},
|
||||
success: false
|
||||
});
|
||||
|
||||
return bounce;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error processing bounce: ${error.message}`, {
|
||||
error: error.message,
|
||||
bounceData
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an SMTP failure as a bounce
|
||||
* @param recipient Recipient email
|
||||
* @param smtpResponse SMTP error response
|
||||
* @param options Additional options
|
||||
* @returns Processed bounce record
|
||||
*/
|
||||
public async processSmtpFailure(
|
||||
recipient: string,
|
||||
smtpResponse: string,
|
||||
options: {
|
||||
sender?: string;
|
||||
originalEmailId?: string;
|
||||
statusCode?: string;
|
||||
headers?: Record<string, string>;
|
||||
} = {}
|
||||
): Promise<BounceRecord> {
|
||||
// Create bounce data from SMTP failure
|
||||
const bounceData: Partial<BounceRecord> = {
|
||||
recipient,
|
||||
sender: options.sender || '',
|
||||
domain: recipient.split('@')[1],
|
||||
smtpResponse,
|
||||
statusCode: options.statusCode,
|
||||
headers: options.headers,
|
||||
originalEmailId: options.originalEmailId,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Process as a regular bounce
|
||||
return this.processBounce(bounceData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a bounce notification email
|
||||
* @param bounceEmail The email containing bounce information
|
||||
* @returns Processed bounce record or null if not a bounce
|
||||
*/
|
||||
public async processBounceEmail(bounceEmail: plugins.smartmail.Smartmail<any>): Promise<BounceRecord | null> {
|
||||
try {
|
||||
// Check if this is a bounce notification
|
||||
const subject = bounceEmail.getSubject();
|
||||
const body = bounceEmail.getBody();
|
||||
|
||||
// Check for common bounce notification subject patterns
|
||||
const isBounceSubject = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject);
|
||||
|
||||
if (!isBounceSubject) {
|
||||
// Not a bounce notification based on subject
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract original recipient from the body or headers
|
||||
let recipient = '';
|
||||
let originalMessageId = '';
|
||||
|
||||
// Extract recipient from common bounce formats
|
||||
const recipientMatch = body.match(/(?:failed recipient|to[:=]\s*|recipient:|delivery failed:)\s*<?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>?/i);
|
||||
if (recipientMatch && recipientMatch[1]) {
|
||||
recipient = recipientMatch[1];
|
||||
}
|
||||
|
||||
// Extract diagnostic code
|
||||
let diagnosticCode = '';
|
||||
const diagnosticMatch = body.match(/diagnostic(?:-|\\s+)code:\s*(.+)(?:\n|$)/i);
|
||||
if (diagnosticMatch && diagnosticMatch[1]) {
|
||||
diagnosticCode = diagnosticMatch[1].trim();
|
||||
}
|
||||
|
||||
// Extract SMTP status code
|
||||
let statusCode = '';
|
||||
const statusMatch = body.match(/status(?:-|\\s+)code:\s*([0-9.]+)/i);
|
||||
if (statusMatch && statusMatch[1]) {
|
||||
statusCode = statusMatch[1].trim();
|
||||
}
|
||||
|
||||
// If recipient not found in standard patterns, try DSN (Delivery Status Notification) format
|
||||
if (!recipient) {
|
||||
// Look for DSN format with Original-Recipient or Final-Recipient fields
|
||||
const originalRecipientMatch = body.match(/original-recipient:.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
const finalRecipientMatch = body.match(/final-recipient:.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
|
||||
if (originalRecipientMatch && originalRecipientMatch[1]) {
|
||||
recipient = originalRecipientMatch[1];
|
||||
} else if (finalRecipientMatch && finalRecipientMatch[1]) {
|
||||
recipient = finalRecipientMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// If still no recipient, can't process as bounce
|
||||
if (!recipient) {
|
||||
logger.log('warn', 'Could not extract recipient from bounce notification', {
|
||||
subject,
|
||||
sender: bounceEmail.options.from
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract original message ID if available
|
||||
const messageIdMatch = body.match(/original[ -]message[ -]id:[ \t]*<?([^>]+)>?/i);
|
||||
if (messageIdMatch && messageIdMatch[1]) {
|
||||
originalMessageId = messageIdMatch[1].trim();
|
||||
}
|
||||
|
||||
// Create bounce data
|
||||
const bounceData: Partial<BounceRecord> = {
|
||||
recipient,
|
||||
sender: bounceEmail.options.from,
|
||||
domain: recipient.split('@')[1],
|
||||
subject: bounceEmail.getSubject(),
|
||||
diagnosticCode,
|
||||
statusCode,
|
||||
timestamp: Date.now(),
|
||||
headers: {}
|
||||
};
|
||||
|
||||
// Process as a regular bounce
|
||||
return this.processBounce(bounceData);
|
||||
} catch (error) {
|
||||
logger.log('error', `Error processing bounce email: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a hard bounce by adding to suppression list
|
||||
* @param bounce The bounce record
|
||||
*/
|
||||
private async handleHardBounce(bounce: BounceRecord): Promise<void> {
|
||||
// Add to suppression list permanently (no expiry)
|
||||
this.addToSuppressionList(bounce.recipient, `Hard bounce: ${bounce.bounceType}`, undefined);
|
||||
|
||||
// Increment bounce count in cache
|
||||
this.updateBounceCache(bounce);
|
||||
|
||||
// Save to permanent storage
|
||||
this.saveBounceRecord(bounce);
|
||||
|
||||
// Log hard bounce for monitoring
|
||||
logger.log('warn', `Hard bounce for ${bounce.recipient}: ${bounce.bounceType}`, {
|
||||
domain: bounce.domain,
|
||||
smtpResponse: bounce.smtpResponse,
|
||||
diagnosticCode: bounce.diagnosticCode
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a soft bounce by scheduling a retry if eligible
|
||||
* @param bounce The bounce record
|
||||
*/
|
||||
private async handleSoftBounce(bounce: BounceRecord): Promise<void> {
|
||||
// Check if we've exceeded max retries
|
||||
if (bounce.retryCount >= this.retryStrategy.maxRetries) {
|
||||
logger.log('warn', `Max retries exceeded for ${bounce.recipient}, treating as hard bounce`);
|
||||
|
||||
// Convert to hard bounce after max retries
|
||||
bounce.bounceCategory = BounceCategory.HARD;
|
||||
await this.handleHardBounce(bounce);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate next retry time with exponential backoff
|
||||
const delay = Math.min(
|
||||
this.retryStrategy.initialDelay * Math.pow(this.retryStrategy.backoffFactor, bounce.retryCount),
|
||||
this.retryStrategy.maxDelay
|
||||
);
|
||||
|
||||
bounce.retryCount++;
|
||||
bounce.nextRetryTime = Date.now() + delay;
|
||||
|
||||
// Add to suppression list temporarily (with expiry)
|
||||
this.addToSuppressionList(
|
||||
bounce.recipient,
|
||||
`Soft bounce: ${bounce.bounceType}`,
|
||||
bounce.nextRetryTime
|
||||
);
|
||||
|
||||
// Log the retry schedule
|
||||
logger.log('info', `Scheduled retry ${bounce.retryCount} for ${bounce.recipient} at ${new Date(bounce.nextRetryTime).toISOString()}`, {
|
||||
bounceType: bounce.bounceType,
|
||||
retryCount: bounce.retryCount,
|
||||
nextRetry: bounce.nextRetryTime
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an email address to the suppression list
|
||||
* @param email Email address to suppress
|
||||
* @param reason Reason for suppression
|
||||
* @param expiresAt Expiration timestamp (undefined for permanent)
|
||||
*/
|
||||
public addToSuppressionList(
|
||||
email: string,
|
||||
reason: string,
|
||||
expiresAt?: number
|
||||
): void {
|
||||
this.suppressionList.set(email.toLowerCase(), {
|
||||
reason,
|
||||
timestamp: Date.now(),
|
||||
expiresAt
|
||||
});
|
||||
|
||||
this.saveSuppressionList();
|
||||
|
||||
logger.log('info', `Added ${email} to suppression list`, {
|
||||
reason,
|
||||
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : 'permanent'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an email address from the suppression list
|
||||
* @param email Email address to remove
|
||||
*/
|
||||
public removeFromSuppressionList(email: string): void {
|
||||
const wasRemoved = this.suppressionList.delete(email.toLowerCase());
|
||||
|
||||
if (wasRemoved) {
|
||||
this.saveSuppressionList();
|
||||
logger.log('info', `Removed ${email} from suppression list`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an email is on the suppression list
|
||||
* @param email Email address to check
|
||||
* @returns Whether the email is suppressed
|
||||
*/
|
||||
public isEmailSuppressed(email: string): boolean {
|
||||
const lowercaseEmail = email.toLowerCase();
|
||||
const suppression = this.suppressionList.get(lowercaseEmail);
|
||||
|
||||
if (!suppression) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if suppression has expired
|
||||
if (suppression.expiresAt && Date.now() > suppression.expiresAt) {
|
||||
this.suppressionList.delete(lowercaseEmail);
|
||||
this.saveSuppressionList();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suppression information for an email
|
||||
* @param email Email address to check
|
||||
* @returns Suppression information or null if not suppressed
|
||||
*/
|
||||
public getSuppressionInfo(email: string): {
|
||||
reason: string;
|
||||
timestamp: number;
|
||||
expiresAt?: number;
|
||||
} | null {
|
||||
const lowercaseEmail = email.toLowerCase();
|
||||
const suppression = this.suppressionList.get(lowercaseEmail);
|
||||
|
||||
if (!suppression) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if suppression has expired
|
||||
if (suppression.expiresAt && Date.now() > suppression.expiresAt) {
|
||||
this.suppressionList.delete(lowercaseEmail);
|
||||
this.saveSuppressionList();
|
||||
return null;
|
||||
}
|
||||
|
||||
return suppression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save suppression list to disk
|
||||
*/
|
||||
private saveSuppressionList(): void {
|
||||
try {
|
||||
const suppressionData = JSON.stringify(Array.from(this.suppressionList.entries()));
|
||||
plugins.smartfile.memory.toFsSync(
|
||||
suppressionData,
|
||||
plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json')
|
||||
);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save suppression list: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load suppression list from disk
|
||||
*/
|
||||
private loadSuppressionList(): void {
|
||||
try {
|
||||
const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json');
|
||||
|
||||
if (plugins.fs.existsSync(suppressionPath)) {
|
||||
const data = plugins.fs.readFileSync(suppressionPath, 'utf8');
|
||||
const entries = JSON.parse(data);
|
||||
|
||||
this.suppressionList = new Map(entries);
|
||||
|
||||
// Clean expired entries
|
||||
const now = Date.now();
|
||||
let expiredCount = 0;
|
||||
|
||||
for (const [email, info] of this.suppressionList.entries()) {
|
||||
if (info.expiresAt && now > info.expiresAt) {
|
||||
this.suppressionList.delete(email);
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredCount > 0) {
|
||||
logger.log('info', `Cleaned ${expiredCount} expired entries from suppression list`);
|
||||
this.saveSuppressionList();
|
||||
}
|
||||
|
||||
logger.log('info', `Loaded ${this.suppressionList.size} entries from suppression list`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load suppression list: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save bounce record to disk
|
||||
* @param bounce Bounce record to save
|
||||
*/
|
||||
private saveBounceRecord(bounce: BounceRecord): void {
|
||||
try {
|
||||
const bounceData = JSON.stringify(bounce);
|
||||
const bouncePath = plugins.path.join(
|
||||
paths.dataDir,
|
||||
'emails',
|
||||
'bounces',
|
||||
`${bounce.id}.json`
|
||||
);
|
||||
|
||||
// Ensure directory exists
|
||||
const bounceDir = plugins.path.join(paths.dataDir, 'emails', 'bounces');
|
||||
plugins.smartfile.fs.ensureDirSync(bounceDir);
|
||||
|
||||
plugins.smartfile.memory.toFsSync(bounceData, bouncePath);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save bounce record: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update bounce cache with new bounce information
|
||||
* @param bounce Bounce record to update cache with
|
||||
*/
|
||||
private updateBounceCache(bounce: BounceRecord): void {
|
||||
const email = bounce.recipient.toLowerCase();
|
||||
const existing = this.bounceCache.get(email);
|
||||
|
||||
if (existing) {
|
||||
// Update existing cache entry
|
||||
existing.lastBounce = bounce.timestamp;
|
||||
existing.count++;
|
||||
existing.type = bounce.bounceType;
|
||||
existing.category = bounce.bounceCategory;
|
||||
} else {
|
||||
// Create new cache entry
|
||||
this.bounceCache.set(email, {
|
||||
lastBounce: bounce.timestamp,
|
||||
count: 1,
|
||||
type: bounce.bounceType,
|
||||
category: bounce.bounceCategory
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check bounce history for an email address
|
||||
* @param email Email address to check
|
||||
* @returns Bounce information or null if no bounces
|
||||
*/
|
||||
public getBounceInfo(email: string): {
|
||||
lastBounce: number;
|
||||
count: number;
|
||||
type: BounceType;
|
||||
category: BounceCategory;
|
||||
} | null {
|
||||
return this.bounceCache.get(email.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze SMTP response and diagnostic codes to determine bounce type
|
||||
* @param smtpResponse SMTP response string
|
||||
* @param diagnosticCode Diagnostic code from bounce
|
||||
* @param statusCode Status code from bounce
|
||||
* @returns Detected bounce type and category
|
||||
*/
|
||||
private detectBounceType(
|
||||
smtpResponse: string,
|
||||
diagnosticCode: string,
|
||||
statusCode: string
|
||||
): {
|
||||
type: BounceType;
|
||||
category: BounceCategory;
|
||||
} {
|
||||
// Combine all text for comprehensive pattern matching
|
||||
const fullText = `${smtpResponse} ${diagnosticCode} ${statusCode}`.toLowerCase();
|
||||
|
||||
// Check for auto-responses first
|
||||
if (this.matchesPattern(fullText, BounceType.AUTO_RESPONSE) ||
|
||||
this.matchesPattern(fullText, BounceType.CHALLENGE_RESPONSE)) {
|
||||
return {
|
||||
type: BounceType.AUTO_RESPONSE,
|
||||
category: BounceCategory.AUTO_RESPONSE
|
||||
};
|
||||
}
|
||||
|
||||
// Check for hard bounces
|
||||
for (const bounceType of [
|
||||
BounceType.INVALID_RECIPIENT,
|
||||
BounceType.DOMAIN_NOT_FOUND,
|
||||
BounceType.MAILBOX_FULL,
|
||||
BounceType.MAILBOX_INACTIVE,
|
||||
BounceType.BLOCKED,
|
||||
BounceType.SPAM_RELATED,
|
||||
BounceType.POLICY_RELATED
|
||||
]) {
|
||||
if (this.matchesPattern(fullText, bounceType)) {
|
||||
return {
|
||||
type: bounceType,
|
||||
category: BounceCategory.HARD
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for soft bounces
|
||||
for (const bounceType of [
|
||||
BounceType.SERVER_UNAVAILABLE,
|
||||
BounceType.TEMPORARY_FAILURE,
|
||||
BounceType.QUOTA_EXCEEDED,
|
||||
BounceType.NETWORK_ERROR,
|
||||
BounceType.TIMEOUT
|
||||
]) {
|
||||
if (this.matchesPattern(fullText, bounceType)) {
|
||||
return {
|
||||
type: bounceType,
|
||||
category: BounceCategory.SOFT
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle DSN (Delivery Status Notification) status codes
|
||||
if (statusCode) {
|
||||
// Format: class.subject.detail
|
||||
const parts = statusCode.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const statusClass = parts[0];
|
||||
const statusSubject = parts[1];
|
||||
|
||||
// 5.X.X is permanent failure (hard bounce)
|
||||
if (statusClass === '5') {
|
||||
// Try to determine specific type based on subject
|
||||
if (statusSubject === '1') {
|
||||
return { type: BounceType.INVALID_RECIPIENT, category: BounceCategory.HARD };
|
||||
} else if (statusSubject === '2') {
|
||||
return { type: BounceType.MAILBOX_FULL, category: BounceCategory.HARD };
|
||||
} else if (statusSubject === '7') {
|
||||
return { type: BounceType.BLOCKED, category: BounceCategory.HARD };
|
||||
} else {
|
||||
return { type: BounceType.UNKNOWN, category: BounceCategory.HARD };
|
||||
}
|
||||
}
|
||||
|
||||
// 4.X.X is temporary failure (soft bounce)
|
||||
if (statusClass === '4') {
|
||||
// Try to determine specific type based on subject
|
||||
if (statusSubject === '2') {
|
||||
return { type: BounceType.QUOTA_EXCEEDED, category: BounceCategory.SOFT };
|
||||
} else if (statusSubject === '3') {
|
||||
return { type: BounceType.SERVER_UNAVAILABLE, category: BounceCategory.SOFT };
|
||||
} else if (statusSubject === '4') {
|
||||
return { type: BounceType.NETWORK_ERROR, category: BounceCategory.SOFT };
|
||||
} else {
|
||||
return { type: BounceType.TEMPORARY_FAILURE, category: BounceCategory.SOFT };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to unknown
|
||||
return {
|
||||
type: BounceType.UNKNOWN,
|
||||
category: BounceCategory.UNKNOWN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text matches any pattern for a bounce type
|
||||
* @param text Text to check against patterns
|
||||
* @param bounceType Bounce type to get patterns for
|
||||
* @returns Whether the text matches any pattern
|
||||
*/
|
||||
private matchesPattern(text: string, bounceType: BounceType): boolean {
|
||||
const patterns = BOUNCE_PATTERNS[bounceType];
|
||||
|
||||
if (!patterns) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all known hard bounced addresses
|
||||
* @returns Array of hard bounced email addresses
|
||||
*/
|
||||
public getHardBouncedAddresses(): string[] {
|
||||
const hardBounced: string[] = [];
|
||||
|
||||
for (const [email, info] of this.bounceCache.entries()) {
|
||||
if (info.category === BounceCategory.HARD) {
|
||||
hardBounced.push(email);
|
||||
}
|
||||
}
|
||||
|
||||
return hardBounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suppression list
|
||||
* @returns Array of suppressed email addresses
|
||||
*/
|
||||
public getSuppressionList(): string[] {
|
||||
return Array.from(this.suppressionList.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old bounce records (for maintenance)
|
||||
* @param olderThan Timestamp to remove records older than
|
||||
* @returns Number of records removed
|
||||
*/
|
||||
public clearOldBounceRecords(olderThan: number): number {
|
||||
let removed = 0;
|
||||
|
||||
this.bounceStore = this.bounceStore.filter(bounce => {
|
||||
if (bounce.timestamp < olderThan) {
|
||||
removed++;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
@ -31,15 +31,42 @@ export class MtaConnector {
|
||||
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 {
|
||||
// Process recipients
|
||||
const toArray = Array.isArray(toAddresses)
|
||||
? toAddresses
|
||||
: toAddresses.split(',').map(addr => addr.trim());
|
||||
// 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 toArray) {
|
||||
for (const recipient of validRecipients) {
|
||||
smartmail.addRecipient(recipient);
|
||||
}
|
||||
}
|
||||
@ -57,15 +84,15 @@ export class MtaConnector {
|
||||
const mimeEmail = await smartmail.toMimeFormat(smartmail.options.creationObjectRef);
|
||||
|
||||
// Parse the MIME email to create an MTA Email
|
||||
return this.sendMimeEmail(mimeEmail, toArray);
|
||||
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, toArray);
|
||||
return this.sendDirectEmail(smartmail, validRecipients);
|
||||
}
|
||||
} else {
|
||||
// Use direct conversion
|
||||
return this.sendDirectEmail(smartmail, toArray);
|
||||
return this.sendDirectEmail(smartmail, validRecipients);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to send email via MTA: ${error.message}`, {
|
||||
@ -73,6 +100,30 @@ export class MtaConnector {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import { RuleManager } from './classes.rulemanager.js';
|
||||
import { ApiManager } from './classes.apimanager.js';
|
||||
import { TemplateManager } from './classes.templatemanager.js';
|
||||
import { EmailValidator } from './classes.emailvalidator.js';
|
||||
import { BounceManager } from './classes.bouncemanager.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { SzPlatformService } from '../platformservice.js';
|
||||
|
||||
@ -44,6 +45,7 @@ export class EmailService {
|
||||
public ruleManager: RuleManager;
|
||||
public templateManager: TemplateManager;
|
||||
public emailValidator: EmailValidator;
|
||||
public bounceManager: BounceManager;
|
||||
|
||||
// configuration
|
||||
private config: IEmailConstructorOptions;
|
||||
@ -62,6 +64,9 @@ export class EmailService {
|
||||
|
||||
// Initialize validator
|
||||
this.emailValidator = new EmailValidator();
|
||||
|
||||
// Initialize bounce manager
|
||||
this.bounceManager = new BounceManager();
|
||||
|
||||
// Initialize template manager
|
||||
this.templateManager = new TemplateManager(this.config.templateConfig);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
export interface IEmailValidationResult {
|
||||
isValid: boolean;
|
||||
@ -21,10 +22,28 @@ export interface IEmailValidationResult {
|
||||
*/
|
||||
export class EmailValidator {
|
||||
private validator: plugins.smartmail.EmailAddressValidator;
|
||||
private dnsCache: Map<string, any> = new Map();
|
||||
private dnsCache: LRUCache<string, string[]>;
|
||||
|
||||
constructor() {
|
||||
constructor(options?: {
|
||||
maxCacheSize?: number;
|
||||
cacheTTL?: number;
|
||||
}) {
|
||||
this.validator = new plugins.smartmail.EmailAddressValidator();
|
||||
|
||||
// Initialize LRU cache for DNS records
|
||||
this.dnsCache = new LRUCache<string, string[]>({
|
||||
// Default to 1000 entries (reasonable for most applications)
|
||||
max: options?.maxCacheSize || 1000,
|
||||
// Default TTL of 1 hour (DNS records don't change frequently)
|
||||
ttl: options?.cacheTTL || 60 * 60 * 1000,
|
||||
// Optional cache monitoring
|
||||
allowStale: false,
|
||||
updateAgeOnGet: true,
|
||||
// Add logging for cache events in production environments
|
||||
disposeAfter: (value, key) => {
|
||||
logger.log('debug', `DNS cache entry expired for domain: ${key}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -162,19 +181,20 @@ export class EmailValidator {
|
||||
* @returns Array of MX records
|
||||
*/
|
||||
private async getMxRecords(domain: string): Promise<string[]> {
|
||||
if (this.dnsCache.has(domain)) {
|
||||
return this.dnsCache.get(domain);
|
||||
// Check cache first
|
||||
const cachedRecords = this.dnsCache.get(domain);
|
||||
if (cachedRecords) {
|
||||
logger.log('debug', `Using cached MX records for domain: ${domain}`);
|
||||
return cachedRecords;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use smartmail's getMxRecords method
|
||||
const records = await this.validator.getMxRecords(domain);
|
||||
this.dnsCache.set(domain, records);
|
||||
|
||||
// Cache expires after 1 hour
|
||||
setTimeout(() => {
|
||||
this.dnsCache.delete(domain);
|
||||
}, 60 * 60 * 1000);
|
||||
// Store in cache (TTL is handled by the LRU cache configuration)
|
||||
this.dnsCache.set(domain, records);
|
||||
logger.log('debug', `Cached MX records for domain: ${domain}`);
|
||||
|
||||
return records;
|
||||
} catch (error) {
|
||||
|
@ -1,3 +1,19 @@
|
||||
import { EmailService } from './classes.emailservice.js';
|
||||
import { BounceManager, BounceType, BounceCategory } from './classes.bouncemanager.js';
|
||||
import { EmailValidator } from './classes.emailvalidator.js';
|
||||
import { TemplateManager } from './classes.templatemanager.js';
|
||||
import { RuleManager } from './classes.rulemanager.js';
|
||||
import { ApiManager } from './classes.apimanager.js';
|
||||
import { MtaConnector } from './classes.connector.mta.js';
|
||||
|
||||
export { EmailService as Email };
|
||||
export {
|
||||
EmailService as Email,
|
||||
BounceManager,
|
||||
BounceType,
|
||||
BounceCategory,
|
||||
EmailValidator,
|
||||
TemplateManager,
|
||||
RuleManager,
|
||||
ApiManager,
|
||||
MtaConnector
|
||||
};
|
Reference in New Issue
Block a user