feat: Implement SMTP over WebSocket client with error handling and Nodemailer transport
- Added error classes for various SMTP-related issues (ConnectionError, AuthenticationError, etc.) in `errors.ts`. - Created main entry point for the SMTP over WebSocket client library in `index.ts`, exporting client, types, errors, and transport. - Developed Nodemailer transport adapter for SMTP over WebSocket in `transport.ts`, including methods for sending mail and verifying transport configuration. - Defined type definitions for the SMTP over WebSocket protocol in `types.ts`, including message types, connection states, and client configuration options.
This commit is contained in:
parent
619cb97fa3
commit
d059b80682
6 changed files with 1836 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -8,7 +8,6 @@ yarn-error.log*
|
|||
lib/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
src/
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
|
|
951
src/client.ts
Normal file
951
src/client.ts
Normal file
|
@ -0,0 +1,951 @@
|
|||
/**
|
||||
* @fileoverview SMTP over WebSocket client with intelligent queue management
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import { EventEmitter } from 'events';
|
||||
import {
|
||||
SMTPOverWsMessage,
|
||||
SMTPOverWsMessageType,
|
||||
ConnectionState,
|
||||
QueuedMessage,
|
||||
MessagePriority,
|
||||
SMTPClientConfig,
|
||||
Logger,
|
||||
ClientStats,
|
||||
ClientEvents,
|
||||
SendOptions,
|
||||
AuthenticateMessage,
|
||||
SMTPChannelOpenMessage,
|
||||
SMTPToServerMessage,
|
||||
AuthenticateResponseMessage,
|
||||
SMTPChannelErrorMessage,
|
||||
SMTPFromServerMessage
|
||||
} from './types';
|
||||
import {
|
||||
SMTPWSError,
|
||||
ConnectionError,
|
||||
AuthenticationError,
|
||||
ChannelError,
|
||||
TimeoutError,
|
||||
QueueError,
|
||||
MessageError,
|
||||
ShutdownError,
|
||||
ErrorFactory
|
||||
} from './errors';
|
||||
|
||||
/**
|
||||
* Default logger implementation
|
||||
*/
|
||||
class DefaultLogger implements Logger {
|
||||
constructor(private enableDebug: boolean = false) {}
|
||||
|
||||
debug(message: string, ...args: any[]): void {
|
||||
if (this.enableDebug) {
|
||||
console.debug(`[SMTP-WS-DEBUG] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string, ...args: any[]): void {
|
||||
if (this.enableDebug) {
|
||||
console.info(`[SMTP-WS-INFO] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string, ...args: any[]): void {
|
||||
if (this.enableDebug) {
|
||||
console.warn(`[SMTP-WS-WARN] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
error(message: string, ...args: any[]): void {
|
||||
if (this.enableDebug) {
|
||||
console.error(`[SMTP-WS-ERROR] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP over WebSocket client with intelligent queue management and automatic reconnection
|
||||
*/
|
||||
export class SMTPOverWSClient extends EventEmitter {
|
||||
private config: Required<SMTPClientConfig>;
|
||||
private ws: WebSocket | null = null;
|
||||
private state: ConnectionState = ConnectionState.DISCONNECTED;
|
||||
private messageQueue: QueuedMessage[] = [];
|
||||
private currentMessage: QueuedMessage | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private authTimer: NodeJS.Timeout | null = null;
|
||||
private channelTimer: NodeJS.Timeout | null = null;
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
private messageTimer: NodeJS.Timeout | null = null;
|
||||
private isProcessingQueue = false;
|
||||
private isShuttingDown = false;
|
||||
private logger: Logger;
|
||||
private stats: ClientStats;
|
||||
private connectionStartTime: number = 0;
|
||||
private messageIdCounter = 0;
|
||||
|
||||
constructor(config: SMTPClientConfig) {
|
||||
super();
|
||||
|
||||
// Validate configuration
|
||||
this.validateConfig(config);
|
||||
|
||||
// Set defaults
|
||||
this.config = {
|
||||
reconnectInterval: 5000,
|
||||
maxReconnectAttempts: 10,
|
||||
authTimeout: 30000,
|
||||
channelTimeout: 10000,
|
||||
messageTimeout: 60000,
|
||||
maxConcurrentMessages: 1,
|
||||
debug: false,
|
||||
heartbeatInterval: 30000,
|
||||
maxQueueSize: 1000,
|
||||
...config,
|
||||
logger: config.logger || new DefaultLogger(config.debug ?? false)
|
||||
};
|
||||
|
||||
this.logger = this.config.logger;
|
||||
this.stats = this.initializeStats();
|
||||
|
||||
this.logger.debug('SMTP WebSocket client initialized', {
|
||||
url: this.config.url,
|
||||
maxQueueSize: this.config.maxQueueSize
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMTP command with automatic queue management
|
||||
*/
|
||||
public async sendSMTPCommand(data: string, options: SendOptions = {}): Promise<string> {
|
||||
if (this.isShuttingDown) {
|
||||
throw new ShutdownError('Client is shutting down');
|
||||
}
|
||||
|
||||
// Check queue size limit
|
||||
if (this.messageQueue.length >= this.config.maxQueueSize) {
|
||||
throw ErrorFactory.queueError(
|
||||
`Queue is full (${this.config.maxQueueSize} messages)`,
|
||||
this.messageQueue.length
|
||||
);
|
||||
}
|
||||
|
||||
const messageId = this.generateMessageId();
|
||||
const priority = options.priority ?? MessagePriority.NORMAL;
|
||||
const timeout = options.timeout ?? this.config.messageTimeout;
|
||||
const retries = options.retries ?? 3;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const queuedMessage: QueuedMessage = {
|
||||
id: messageId,
|
||||
data,
|
||||
resolve,
|
||||
reject,
|
||||
timestamp: Date.now(),
|
||||
retries,
|
||||
priority
|
||||
};
|
||||
|
||||
// Insert message based on priority
|
||||
this.insertMessageByPriority(queuedMessage);
|
||||
|
||||
this.stats.messagesQueued++;
|
||||
this.emit('messageQueued', messageId, this.messageQueue.length);
|
||||
|
||||
this.logger.debug('Message queued', {
|
||||
messageId,
|
||||
priority,
|
||||
queueSize: this.messageQueue.length,
|
||||
data: data.substring(0, 100) + (data.length > 100 ? '...' : '')
|
||||
});
|
||||
|
||||
// Set message timeout
|
||||
setTimeout(() => {
|
||||
if (this.messageQueue.includes(queuedMessage) || this.currentMessage === queuedMessage) {
|
||||
const error = ErrorFactory.timeout('Message', timeout, { messageId });
|
||||
this.removeMessageFromQueue(messageId);
|
||||
reject(error);
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
// Start processing if not already running
|
||||
if (!this.isProcessingQueue) {
|
||||
this.processQueue().catch(error => {
|
||||
this.logger.error('Queue processing failed', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current client statistics
|
||||
*/
|
||||
public getStats(): ClientStats {
|
||||
return {
|
||||
...this.stats,
|
||||
queueSize: this.messageQueue.length,
|
||||
connectionUptime: this.connectionStartTime > 0 ? Date.now() - this.connectionStartTime : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current connection state
|
||||
*/
|
||||
public getConnectionState(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current queue size
|
||||
*/
|
||||
public getQueueSize(): number {
|
||||
return this.messageQueue.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all queued messages
|
||||
*/
|
||||
public clearQueue(): void {
|
||||
const clearedCount = this.messageQueue.length;
|
||||
|
||||
// Reject all queued messages
|
||||
for (const message of this.messageQueue) {
|
||||
message.reject(new QueueError('Queue cleared'));
|
||||
}
|
||||
|
||||
this.messageQueue = [];
|
||||
this.logger.info('Queue cleared', { clearedCount });
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown of the client
|
||||
*/
|
||||
public async shutdown(timeout: number = 30000): Promise<void> {
|
||||
if (this.isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isShuttingDown = true;
|
||||
this.logger.info('Initiating client shutdown', { queueSize: this.messageQueue.length });
|
||||
|
||||
// Stop accepting new messages and clear timers
|
||||
this.clearTimers();
|
||||
|
||||
try {
|
||||
// Wait for current queue processing to complete or timeout
|
||||
if (this.isProcessingQueue) {
|
||||
await Promise.race([
|
||||
this.waitForQueueCompletion(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new TimeoutError('Shutdown timeout', timeout)), timeout)
|
||||
)
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn('Shutdown timeout reached, forcing shutdown', error);
|
||||
}
|
||||
|
||||
// Reject any remaining messages
|
||||
this.rejectAllQueuedMessages(new ShutdownError('Client shutting down'));
|
||||
|
||||
// Close connection
|
||||
await this.disconnect();
|
||||
|
||||
// Remove all listeners
|
||||
this.removeAllListeners();
|
||||
|
||||
this.logger.info('Client shutdown completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the message queue
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isProcessingQueue || this.messageQueue.length === 0 || this.isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessingQueue = true;
|
||||
this.emit('queueProcessingStarted', this.messageQueue.length);
|
||||
|
||||
this.logger.info('Queue processing started', { queueSize: this.messageQueue.length });
|
||||
|
||||
let processed = 0;
|
||||
let failed = 0;
|
||||
|
||||
try {
|
||||
// Connect if not connected
|
||||
if (this.state === ConnectionState.DISCONNECTED) {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
// Process messages sequentially one at a time
|
||||
while (this.messageQueue.length > 0 && !this.isShuttingDown) {
|
||||
const message = this.messageQueue.shift()!;
|
||||
|
||||
try {
|
||||
this.currentMessage = message;
|
||||
const startTime = Date.now();
|
||||
|
||||
await this.processMessage(message);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
this.stats.messagesProcessed++;
|
||||
this.stats.averageResponseTime =
|
||||
(this.stats.averageResponseTime * (this.stats.messagesProcessed - 1) + responseTime) /
|
||||
this.stats.messagesProcessed;
|
||||
|
||||
this.emit('messageProcessed', message.id, responseTime);
|
||||
this.logger.debug('Message processed successfully', {
|
||||
messageId: message.id,
|
||||
responseTime
|
||||
});
|
||||
|
||||
processed++;
|
||||
} catch (error) {
|
||||
this.stats.messagesFailed++;
|
||||
this.stats.lastError = (error as Error).message;
|
||||
this.stats.lastErrorTime = new Date();
|
||||
|
||||
this.emit('messageFailed', message.id, error as Error);
|
||||
this.logger.error('Message processing failed', {
|
||||
messageId: message.id,
|
||||
error: (error as Error).message
|
||||
});
|
||||
|
||||
failed++;
|
||||
message.reject(error as Error);
|
||||
} finally {
|
||||
this.currentMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('Queue processing error', error);
|
||||
this.rejectAllQueuedMessages(error as Error);
|
||||
failed += this.messageQueue.length;
|
||||
} finally {
|
||||
this.isProcessingQueue = false;
|
||||
|
||||
// Disconnect if queue is empty and not shutting down
|
||||
if (this.messageQueue.length === 0 && !this.isShuttingDown) {
|
||||
await this.disconnect();
|
||||
}
|
||||
|
||||
this.emit('queueProcessingCompleted', processed, failed);
|
||||
this.logger.info('Queue processing completed', { processed, failed });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single message
|
||||
*/
|
||||
private async processMessage(message: QueuedMessage): Promise<void> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
// Open SMTP channel
|
||||
await this.openSMTPChannel();
|
||||
|
||||
// Wait for SMTP response
|
||||
const responsePromise = this.waitForSMTPResponse(message.id);
|
||||
|
||||
// Send SMTP data
|
||||
this.sendSMTPData(message.data);
|
||||
|
||||
// Wait for response
|
||||
const response = await responsePromise;
|
||||
message.resolve(response);
|
||||
|
||||
// Close SMTP channel
|
||||
await this.closeSMTPChannel();
|
||||
|
||||
resolve();
|
||||
|
||||
} catch (error) {
|
||||
// Retry logic
|
||||
if (message.retries > 0) {
|
||||
message.retries--;
|
||||
this.logger.debug('Retrying message', {
|
||||
messageId: message.id,
|
||||
retriesLeft: message.retries
|
||||
});
|
||||
|
||||
// Re-queue message
|
||||
this.insertMessageByPriority(message);
|
||||
resolve();
|
||||
} else {
|
||||
reject(ErrorFactory.messageError(
|
||||
(error as Error).message,
|
||||
message.id,
|
||||
message.retries
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
private async connect(): Promise<void> {
|
||||
if (this.state !== ConnectionState.DISCONNECTED && this.state !== ConnectionState.FAILED) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(ConnectionState.CONNECTING);
|
||||
this.logger.debug('Connecting to WebSocket server', { url: this.config.url });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new WebSocket(this.config.url);
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (this.ws) {
|
||||
this.ws.terminate();
|
||||
}
|
||||
reject(ErrorFactory.timeout('Connection', this.config.authTimeout));
|
||||
}, this.config.authTimeout);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
this.connectionStartTime = Date.now();
|
||||
this.stats.totalConnections++;
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
this.setState(ConnectionState.CONNECTED);
|
||||
this.emit('connected');
|
||||
this.logger.info('WebSocket connected');
|
||||
|
||||
// Start heartbeat
|
||||
this.startHeartbeat();
|
||||
|
||||
// Authenticate
|
||||
this.authenticate().then(resolve).catch(reject);
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
this.handleMessage(data.toString());
|
||||
});
|
||||
|
||||
this.ws.on('close', (code, reason) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
this.logger.info('WebSocket closed', { code, reason: reason.toString() });
|
||||
this.handleDisconnection(`Connection closed: ${code} ${reason}`);
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
this.logger.error('WebSocket error', error);
|
||||
const wsError = ErrorFactory.fromWebSocketError(error);
|
||||
this.emit('error', wsError);
|
||||
reject(wsError);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
reject(ErrorFactory.fromWebSocketError(error as Error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with the server
|
||||
*/
|
||||
private async authenticate(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.setState(ConnectionState.AUTHENTICATING);
|
||||
this.logger.debug('Authenticating with server');
|
||||
|
||||
this.authTimer = setTimeout(() => {
|
||||
reject(ErrorFactory.timeout('Authentication', this.config.authTimeout));
|
||||
}, this.config.authTimeout);
|
||||
|
||||
const authMessage: AuthenticateMessage = {
|
||||
type: SMTPOverWsMessageType.AUTHENTICATE,
|
||||
data: { apiKey: this.config.apiKey }
|
||||
};
|
||||
|
||||
this.sendMessage(authMessage);
|
||||
|
||||
const onAuthResponse = (message: AuthenticateResponseMessage) => {
|
||||
if (this.authTimer) {
|
||||
clearTimeout(this.authTimer);
|
||||
this.authTimer = null;
|
||||
}
|
||||
|
||||
if (message.data.success) {
|
||||
this.setState(ConnectionState.AUTHENTICATED);
|
||||
this.emit('authenticated');
|
||||
this.logger.info('Authentication successful');
|
||||
resolve();
|
||||
} else {
|
||||
const error = ErrorFactory.fromAuthenticationFailure(message.data.error);
|
||||
this.emit('error', error);
|
||||
this.logger.error('Authentication failed', { error: message.data.error });
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
this.once('authenticate_response', onAuthResponse);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open SMTP channel
|
||||
*/
|
||||
private async openSMTPChannel(): Promise<void> {
|
||||
if (this.state !== ConnectionState.AUTHENTICATED) {
|
||||
throw new ChannelError('Cannot open SMTP channel: not authenticated');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.setState(ConnectionState.CHANNEL_OPENING);
|
||||
this.logger.debug('Opening SMTP channel');
|
||||
|
||||
this.channelTimer = setTimeout(() => {
|
||||
reject(ErrorFactory.timeout('Channel open', this.config.channelTimeout));
|
||||
}, this.config.channelTimeout);
|
||||
|
||||
const openMessage: SMTPChannelOpenMessage = {
|
||||
type: SMTPOverWsMessageType.SMTP_CHANNEL_OPEN
|
||||
};
|
||||
|
||||
this.sendMessage(openMessage);
|
||||
|
||||
const onChannelReady = () => {
|
||||
// Channel is ready, now wait for SMTP 220 greeting
|
||||
this.logger.debug('Channel ready, waiting for SMTP greeting');
|
||||
|
||||
const onGreeting = async (message: SMTPFromServerMessage) => {
|
||||
this.logger.debug('RX SMTP greeting', {
|
||||
response: message.data.trim(),
|
||||
size: message.data.length
|
||||
});
|
||||
|
||||
// Check if it's a 220 greeting
|
||||
if (message.data.startsWith('220')) {
|
||||
try {
|
||||
// Automatically send EHLO after greeting
|
||||
this.logger.debug('Auto-sending EHLO after greeting');
|
||||
this.sendSMTPData('EHLO client\r\n');
|
||||
|
||||
// Wait for EHLO response
|
||||
const onEhloResponse = (ehloMessage: SMTPFromServerMessage) => {
|
||||
if (this.channelTimer) {
|
||||
clearTimeout(this.channelTimer);
|
||||
this.channelTimer = null;
|
||||
}
|
||||
|
||||
this.logger.debug('RX SMTP EHLO response', {
|
||||
response: ehloMessage.data.trim(),
|
||||
size: ehloMessage.data.length
|
||||
});
|
||||
|
||||
if (ehloMessage.data.startsWith('250')) {
|
||||
this.setState(ConnectionState.CHANNEL_READY);
|
||||
this.emit('channelOpened');
|
||||
this.logger.debug('SMTP channel ready after EHLO');
|
||||
resolve();
|
||||
} else {
|
||||
const error = ErrorFactory.fromChannelFailure(`EHLO rejected: ${ehloMessage.data.trim()}`);
|
||||
this.emit('channelError', error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
this.once('smtp_from_server', onEhloResponse);
|
||||
|
||||
} catch (error) {
|
||||
if (this.channelTimer) {
|
||||
clearTimeout(this.channelTimer);
|
||||
this.channelTimer = null;
|
||||
}
|
||||
const channelError = ErrorFactory.fromChannelFailure(`Failed to send EHLO: ${(error as Error).message}`);
|
||||
this.emit('channelError', channelError);
|
||||
reject(channelError);
|
||||
}
|
||||
} else {
|
||||
if (this.channelTimer) {
|
||||
clearTimeout(this.channelTimer);
|
||||
this.channelTimer = null;
|
||||
}
|
||||
const error = ErrorFactory.fromChannelFailure(`Expected 220 greeting, got: ${message.data.trim()}`);
|
||||
this.emit('channelError', error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
this.once('smtp_from_server', onGreeting);
|
||||
};
|
||||
|
||||
const onChannelError = (message: SMTPChannelErrorMessage) => {
|
||||
if (this.channelTimer) {
|
||||
clearTimeout(this.channelTimer);
|
||||
this.channelTimer = null;
|
||||
}
|
||||
this.setState(ConnectionState.CHANNEL_ERROR);
|
||||
const error = ErrorFactory.fromChannelFailure(message.data.error);
|
||||
this.emit('channelError', error);
|
||||
this.logger.error('SMTP channel error', { error: message.data.error });
|
||||
reject(error);
|
||||
};
|
||||
|
||||
this.once('smtp_channel_ready', onChannelReady);
|
||||
this.once('smtp_channel_error', onChannelError);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close SMTP channel
|
||||
*/
|
||||
private async closeSMTPChannel(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.state === ConnectionState.CHANNEL_READY) {
|
||||
const onChannelClosed = () => {
|
||||
this.setState(ConnectionState.AUTHENTICATED);
|
||||
this.emit('channelClosed');
|
||||
this.logger.debug('SMTP channel closed');
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.once('smtp_channel_closed', onChannelClosed);
|
||||
|
||||
// Fallback timeout
|
||||
setTimeout(() => {
|
||||
this.removeListener('smtp_channel_closed', onChannelClosed);
|
||||
this.setState(ConnectionState.AUTHENTICATED);
|
||||
resolve();
|
||||
}, 5000);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMTP data to server
|
||||
*/
|
||||
private sendSMTPData(data: string): void {
|
||||
const message: SMTPToServerMessage = {
|
||||
type: SMTPOverWsMessageType.SMTP_TO_SERVER,
|
||||
data
|
||||
};
|
||||
this.logger.debug('TX SMTP command', {
|
||||
command: data.trim(),
|
||||
size: data.length
|
||||
});
|
||||
this.sendMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for SMTP response
|
||||
*/
|
||||
private waitForSMTPResponse(messageId: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(ErrorFactory.timeout('SMTP response', this.config.messageTimeout, { messageId }));
|
||||
}, this.config.messageTimeout);
|
||||
|
||||
const onResponse = (message: SMTPFromServerMessage) => {
|
||||
clearTimeout(timeout);
|
||||
this.logger.debug('RX SMTP response', {
|
||||
messageId,
|
||||
response: message.data.trim(),
|
||||
size: message.data.length
|
||||
});
|
||||
resolve(message.data);
|
||||
};
|
||||
|
||||
this.once('smtp_from_server', onResponse);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send WebSocket message
|
||||
*/
|
||||
private sendMessage(message: SMTPOverWsMessage): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
const messageStr = JSON.stringify(message);
|
||||
this.logger.debug('TX WebSocket message', {
|
||||
type: message.type,
|
||||
data: message.type === 'authenticate' ? '[REDACTED]' : (message as any).data,
|
||||
raw: messageStr.length > 200 ? messageStr.substring(0, 200) + '...' : messageStr
|
||||
});
|
||||
this.ws.send(messageStr);
|
||||
} else {
|
||||
throw new ConnectionError('WebSocket is not connected');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming WebSocket message
|
||||
*/
|
||||
private handleMessage(data: string): void {
|
||||
try {
|
||||
const message: SMTPOverWsMessage = JSON.parse(data);
|
||||
this.logger.debug('RX WebSocket message', {
|
||||
type: message.type,
|
||||
data: message.type === 'authenticate_response' ? '[REDACTED]' : (message as any).data,
|
||||
raw: data.length > 200 ? data.substring(0, 200) + '...' : data
|
||||
});
|
||||
// Use setImmediate to avoid catching errors from event handlers
|
||||
setImmediate(() => {
|
||||
(this as any).emit(message.type, message);
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to parse WebSocket message', { data, error });
|
||||
this.emit('error', ErrorFactory.protocolError('Invalid message format', undefined, { data }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket disconnection
|
||||
*/
|
||||
private handleDisconnection(reason?: string): void {
|
||||
this.setState(ConnectionState.DISCONNECTED);
|
||||
this.emit('disconnected', reason);
|
||||
this.stopHeartbeat();
|
||||
this.clearTimers();
|
||||
|
||||
if (this.isProcessingQueue && !this.isShuttingDown &&
|
||||
this.reconnectAttempts < this.config.maxReconnectAttempts) {
|
||||
this.scheduleReconnect();
|
||||
} else if (this.isProcessingQueue && !this.isShuttingDown) {
|
||||
this.logger.error('Max reconnection attempts reached');
|
||||
this.setState(ConnectionState.FAILED);
|
||||
this.rejectAllQueuedMessages(new ConnectionError('Max reconnection attempts reached'));
|
||||
this.isProcessingQueue = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection attempt
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
this.reconnectAttempts++;
|
||||
this.stats.reconnectionAttempts++;
|
||||
this.setState(ConnectionState.RECONNECTING);
|
||||
|
||||
this.emit('reconnecting', this.reconnectAttempts, this.config.maxReconnectAttempts);
|
||||
this.logger.info('Scheduling reconnection', {
|
||||
attempt: this.reconnectAttempts,
|
||||
maxAttempts: this.config.maxReconnectAttempts,
|
||||
delay: this.config.reconnectInterval
|
||||
});
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect()
|
||||
.then(() => {
|
||||
this.emit('reconnected');
|
||||
this.logger.info('Reconnection successful');
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error('Reconnection failed', error);
|
||||
this.handleDisconnection('Reconnection failed');
|
||||
});
|
||||
}, this.config.reconnectInterval * Math.min(this.reconnectAttempts, 5)); // Exponential backoff
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat timer
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
if (this.config.heartbeatInterval > 0) {
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.ping();
|
||||
}
|
||||
}, this.config.heartbeatInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat timer
|
||||
*/
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all timers
|
||||
*/
|
||||
private clearTimers(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.authTimer) {
|
||||
clearTimeout(this.authTimer);
|
||||
this.authTimer = null;
|
||||
}
|
||||
|
||||
if (this.channelTimer) {
|
||||
clearTimeout(this.channelTimer);
|
||||
this.channelTimer = null;
|
||||
}
|
||||
|
||||
if (this.messageTimer) {
|
||||
clearTimeout(this.messageTimer);
|
||||
this.messageTimer = null;
|
||||
}
|
||||
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set connection state and emit event
|
||||
*/
|
||||
private setState(newState: ConnectionState): void {
|
||||
if (this.state !== newState) {
|
||||
const oldState = this.state;
|
||||
this.state = newState;
|
||||
this.emit('stateChanged', oldState, newState);
|
||||
this.logger.debug('State changed', { from: oldState, to: newState });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject all queued messages
|
||||
*/
|
||||
private rejectAllQueuedMessages(error: Error): void {
|
||||
if (this.currentMessage) {
|
||||
this.currentMessage.reject(error);
|
||||
this.currentMessage = null;
|
||||
}
|
||||
|
||||
while (this.messageQueue.length > 0) {
|
||||
const message = this.messageQueue.shift()!;
|
||||
message.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert message into queue based on priority
|
||||
*/
|
||||
private insertMessageByPriority(message: QueuedMessage): void {
|
||||
let insertIndex = this.messageQueue.length;
|
||||
|
||||
// Find insertion point based on priority
|
||||
for (let i = 0; i < this.messageQueue.length; i++) {
|
||||
if (message.priority > this.messageQueue[i]!.priority) {
|
||||
insertIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.messageQueue.splice(insertIndex, 0, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove message from queue by ID
|
||||
*/
|
||||
private removeMessageFromQueue(messageId: string): boolean {
|
||||
const index = this.messageQueue.findIndex(msg => msg.id === messageId);
|
||||
if (index !== -1) {
|
||||
this.messageQueue.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique message ID
|
||||
*/
|
||||
private generateMessageId(): string {
|
||||
return `msg_${Date.now()}_${++this.messageIdCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate client configuration
|
||||
*/
|
||||
private validateConfig(config: SMTPClientConfig): void {
|
||||
if (!config.url) {
|
||||
throw ErrorFactory.configurationError('URL is required', 'url');
|
||||
}
|
||||
|
||||
if (!config.apiKey) {
|
||||
throw ErrorFactory.configurationError('API key is required', 'apiKey');
|
||||
}
|
||||
|
||||
if (config.reconnectInterval && config.reconnectInterval < 1000) {
|
||||
throw ErrorFactory.configurationError('Reconnect interval must be at least 1000ms', 'reconnectInterval');
|
||||
}
|
||||
|
||||
if (config.maxReconnectAttempts && config.maxReconnectAttempts < 1) {
|
||||
throw ErrorFactory.configurationError('Max reconnect attempts must be at least 1', 'maxReconnectAttempts');
|
||||
}
|
||||
|
||||
if (config.maxQueueSize && config.maxQueueSize < 1) {
|
||||
throw ErrorFactory.configurationError('Max queue size must be at least 1', 'maxQueueSize');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize statistics
|
||||
*/
|
||||
private initializeStats(): ClientStats {
|
||||
return {
|
||||
messagesQueued: 0,
|
||||
messagesProcessed: 0,
|
||||
messagesFailed: 0,
|
||||
reconnectionAttempts: 0,
|
||||
totalConnections: 0,
|
||||
averageResponseTime: 0,
|
||||
queueSize: 0,
|
||||
connectionUptime: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for queue processing to complete
|
||||
*/
|
||||
private waitForQueueCompletion(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.isProcessingQueue) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const onComplete = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.once('queueProcessingCompleted', onComplete);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from WebSocket server
|
||||
*/
|
||||
private async disconnect(): Promise<void> {
|
||||
this.clearTimers();
|
||||
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.close();
|
||||
}
|
||||
|
||||
this.ws = null;
|
||||
this.setState(ConnectionState.DISCONNECTED);
|
||||
this.connectionStartTime = 0;
|
||||
}
|
||||
|
||||
// Type-safe event emitter methods
|
||||
public on<K extends keyof ClientEvents>(event: K, listener: ClientEvents[K]): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
public once<K extends keyof ClientEvents>(event: K, listener: ClientEvents[K]): this {
|
||||
return super.once(event, listener);
|
||||
}
|
||||
|
||||
public emit<K extends keyof ClientEvents>(event: K, ...args: Parameters<ClientEvents[K]>): boolean {
|
||||
return super.emit(event, ...args);
|
||||
}
|
||||
}
|
207
src/errors.ts
Normal file
207
src/errors.ts
Normal file
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* @fileoverview Error classes for SMTP over WebSocket client
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base error class for all SMTP WebSocket client errors
|
||||
*/
|
||||
export abstract class SMTPWSError extends Error {
|
||||
public readonly code: string;
|
||||
public readonly timestamp: Date;
|
||||
public readonly context?: Record<string, any>;
|
||||
|
||||
constructor(message: string, code: string, context?: Record<string, any>) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.code = code;
|
||||
this.timestamp = new Date();
|
||||
this.context = context ?? {};
|
||||
|
||||
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert error to JSON for logging
|
||||
*/
|
||||
toJSON(): Record<string, any> {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
timestamp: this.timestamp.toISOString(),
|
||||
context: this.context,
|
||||
stack: this.stack
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection-related errors
|
||||
*/
|
||||
export class ConnectionError extends SMTPWSError {
|
||||
constructor(message: string, context?: Record<string, any>) {
|
||||
super(message, 'CONNECTION_ERROR', context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication-related errors
|
||||
*/
|
||||
export class AuthenticationError extends SMTPWSError {
|
||||
constructor(message: string, context?: Record<string, any>) {
|
||||
super(message, 'AUTHENTICATION_ERROR', context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP channel-related errors
|
||||
*/
|
||||
export class ChannelError extends SMTPWSError {
|
||||
constructor(message: string, context?: Record<string, any>) {
|
||||
super(message, 'CHANNEL_ERROR', context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Message timeout errors
|
||||
*/
|
||||
export class TimeoutError extends SMTPWSError {
|
||||
constructor(message: string, timeout: number, context?: Record<string, any>) {
|
||||
super(message, 'TIMEOUT_ERROR', { ...context, timeout });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue-related errors
|
||||
*/
|
||||
export class QueueError extends SMTPWSError {
|
||||
constructor(message: string, context?: Record<string, any>) {
|
||||
super(message, 'QUEUE_ERROR', context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Protocol-related errors
|
||||
*/
|
||||
export class ProtocolError extends SMTPWSError {
|
||||
constructor(message: string, context?: Record<string, any>) {
|
||||
super(message, 'PROTOCOL_ERROR', context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration-related errors
|
||||
*/
|
||||
export class ConfigurationError extends SMTPWSError {
|
||||
constructor(message: string, context?: Record<string, any>) {
|
||||
super(message, 'CONFIGURATION_ERROR', context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Message processing errors
|
||||
*/
|
||||
export class MessageError extends SMTPWSError {
|
||||
public readonly messageId: string;
|
||||
public readonly retryCount: number;
|
||||
|
||||
constructor(message: string, messageId: string, retryCount: number = 0, context?: Record<string, any>) {
|
||||
super(message, 'MESSAGE_ERROR', { ...context, messageId, retryCount });
|
||||
this.messageId = messageId;
|
||||
this.retryCount = retryCount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client shutdown errors
|
||||
*/
|
||||
export class ShutdownError extends SMTPWSError {
|
||||
constructor(message: string, context?: Record<string, any>) {
|
||||
super(message, 'SHUTDOWN_ERROR', context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-related errors
|
||||
*/
|
||||
export class NetworkError extends SMTPWSError {
|
||||
constructor(message: string, context?: Record<string, any>) {
|
||||
super(message, 'NETWORK_ERROR', context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error factory for creating appropriate error types
|
||||
*/
|
||||
export class ErrorFactory {
|
||||
/**
|
||||
* Create an error from WebSocket error events
|
||||
*/
|
||||
static fromWebSocketError(error: Error, context?: Record<string, any>): SMTPWSError {
|
||||
if (error.message.includes('timeout')) {
|
||||
return new TimeoutError(error.message, 0, context);
|
||||
}
|
||||
|
||||
if (error.message.includes('connection')) {
|
||||
return new ConnectionError(error.message, context);
|
||||
}
|
||||
|
||||
if (error.message.includes('network') || error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) {
|
||||
return new NetworkError(error.message, context);
|
||||
}
|
||||
|
||||
return new ConnectionError(error.message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error from authentication failure
|
||||
*/
|
||||
static fromAuthenticationFailure(errorMessage?: string, context?: Record<string, any>): AuthenticationError {
|
||||
return new AuthenticationError(errorMessage || 'Authentication failed', context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error from channel failure
|
||||
*/
|
||||
static fromChannelFailure(errorMessage: string, context?: Record<string, any>): ChannelError {
|
||||
return new ChannelError(errorMessage, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timeout error
|
||||
*/
|
||||
static timeout(operation: string, timeout: number, context?: Record<string, any>): TimeoutError {
|
||||
return new TimeoutError(`${operation} timed out after ${timeout}ms`, timeout, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a queue error
|
||||
*/
|
||||
static queueError(message: string, queueSize: number, context?: Record<string, any>): QueueError {
|
||||
return new QueueError(message, { ...context, queueSize });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message error
|
||||
*/
|
||||
static messageError(message: string, messageId: string, retryCount: number = 0, context?: Record<string, any>): MessageError {
|
||||
return new MessageError(message, messageId, retryCount, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configuration error
|
||||
*/
|
||||
static configurationError(message: string, field?: string, context?: Record<string, any>): ConfigurationError {
|
||||
return new ConfigurationError(message, { ...context, field });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a protocol error
|
||||
*/
|
||||
static protocolError(message: string, messageType?: string, context?: Record<string, any>): ProtocolError {
|
||||
return new ProtocolError(message, { ...context, messageType });
|
||||
}
|
||||
}
|
63
src/index.ts
Normal file
63
src/index.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* @fileoverview Main entry point for SMTP over WebSocket client library
|
||||
*/
|
||||
|
||||
// Export main client class
|
||||
export { SMTPOverWSClient } from './client';
|
||||
|
||||
// Export all types
|
||||
export {
|
||||
SMTPOverWsMessageType,
|
||||
ConnectionState,
|
||||
MessagePriority,
|
||||
type SMTPOverWsMessageBase,
|
||||
type AuthenticateMessage,
|
||||
type AuthenticateResponseMessage,
|
||||
type SMTPChannelOpenMessage,
|
||||
type SMTPChannelReadyMessage,
|
||||
type SMTPChannelClosedMessage,
|
||||
type SMTPChannelErrorMessage,
|
||||
type SMTPToServerMessage,
|
||||
type SMTPFromServerMessage,
|
||||
type SMTPOverWsMessage,
|
||||
type QueuedMessage,
|
||||
type SMTPClientConfig,
|
||||
type Logger,
|
||||
type ClientStats,
|
||||
type ClientEvents,
|
||||
type SendOptions
|
||||
} from './types';
|
||||
|
||||
// Export all error classes
|
||||
export {
|
||||
SMTPWSError,
|
||||
ConnectionError,
|
||||
AuthenticationError,
|
||||
ChannelError,
|
||||
TimeoutError,
|
||||
QueueError,
|
||||
MessageError,
|
||||
ShutdownError,
|
||||
NetworkError,
|
||||
ProtocolError,
|
||||
ConfigurationError,
|
||||
ErrorFactory
|
||||
} from './errors';
|
||||
|
||||
// Export Nodemailer transport
|
||||
export {
|
||||
SMTPWSTransport,
|
||||
createTransport,
|
||||
type TransportOptions,
|
||||
type Envelope,
|
||||
type MailMessage,
|
||||
type SendResult,
|
||||
type TransportInfo
|
||||
} from './transport';
|
||||
|
||||
// Version information (will be updated by build process)
|
||||
export const VERSION = '1.0.0';
|
||||
|
||||
// Re-export for convenience
|
||||
import { SMTPOverWSClient } from './client';
|
||||
export default SMTPOverWSClient;
|
352
src/transport.ts
Normal file
352
src/transport.ts
Normal file
|
@ -0,0 +1,352 @@
|
|||
/**
|
||||
* @fileoverview Nodemailer transport adapter for SMTP over WebSocket
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { SMTPOverWSClient } from './client';
|
||||
import { SMTPClientConfig, ConnectionState } from './types';
|
||||
import { ConnectionError, MessageError, TimeoutError } from './errors';
|
||||
|
||||
/**
|
||||
* Nodemailer transport interface compatibility
|
||||
*/
|
||||
export interface TransportOptions extends Omit<SMTPClientConfig, 'url' | 'apiKey'> {
|
||||
/** WebSocket server URL */
|
||||
host: string;
|
||||
|
||||
/** WebSocket server port */
|
||||
port?: number;
|
||||
|
||||
/** Use secure WebSocket (wss) */
|
||||
secure?: boolean;
|
||||
|
||||
/** API key for authentication */
|
||||
auth: {
|
||||
user: string; // API key
|
||||
pass?: string; // Optional, for future use
|
||||
};
|
||||
|
||||
/** Transport name */
|
||||
name?: string;
|
||||
|
||||
/** Transport version */
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail envelope information
|
||||
*/
|
||||
export interface Envelope {
|
||||
from: string;
|
||||
to: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail data structure (nodemailer format)
|
||||
*/
|
||||
export interface MailMessage {
|
||||
data: any;
|
||||
message: {
|
||||
_envelope: Envelope;
|
||||
_raw: string | Buffer;
|
||||
};
|
||||
mailer: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport send result
|
||||
*/
|
||||
export interface SendResult {
|
||||
envelope: Envelope;
|
||||
messageId: string;
|
||||
accepted: string[];
|
||||
rejected: string[];
|
||||
pending: string[];
|
||||
response: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport info for Nodemailer compatibility
|
||||
*/
|
||||
export interface TransportInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP over WebSocket Nodemailer Transport
|
||||
*/
|
||||
export class SMTPWSTransport extends EventEmitter {
|
||||
public name: string = 'SMTPWS';
|
||||
public version: string = '1.0.0';
|
||||
|
||||
private client: SMTPOverWSClient;
|
||||
private options: TransportOptions;
|
||||
private _isIdle: boolean = true;
|
||||
|
||||
constructor(options: TransportOptions) {
|
||||
super();
|
||||
|
||||
this.options = options;
|
||||
|
||||
// Convert transport options to client config
|
||||
const protocol = options.secure ? 'wss' : 'ws';
|
||||
const port = options.port || (options.secure ? 443 : 3000);
|
||||
const url = `${protocol}://${options.host}:${port}/smtp`;
|
||||
|
||||
const clientConfig: SMTPClientConfig = {
|
||||
url,
|
||||
apiKey: options.auth.user,
|
||||
...options
|
||||
};
|
||||
|
||||
this.client = new SMTPOverWSClient(clientConfig);
|
||||
this.setupClientEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transport info
|
||||
*/
|
||||
public getTransportInfo(): TransportInfo {
|
||||
return {
|
||||
name: this.name,
|
||||
version: this.version,
|
||||
host: this.options.host,
|
||||
port: this.options.port || 3000,
|
||||
secure: this.options.secure || false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send mail using the transport
|
||||
*/
|
||||
public async send(mail: MailMessage, callback?: (err: Error | null, info?: SendResult) => void): Promise<SendResult> {
|
||||
try {
|
||||
this._isIdle = false;
|
||||
this.emit('idle', false);
|
||||
|
||||
const result = await this.sendMail(mail);
|
||||
|
||||
this._isIdle = true;
|
||||
this.emit('idle', true);
|
||||
|
||||
if (callback) {
|
||||
callback(null, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
this._isIdle = true;
|
||||
this.emit('idle', true);
|
||||
|
||||
if (callback) {
|
||||
callback(error as Error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify transport configuration
|
||||
*/
|
||||
public async verify(): Promise<boolean> {
|
||||
try {
|
||||
// Test full connection cycle: connect -> authenticate -> open SMTP -> close SMTP -> disconnect
|
||||
await this.client.sendSMTPCommand('EHLO transport-verify\r\n');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as any;
|
||||
let message = 'Transport verification failed';
|
||||
|
||||
// Provide more specific error messages
|
||||
if (err.code === 'AUTHENTICATION_ERROR') {
|
||||
message = `Authentication failed: ${err.message}. Check your API key.`;
|
||||
} else if (err.code === 'CONNECTION_ERROR') {
|
||||
message = `Connection failed: ${err.message}. Check your host and port.`;
|
||||
} else if (err.code === 'TIMEOUT_ERROR') {
|
||||
message = `Connection timeout: ${err.message}. Server may be unreachable.`;
|
||||
} else {
|
||||
message = `${message}: ${err.message}`;
|
||||
}
|
||||
|
||||
throw new ConnectionError(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the transport
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
await this.client.shutdown();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if transport is idle
|
||||
*/
|
||||
public isIdle(): boolean {
|
||||
return this._isIdle && this.client.getQueueSize() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to send mail
|
||||
*/
|
||||
private async sendMail(mail: MailMessage): Promise<SendResult> {
|
||||
const envelope = mail.message._envelope;
|
||||
const raw = mail.message._raw;
|
||||
const messageId = this.generateMessageId();
|
||||
const accepted: string[] = [];
|
||||
const rejected: string[] = [];
|
||||
const responses: string[] = [];
|
||||
|
||||
try {
|
||||
// Debug envelope
|
||||
console.log('DEBUG envelope:', {
|
||||
from: envelope.from,
|
||||
to: envelope.to,
|
||||
messageId,
|
||||
envelopeKeys: Object.keys(envelope || {}),
|
||||
envelope: envelope
|
||||
});
|
||||
|
||||
// EHLO is now sent automatically when channel opens
|
||||
// Send MAIL FROM
|
||||
const mailFromResponse = await this.client.sendSMTPCommand(`MAIL FROM: <${envelope.from}>\r\n`);
|
||||
responses.push(mailFromResponse);
|
||||
|
||||
if (!this.isSuccessResponse(mailFromResponse)) {
|
||||
throw new MessageError(`MAIL FROM rejected: ${mailFromResponse}`, messageId);
|
||||
}
|
||||
|
||||
// Send RCPT TO for each recipient
|
||||
for (const recipient of envelope.to) {
|
||||
try {
|
||||
const rcptResponse = await this.client.sendSMTPCommand(`RCPT TO: <${recipient}>\r\n`);
|
||||
responses.push(rcptResponse);
|
||||
|
||||
if (this.isSuccessResponse(rcptResponse)) {
|
||||
accepted.push(recipient);
|
||||
} else {
|
||||
rejected.push(recipient);
|
||||
}
|
||||
} catch (error) {
|
||||
rejected.push(recipient);
|
||||
responses.push(`Error for ${recipient}: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If no recipients were accepted, fail
|
||||
if (accepted.length === 0) {
|
||||
throw new MessageError('All recipients were rejected', messageId);
|
||||
}
|
||||
|
||||
// Send DATA command
|
||||
const dataResponse = await this.client.sendSMTPCommand('DATA\r\n');
|
||||
responses.push(dataResponse);
|
||||
|
||||
if (!this.isSuccessResponse(dataResponse)) {
|
||||
throw new MessageError(`DATA command rejected: ${dataResponse}`, messageId);
|
||||
}
|
||||
|
||||
// Send message content
|
||||
const messageData = this.prepareMessageData(raw);
|
||||
const contentResponse = await this.client.sendSMTPCommand(messageData);
|
||||
responses.push(contentResponse);
|
||||
|
||||
if (!this.isSuccessResponse(contentResponse)) {
|
||||
throw new MessageError(`Message data rejected: ${contentResponse}`, messageId);
|
||||
}
|
||||
|
||||
// Send QUIT
|
||||
try {
|
||||
const quitResponse = await this.client.sendSMTPCommand('QUIT\r\n');
|
||||
responses.push(quitResponse);
|
||||
} catch (error) {
|
||||
// QUIT failure is not critical
|
||||
}
|
||||
|
||||
return {
|
||||
envelope,
|
||||
messageId,
|
||||
accepted,
|
||||
rejected,
|
||||
pending: [],
|
||||
response: responses.join('\n')
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
// Try to send RSET to clean up
|
||||
try {
|
||||
await this.client.sendSMTPCommand('RSET\r\n');
|
||||
} catch {
|
||||
// Ignore RSET failures
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup client event forwarding
|
||||
*/
|
||||
private setupClientEvents(): void {
|
||||
this.client.on('error', (error) => {
|
||||
this.emit('error', error);
|
||||
});
|
||||
|
||||
this.client.on('connected', () => {
|
||||
this.emit('connect');
|
||||
});
|
||||
|
||||
this.client.on('disconnected', () => {
|
||||
this.emit('close');
|
||||
});
|
||||
|
||||
this.client.on('messageProcessed', () => {
|
||||
this.emit('idle', this._isIdle);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if SMTP response indicates success
|
||||
*/
|
||||
private isSuccessResponse(response: string): boolean {
|
||||
const statusCode = response.substring(0, 3);
|
||||
return statusCode.startsWith('2') || statusCode.startsWith('3');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare message data for transmission
|
||||
*/
|
||||
private prepareMessageData(raw: string | Buffer): string {
|
||||
let messageData = raw.toString();
|
||||
|
||||
// Ensure message ends with CRLF.CRLF
|
||||
if (!messageData.endsWith('\r\n')) {
|
||||
messageData += '\r\n';
|
||||
}
|
||||
messageData += '.\r\n';
|
||||
|
||||
// Escape lines that start with a dot
|
||||
messageData = messageData.replace(/\n\./g, '\n..');
|
||||
|
||||
return messageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique message ID
|
||||
*/
|
||||
private generateMessageId(): string {
|
||||
return `smtp-ws-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create transport instance
|
||||
*/
|
||||
export function createTransport(options: TransportOptions): SMTPWSTransport {
|
||||
return new SMTPWSTransport(options);
|
||||
}
|
263
src/types.ts
Normal file
263
src/types.ts
Normal file
|
@ -0,0 +1,263 @@
|
|||
/**
|
||||
* @fileoverview Type definitions for SMTP over WebSocket protocol
|
||||
*/
|
||||
|
||||
/**
|
||||
* Message types supported by the SMTP over WebSocket protocol
|
||||
*/
|
||||
export enum SMTPOverWsMessageType {
|
||||
AUTHENTICATE = 'authenticate',
|
||||
AUTHENTICATE_RESPONSE = 'authenticate_response',
|
||||
SMTP_TO_SERVER = 'smtp_to_server',
|
||||
SMTP_FROM_SERVER = 'smtp_from_server',
|
||||
SMTP_CHANNEL_OPEN = 'smtp_channel_open',
|
||||
SMTP_CHANNEL_CLOSED = 'smtp_channel_closed',
|
||||
SMTP_CHANNEL_ERROR = 'smtp_channel_error',
|
||||
SMTP_CHANNEL_READY = 'smtp_channel_ready'
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for all WebSocket messages
|
||||
*/
|
||||
export interface SMTPOverWsMessageBase {
|
||||
type: SMTPOverWsMessageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication request message
|
||||
*/
|
||||
export interface AuthenticateMessage extends SMTPOverWsMessageBase {
|
||||
type: SMTPOverWsMessageType.AUTHENTICATE;
|
||||
data: {
|
||||
apiKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication response message
|
||||
*/
|
||||
export interface AuthenticateResponseMessage extends SMTPOverWsMessageBase {
|
||||
type: SMTPOverWsMessageType.AUTHENTICATE_RESPONSE;
|
||||
data: {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP channel open request message
|
||||
*/
|
||||
export interface SMTPChannelOpenMessage extends SMTPOverWsMessageBase {
|
||||
type: SMTPOverWsMessageType.SMTP_CHANNEL_OPEN;
|
||||
data?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP channel ready notification message
|
||||
*/
|
||||
export interface SMTPChannelReadyMessage extends SMTPOverWsMessageBase {
|
||||
type: SMTPOverWsMessageType.SMTP_CHANNEL_READY;
|
||||
data?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP channel closed notification message
|
||||
*/
|
||||
export interface SMTPChannelClosedMessage extends SMTPOverWsMessageBase {
|
||||
type: SMTPOverWsMessageType.SMTP_CHANNEL_CLOSED;
|
||||
data?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP channel error notification message
|
||||
*/
|
||||
export interface SMTPChannelErrorMessage extends SMTPOverWsMessageBase {
|
||||
type: SMTPOverWsMessageType.SMTP_CHANNEL_ERROR;
|
||||
data: {
|
||||
error: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP data to server message
|
||||
*/
|
||||
export interface SMTPToServerMessage extends SMTPOverWsMessageBase {
|
||||
type: SMTPOverWsMessageType.SMTP_TO_SERVER;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP data from server message
|
||||
*/
|
||||
export interface SMTPFromServerMessage extends SMTPOverWsMessageBase {
|
||||
type: SMTPOverWsMessageType.SMTP_FROM_SERVER;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all possible WebSocket messages
|
||||
*/
|
||||
export type SMTPOverWsMessage =
|
||||
| AuthenticateMessage
|
||||
| AuthenticateResponseMessage
|
||||
| SMTPChannelOpenMessage
|
||||
| SMTPChannelReadyMessage
|
||||
| SMTPChannelClosedMessage
|
||||
| SMTPChannelErrorMessage
|
||||
| SMTPToServerMessage
|
||||
| SMTPFromServerMessage;
|
||||
|
||||
/**
|
||||
* Connection state enum
|
||||
*/
|
||||
export enum ConnectionState {
|
||||
DISCONNECTED = 'disconnected',
|
||||
CONNECTING = 'connecting',
|
||||
CONNECTED = 'connected',
|
||||
AUTHENTICATING = 'authenticating',
|
||||
AUTHENTICATED = 'authenticated',
|
||||
CHANNEL_OPENING = 'channel_opening',
|
||||
CHANNEL_READY = 'channel_ready',
|
||||
CHANNEL_ERROR = 'channel_error',
|
||||
CHANNEL_CLOSED = 'channel_closed',
|
||||
RECONNECTING = 'reconnecting',
|
||||
FAILED = 'failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Queued message structure
|
||||
*/
|
||||
export interface QueuedMessage {
|
||||
id: string;
|
||||
data: string;
|
||||
resolve: (response: string) => void;
|
||||
reject: (error: Error) => void;
|
||||
timestamp: number;
|
||||
retries: number;
|
||||
priority: MessagePriority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message priority levels
|
||||
*/
|
||||
export enum MessagePriority {
|
||||
LOW = 0,
|
||||
NORMAL = 1,
|
||||
HIGH = 2,
|
||||
CRITICAL = 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Client configuration options
|
||||
*/
|
||||
export interface SMTPClientConfig {
|
||||
/** WebSocket server URL */
|
||||
url: string;
|
||||
|
||||
/** API key for authentication */
|
||||
apiKey: string;
|
||||
|
||||
/** Interval between reconnection attempts in milliseconds */
|
||||
reconnectInterval?: number;
|
||||
|
||||
/** Maximum number of reconnection attempts */
|
||||
maxReconnectAttempts?: number;
|
||||
|
||||
/** Authentication timeout in milliseconds */
|
||||
authTimeout?: number;
|
||||
|
||||
/** Channel open/close timeout in milliseconds */
|
||||
channelTimeout?: number;
|
||||
|
||||
/** Message timeout in milliseconds */
|
||||
messageTimeout?: number;
|
||||
|
||||
/** Maximum number of concurrent messages */
|
||||
maxConcurrentMessages?: number;
|
||||
|
||||
/** Enable debug logging */
|
||||
debug?: boolean;
|
||||
|
||||
/** Custom logger function */
|
||||
logger?: Logger;
|
||||
|
||||
/** Connection heartbeat interval in milliseconds */
|
||||
heartbeatInterval?: number;
|
||||
|
||||
/** Maximum queue size */
|
||||
maxQueueSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger interface
|
||||
*/
|
||||
export interface Logger {
|
||||
debug(message: string, ...args: any[]): void;
|
||||
info(message: string, ...args: any[]): void;
|
||||
warn(message: string, ...args: any[]): void;
|
||||
error(message: string, ...args: any[]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client statistics
|
||||
*/
|
||||
export interface ClientStats {
|
||||
messagesQueued: number;
|
||||
messagesProcessed: number;
|
||||
messagesFailed: number;
|
||||
reconnectionAttempts: number;
|
||||
totalConnections: number;
|
||||
averageResponseTime: number;
|
||||
queueSize: number;
|
||||
connectionUptime: number;
|
||||
lastError?: string;
|
||||
lastErrorTime?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event types emitted by the client
|
||||
*/
|
||||
export interface ClientEvents {
|
||||
connecting: () => void;
|
||||
connected: () => void;
|
||||
authenticated: () => void;
|
||||
disconnected: (reason?: string) => void;
|
||||
reconnecting: (attempt: number, maxAttempts: number) => void;
|
||||
reconnected: () => void;
|
||||
error: (error: Error) => void;
|
||||
messageQueued: (messageId: string, queueSize: number) => void;
|
||||
messageProcessed: (messageId: string, responseTime: number) => void;
|
||||
messageFailed: (messageId: string, error: Error) => void;
|
||||
queueProcessingStarted: (queueSize: number) => void;
|
||||
queueProcessingCompleted: (processed: number, failed: number) => void;
|
||||
channelOpened: () => void;
|
||||
channelClosed: () => void;
|
||||
channelError: (error: Error) => void;
|
||||
stateChanged: (oldState: ConnectionState, newState: ConnectionState) => void;
|
||||
// WebSocket message events
|
||||
authenticate: (message: AuthenticateMessage) => void;
|
||||
authenticate_response: (message: AuthenticateResponseMessage) => void;
|
||||
smtp_channel_open: (message: SMTPChannelOpenMessage) => void;
|
||||
smtp_channel_ready: (message: SMTPChannelReadyMessage) => void;
|
||||
smtp_channel_closed: (message: SMTPChannelClosedMessage) => void;
|
||||
smtp_channel_error: (message: SMTPChannelErrorMessage) => void;
|
||||
smtp_to_server: (message: SMTPToServerMessage) => void;
|
||||
smtp_from_server: (message: SMTPFromServerMessage) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message send options
|
||||
*/
|
||||
export interface SendOptions {
|
||||
/** Message priority */
|
||||
priority?: MessagePriority;
|
||||
|
||||
/** Message timeout in milliseconds */
|
||||
timeout?: number;
|
||||
|
||||
/** Number of retry attempts */
|
||||
retries?: number;
|
||||
|
||||
/** Whether to skip queue and send immediately */
|
||||
immediate?: boolean;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue