2026-02-10 15:54:09 +00:00
|
|
|
import * as plugins from '../../plugins.js';
|
|
|
|
|
import { logger } from '../../logger.js';
|
|
|
|
|
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
2026-02-10 20:30:43 +00:00
|
|
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
2026-02-10 15:54:09 +00:00
|
|
|
import { MtaConnectionError, MtaAuthenticationError, MtaDeliveryError, MtaConfigurationError, MtaTimeoutError, MtaProtocolError } from '../../errors/index.js';
|
|
|
|
|
import { Email } from '../core/classes.email.js';
|
|
|
|
|
/**
|
|
|
|
|
* SMTP client for sending emails to remote mail servers
|
|
|
|
|
*/
|
|
|
|
|
export class SmtpClient {
|
|
|
|
|
options;
|
|
|
|
|
connected = false;
|
|
|
|
|
socket;
|
|
|
|
|
supportedExtensions = new Set();
|
|
|
|
|
/**
|
|
|
|
|
* Create a new SMTP client instance
|
|
|
|
|
* @param options SMTP client connection options
|
|
|
|
|
*/
|
|
|
|
|
constructor(options) {
|
|
|
|
|
// Set default options
|
|
|
|
|
this.options = {
|
|
|
|
|
...options,
|
|
|
|
|
connectionTimeout: options.connectionTimeout || 30000, // 30 seconds
|
|
|
|
|
socketTimeout: options.socketTimeout || 60000, // 60 seconds
|
|
|
|
|
commandTimeout: options.commandTimeout || 30000, // 30 seconds
|
|
|
|
|
secure: options.secure || false,
|
|
|
|
|
domain: options.domain || 'localhost',
|
|
|
|
|
tls: {
|
|
|
|
|
rejectUnauthorized: options.tls?.rejectUnauthorized !== false, // Default to true
|
|
|
|
|
minVersion: options.tls?.minVersion || 'TLSv1.2'
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Connect to the SMTP server
|
|
|
|
|
*/
|
|
|
|
|
async connect() {
|
|
|
|
|
if (this.connected && this.socket) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
logger.log('info', `Connecting to SMTP server ${this.options.host}:${this.options.port}`);
|
|
|
|
|
// Create socket
|
|
|
|
|
const socket = new plugins.net.Socket();
|
|
|
|
|
// Set timeouts
|
|
|
|
|
socket.setTimeout(this.options.socketTimeout);
|
|
|
|
|
// Connect to the server
|
|
|
|
|
await new Promise((resolve, reject) => {
|
|
|
|
|
// Handle connection events
|
|
|
|
|
socket.once('connect', () => {
|
|
|
|
|
logger.log('debug', `Connected to ${this.options.host}:${this.options.port}`);
|
|
|
|
|
resolve();
|
|
|
|
|
});
|
|
|
|
|
socket.once('timeout', () => {
|
|
|
|
|
reject(MtaConnectionError.timeout(this.options.host, this.options.port, this.options.connectionTimeout));
|
|
|
|
|
});
|
|
|
|
|
socket.once('error', (err) => {
|
|
|
|
|
if (err.code === 'ECONNREFUSED') {
|
|
|
|
|
reject(MtaConnectionError.refused(this.options.host, this.options.port));
|
|
|
|
|
}
|
|
|
|
|
else if (err.code === 'ENOTFOUND') {
|
|
|
|
|
reject(MtaConnectionError.dnsError(this.options.host, err));
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
reject(new MtaConnectionError(`Connection error to ${this.options.host}:${this.options.port}: ${err.message}`, {
|
|
|
|
|
data: {
|
|
|
|
|
host: this.options.host,
|
|
|
|
|
port: this.options.port,
|
|
|
|
|
error: err.message,
|
|
|
|
|
code: err.code
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// Connect to the server
|
|
|
|
|
const connectOptions = {
|
|
|
|
|
host: this.options.host,
|
|
|
|
|
port: this.options.port
|
|
|
|
|
};
|
|
|
|
|
// For direct TLS connections
|
|
|
|
|
if (this.options.secure) {
|
|
|
|
|
const tlsSocket = plugins.tls.connect({
|
|
|
|
|
...connectOptions,
|
|
|
|
|
rejectUnauthorized: this.options.tls.rejectUnauthorized,
|
|
|
|
|
minVersion: this.options.tls.minVersion,
|
|
|
|
|
ca: this.options.tls.ca ? [this.options.tls.ca] : undefined
|
|
|
|
|
});
|
|
|
|
|
tlsSocket.once('secureConnect', () => {
|
|
|
|
|
logger.log('debug', `Secure connection established to ${this.options.host}:${this.options.port}`);
|
|
|
|
|
this.socket = tlsSocket;
|
|
|
|
|
resolve();
|
|
|
|
|
});
|
|
|
|
|
tlsSocket.once('error', (err) => {
|
|
|
|
|
reject(new MtaConnectionError(`TLS connection error to ${this.options.host}:${this.options.port}: ${err.message}`, {
|
|
|
|
|
data: {
|
|
|
|
|
host: this.options.host,
|
|
|
|
|
port: this.options.port,
|
|
|
|
|
error: err.message,
|
|
|
|
|
code: err.code
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
tlsSocket.setTimeout(this.options.socketTimeout);
|
|
|
|
|
tlsSocket.once('timeout', () => {
|
|
|
|
|
reject(MtaConnectionError.timeout(this.options.host, this.options.port, this.options.connectionTimeout));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
socket.connect(connectOptions);
|
|
|
|
|
this.socket = socket;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// Wait for server greeting
|
|
|
|
|
const greeting = await this.readResponse();
|
|
|
|
|
if (!greeting.startsWith('220')) {
|
|
|
|
|
throw new MtaConnectionError(`Unexpected greeting from server: ${greeting}`, {
|
|
|
|
|
data: {
|
|
|
|
|
host: this.options.host,
|
|
|
|
|
port: this.options.port,
|
|
|
|
|
greeting
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// Send EHLO
|
|
|
|
|
await this.sendEhlo();
|
|
|
|
|
// Start TLS if not secure and supported
|
|
|
|
|
if (!this.options.secure && this.supportedExtensions.has('STARTTLS')) {
|
|
|
|
|
await this.startTls();
|
|
|
|
|
// Send EHLO again after STARTTLS
|
|
|
|
|
await this.sendEhlo();
|
|
|
|
|
}
|
|
|
|
|
// Authenticate if credentials provided
|
|
|
|
|
if (this.options.auth) {
|
|
|
|
|
await this.authenticate();
|
|
|
|
|
}
|
|
|
|
|
this.connected = true;
|
|
|
|
|
logger.log('info', `Successfully connected to SMTP server ${this.options.host}:${this.options.port}`);
|
|
|
|
|
// Set up error handling for the socket
|
|
|
|
|
this.socket.on('error', (err) => {
|
|
|
|
|
logger.log('error', `Socket error: ${err.message}`);
|
|
|
|
|
this.connected = false;
|
|
|
|
|
this.socket = undefined;
|
|
|
|
|
});
|
|
|
|
|
this.socket.on('close', () => {
|
|
|
|
|
logger.log('debug', 'Socket closed');
|
|
|
|
|
this.connected = false;
|
|
|
|
|
this.socket = undefined;
|
|
|
|
|
});
|
|
|
|
|
this.socket.on('timeout', () => {
|
|
|
|
|
logger.log('error', 'Socket timeout');
|
|
|
|
|
this.connected = false;
|
|
|
|
|
if (this.socket) {
|
|
|
|
|
this.socket.destroy();
|
|
|
|
|
this.socket = undefined;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
catch (error) {
|
|
|
|
|
// Clean up socket if connection failed
|
|
|
|
|
if (this.socket) {
|
|
|
|
|
this.socket.destroy();
|
|
|
|
|
this.socket = undefined;
|
|
|
|
|
}
|
|
|
|
|
logger.log('error', `Failed to connect to SMTP server: ${error.message}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Send EHLO command to the server
|
|
|
|
|
*/
|
|
|
|
|
async sendEhlo() {
|
|
|
|
|
// Clear previous extensions
|
|
|
|
|
this.supportedExtensions.clear();
|
|
|
|
|
// Send EHLO - don't allow pipelining for this command
|
|
|
|
|
const response = await this.sendCommand(`EHLO ${this.options.domain}`, false);
|
|
|
|
|
// Parse supported extensions
|
|
|
|
|
const lines = response.split('\r\n');
|
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
|
|
|
const line = lines[i];
|
|
|
|
|
if (line.startsWith('250-') || line.startsWith('250 ')) {
|
|
|
|
|
const extension = line.substring(4).split(' ')[0];
|
|
|
|
|
this.supportedExtensions.add(extension);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Check if server supports pipelining
|
|
|
|
|
this.supportsPipelining = this.supportedExtensions.has('PIPELINING');
|
|
|
|
|
logger.log('debug', `Server supports extensions: ${Array.from(this.supportedExtensions).join(', ')}`);
|
|
|
|
|
if (this.supportsPipelining) {
|
|
|
|
|
logger.log('info', 'Server supports PIPELINING - will use for improved performance');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Start TLS negotiation
|
|
|
|
|
*/
|
|
|
|
|
async startTls() {
|
|
|
|
|
logger.log('debug', 'Starting TLS negotiation');
|
|
|
|
|
// Send STARTTLS command
|
|
|
|
|
const response = await this.sendCommand('STARTTLS');
|
|
|
|
|
if (!response.startsWith('220')) {
|
|
|
|
|
throw new MtaConnectionError(`Failed to start TLS: ${response}`, {
|
|
|
|
|
data: {
|
|
|
|
|
host: this.options.host,
|
|
|
|
|
port: this.options.port,
|
|
|
|
|
response
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (!this.socket) {
|
|
|
|
|
throw new MtaConnectionError('No socket available for TLS upgrade', {
|
|
|
|
|
data: {
|
|
|
|
|
host: this.options.host,
|
|
|
|
|
port: this.options.port
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// Upgrade socket to TLS
|
|
|
|
|
const currentSocket = this.socket;
|
|
|
|
|
this.socket = await this.upgradeTls(currentSocket);
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Upgrade socket to TLS
|
|
|
|
|
* @param socket Original socket
|
|
|
|
|
*/
|
|
|
|
|
async upgradeTls(socket) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const tlsOptions = {
|
|
|
|
|
socket,
|
|
|
|
|
servername: this.options.host,
|
|
|
|
|
rejectUnauthorized: this.options.tls.rejectUnauthorized,
|
|
|
|
|
minVersion: this.options.tls.minVersion,
|
|
|
|
|
ca: this.options.tls.ca ? [this.options.tls.ca] : undefined
|
|
|
|
|
};
|
|
|
|
|
const tlsSocket = plugins.tls.connect(tlsOptions);
|
|
|
|
|
tlsSocket.once('secureConnect', () => {
|
|
|
|
|
logger.log('debug', 'TLS negotiation successful');
|
|
|
|
|
resolve(tlsSocket);
|
|
|
|
|
});
|
|
|
|
|
tlsSocket.once('error', (err) => {
|
|
|
|
|
reject(new MtaConnectionError(`TLS error: ${err.message}`, {
|
|
|
|
|
data: {
|
|
|
|
|
host: this.options.host,
|
|
|
|
|
port: this.options.port,
|
|
|
|
|
error: err.message,
|
|
|
|
|
code: err.code
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
tlsSocket.setTimeout(this.options.socketTimeout);
|
|
|
|
|
tlsSocket.once('timeout', () => {
|
|
|
|
|
reject(MtaTimeoutError.commandTimeout('STARTTLS', this.options.host, this.options.socketTimeout));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Authenticate with the server
|
|
|
|
|
*/
|
|
|
|
|
async authenticate() {
|
|
|
|
|
if (!this.options.auth) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const { user, pass, method = 'LOGIN' } = this.options.auth;
|
|
|
|
|
logger.log('debug', `Authenticating as ${user} using ${method}`);
|
|
|
|
|
try {
|
|
|
|
|
switch (method) {
|
|
|
|
|
case 'PLAIN':
|
|
|
|
|
await this.authPlain(user, pass);
|
|
|
|
|
break;
|
|
|
|
|
case 'LOGIN':
|
|
|
|
|
await this.authLogin(user, pass);
|
|
|
|
|
break;
|
|
|
|
|
case 'OAUTH2':
|
|
|
|
|
await this.authOAuth2(user, pass);
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
throw new MtaAuthenticationError(`Authentication method ${method} not supported by client`, {
|
|
|
|
|
data: {
|
|
|
|
|
method
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
logger.log('info', `Successfully authenticated as ${user}`);
|
|
|
|
|
}
|
|
|
|
|
catch (error) {
|
|
|
|
|
logger.log('error', `Authentication failed: ${error.message}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Authenticate using PLAIN method
|
|
|
|
|
* @param user Username
|
|
|
|
|
* @param pass Password
|
|
|
|
|
*/
|
|
|
|
|
async authPlain(user, pass) {
|
|
|
|
|
// PLAIN authentication format: \0username\0password
|
|
|
|
|
const authString = Buffer.from(`\0${user}\0${pass}`).toString('base64');
|
|
|
|
|
const response = await this.sendCommand(`AUTH PLAIN ${authString}`);
|
|
|
|
|
if (!response.startsWith('235')) {
|
|
|
|
|
throw MtaAuthenticationError.invalidCredentials(this.options.host, user);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Authenticate using LOGIN method
|
|
|
|
|
* @param user Username
|
|
|
|
|
* @param pass Password
|
|
|
|
|
*/
|
|
|
|
|
async authLogin(user, pass) {
|
|
|
|
|
// Start LOGIN authentication
|
|
|
|
|
const response = await this.sendCommand('AUTH LOGIN');
|
|
|
|
|
if (!response.startsWith('334')) {
|
|
|
|
|
throw new MtaAuthenticationError(`Server did not accept AUTH LOGIN: ${response}`, {
|
|
|
|
|
data: {
|
|
|
|
|
host: this.options.host,
|
|
|
|
|
response
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// Send username (base64)
|
|
|
|
|
const userResponse = await this.sendCommand(Buffer.from(user).toString('base64'));
|
|
|
|
|
if (!userResponse.startsWith('334')) {
|
|
|
|
|
throw MtaAuthenticationError.invalidCredentials(this.options.host, user);
|
|
|
|
|
}
|
|
|
|
|
// Send password (base64)
|
|
|
|
|
const passResponse = await this.sendCommand(Buffer.from(pass).toString('base64'));
|
|
|
|
|
if (!passResponse.startsWith('235')) {
|
|
|
|
|
throw MtaAuthenticationError.invalidCredentials(this.options.host, user);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Authenticate using OAuth2 method
|
|
|
|
|
* @param user Username
|
|
|
|
|
* @param token OAuth2 token
|
|
|
|
|
*/
|
|
|
|
|
async authOAuth2(user, token) {
|
|
|
|
|
// XOAUTH2 format
|
|
|
|
|
const authString = `user=${user}\x01auth=Bearer ${token}\x01\x01`;
|
|
|
|
|
const response = await this.sendCommand(`AUTH XOAUTH2 ${Buffer.from(authString).toString('base64')}`);
|
|
|
|
|
if (!response.startsWith('235')) {
|
|
|
|
|
throw MtaAuthenticationError.invalidCredentials(this.options.host, user);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Send an email through the SMTP client
|
|
|
|
|
* @param email Email to send
|
|
|
|
|
* @param processingMode Optional processing mode
|
|
|
|
|
*/
|
|
|
|
|
async sendMail(email, processingMode) {
|
|
|
|
|
// Ensure we're connected
|
|
|
|
|
if (!this.connected || !this.socket) {
|
|
|
|
|
await this.connect();
|
|
|
|
|
}
|
|
|
|
|
const startTime = Date.now();
|
|
|
|
|
const result = {
|
|
|
|
|
success: false,
|
|
|
|
|
acceptedRecipients: [],
|
|
|
|
|
rejectedRecipients: [],
|
|
|
|
|
timestamp: startTime,
|
|
|
|
|
secure: this.options.secure || this.socket instanceof plugins.tls.TLSSocket,
|
|
|
|
|
authenticated: !!this.options.auth
|
|
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
logger.log('info', `Sending email to ${email.getAllRecipients().join(', ')}`);
|
|
|
|
|
// Apply DKIM signing if configured
|
|
|
|
|
if (this.options.dkim?.enabled) {
|
|
|
|
|
await this.applyDkimSignature(email);
|
|
|
|
|
result.dkimSigned = true;
|
|
|
|
|
}
|
|
|
|
|
// Get envelope and recipients
|
|
|
|
|
const envelope_from = email.getEnvelopeFrom() || email.from;
|
|
|
|
|
const recipients = email.getAllRecipients();
|
|
|
|
|
// Check if we can use pipelining for MAIL FROM and RCPT TO commands
|
|
|
|
|
if (this.supportsPipelining && recipients.length > 0) {
|
|
|
|
|
logger.log('debug', 'Using SMTP pipelining for sending');
|
|
|
|
|
// Send MAIL FROM command first (always needed)
|
|
|
|
|
const mailFromCmd = `MAIL FROM:<${envelope_from}> SIZE=${this.getEmailSize(email)}`;
|
|
|
|
|
let mailFromResponse;
|
|
|
|
|
try {
|
|
|
|
|
mailFromResponse = await this.sendCommand(mailFromCmd);
|
|
|
|
|
if (!mailFromResponse.startsWith('250')) {
|
|
|
|
|
throw new MtaDeliveryError(`MAIL FROM command failed: ${mailFromResponse}`, {
|
|
|
|
|
data: {
|
|
|
|
|
command: mailFromCmd,
|
|
|
|
|
response: mailFromResponse
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (error) {
|
|
|
|
|
logger.log('error', `MAIL FROM failed: ${error.message}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
// Pipeline all RCPT TO commands
|
|
|
|
|
const rcptPromises = recipients.map(recipient => {
|
|
|
|
|
return this.sendCommand(`RCPT TO:<${recipient}>`)
|
|
|
|
|
.then(response => {
|
|
|
|
|
if (response.startsWith('250')) {
|
|
|
|
|
result.acceptedRecipients.push(recipient);
|
|
|
|
|
return { recipient, accepted: true, response };
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
result.rejectedRecipients.push(recipient);
|
|
|
|
|
logger.log('warn', `Recipient ${recipient} rejected: ${response}`);
|
|
|
|
|
return { recipient, accepted: false, response };
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(error => {
|
|
|
|
|
result.rejectedRecipients.push(recipient);
|
|
|
|
|
logger.log('warn', `Recipient ${recipient} rejected with error: ${error.message}`);
|
|
|
|
|
return { recipient, accepted: false, error: error.message };
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
// Wait for all RCPT TO commands to complete
|
|
|
|
|
await Promise.all(rcptPromises);
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
// Fall back to sequential commands if pipelining not supported
|
|
|
|
|
logger.log('debug', 'Using sequential SMTP commands for sending');
|
|
|
|
|
// Send MAIL FROM
|
|
|
|
|
await this.sendCommand(`MAIL FROM:<${envelope_from}> SIZE=${this.getEmailSize(email)}`);
|
|
|
|
|
// Send RCPT TO for each recipient
|
|
|
|
|
for (const recipient of recipients) {
|
|
|
|
|
try {
|
|
|
|
|
await this.sendCommand(`RCPT TO:<${recipient}>`);
|
|
|
|
|
result.acceptedRecipients.push(recipient);
|
|
|
|
|
}
|
|
|
|
|
catch (error) {
|
|
|
|
|
logger.log('warn', `Recipient ${recipient} rejected: ${error.message}`);
|
|
|
|
|
result.rejectedRecipients.push(recipient);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Check if at least one recipient was accepted
|
|
|
|
|
if (result.acceptedRecipients.length === 0) {
|
|
|
|
|
throw new MtaDeliveryError('All recipients were rejected', {
|
|
|
|
|
data: {
|
|
|
|
|
recipients,
|
|
|
|
|
rejectedRecipients: result.rejectedRecipients
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// Send DATA
|
|
|
|
|
const dataResponse = await this.sendCommand('DATA');
|
|
|
|
|
if (!dataResponse.startsWith('354')) {
|
|
|
|
|
throw new MtaProtocolError(`Failed to start DATA phase: ${dataResponse}`, {
|
|
|
|
|
data: {
|
|
|
|
|
response: dataResponse
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// Format email content efficiently
|
|
|
|
|
const emailContent = await this.getFormattedEmail(email);
|
|
|
|
|
// Send email content
|
|
|
|
|
const finalResponse = await this.sendCommand(emailContent + '\r\n.');
|
|
|
|
|
// Extract message ID if available
|
|
|
|
|
const messageIdMatch = finalResponse.match(/\[(.*?)\]/);
|
|
|
|
|
if (messageIdMatch) {
|
|
|
|
|
result.messageId = messageIdMatch[1];
|
|
|
|
|
}
|
|
|
|
|
result.success = true;
|
|
|
|
|
result.response = finalResponse;
|
|
|
|
|
logger.log('info', `Email sent successfully to ${result.acceptedRecipients.join(', ')}`);
|
|
|
|
|
// Log security event
|
|
|
|
|
SecurityLogger.getInstance().logEvent({
|
|
|
|
|
level: SecurityLogLevel.INFO,
|
|
|
|
|
type: SecurityEventType.EMAIL_DELIVERY,
|
|
|
|
|
message: 'Email sent successfully',
|
|
|
|
|
details: {
|
|
|
|
|
recipients: result.acceptedRecipients,
|
|
|
|
|
rejectedRecipients: result.rejectedRecipients,
|
|
|
|
|
messageId: result.messageId,
|
|
|
|
|
secure: result.secure,
|
|
|
|
|
authenticated: result.authenticated,
|
|
|
|
|
server: `${this.options.host}:${this.options.port}`,
|
|
|
|
|
dkimSigned: result.dkimSigned
|
|
|
|
|
},
|
|
|
|
|
success: true
|
|
|
|
|
});
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
catch (error) {
|
|
|
|
|
logger.log('error', `Failed to send email: ${error.message}`);
|
|
|
|
|
// Format error for result
|
|
|
|
|
result.error = error.message;
|
|
|
|
|
// Extract SMTP code if available
|
|
|
|
|
if (error.context?.data?.statusCode) {
|
|
|
|
|
result.responseCode = error.context.data.statusCode;
|
|
|
|
|
}
|
|
|
|
|
// Log security event
|
|
|
|
|
SecurityLogger.getInstance().logEvent({
|
|
|
|
|
level: SecurityLogLevel.ERROR,
|
|
|
|
|
type: SecurityEventType.EMAIL_DELIVERY,
|
|
|
|
|
message: 'Email delivery failed',
|
|
|
|
|
details: {
|
|
|
|
|
error: error.message,
|
|
|
|
|
server: `${this.options.host}:${this.options.port}`,
|
|
|
|
|
recipients: email.getAllRecipients(),
|
|
|
|
|
acceptedRecipients: result.acceptedRecipients,
|
|
|
|
|
rejectedRecipients: result.rejectedRecipients,
|
|
|
|
|
secure: result.secure,
|
|
|
|
|
authenticated: result.authenticated
|
|
|
|
|
},
|
|
|
|
|
success: false
|
|
|
|
|
});
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Apply DKIM signature to email
|
|
|
|
|
* @param email Email to sign
|
|
|
|
|
*/
|
|
|
|
|
async applyDkimSignature(email) {
|
|
|
|
|
if (!this.options.dkim?.enabled || !this.options.dkim?.privateKey) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
logger.log('debug', `Signing email with DKIM for domain ${this.options.dkim.domain}`);
|
|
|
|
|
const emailContent = await this.getFormattedEmail(email);
|
2026-02-10 20:30:43 +00:00
|
|
|
// Sign via Rust bridge
|
|
|
|
|
const bridge = RustSecurityBridge.getInstance();
|
|
|
|
|
const signResult = await bridge.signDkim({
|
|
|
|
|
rawMessage: emailContent,
|
|
|
|
|
domain: this.options.dkim.domain,
|
2026-02-10 15:54:09 +00:00
|
|
|
selector: this.options.dkim.selector,
|
|
|
|
|
privateKey: this.options.dkim.privateKey,
|
2026-02-10 20:30:43 +00:00
|
|
|
});
|
|
|
|
|
if (signResult.header) {
|
|
|
|
|
email.addHeader('DKIM-Signature', signResult.header);
|
2026-02-10 15:54:09 +00:00
|
|
|
}
|
|
|
|
|
logger.log('debug', 'DKIM signature applied successfully');
|
|
|
|
|
}
|
|
|
|
|
catch (error) {
|
|
|
|
|
logger.log('error', `Failed to apply DKIM signature: ${error.message}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Format email for SMTP transmission
|
|
|
|
|
* @param email Email to format
|
|
|
|
|
*/
|
|
|
|
|
async getFormattedEmail(email) {
|
|
|
|
|
// This is a simplified implementation
|
|
|
|
|
// In a full implementation, this would use proper MIME formatting
|
|
|
|
|
let content = '';
|
|
|
|
|
// Add headers
|
|
|
|
|
content += `From: ${email.from}\r\n`;
|
|
|
|
|
content += `To: ${email.to.join(', ')}\r\n`;
|
|
|
|
|
content += `Subject: ${email.subject}\r\n`;
|
|
|
|
|
content += `Date: ${new Date().toUTCString()}\r\n`;
|
|
|
|
|
content += `Message-ID: <${plugins.uuid.v4()}@${this.options.domain}>\r\n`;
|
|
|
|
|
// Add additional headers
|
|
|
|
|
for (const [name, value] of Object.entries(email.headers || {})) {
|
|
|
|
|
content += `${name}: ${value}\r\n`;
|
|
|
|
|
}
|
|
|
|
|
// Add content type for multipart
|
|
|
|
|
if (email.attachments && email.attachments.length > 0) {
|
|
|
|
|
const boundary = `----_=_NextPart_${Math.random().toString(36).substr(2)}`;
|
|
|
|
|
content += `MIME-Version: 1.0\r\n`;
|
|
|
|
|
content += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
|
|
|
|
|
content += `\r\n`;
|
|
|
|
|
// Add text part
|
|
|
|
|
content += `--${boundary}\r\n`;
|
|
|
|
|
content += `Content-Type: text/plain; charset="UTF-8"\r\n`;
|
|
|
|
|
content += `\r\n`;
|
|
|
|
|
content += `${email.text}\r\n`;
|
|
|
|
|
// Add HTML part if present
|
|
|
|
|
if (email.html) {
|
|
|
|
|
content += `--${boundary}\r\n`;
|
|
|
|
|
content += `Content-Type: text/html; charset="UTF-8"\r\n`;
|
|
|
|
|
content += `\r\n`;
|
|
|
|
|
content += `${email.html}\r\n`;
|
|
|
|
|
}
|
|
|
|
|
// Add attachments
|
|
|
|
|
for (const attachment of email.attachments) {
|
|
|
|
|
content += `--${boundary}\r\n`;
|
|
|
|
|
content += `Content-Type: ${attachment.contentType || 'application/octet-stream'}; name="${attachment.filename}"\r\n`;
|
|
|
|
|
content += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
|
|
|
|
|
content += `Content-Transfer-Encoding: base64\r\n`;
|
|
|
|
|
content += `\r\n`;
|
|
|
|
|
// Add base64 encoded content
|
|
|
|
|
const base64Content = attachment.content.toString('base64');
|
|
|
|
|
// Split into lines of 76 characters
|
|
|
|
|
for (let i = 0; i < base64Content.length; i += 76) {
|
|
|
|
|
content += base64Content.substring(i, i + 76) + '\r\n';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// End boundary
|
|
|
|
|
content += `--${boundary}--\r\n`;
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
// Simple email with just text
|
|
|
|
|
content += `Content-Type: text/plain; charset="UTF-8"\r\n`;
|
|
|
|
|
content += `\r\n`;
|
|
|
|
|
content += `${email.text}\r\n`;
|
|
|
|
|
}
|
|
|
|
|
return content;
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Get size of email in bytes
|
|
|
|
|
* @param email Email to measure
|
|
|
|
|
*/
|
|
|
|
|
getEmailSize(email) {
|
|
|
|
|
// Simplified size estimation
|
|
|
|
|
let size = 0;
|
|
|
|
|
// Headers
|
|
|
|
|
size += `From: ${email.from}\r\n`.length;
|
|
|
|
|
size += `To: ${email.to.join(', ')}\r\n`.length;
|
|
|
|
|
size += `Subject: ${email.subject}\r\n`.length;
|
|
|
|
|
// Body
|
|
|
|
|
size += (email.text?.length || 0) + 2; // +2 for CRLF
|
|
|
|
|
// HTML part if present
|
|
|
|
|
if (email.html) {
|
|
|
|
|
size += email.html.length + 2;
|
|
|
|
|
}
|
|
|
|
|
// Attachments
|
|
|
|
|
for (const attachment of email.attachments || []) {
|
|
|
|
|
size += attachment.content.length;
|
|
|
|
|
}
|
|
|
|
|
// Add overhead for MIME boundaries and headers
|
|
|
|
|
const overhead = email.attachments?.length ? 1000 + (email.attachments.length * 200) : 200;
|
|
|
|
|
return size + overhead;
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Send SMTP command and wait for response
|
|
|
|
|
* @param command SMTP command to send
|
|
|
|
|
*/
|
|
|
|
|
// Queue for command pipelining
|
|
|
|
|
commandQueue = [];
|
|
|
|
|
// Flag to indicate if we're currently processing commands
|
|
|
|
|
processingCommands = false;
|
|
|
|
|
// Flag to indicate if server supports pipelining
|
|
|
|
|
supportsPipelining = false;
|
|
|
|
|
/**
|
|
|
|
|
* Send an SMTP command and wait for response
|
|
|
|
|
* @param command SMTP command to send
|
|
|
|
|
* @param allowPipelining Whether this command can be pipelined
|
|
|
|
|
*/
|
|
|
|
|
async sendCommand(command, allowPipelining = true) {
|
|
|
|
|
if (!this.socket) {
|
|
|
|
|
throw new MtaConnectionError('Not connected to server', {
|
|
|
|
|
data: {
|
|
|
|
|
host: this.options.host,
|
|
|
|
|
port: this.options.port
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// Log command if not sensitive
|
|
|
|
|
if (!command.startsWith('AUTH')) {
|
|
|
|
|
logger.log('debug', `> ${command}`);
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
logger.log('debug', '> AUTH ***');
|
|
|
|
|
}
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
// Set up timeout for command
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
// Remove this command from the queue if it times out
|
|
|
|
|
const index = this.commandQueue.findIndex(item => item.command === command);
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
this.commandQueue.splice(index, 1);
|
|
|
|
|
}
|
|
|
|
|
reject(MtaTimeoutError.commandTimeout(command.split(' ')[0], this.options.host, this.options.commandTimeout));
|
|
|
|
|
}, this.options.commandTimeout);
|
|
|
|
|
// Add command to the queue
|
|
|
|
|
this.commandQueue.push({
|
|
|
|
|
command,
|
|
|
|
|
resolve,
|
|
|
|
|
reject,
|
|
|
|
|
timeout
|
|
|
|
|
});
|
|
|
|
|
// Process command queue if we can pipeline or if not currently processing commands
|
|
|
|
|
if ((this.supportsPipelining && allowPipelining) || !this.processingCommands) {
|
|
|
|
|
this.processCommandQueue();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Process the command queue - either one by one or pipelined if supported
|
|
|
|
|
*/
|
|
|
|
|
processCommandQueue() {
|
|
|
|
|
if (this.processingCommands || this.commandQueue.length === 0 || !this.socket) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.processingCommands = true;
|
|
|
|
|
try {
|
|
|
|
|
// If pipelining is supported, send all commands at once
|
|
|
|
|
if (this.supportsPipelining) {
|
|
|
|
|
// Send all commands in queue at once
|
|
|
|
|
const commands = this.commandQueue.map(item => item.command).join('\r\n') + '\r\n';
|
|
|
|
|
this.socket.write(commands, (err) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
// Handle write error for all commands
|
|
|
|
|
const error = new MtaConnectionError(`Failed to send commands: ${err.message}`, {
|
|
|
|
|
data: {
|
|
|
|
|
error: err.message
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// Fail all pending commands
|
|
|
|
|
while (this.commandQueue.length > 0) {
|
|
|
|
|
const item = this.commandQueue.shift();
|
|
|
|
|
clearTimeout(item.timeout);
|
|
|
|
|
item.reject(error);
|
|
|
|
|
}
|
|
|
|
|
this.processingCommands = false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// Process responses one by one in order
|
|
|
|
|
this.processResponses();
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
// Process commands one by one if pipelining not supported
|
|
|
|
|
this.processNextCommand();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (error) {
|
|
|
|
|
logger.log('error', `Error processing command queue: ${error.message}`);
|
|
|
|
|
this.processingCommands = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Process the next command in the queue (non-pipelined mode)
|
|
|
|
|
*/
|
|
|
|
|
processNextCommand() {
|
|
|
|
|
if (this.commandQueue.length === 0 || !this.socket) {
|
|
|
|
|
this.processingCommands = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const currentCommand = this.commandQueue[0];
|
|
|
|
|
this.socket.write(currentCommand.command + '\r\n', (err) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
// Handle write error
|
|
|
|
|
const error = new MtaConnectionError(`Failed to send command: ${err.message}`, {
|
|
|
|
|
data: {
|
|
|
|
|
command: currentCommand.command.split(' ')[0],
|
|
|
|
|
error: err.message
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// Remove from queue
|
|
|
|
|
this.commandQueue.shift();
|
|
|
|
|
clearTimeout(currentCommand.timeout);
|
|
|
|
|
currentCommand.reject(error);
|
|
|
|
|
// Continue with next command
|
|
|
|
|
this.processNextCommand();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Read response
|
|
|
|
|
this.readResponse()
|
|
|
|
|
.then((response) => {
|
|
|
|
|
// Remove from queue and resolve
|
|
|
|
|
this.commandQueue.shift();
|
|
|
|
|
clearTimeout(currentCommand.timeout);
|
|
|
|
|
currentCommand.resolve(response);
|
|
|
|
|
// Process next command
|
|
|
|
|
this.processNextCommand();
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
// Remove from queue and reject
|
|
|
|
|
this.commandQueue.shift();
|
|
|
|
|
clearTimeout(currentCommand.timeout);
|
|
|
|
|
currentCommand.reject(err);
|
|
|
|
|
// Process next command
|
|
|
|
|
this.processNextCommand();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Process responses for pipelined commands
|
|
|
|
|
*/
|
|
|
|
|
async processResponses() {
|
|
|
|
|
try {
|
|
|
|
|
// Process responses for each command in order
|
|
|
|
|
while (this.commandQueue.length > 0) {
|
|
|
|
|
const currentCommand = this.commandQueue[0];
|
|
|
|
|
try {
|
|
|
|
|
// Wait for response
|
|
|
|
|
const response = await this.readResponse();
|
|
|
|
|
// Remove from queue and resolve
|
|
|
|
|
this.commandQueue.shift();
|
|
|
|
|
clearTimeout(currentCommand.timeout);
|
|
|
|
|
currentCommand.resolve(response);
|
|
|
|
|
}
|
|
|
|
|
catch (error) {
|
|
|
|
|
// Remove from queue and reject
|
|
|
|
|
this.commandQueue.shift();
|
|
|
|
|
clearTimeout(currentCommand.timeout);
|
|
|
|
|
currentCommand.reject(error);
|
|
|
|
|
// Stop processing if this is a critical error
|
|
|
|
|
if (error instanceof MtaConnectionError &&
|
|
|
|
|
(error.message.includes('Connection closed') || error.message.includes('Not connected'))) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (error) {
|
|
|
|
|
logger.log('error', `Error processing responses: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
finally {
|
|
|
|
|
this.processingCommands = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Read response from the server
|
|
|
|
|
*/
|
|
|
|
|
async readResponse() {
|
|
|
|
|
if (!this.socket) {
|
|
|
|
|
throw new MtaConnectionError('Not connected to server', {
|
|
|
|
|
data: {
|
|
|
|
|
host: this.options.host,
|
|
|
|
|
port: this.options.port
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
// Use an array to collect response chunks instead of string concatenation
|
|
|
|
|
const responseChunks = [];
|
|
|
|
|
// Single function to clean up all listeners
|
|
|
|
|
const cleanupListeners = () => {
|
|
|
|
|
if (!this.socket)
|
|
|
|
|
return;
|
|
|
|
|
this.socket.removeListener('data', onData);
|
|
|
|
|
this.socket.removeListener('error', onError);
|
|
|
|
|
this.socket.removeListener('close', onClose);
|
|
|
|
|
this.socket.removeListener('end', onEnd);
|
|
|
|
|
};
|
|
|
|
|
const onData = (data) => {
|
|
|
|
|
// Store buffer directly, avoiding unnecessary string conversion
|
|
|
|
|
responseChunks.push(data);
|
|
|
|
|
// Convert to string only for response checking
|
|
|
|
|
const responseData = Buffer.concat(responseChunks).toString();
|
|
|
|
|
// Check if this is a complete response
|
|
|
|
|
if (this.isCompleteResponse(responseData)) {
|
|
|
|
|
// Clean up listeners
|
|
|
|
|
cleanupListeners();
|
|
|
|
|
const trimmedResponse = responseData.trim();
|
|
|
|
|
logger.log('debug', `< ${trimmedResponse}`);
|
|
|
|
|
// Check if this is an error response
|
|
|
|
|
if (this.isErrorResponse(responseData)) {
|
|
|
|
|
const code = responseData.substring(0, 3);
|
|
|
|
|
reject(this.createErrorFromResponse(trimmedResponse, code));
|
|
|
|
|
}
|
|
|
|
|
else {
|
|
|
|
|
resolve(trimmedResponse);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const onError = (err) => {
|
|
|
|
|
cleanupListeners();
|
|
|
|
|
reject(new MtaConnectionError(`Socket error while waiting for response: ${err.message}`, {
|
|
|
|
|
data: {
|
|
|
|
|
error: err.message
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
const onClose = () => {
|
|
|
|
|
cleanupListeners();
|
|
|
|
|
const responseData = Buffer.concat(responseChunks).toString();
|
|
|
|
|
reject(new MtaConnectionError('Connection closed while waiting for response', {
|
|
|
|
|
data: {
|
|
|
|
|
partialResponse: responseData
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
const onEnd = () => {
|
|
|
|
|
cleanupListeners();
|
|
|
|
|
const responseData = Buffer.concat(responseChunks).toString();
|
|
|
|
|
reject(new MtaConnectionError('Connection ended while waiting for response', {
|
|
|
|
|
data: {
|
|
|
|
|
partialResponse: responseData
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
// Set up listeners
|
|
|
|
|
this.socket.on('data', onData);
|
|
|
|
|
this.socket.once('error', onError);
|
|
|
|
|
this.socket.once('close', onClose);
|
|
|
|
|
this.socket.once('end', onEnd);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Check if the response is complete
|
|
|
|
|
* @param response Response to check
|
|
|
|
|
*/
|
|
|
|
|
isCompleteResponse(response) {
|
|
|
|
|
// Check if it's a multi-line response
|
|
|
|
|
const lines = response.split('\r\n');
|
|
|
|
|
const lastLine = lines[lines.length - 2]; // Second to last because of the trailing CRLF
|
|
|
|
|
// Check if the last line starts with a code followed by a space
|
|
|
|
|
// If it does, this is a complete response
|
|
|
|
|
if (lastLine && /^\d{3} /.test(lastLine)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
// For single line responses
|
|
|
|
|
if (lines.length === 2 && lines[0].length >= 3 && /^\d{3} /.test(lines[0])) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Check if the response is an error
|
|
|
|
|
* @param response Response to check
|
|
|
|
|
*/
|
|
|
|
|
isErrorResponse(response) {
|
|
|
|
|
// Get the status code (first 3 characters)
|
|
|
|
|
const code = response.substring(0, 3);
|
|
|
|
|
// 4xx and 5xx are error codes
|
|
|
|
|
return code.startsWith('4') || code.startsWith('5');
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Create appropriate error from response
|
|
|
|
|
* @param response Error response
|
|
|
|
|
* @param code SMTP status code
|
|
|
|
|
*/
|
|
|
|
|
createErrorFromResponse(response, code) {
|
|
|
|
|
// Extract message part
|
|
|
|
|
const message = response.substring(4).trim();
|
|
|
|
|
switch (code.charAt(0)) {
|
|
|
|
|
case '4': // Temporary errors
|
|
|
|
|
return MtaDeliveryError.temporary(message, 'recipient', code, response);
|
|
|
|
|
case '5': // Permanent errors
|
|
|
|
|
return MtaDeliveryError.permanent(message, 'recipient', code, response);
|
|
|
|
|
default:
|
|
|
|
|
return new MtaDeliveryError(`Unexpected error response: ${response}`, {
|
|
|
|
|
data: {
|
|
|
|
|
response,
|
|
|
|
|
code
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Close the connection to the server
|
|
|
|
|
*/
|
|
|
|
|
async close() {
|
|
|
|
|
if (!this.connected || !this.socket) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
// Send QUIT
|
|
|
|
|
await this.sendCommand('QUIT');
|
|
|
|
|
}
|
|
|
|
|
catch (error) {
|
|
|
|
|
logger.log('warn', `Error sending QUIT command: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
finally {
|
|
|
|
|
// Close socket
|
|
|
|
|
this.socket.destroy();
|
|
|
|
|
this.socket = undefined;
|
|
|
|
|
this.connected = false;
|
|
|
|
|
logger.log('info', 'SMTP connection closed');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Checks if the connection is active
|
|
|
|
|
*/
|
|
|
|
|
isConnected() {
|
|
|
|
|
return this.connected && !!this.socket;
|
|
|
|
|
}
|
|
|
|
|
/**
|
|
|
|
|
* Update SMTP client options
|
|
|
|
|
* @param options New options
|
|
|
|
|
*/
|
|
|
|
|
updateOptions(options) {
|
|
|
|
|
this.options = {
|
|
|
|
|
...this.options,
|
|
|
|
|
...options
|
|
|
|
|
};
|
|
|
|
|
logger.log('info', 'SMTP client options updated');
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-10 20:30:43 +00:00
|
|
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5zbXRwLmNsaWVudC5sZWdhY3kuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2NsYXNzZXMuc210cC5jbGllbnQubGVnYWN5LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFDNUMsT0FBTyxFQUFFLE1BQU0sRUFBRSxNQUFNLGlCQUFpQixDQUFDO0FBQ3pDLE9BQU8sRUFDTCxjQUFjLEVBQ2QsZ0JBQWdCLEVBQ2hCLGlCQUFpQixFQUNsQixNQUFNLHlCQUF5QixDQUFDO0FBQ2pDLE9BQU8sRUFBRSxrQkFBa0IsRUFBRSxNQUFNLDhDQUE4QyxDQUFDO0FBRWxGLE9BQU8sRUFDTCxrQkFBa0IsRUFDbEIsc0JBQXNCLEVBQ3RCLGdCQUFnQixFQUNoQixxQkFBcUIsRUFDckIsZUFBZSxFQUNmLGdCQUFnQixFQUNqQixNQUFNLHVCQUF1QixDQUFDO0FBRS9CLE9BQU8sRUFBRSxLQUFLLEVBQUUsTUFBTSwwQkFBMEIsQ0FBQztBQWtMakQ7O0dBRUc7QUFDSCxNQUFNLE9BQU8sVUFBVTtJQUNiLE9BQU8sQ0FBcUI7SUFDNUIsU0FBUyxHQUFZLEtBQUssQ0FBQztJQUMzQixNQUFNLENBQThDO0lBQ3BELG1CQUFtQixHQUFnQixJQUFJLEdBQUcsRUFBRSxDQUFDO0lBRXJEOzs7T0FHRztJQUNILFlBQVksT0FBMkI7UUFDckMsc0JBQXNCO1FBQ3RCLElBQUksQ0FBQyxPQUFPLEdBQUc7WUFDYixHQUFHLE9BQU87WUFDVixpQkFBaUIsRUFBRSxPQUFPLENBQUMsaUJBQWlCLElBQUksS0FBSyxFQUFFLGFBQWE7WUFDcEUsYUFBYSxFQUFFLE9BQU8sQ0FBQyxhQUFhLElBQUksS0FBSyxFQUFFLGFBQWE7WUFDNUQsY0FBYyxFQUFFLE9BQU8sQ0FBQyxjQUFjLElBQUksS0FBSyxFQUFFLGFBQWE7WUFDOUQsTUFBTSxFQUFFLE9BQU8sQ0FB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
|