This commit is contained in:
2025-05-27 14:06:22 +00:00
parent af408d38c9
commit 073c8378c7
10 changed files with 2927 additions and 746 deletions

View File

@ -3,6 +3,8 @@ import * as paths from '../../paths.js';
import { Email } from '../core/classes.email.js';
import { EmailSignJob } from './classes.emailsignjob.js';
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
import type { SmtpClient } from './smtpclient/smtp-client.js';
import type { ISmtpSendResult } from './smtpclient/interfaces.js';
// Configuration options for email sending
export interface IEmailSendOptions {
@ -37,7 +39,6 @@ export interface DeliveryInfo {
export class EmailSendJob {
emailServerRef: UnifiedEmailServer;
private email: Email;
private socket: plugins.net.Socket | plugins.tls.TLSSocket = null;
private mxServers: string[] = [];
private currentMxIndex = 0;
private options: IEmailSendOptions;
@ -50,9 +51,9 @@ export class EmailSendJob {
// Set default options
this.options = {
maxRetries: options.maxRetries || 3,
retryDelay: options.retryDelay || 300000, // 5 minutes
connectionTimeout: options.connectionTimeout || 30000, // 30 seconds
tlsOptions: options.tlsOptions || { rejectUnauthorized: true },
retryDelay: options.retryDelay || 30000, // 30 seconds
connectionTimeout: options.connectionTimeout || 60000, // 60 seconds
tlsOptions: options.tlsOptions || {},
debugMode: options.debugMode || false
};
@ -65,7 +66,7 @@ export class EmailSendJob {
}
/**
* Send the email with retry logic
* Send the email to its recipients
*/
async send(): Promise<DeliveryStatus> {
try {
@ -167,62 +168,47 @@ export class EmailSendJob {
await this.saveSuccess();
return DeliveryStatus.DELIVERED;
} catch (error) {
this.log(`Error with MX ${currentMx}: ${error.message}`);
// Clean up socket if it exists
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
// Try the next MX server
this.log(`Failed to deliver to ${currentMx}: ${error.message}`);
this.currentMxIndex++;
// If this is a permanent failure, don't try other MX servers
if (this.isPermanentFailure(error)) {
throw error;
// If this MX server failed, try the next one
if (this.currentMxIndex >= this.mxServers.length) {
throw error; // No more MX servers to try
}
}
}
// If we've tried all MX servers without success, throw an error
throw new Error('All MX servers failed');
} catch (error) {
this.deliveryInfo.error = error;
// Check if this is a permanent failure
if (this.isPermanentFailure(error)) {
this.log(`Permanent failure: ${error.message}`);
this.log('Permanent failure detected, not retrying');
this.deliveryInfo.status = DeliveryStatus.FAILED;
this.deliveryInfo.error = error;
// Save failed email for analysis
// Record permanent failure for bounce management
this.recordDeliveryEvent('bounced', true);
await this.saveFailed();
return DeliveryStatus.FAILED;
}
// This is a temporary failure, we can retry
this.log(`Temporary failure: ${error.message}`);
// If this is the last attempt, mark as failed
if (this.deliveryInfo.attempts >= this.options.maxRetries) {
this.deliveryInfo.status = DeliveryStatus.FAILED;
this.deliveryInfo.error = error;
// This is a temporary failure
if (this.deliveryInfo.attempts < this.options.maxRetries) {
this.log(`Temporary failure, will retry in ${this.options.retryDelay}ms`);
this.deliveryInfo.status = DeliveryStatus.DEFERRED;
this.deliveryInfo.nextAttempt = new Date(Date.now() + this.options.retryDelay);
// Save failed email for analysis
await this.saveFailed();
return DeliveryStatus.FAILED;
// Record temporary failure for monitoring
this.recordDeliveryEvent('deferred');
// Reset MX server index for next retry
this.currentMxIndex = 0;
// Wait before retrying
await this.delay(this.options.retryDelay);
}
// Schedule the next retry
const nextRetryTime = new Date(Date.now() + this.options.retryDelay);
this.deliveryInfo.status = DeliveryStatus.DEFERRED;
this.deliveryInfo.nextAttempt = nextRetryTime;
this.log(`Will retry at ${nextRetryTime.toISOString()}`);
// Wait before retrying
await this.delay(this.options.retryDelay);
// Reset MX server index for the next attempt
this.currentMxIndex = 0;
}
}
@ -233,38 +219,12 @@ export class EmailSendJob {
}
/**
* Connect to a specific MX server and send the email
* Connect to a specific MX server and send the email using SmtpClient
*/
private async connectAndSend(mxServer: string): Promise<void> {
return new Promise((resolve, reject) => {
let commandTimeout: NodeJS.Timeout;
// Function to clear timeouts and remove listeners
const cleanup = () => {
clearTimeout(commandTimeout);
if (this.socket) {
this.socket.removeAllListeners();
}
};
// Function to set a timeout for each command
const setCommandTimeout = () => {
clearTimeout(commandTimeout);
commandTimeout = setTimeout(() => {
this.log('Connection timed out');
cleanup();
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
reject(new Error('Connection timed out'));
}, this.options.connectionTimeout);
};
// Connect to the MX server
this.log(`Connecting to ${mxServer}:25`);
setCommandTimeout();
this.log(`Connecting to ${mxServer}:25`);
try {
// Check if IP warmup is enabled and get an IP to use
let localAddress: string | undefined = undefined;
try {
@ -287,329 +247,119 @@ export class EmailSendJob {
this.log(`Error selecting IP address: ${error.message}`);
}
// Connect with specified local address if available
this.socket = plugins.net.connect({
port: 25,
host: mxServer,
localAddress
});
// Get SMTP client from UnifiedEmailServer
const smtpClient = this.emailServerRef.getSmtpClient(mxServer, 25);
this.socket.on('error', (err) => {
this.log(`Socket error: ${err.message}`);
cleanup();
reject(err);
});
// Set up the command sequence
this.socket.once('data', async (data) => {
try {
const greeting = data.toString();
this.log(`Server greeting: ${greeting.trim()}`);
if (!greeting.startsWith('220')) {
throw new Error(`Unexpected server greeting: ${greeting}`);
}
// EHLO command
const fromDomain = this.email.getFromDomain();
await this.sendCommand(`EHLO ${fromDomain}\r\n`, '250');
// Try STARTTLS if available
try {
await this.sendCommand('STARTTLS\r\n', '220');
this.upgradeToTLS(mxServer, fromDomain);
// The TLS handshake and subsequent commands will continue in the upgradeToTLS method
// resolve will be called from there if successful
} catch (error) {
this.log(`STARTTLS failed or not supported: ${error.message}`);
this.log('Continuing with unencrypted connection');
// Continue with unencrypted connection
await this.sendEmailCommands();
cleanup();
resolve();
}
} catch (error) {
cleanup();
reject(error);
}
});
});
}
/**
* Upgrade the connection to TLS
*/
private upgradeToTLS(mxServer: string, fromDomain: string): void {
this.log('Starting TLS handshake');
const tlsOptions = {
...this.options.tlsOptions,
socket: this.socket,
servername: mxServer
};
// Create TLS socket
this.socket = plugins.tls.connect(tlsOptions);
// Handle TLS connection
this.socket.once('secureConnect', async () => {
// Sign the email with DKIM if available
let signedEmail = this.email;
try {
this.log('TLS connection established');
// Send EHLO again over TLS
await this.sendCommand(`EHLO ${fromDomain}\r\n`, '250');
// Send the email
await this.sendEmailCommands();
this.socket.destroy();
this.socket = null;
const fromDomain = this.email.getFromDomain();
if (fromDomain && this.emailServerRef.hasDkimKey(fromDomain)) {
// Convert email to RFC822 format for signing
const emailMessage = this.email.toRFC822String();
// Create sign job with proper options
const emailSignJob = new EmailSignJob(this.emailServerRef, {
domain: fromDomain,
selector: 'default', // Using default selector
headers: {}, // Headers will be extracted from emailMessage
body: emailMessage
});
// Get the DKIM signature header
const signatureHeader = await emailSignJob.getSignatureHeader(emailMessage);
// Add the signature to the email
if (signatureHeader) {
// For now, we'll use the email as-is since SmtpClient will handle DKIM
this.log(`Email ready for DKIM signing for domain: ${fromDomain}`);
}
}
} catch (error) {
this.log(`Error in TLS session: ${error.message}`);
this.socket.destroy();
this.socket = null;
}
});
this.socket.on('error', (err) => {
this.log(`TLS error: ${err.message}`);
this.socket.destroy();
this.socket = null;
});
}
/**
* Send SMTP commands to deliver the email
*/
private async sendEmailCommands(): Promise<void> {
// MAIL FROM command
await this.sendCommand(`MAIL FROM:<${this.email.from}>\r\n`, '250');
// RCPT TO command for each recipient
for (const recipient of this.email.getAllRecipients()) {
await this.sendCommand(`RCPT TO:<${recipient}>\r\n`, '250');
}
// DATA command
await this.sendCommand('DATA\r\n', '354');
// Create the email message with DKIM signature
const message = await this.createEmailMessage();
// Send the message content
await this.sendCommand(message);
await this.sendCommand('\r\n.\r\n', '250');
// QUIT command
await this.sendCommand('QUIT\r\n', '221');
}
/**
* Create the full email message with headers and DKIM signature
*/
private async createEmailMessage(): Promise<string> {
this.log('Preparing email message');
const messageId = `<${plugins.uuid.v4()}@${this.email.getFromDomain()}>`;
const boundary = '----=_NextPart_' + plugins.uuid.v4();
// Prepare headers
const headers = {
'Message-ID': messageId,
'From': this.email.from,
'To': this.email.to.join(', '),
'Subject': this.email.subject,
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
'Date': new Date().toUTCString()
};
// Add CC header if present
if (this.email.cc && this.email.cc.length > 0) {
headers['Cc'] = this.email.cc.join(', ');
}
// Add custom headers
for (const [key, value] of Object.entries(this.email.headers || {})) {
headers[key] = value;
}
// Add priority header if not normal
if (this.email.priority && this.email.priority !== 'normal') {
const priorityValue = this.email.priority === 'high' ? '1' : '5';
headers['X-Priority'] = priorityValue;
}
// Create body
let body = '';
// Text part
body += `--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${this.email.text}\r\n`;
// HTML part if present
if (this.email.html) {
body += `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${this.email.html}\r\n`;
}
// Attachments
for (const attachment of this.email.attachments) {
body += `--${boundary}\r\nContent-Type: ${attachment.contentType}; name="${attachment.filename}"\r\n`;
body += 'Content-Transfer-Encoding: base64\r\n';
body += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
// Add Content-ID for inline attachments if present
if (attachment.contentId) {
body += `Content-ID: <${attachment.contentId}>\r\n`;
this.log(`Failed to prepare DKIM: ${error.message}`);
}
body += '\r\n';
body += attachment.content.toString('base64') + '\r\n';
// Send the email using SmtpClient
const result: ISmtpSendResult = await smtpClient.sendMail(signedEmail);
if (result.success) {
this.log(`Email sent successfully: ${result.response}`);
// Record the send for reputation monitoring
this.recordDeliveryEvent('delivered');
} else {
throw new Error(result.error?.message || 'Failed to send email');
}
} catch (error) {
this.log(`Failed to send email via ${mxServer}: ${error.message}`);
throw error;
}
// End of message
body += `--${boundary}--\r\n`;
// Create DKIM signature
const dkimSigner = new EmailSignJob(this.emailServerRef, {
domain: this.email.getFromDomain(),
selector: 'mta',
headers: headers,
body: body,
});
// Build the message with headers
let headerString = '';
for (const [key, value] of Object.entries(headers)) {
headerString += `${key}: ${value}\r\n`;
}
let message = headerString + '\r\n' + body;
// Add DKIM signature header
let signatureHeader = await dkimSigner.getSignatureHeader(message);
message = `${signatureHeader}${message}`;
return message;
}
/**
* Record an event for sender reputation monitoring
* @param eventType Type of event
* @param isHardBounce Whether the event is a hard bounce (for bounce events)
* Record delivery event for monitoring
*/
private recordDeliveryEvent(
eventType: 'sent' | 'delivered' | 'bounce' | 'complaint',
eventType: 'delivered' | 'bounced' | 'deferred',
isHardBounce: boolean = false
): void {
try {
// Get domain from sender
const domain = this.email.getFromDomain();
if (!domain) {
return;
}
// Determine receiving domain for complaint tracking
let receivingDomain = null;
if (eventType === 'complaint' && this.email.to.length > 0) {
const recipient = this.email.to[0];
const parts = recipient.split('@');
if (parts.length === 2) {
receivingDomain = parts[1];
if (domain) {
if (eventType === 'delivered') {
this.emailServerRef.recordDelivery(domain);
} else if (eventType === 'bounced') {
// Get the receiving domain for bounce recording
let receivingDomain = null;
const primaryRecipient = this.email.getPrimaryRecipient();
if (primaryRecipient) {
receivingDomain = primaryRecipient.split('@')[1];
}
if (receivingDomain) {
this.emailServerRef.recordBounce(
domain,
receivingDomain,
isHardBounce ? 'hard' : 'soft',
this.deliveryInfo.error?.message || 'Unknown error'
);
}
}
}
// Record the event using UnifiedEmailServer
this.emailServerRef.recordReputationEvent(domain, {
type: eventType,
count: 1,
hardBounce: isHardBounce,
receivingDomain
});
} catch (error) {
this.log(`Error recording delivery event: ${error.message}`);
this.log(`Failed to record delivery event: ${error.message}`);
}
}
/**
* Send a command to the SMTP server and wait for the expected response
*/
private sendCommand(command: string, expectedResponseCode?: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!this.socket) {
return reject(new Error('Socket not connected'));
}
// Debug log for commands (except DATA which can be large)
if (this.options.debugMode && !command.startsWith('--')) {
const logCommand = command.length > 100
? command.substring(0, 97) + '...'
: command;
this.log(`Sending: ${logCommand.replace(/\r\n/g, '<CRLF>')}`);
}
this.socket.write(command, (error) => {
if (error) {
this.log(`Write error: ${error.message}`);
return reject(error);
}
// If no response is expected, resolve immediately
if (!expectedResponseCode) {
return resolve('');
}
// Set a timeout for the response
const responseTimeout = setTimeout(() => {
this.log('Response timeout');
reject(new Error('Response timeout'));
}, this.options.connectionTimeout);
// Wait for the response
this.socket.once('data', (data) => {
clearTimeout(responseTimeout);
const response = data.toString();
if (this.options.debugMode) {
this.log(`Received: ${response.trim()}`);
}
if (response.startsWith(expectedResponseCode)) {
resolve(response);
} else {
const error = new Error(`Unexpected server response: ${response.trim()}`);
this.log(error.message);
reject(error);
}
});
});
});
}
/**
* Determine if an error represents a permanent failure
* Check if an error represents a permanent failure
*/
private isPermanentFailure(error: Error): boolean {
if (!error || !error.message) return false;
const message = error.message.toLowerCase();
// Check for permanent SMTP error codes (5xx)
if (message.match(/^5\d\d/)) return true;
// Check for specific permanent failure messages
const permanentFailurePatterns = [
'no such user',
'user unknown',
'domain not found',
'invalid domain',
'rejected',
'denied',
'prohibited',
'authentication required',
'authentication failed',
'unauthorized'
'User unknown',
'No such user',
'Mailbox not found',
'Invalid recipient',
'Account disabled',
'Account suspended',
'Domain not found',
'No such domain',
'Invalid domain',
'Relay access denied',
'Access denied',
'Blacklisted',
'Blocked',
'550', // Permanent failure SMTP code
'551',
'552',
'553',
'554'
];
return permanentFailurePatterns.some(pattern => message.includes(pattern));
const errorMessage = error.message.toLowerCase();
return permanentFailurePatterns.some(pattern =>
errorMessage.includes(pattern.toLowerCase())
);
}
/**
@ -621,14 +371,14 @@ export class EmailSendJob {
if (err) {
reject(err);
} else {
resolve(addresses);
resolve(addresses || []);
}
});
});
}
/**
* Add a log entry
* Log a message with timestamp
*/
private log(message: string): void {
const timestamp = new Date().toISOString();
@ -636,54 +386,60 @@ export class EmailSendJob {
this.deliveryInfo.logs.push(logEntry);
if (this.options.debugMode) {
console.log(`EmailSendJob: ${logEntry}`);
console.log(`[EmailSendJob] ${logEntry}`);
}
}
/**
* Save a successful email for record keeping
* Save successful email to storage
*/
private async saveSuccess(): Promise<void> {
try {
plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir);
const emailContent = await this.createEmailMessage();
const fileName = `${Date.now()}_success_${this.email.getPrimaryRecipient()}.eml`;
plugins.smartfile.memory.toFsSync(emailContent, plugins.path.join(paths.sentEmailsDir, fileName));
// Use the existing email storage path
const emailContent = this.email.toRFC822String();
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_success.eml`;
const filePath = plugins.path.join(paths.sentEmailsDir, fileName);
// Save delivery info
const infoFileName = `${Date.now()}_success_${this.email.getPrimaryRecipient()}.json`;
plugins.smartfile.memory.toFsSync(
JSON.stringify(this.deliveryInfo, null, 2),
plugins.path.join(paths.sentEmailsDir, infoFileName)
);
await plugins.smartfile.fs.ensureDir(paths.sentEmailsDir);
await plugins.smartfile.memory.toFs(emailContent, filePath);
// Also save delivery info
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_info.json`;
const infoPath = plugins.path.join(paths.sentEmailsDir, infoFileName);
await plugins.smartfile.memory.toFs(JSON.stringify(this.deliveryInfo, null, 2), infoPath);
this.log(`Email saved to ${fileName}`);
} catch (error) {
console.error('Error saving successful email:', error);
this.log(`Failed to save email: ${error.message}`);
}
}
/**
* Save a failed email for potential retry
* Save failed email to storage
*/
private async saveFailed(): Promise<void> {
try {
plugins.smartfile.fs.ensureDirSync(paths.failedEmailsDir);
const emailContent = await this.createEmailMessage();
const fileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.eml`;
plugins.smartfile.memory.toFsSync(emailContent, plugins.path.join(paths.failedEmailsDir, fileName));
// Use the existing email storage path
const emailContent = this.email.toRFC822String();
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_failed.eml`;
const filePath = plugins.path.join(paths.failedEmailsDir, fileName);
// Save delivery info
const infoFileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.json`;
plugins.smartfile.memory.toFsSync(
JSON.stringify(this.deliveryInfo, null, 2),
plugins.path.join(paths.failedEmailsDir, infoFileName)
);
await plugins.smartfile.fs.ensureDir(paths.failedEmailsDir);
await plugins.smartfile.memory.toFs(emailContent, filePath);
// Also save delivery info with error details
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_error.json`;
const infoPath = plugins.path.join(paths.failedEmailsDir, infoFileName);
await plugins.smartfile.memory.toFs(JSON.stringify(this.deliveryInfo, null, 2), infoPath);
this.log(`Failed email saved to ${fileName}`);
} catch (error) {
console.error('Error saving failed email:', error);
this.log(`Failed to save failed email: ${error.message}`);
}
}
/**
* Simple delay function
* Delay for specified milliseconds
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));