2025-05-22 10:18:02 +00:00
|
|
|
/**
|
|
|
|
* SMTP Client Core Implementation
|
|
|
|
* Main client class with delegation to handlers
|
|
|
|
*/
|
|
|
|
|
|
|
|
import { EventEmitter } from 'node:events';
|
|
|
|
import type { Email } from '../../core/classes.email.js';
|
|
|
|
import type {
|
|
|
|
ISmtpClientOptions,
|
|
|
|
ISmtpSendResult,
|
|
|
|
ISmtpConnection,
|
|
|
|
IConnectionPoolStatus,
|
|
|
|
ConnectionState
|
|
|
|
} from './interfaces.js';
|
|
|
|
import { CONNECTION_STATES, SmtpErrorType } from './constants.js';
|
|
|
|
import type { ConnectionManager } from './connection-manager.js';
|
|
|
|
import type { CommandHandler } from './command-handler.js';
|
|
|
|
import type { AuthHandler } from './auth-handler.js';
|
|
|
|
import type { TlsHandler } from './tls-handler.js';
|
|
|
|
import type { SmtpErrorHandler } from './error-handler.js';
|
|
|
|
import { validateSender, validateRecipients } from './utils/validation.js';
|
|
|
|
import { logEmailSend, logPerformance, logDebug } from './utils/logging.js';
|
|
|
|
|
|
|
|
interface ISmtpClientDependencies {
|
|
|
|
options: ISmtpClientOptions;
|
|
|
|
connectionManager: ConnectionManager;
|
|
|
|
commandHandler: CommandHandler;
|
|
|
|
authHandler: AuthHandler;
|
|
|
|
tlsHandler: TlsHandler;
|
|
|
|
errorHandler: SmtpErrorHandler;
|
|
|
|
}
|
|
|
|
|
|
|
|
export class SmtpClient extends EventEmitter {
|
|
|
|
private options: ISmtpClientOptions;
|
|
|
|
private connectionManager: ConnectionManager;
|
|
|
|
private commandHandler: CommandHandler;
|
|
|
|
private authHandler: AuthHandler;
|
|
|
|
private tlsHandler: TlsHandler;
|
|
|
|
private errorHandler: SmtpErrorHandler;
|
|
|
|
private isShuttingDown: boolean = false;
|
|
|
|
|
|
|
|
constructor(dependencies: ISmtpClientDependencies) {
|
|
|
|
super();
|
|
|
|
|
|
|
|
this.options = dependencies.options;
|
|
|
|
this.connectionManager = dependencies.connectionManager;
|
|
|
|
this.commandHandler = dependencies.commandHandler;
|
|
|
|
this.authHandler = dependencies.authHandler;
|
|
|
|
this.tlsHandler = dependencies.tlsHandler;
|
|
|
|
this.errorHandler = dependencies.errorHandler;
|
|
|
|
|
|
|
|
this.setupEventForwarding();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Send an email
|
|
|
|
*/
|
|
|
|
public async sendMail(email: Email): Promise<ISmtpSendResult> {
|
|
|
|
const startTime = Date.now();
|
2025-05-25 11:18:12 +00:00
|
|
|
|
|
|
|
// Extract clean email addresses without display names for SMTP operations
|
|
|
|
const fromAddress = email.getFromAddress();
|
|
|
|
const recipients = email.getToAddresses();
|
|
|
|
const ccRecipients = email.getCcAddresses();
|
|
|
|
const bccRecipients = email.getBccAddresses();
|
|
|
|
|
|
|
|
// Combine all recipients for SMTP operations
|
|
|
|
const allRecipients = [...recipients, ...ccRecipients, ...bccRecipients];
|
2025-05-22 10:18:02 +00:00
|
|
|
|
|
|
|
// Validate email addresses
|
|
|
|
if (!validateSender(fromAddress)) {
|
|
|
|
throw new Error(`Invalid sender address: ${fromAddress}`);
|
|
|
|
}
|
|
|
|
|
2025-05-25 11:18:12 +00:00
|
|
|
const recipientErrors = validateRecipients(allRecipients);
|
2025-05-22 10:18:02 +00:00
|
|
|
if (recipientErrors.length > 0) {
|
|
|
|
throw new Error(`Invalid recipients: ${recipientErrors.join(', ')}`);
|
|
|
|
}
|
|
|
|
|
2025-05-25 11:18:12 +00:00
|
|
|
logEmailSend('start', allRecipients, this.options);
|
2025-05-22 10:18:02 +00:00
|
|
|
|
|
|
|
let connection: ISmtpConnection | null = null;
|
|
|
|
const result: ISmtpSendResult = {
|
|
|
|
success: false,
|
|
|
|
acceptedRecipients: [],
|
|
|
|
rejectedRecipients: [],
|
|
|
|
envelope: {
|
|
|
|
from: fromAddress,
|
2025-05-25 11:18:12 +00:00
|
|
|
to: allRecipients
|
2025-05-22 10:18:02 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
try {
|
|
|
|
// Get connection
|
|
|
|
connection = await this.connectionManager.getConnection();
|
|
|
|
connection.state = CONNECTION_STATES.BUSY as ConnectionState;
|
|
|
|
|
|
|
|
// Wait for greeting if new connection
|
|
|
|
if (!connection.capabilities) {
|
|
|
|
await this.commandHandler.waitForGreeting(connection);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Perform EHLO
|
|
|
|
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
|
|
|
|
|
|
|
// Upgrade to TLS if needed
|
|
|
|
if (this.tlsHandler.shouldUseTLS(connection)) {
|
|
|
|
await this.tlsHandler.upgradeToTLS(connection);
|
|
|
|
// Re-send EHLO after TLS upgrade
|
|
|
|
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Authenticate if needed
|
|
|
|
if (this.options.auth) {
|
|
|
|
await this.authHandler.authenticate(connection);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Send MAIL FROM
|
|
|
|
const mailFromResponse = await this.commandHandler.sendMailFrom(connection, fromAddress);
|
|
|
|
if (mailFromResponse.code >= 400) {
|
|
|
|
throw new Error(`MAIL FROM failed: ${mailFromResponse.message}`);
|
|
|
|
}
|
|
|
|
|
2025-05-25 11:18:12 +00:00
|
|
|
// Send RCPT TO for each recipient (includes TO, CC, and BCC)
|
|
|
|
for (const recipient of allRecipients) {
|
2025-05-22 10:18:02 +00:00
|
|
|
try {
|
|
|
|
const rcptResponse = await this.commandHandler.sendRcptTo(connection, recipient);
|
|
|
|
if (rcptResponse.code >= 400) {
|
|
|
|
result.rejectedRecipients.push(recipient);
|
|
|
|
logDebug(`Recipient rejected: ${recipient}`, this.options, { response: rcptResponse });
|
|
|
|
} else {
|
|
|
|
result.acceptedRecipients.push(recipient);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
result.rejectedRecipients.push(recipient);
|
|
|
|
logDebug(`Recipient error: ${recipient}`, this.options, { error });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check if we have any accepted recipients
|
|
|
|
if (result.acceptedRecipients.length === 0) {
|
|
|
|
throw new Error('All recipients were rejected');
|
|
|
|
}
|
|
|
|
|
|
|
|
// Send DATA command
|
|
|
|
const dataResponse = await this.commandHandler.sendData(connection);
|
|
|
|
if (dataResponse.code !== 354) {
|
|
|
|
throw new Error(`DATA command failed: ${dataResponse.message}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Send email content
|
|
|
|
const emailData = await this.formatEmailData(email);
|
|
|
|
const sendResponse = await this.commandHandler.sendDataContent(connection, emailData);
|
|
|
|
|
|
|
|
if (sendResponse.code >= 400) {
|
|
|
|
throw new Error(`Email data rejected: ${sendResponse.message}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Success
|
|
|
|
result.success = true;
|
|
|
|
result.messageId = this.extractMessageId(sendResponse.message);
|
|
|
|
result.response = sendResponse.message;
|
|
|
|
|
|
|
|
connection.messageCount++;
|
|
|
|
logEmailSend('success', recipients, this.options, {
|
|
|
|
messageId: result.messageId,
|
|
|
|
duration: Date.now() - startTime
|
|
|
|
});
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
result.success = false;
|
|
|
|
result.error = error instanceof Error ? error : new Error(String(error));
|
|
|
|
|
|
|
|
// Classify error and determine if we should retry
|
|
|
|
const errorType = this.errorHandler.classifyError(result.error);
|
|
|
|
result.error = this.errorHandler.createError(
|
|
|
|
result.error.message,
|
|
|
|
errorType,
|
|
|
|
{ command: 'SEND_MAIL' },
|
|
|
|
result.error
|
|
|
|
);
|
|
|
|
|
|
|
|
logEmailSend('failure', recipients, this.options, {
|
|
|
|
error: result.error,
|
|
|
|
duration: Date.now() - startTime
|
|
|
|
});
|
|
|
|
|
|
|
|
} finally {
|
|
|
|
// Release connection
|
|
|
|
if (connection) {
|
|
|
|
connection.state = CONNECTION_STATES.READY as ConnectionState;
|
|
|
|
this.connectionManager.updateActivity(connection);
|
|
|
|
this.connectionManager.releaseConnection(connection);
|
|
|
|
}
|
|
|
|
|
|
|
|
logPerformance('sendMail', Date.now() - startTime, this.options);
|
|
|
|
}
|
|
|
|
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Test connection to SMTP server
|
|
|
|
*/
|
|
|
|
public async verify(): Promise<boolean> {
|
|
|
|
let connection: ISmtpConnection | null = null;
|
|
|
|
|
|
|
|
try {
|
|
|
|
connection = await this.connectionManager.createConnection();
|
|
|
|
await this.commandHandler.waitForGreeting(connection);
|
|
|
|
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
|
|
|
|
|
|
|
if (this.tlsHandler.shouldUseTLS(connection)) {
|
|
|
|
await this.tlsHandler.upgradeToTLS(connection);
|
|
|
|
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.options.auth) {
|
|
|
|
await this.authHandler.authenticate(connection);
|
|
|
|
}
|
|
|
|
|
|
|
|
await this.commandHandler.sendQuit(connection);
|
|
|
|
return true;
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
logDebug('Connection verification failed', this.options, { error });
|
|
|
|
return false;
|
|
|
|
|
|
|
|
} finally {
|
|
|
|
if (connection) {
|
|
|
|
this.connectionManager.closeConnection(connection);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Check if client is connected
|
|
|
|
*/
|
|
|
|
public isConnected(): boolean {
|
|
|
|
const status = this.connectionManager.getPoolStatus();
|
|
|
|
return status.total > 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get connection pool status
|
|
|
|
*/
|
|
|
|
public getPoolStatus(): IConnectionPoolStatus {
|
|
|
|
return this.connectionManager.getPoolStatus();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Update client options
|
|
|
|
*/
|
|
|
|
public updateOptions(newOptions: Partial<ISmtpClientOptions>): void {
|
|
|
|
this.options = { ...this.options, ...newOptions };
|
|
|
|
logDebug('Client options updated', this.options);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Close all connections and shutdown client
|
|
|
|
*/
|
|
|
|
public async close(): Promise<void> {
|
|
|
|
if (this.isShuttingDown) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.isShuttingDown = true;
|
|
|
|
logDebug('Shutting down SMTP client', this.options);
|
|
|
|
|
|
|
|
try {
|
|
|
|
this.connectionManager.closeAllConnections();
|
|
|
|
this.emit('close');
|
|
|
|
} catch (error) {
|
|
|
|
logDebug('Error during client shutdown', this.options, { error });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private async formatEmailData(email: Email): Promise<string> {
|
|
|
|
// Convert Email object to raw SMTP data
|
|
|
|
const headers: string[] = [];
|
|
|
|
|
|
|
|
// Required headers
|
|
|
|
headers.push(`From: ${email.from}`);
|
|
|
|
headers.push(`To: ${Array.isArray(email.to) ? email.to.join(', ') : email.to}`);
|
|
|
|
headers.push(`Subject: ${email.subject || ''}`);
|
|
|
|
headers.push(`Date: ${new Date().toUTCString()}`);
|
|
|
|
headers.push(`Message-ID: <${Date.now()}.${Math.random().toString(36)}@${this.options.host}>`);
|
|
|
|
|
|
|
|
// Optional headers
|
|
|
|
if (email.cc) {
|
|
|
|
const cc = Array.isArray(email.cc) ? email.cc.join(', ') : email.cc;
|
|
|
|
headers.push(`Cc: ${cc}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (email.bcc) {
|
|
|
|
const bcc = Array.isArray(email.bcc) ? email.bcc.join(', ') : email.bcc;
|
|
|
|
headers.push(`Bcc: ${bcc}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Content headers
|
|
|
|
if (email.html && email.text) {
|
|
|
|
// Multipart message
|
|
|
|
const boundary = `boundary_${Date.now()}_${Math.random().toString(36)}`;
|
|
|
|
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
|
|
|
headers.push('MIME-Version: 1.0');
|
|
|
|
|
|
|
|
const body = [
|
|
|
|
`--${boundary}`,
|
|
|
|
'Content-Type: text/plain; charset=utf-8',
|
|
|
|
'Content-Transfer-Encoding: quoted-printable',
|
|
|
|
'',
|
|
|
|
email.text,
|
|
|
|
'',
|
|
|
|
`--${boundary}`,
|
|
|
|
'Content-Type: text/html; charset=utf-8',
|
|
|
|
'Content-Transfer-Encoding: quoted-printable',
|
|
|
|
'',
|
|
|
|
email.html,
|
|
|
|
'',
|
|
|
|
`--${boundary}--`
|
|
|
|
].join('\r\n');
|
|
|
|
|
|
|
|
return headers.join('\r\n') + '\r\n\r\n' + body;
|
|
|
|
} else if (email.html) {
|
|
|
|
headers.push('Content-Type: text/html; charset=utf-8');
|
|
|
|
headers.push('MIME-Version: 1.0');
|
|
|
|
return headers.join('\r\n') + '\r\n\r\n' + email.html;
|
|
|
|
} else {
|
|
|
|
headers.push('Content-Type: text/plain; charset=utf-8');
|
|
|
|
headers.push('MIME-Version: 1.0');
|
|
|
|
return headers.join('\r\n') + '\r\n\r\n' + (email.text || '');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private extractMessageId(response: string): string | undefined {
|
|
|
|
// Try to extract message ID from server response
|
|
|
|
const match = response.match(/queued as ([^\s]+)/i) ||
|
|
|
|
response.match(/id=([^\s]+)/i) ||
|
|
|
|
response.match(/Message-ID: <([^>]+)>/i);
|
|
|
|
return match ? match[1] : undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
private setupEventForwarding(): void {
|
|
|
|
// Forward events from connection manager
|
|
|
|
this.connectionManager.on('connection', (connection) => {
|
|
|
|
this.emit('connection', connection);
|
|
|
|
});
|
|
|
|
|
|
|
|
this.connectionManager.on('disconnect', (connection) => {
|
|
|
|
this.emit('disconnect', connection);
|
|
|
|
});
|
|
|
|
|
|
|
|
this.connectionManager.on('error', (error) => {
|
|
|
|
this.emit('error', error);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|