This commit is contained in:
2025-05-08 01:13:54 +00:00
parent 63781ab1bd
commit fe2069c48e
47 changed files with 295 additions and 1620 deletions

View File

@@ -1,87 +0,0 @@
import * as plugins from '../plugins.js';
import { EmailService } from './classes.emailservice.js';
import { logger } from '../logger.js';
export class ApiManager {
public emailRef: EmailService;
public typedRouter = new plugins.typedrequest.TypedRouter();
constructor(emailRefArg: EmailService) {
this.emailRef = emailRefArg;
this.emailRef.typedrouter.addTypedRouter(this.typedRouter);
// Register API endpoints
this.registerApiEndpoints();
}
/**
* Register API endpoints for email functionality
*/
private registerApiEndpoints() {
// Register the SendEmail endpoint
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IReq_SendEmail>(
new plugins.typedrequest.TypedHandler('sendEmail', async (requestData) => {
const mailToSend = new plugins.smartmail.Smartmail({
body: requestData.body,
from: requestData.from,
subject: requestData.title,
});
if (requestData.attachments) {
for (const attachment of requestData.attachments) {
mailToSend.addAttachment(
await plugins.smartfile.SmartFile.fromString(
attachment.name,
attachment.binaryAttachmentString,
'binary'
)
);
}
}
// Send email through the service which will route to the appropriate connector
const emailId = await this.emailRef.sendEmail(mailToSend, requestData.to, {});
logger.log(
'info',
`sent an email to ${requestData.to} with subject '${mailToSend.getSubject()}'`,
{
eventType: 'sentEmail',
email: {
to: requestData.to,
subject: mailToSend.getSubject(),
},
}
);
return {
responseId: emailId,
};
})
);
// Add endpoint to check email status
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IReq_CheckEmailStatus>(
new plugins.typedrequest.TypedHandler('checkEmailStatus', async (requestData) => {
// If MTA is enabled, use it to check status
if (this.emailRef.mtaConnector) {
const status = await this.emailRef.mtaConnector.checkEmailStatus(requestData.emailId);
return status;
}
// For Mailgun, we don't have a status check implementation currently
return {
status: 'unknown',
details: { message: 'Status tracking not available for current provider' }
};
})
);
// Add statistics endpoint
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IReq_GetEMailStats>(
new plugins.typedrequest.TypedHandler('getEmailStats', async () => {
return this.emailRef.getStats();
})
);
}
}

View File

@@ -1,902 +0,0 @@
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;
}
}

View File

@@ -1,536 +0,0 @@
import * as plugins from '../plugins.js';
import { EmailService } from './classes.emailservice.js';
import { logger } from '../logger.js';
// Import MTA classes
import {
MtaService,
Email as MtaEmail,
type IEmailOptions,
DeliveryStatus,
type IAttachment
} from '../mta/index.js';
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 }
};
}
}
}

View File

@@ -1,210 +0,0 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { MtaConnector } from './classes.connector.mta.js';
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';
// Import MTA service
import { MtaService, type IMtaConfig } from '../mta/index.js';
export interface IEmailConstructorOptions {
useMta?: boolean;
mtaConfig?: IMtaConfig;
templateConfig?: {
from?: string;
replyTo?: string;
footerHtml?: string;
footerText?: string;
};
loadTemplatesFromDir?: boolean;
}
/**
* Email service with support for both Mailgun and local MTA
*/
export class EmailService {
public platformServiceRef: SzPlatformService;
// typedrouter
public typedrouter = new plugins.typedrequest.TypedRouter();
// connectors
public mtaConnector: MtaConnector;
public qenv = new plugins.qenv.Qenv('./', '.nogit/');
// MTA service
public mtaService: MtaService;
// services
public apiManager: ApiManager;
public ruleManager: RuleManager;
public templateManager: TemplateManager;
public emailValidator: EmailValidator;
public bounceManager: BounceManager;
// configuration
private config: IEmailConstructorOptions;
constructor(platformServiceRefArg: SzPlatformService, options: IEmailConstructorOptions = {}) {
this.platformServiceRef = platformServiceRefArg;
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
// Set default options
this.config = {
useMta: options.useMta ?? true,
mtaConfig: options.mtaConfig || {},
templateConfig: options.templateConfig || {},
loadTemplatesFromDir: options.loadTemplatesFromDir ?? true
};
// Initialize validator
this.emailValidator = new EmailValidator();
// Initialize bounce manager
this.bounceManager = new BounceManager();
// Initialize template manager
this.templateManager = new TemplateManager(this.config.templateConfig);
if (this.config.useMta) {
// Initialize MTA service
this.mtaService = new MtaService(platformServiceRefArg, this.config.mtaConfig);
// Initialize MTA connector
this.mtaConnector = new MtaConnector(this);
}
// Initialize API manager and rule manager
this.apiManager = new ApiManager(this);
this.ruleManager = new RuleManager(this);
// Set up MTA SMTP server webhook if using MTA
if (this.config.useMta) {
// The MTA SMTP server will handle incoming emails directly
// through its SMTP protocol. No additional webhook needed.
}
}
/**
* Start the email service
*/
public async start() {
// Initialize rule manager
await this.ruleManager.init();
// Load email templates if configured
if (this.config.loadTemplatesFromDir) {
try {
await this.templateManager.loadTemplatesFromDirectory(paths.emailTemplatesDir);
} catch (error) {
logger.log('error', `Failed to load email templates: ${error.message}`);
}
}
// Start MTA service if enabled
if (this.config.useMta && this.mtaService) {
await this.mtaService.start();
logger.log('success', 'Started MTA service');
}
logger.log('success', `Started email service`);
}
/**
* Stop the email service
*/
public async stop() {
// Stop MTA service if it's running
if (this.config.useMta && this.mtaService) {
await this.mtaService.stop();
logger.log('info', 'Stopped MTA service');
}
logger.log('info', 'Stopped email service');
}
/**
* Send an email using the configured provider (Mailgun or MTA)
* @param email The email to send
* @param to Recipient(s)
* @param options Additional options
*/
public async sendEmail(
email: plugins.smartmail.Smartmail<any>,
to: string | string[],
options: any = {}
): Promise<string> {
// Determine which connector to use
if (this.config.useMta && this.mtaConnector) {
return this.mtaConnector.sendEmail(email, to, options);
} else {
throw new Error('No email provider configured');
}
}
/**
* Send an email using a template
* @param templateId The template ID
* @param to Recipient email(s)
* @param context The template context data
* @param options Additional options
*/
public async sendTemplateEmail(
templateId: string,
to: string | string[],
context: any = {},
options: any = {}
): Promise<string> {
try {
// Get email from template
const smartmail = await this.templateManager.prepareEmail(templateId, context);
// Send the email
return this.sendEmail(smartmail, to, options);
} catch (error) {
logger.log('error', `Failed to send template email: ${error.message}`, {
templateId,
to,
error: error.message
});
throw error;
}
}
/**
* Validate an email address
* @param email The email address to validate
* @param options Validation options
* @returns Validation result
*/
public async validateEmail(
email: string,
options: {
checkMx?: boolean;
checkDisposable?: boolean;
checkRole?: boolean;
} = {}
): Promise<any> {
return this.emailValidator.validate(email, options);
}
/**
* Get email service statistics
*/
public getStats() {
const stats: any = {
activeProviders: []
};
if (this.config.useMta) {
stats.activeProviders.push('mta');
stats.mta = this.mtaService.getStats();
}
return stats;
}
}

View File

@@ -1,239 +0,0 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { LRUCache } from 'lru-cache';
export interface IEmailValidationResult {
isValid: boolean;
hasMx: boolean;
hasSpamMarkings: boolean;
score: number;
details?: {
formatValid?: boolean;
mxRecords?: string[];
disposable?: boolean;
role?: boolean;
spamIndicators?: string[];
errorMessage?: string;
};
}
/**
* Advanced email validator class using smartmail's capabilities
*/
export class EmailValidator {
private validator: plugins.smartmail.EmailAddressValidator;
private dnsCache: LRUCache<string, string[]>;
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}`);
},
});
}
/**
* Validates an email address using comprehensive checks
* @param email The email to validate
* @param options Validation options
* @returns Validation result with details
*/
public async validate(
email: string,
options: {
checkMx?: boolean;
checkDisposable?: boolean;
checkRole?: boolean;
checkSyntaxOnly?: boolean;
} = {}
): Promise<IEmailValidationResult> {
try {
const result: IEmailValidationResult = {
isValid: false,
hasMx: false,
hasSpamMarkings: false,
score: 0,
details: {
formatValid: false,
spamIndicators: []
}
};
// Always check basic format
result.details.formatValid = this.validator.isValidEmailFormat(email);
if (!result.details.formatValid) {
result.details.errorMessage = 'Invalid email format';
return result;
}
// If syntax-only check is requested, return early
if (options.checkSyntaxOnly) {
result.isValid = true;
result.score = 0.5;
return result;
}
// Get domain for additional checks
const domain = email.split('@')[1];
// Check MX records
if (options.checkMx !== false) {
try {
const mxRecords = await this.getMxRecords(domain);
result.details.mxRecords = mxRecords;
result.hasMx = mxRecords && mxRecords.length > 0;
if (!result.hasMx) {
result.details.spamIndicators.push('No MX records');
result.details.errorMessage = 'Domain has no MX records';
}
} catch (error) {
logger.log('error', `Error checking MX records: ${error.message}`);
result.details.errorMessage = 'Unable to check MX records';
}
}
// Check if domain is disposable
if (options.checkDisposable !== false) {
result.details.disposable = await this.validator.isDisposableEmail(email);
if (result.details.disposable) {
result.details.spamIndicators.push('Disposable email');
}
}
// Check if email is a role account
if (options.checkRole !== false) {
result.details.role = this.validator.isRoleAccount(email);
if (result.details.role) {
result.details.spamIndicators.push('Role account');
}
}
// Calculate spam score and final validity
result.hasSpamMarkings = result.details.spamIndicators.length > 0;
// Calculate a score between 0-1 based on checks
let scoreFactors = 0;
let scoreTotal = 0;
// Format check (highest weight)
scoreFactors += 0.4;
if (result.details.formatValid) scoreTotal += 0.4;
// MX check (high weight)
if (options.checkMx !== false) {
scoreFactors += 0.3;
if (result.hasMx) scoreTotal += 0.3;
}
// Disposable check (medium weight)
if (options.checkDisposable !== false) {
scoreFactors += 0.2;
if (!result.details.disposable) scoreTotal += 0.2;
}
// Role account check (low weight)
if (options.checkRole !== false) {
scoreFactors += 0.1;
if (!result.details.role) scoreTotal += 0.1;
}
// Normalize score based on factors actually checked
result.score = scoreFactors > 0 ? scoreTotal / scoreFactors : 0;
// Email is valid if score is above 0.7 (configurable threshold)
result.isValid = result.score >= 0.7;
return result;
} catch (error) {
logger.log('error', `Email validation error: ${error.message}`);
return {
isValid: false,
hasMx: false,
hasSpamMarkings: true,
score: 0,
details: {
formatValid: false,
errorMessage: `Validation error: ${error.message}`,
spamIndicators: ['Validation error']
}
};
}
}
/**
* Gets MX records for a domain with caching
* @param domain Domain to check
* @returns Array of MX records
*/
private async getMxRecords(domain: string): Promise<string[]> {
// 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);
// 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) {
logger.log('error', `Error fetching MX records for ${domain}: ${error.message}`);
return [];
}
}
/**
* Validates multiple email addresses in batch
* @param emails Array of emails to validate
* @param options Validation options
* @returns Object with email addresses as keys and validation results as values
*/
public async validateBatch(
emails: string[],
options: {
checkMx?: boolean;
checkDisposable?: boolean;
checkRole?: boolean;
checkSyntaxOnly?: boolean;
} = {}
): Promise<Record<string, IEmailValidationResult>> {
const results: Record<string, IEmailValidationResult> = {};
for (const email of emails) {
results[email] = await this.validate(email, options);
}
return results;
}
/**
* Quick check if an email format is valid (synchronous, no DNS checks)
* @param email Email to check
* @returns Boolean indicating if format is valid
*/
public isValidFormat(email: string): boolean {
return this.validator.isValidEmailFormat(email);
}
}

View File

@@ -1,177 +0,0 @@
import * as plugins from '../plugins.js';
import { EmailService } from './classes.emailservice.js';
import { logger } from '../logger.js';
export class RuleManager {
public emailRef: EmailService;
public smartruleInstance = new plugins.smartrule.SmartRule<
plugins.smartmail.Smartmail<any>
>();
constructor(emailRefArg: EmailService) {
this.emailRef = emailRefArg;
// Register MTA handler for incoming emails if MTA is enabled
if (this.emailRef.mtaService) {
this.setupMtaIncomingHandler();
}
}
/**
* Set up handler for incoming emails via MTA's SMTP server
*/
private setupMtaIncomingHandler() {
// The original MtaService doesn't have a direct callback for incoming emails,
// but we can modify this approach based on how you prefer to integrate.
// One option would be to extend the MtaService to add an event emitter.
// For now, we'll use a directory watcher as an example
// This would watch the directory where MTA saves incoming emails
const incomingDir = this.emailRef.mtaService['receivedEmailsDir'] || './received';
// Simple file watcher (in real implementation, use proper file watching)
// This is just conceptual - would need modification to work with your specific setup
this.watchIncomingEmails(incomingDir);
}
/**
* Watch directory for incoming emails (conceptual implementation)
*/
private watchIncomingEmails(directory: string) {
console.log(`Watching for incoming emails in: ${directory}`);
// Conceptual - in a real implementation, set up proper file watching
// or modify the MTA to emit events when emails are received
/*
// Example using a file watcher:
const watcher = plugins.fs.watch(directory, async (eventType, filename) => {
if (eventType === 'rename' && filename.endsWith('.eml')) {
const filePath = plugins.path.join(directory, filename);
await this.handleMtaIncomingEmail(filePath);
}
});
*/
}
/**
* Handle incoming email received via MTA
*/
public async handleMtaIncomingEmail(emailPath: string) {
try {
// Process the email file
const fetchedSmartmail = await this.emailRef.mtaConnector.receiveEmail(emailPath);
console.log('=======================');
console.log('Received a mail via MTA:');
console.log(`From: ${fetchedSmartmail.options.creationObjectRef.From}`);
console.log(`To: ${fetchedSmartmail.options.creationObjectRef.To}`);
console.log(`Subject: ${fetchedSmartmail.options.creationObjectRef.Subject}`);
console.log('^^^^^^^^^^^^^^^^^^^^^^^');
logger.log(
'info',
`email from ${fetchedSmartmail.options.creationObjectRef.From} to ${fetchedSmartmail.options.creationObjectRef.To} with subject '${fetchedSmartmail.options.creationObjectRef.Subject}'`,
{
eventType: 'receivedEmail',
provider: 'mta',
email: {
from: fetchedSmartmail.options.creationObjectRef.From,
to: fetchedSmartmail.options.creationObjectRef.To,
subject: fetchedSmartmail.options.creationObjectRef.Subject,
},
}
);
// Process with rules
this.smartruleInstance.makeDecision(fetchedSmartmail);
} catch (error) {
logger.log('error', `Failed to process incoming MTA email: ${error.message}`, {
eventType: 'emailError',
provider: 'mta',
error: error.message
});
}
}
public async init() {
// Setup email rules
await this.createForwards();
}
/**
* creates the default forwards
*/
public async createForwards() {
const forwards: { originalToAddress: string[]; forwardedToAddress: string[] }[] = [];
console.log(`${forwards.length} forward rules configured:`);
for (const forward of forwards) {
console.log(forward);
}
for (const forward of forwards) {
this.smartruleInstance.createRule(
10,
async (smartmailArg) => {
const matched = forward.originalToAddress.reduce<boolean>((prevValue, currentValue) => {
return smartmailArg.options.creationObjectRef.To.includes(currentValue) || prevValue;
}, false);
if (matched) {
console.log('Forward rule matched');
console.log(forward);
return 'apply-continue';
} else {
return 'continue';
}
},
async (smartmailArg: plugins.smartmail.Smartmail<any>) => {
forward.forwardedToAddress.map(async (toArg) => {
const forwardedSmartMail = new plugins.smartmail.Smartmail({
body:
`
<div style="background: #CCC; padding: 10px; border-radius: 3px;">
<div><b>Original Sender:</b></div>
<div>${smartmailArg.options.creationObjectRef.From}</div>
<div><b>Original Recipient:</b></div>
<div>${smartmailArg.options.creationObjectRef.To}</div>
<div><b>Forwarded to:</b></div>
<div>${forward.forwardedToAddress.reduce<string>((pVal, cVal) => {
return `${pVal ? pVal + ', ' : ''}${cVal}`;
}, null)}</div>
<div><b>Subject:</b></div>
<div>${smartmailArg.getSubject()}</div>
<div><b>The original body can be found below.</b></div>
</div>
` + smartmailArg.getBody(),
from: 'forwarder@mail.lossless.one',
subject: `Forwarded mail for '${smartmailArg.options.creationObjectRef.To}'`,
});
for (const attachment of smartmailArg.attachments) {
forwardedSmartMail.addAttachment(attachment);
}
// Use the EmailService's sendEmail method to send with the appropriate provider
await this.emailRef.sendEmail(forwardedSmartMail, toArg);
console.log(`forwarded mail to ${toArg}`);
logger.log(
'info',
`email from ${
smartmailArg.options.creationObjectRef.From
} to ${toArg} with subject '${smartmailArg.getSubject()}'`,
{
eventType: 'forwardedEmail',
email: {
from: smartmailArg.options.creationObjectRef.From,
to: smartmailArg.options.creationObjectRef.To,
forwardedTo: toArg,
subject: smartmailArg.options.creationObjectRef.Subject,
},
}
);
});
}
);
}
}
}

View File

@@ -1,325 +0,0 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { logger } from '../logger.js';
/**
* Email template type definition
*/
export interface IEmailTemplate<T = any> {
id: string;
name: string;
description: string;
from: string;
subject: string;
bodyHtml: string;
bodyText?: string;
category?: string;
sampleData?: T;
attachments?: Array<{
name: string;
path: string;
contentType?: string;
}>;
}
/**
* Email template context - data used to render the template
*/
export interface ITemplateContext {
[key: string]: any;
}
/**
* Template category definitions
*/
export enum TemplateCategory {
NOTIFICATION = 'notification',
TRANSACTIONAL = 'transactional',
MARKETING = 'marketing',
SYSTEM = 'system'
}
/**
* Enhanced template manager using smartmail's capabilities
*/
export class TemplateManager {
private templates: Map<string, IEmailTemplate> = new Map();
private defaultConfig: {
from: string;
replyTo?: string;
footerHtml?: string;
footerText?: string;
};
constructor(defaultConfig?: {
from?: string;
replyTo?: string;
footerHtml?: string;
footerText?: string;
}) {
// Set default configuration
this.defaultConfig = {
from: defaultConfig?.from || 'noreply@mail.lossless.com',
replyTo: defaultConfig?.replyTo,
footerHtml: defaultConfig?.footerHtml || '',
footerText: defaultConfig?.footerText || ''
};
// Initialize with built-in templates
this.registerBuiltinTemplates();
}
/**
* Register built-in email templates
*/
private registerBuiltinTemplates(): void {
// Welcome email
this.registerTemplate<{
firstName: string;
accountUrl: string;
}>({
id: 'welcome',
name: 'Welcome Email',
description: 'Sent to users when they first sign up',
from: this.defaultConfig.from,
subject: 'Welcome to {{serviceName}}!',
category: TemplateCategory.TRANSACTIONAL,
bodyHtml: `
<h1>Welcome, {{firstName}}!</h1>
<p>Thank you for joining {{serviceName}}. We're excited to have you on board.</p>
<p>To get started, <a href="{{accountUrl}}">visit your account</a>.</p>
`,
bodyText:
`Welcome, {{firstName}}!
Thank you for joining {{serviceName}}. We're excited to have you on board.
To get started, visit your account: {{accountUrl}}
`,
sampleData: {
firstName: 'John',
accountUrl: 'https://example.com/account'
}
});
// Password reset
this.registerTemplate<{
resetUrl: string;
expiryHours: number;
}>({
id: 'password-reset',
name: 'Password Reset',
description: 'Sent when a user requests a password reset',
from: this.defaultConfig.from,
subject: 'Password Reset Request',
category: TemplateCategory.TRANSACTIONAL,
bodyHtml: `
<h2>Password Reset Request</h2>
<p>You recently requested to reset your password. Click the link below to reset it:</p>
<p><a href="{{resetUrl}}">Reset Password</a></p>
<p>This link will expire in {{expiryHours}} hours.</p>
<p>If you didn't request a password reset, please ignore this email.</p>
`,
sampleData: {
resetUrl: 'https://example.com/reset-password?token=abc123',
expiryHours: 24
}
});
// System notification
this.registerTemplate({
id: 'system-notification',
name: 'System Notification',
description: 'General system notification template',
from: this.defaultConfig.from,
subject: '{{subject}}',
category: TemplateCategory.SYSTEM,
bodyHtml: `
<h2>{{title}}</h2>
<div>{{message}}</div>
`,
sampleData: {
subject: 'Important System Notification',
title: 'System Maintenance',
message: 'The system will be undergoing maintenance on Saturday from 2-4am UTC.'
}
});
}
/**
* Register a new email template
* @param template The email template to register
*/
public registerTemplate<T = any>(template: IEmailTemplate<T>): void {
if (this.templates.has(template.id)) {
logger.log('warn', `Template with ID '${template.id}' already exists and will be overwritten`);
}
// Add footer to templates if configured
if (this.defaultConfig.footerHtml && template.bodyHtml) {
template.bodyHtml += this.defaultConfig.footerHtml;
}
if (this.defaultConfig.footerText && template.bodyText) {
template.bodyText += this.defaultConfig.footerText;
}
this.templates.set(template.id, template);
logger.log('info', `Registered email template: ${template.id}`);
}
/**
* Get an email template by ID
* @param templateId The template ID
* @returns The template or undefined if not found
*/
public getTemplate<T = any>(templateId: string): IEmailTemplate<T> | undefined {
return this.templates.get(templateId) as IEmailTemplate<T>;
}
/**
* List all available templates
* @param category Optional category filter
* @returns Array of email templates
*/
public listTemplates(category?: TemplateCategory): IEmailTemplate[] {
const templates = Array.from(this.templates.values());
if (category) {
return templates.filter(template => template.category === category);
}
return templates;
}
/**
* Create a Smartmail instance from a template
* @param templateId The template ID
* @param context The template context data
* @returns A configured Smartmail instance
*/
public async createSmartmail<T = any>(
templateId: string,
context?: ITemplateContext
): Promise<plugins.smartmail.Smartmail<T>> {
const template = this.getTemplate(templateId);
if (!template) {
throw new Error(`Template with ID '${templateId}' not found`);
}
// Create Smartmail instance with template content
const smartmail = new plugins.smartmail.Smartmail<T>({
from: template.from || this.defaultConfig.from,
subject: template.subject,
body: template.bodyHtml || template.bodyText || '',
creationObjectRef: context as T
});
// Add any template attachments
if (template.attachments && template.attachments.length > 0) {
for (const attachment of template.attachments) {
// Load attachment file
try {
const attachmentPath = plugins.path.isAbsolute(attachment.path)
? attachment.path
: plugins.path.join(paths.MtaAttachmentsDir, attachment.path);
// Use appropriate SmartFile method - either read from file or create with empty buffer
// For a file path, use the fromFilePath static method
const file = await plugins.smartfile.SmartFile.fromFilePath(attachmentPath);
// Set content type if specified
if (attachment.contentType) {
(file as any).contentType = attachment.contentType;
}
smartmail.addAttachment(file);
} catch (error) {
logger.log('error', `Failed to add attachment '${attachment.name}': ${error.message}`);
}
}
}
// Apply template variables if context provided
if (context) {
// Use applyVariables from smartmail v2.1.0+
smartmail.applyVariables(context);
}
return smartmail;
}
/**
* Create and completely process a Smartmail instance from a template
* @param templateId The template ID
* @param context The template context data
* @returns A complete, processed Smartmail instance ready to send
*/
public async prepareEmail<T = any>(
templateId: string,
context: ITemplateContext = {}
): Promise<plugins.smartmail.Smartmail<T>> {
const smartmail = await this.createSmartmail<T>(templateId, context);
// Pre-compile all mustache templates (subject, body)
smartmail.getSubject();
smartmail.getBody();
return smartmail;
}
/**
* Create a MIME-formatted email from a template
* @param templateId The template ID
* @param context The template context data
* @returns A MIME-formatted email string
*/
public async createMimeEmail(
templateId: string,
context: ITemplateContext = {}
): Promise<string> {
const smartmail = await this.prepareEmail(templateId, context);
return smartmail.toMimeFormat();
}
/**
* Load templates from a directory
* @param directory The directory containing template JSON files
*/
public async loadTemplatesFromDirectory(directory: string): Promise<void> {
try {
// Ensure directory exists
if (!plugins.fs.existsSync(directory)) {
logger.log('error', `Template directory does not exist: ${directory}`);
return;
}
// Get all JSON files
const files = plugins.fs.readdirSync(directory)
.filter(file => file.endsWith('.json'));
for (const file of files) {
try {
const filePath = plugins.path.join(directory, file);
const content = plugins.fs.readFileSync(filePath, 'utf8');
const template = JSON.parse(content) as IEmailTemplate;
// Validate template
if (!template.id || !template.subject || (!template.bodyHtml && !template.bodyText)) {
logger.log('warn', `Invalid template in ${file}: missing required fields`);
continue;
}
this.registerTemplate(template);
} catch (error) {
logger.log('error', `Error loading template from ${file}: ${error.message}`);
}
}
logger.log('info', `Loaded ${this.templates.size} email templates`);
} catch (error) {
logger.log('error', `Failed to load templates from directory: ${error.message}`);
throw error;
}
}
}

View File

@@ -1,19 +0,0 @@
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,
BounceManager,
BounceType,
BounceCategory,
EmailValidator,
TemplateManager,
RuleManager,
ApiManager,
MtaConnector
};