This commit is contained in:
Siwat Sirichai 2025-08-19 01:59:55 +07:00
commit a89e780165
14 changed files with 2981 additions and 620 deletions

1
.gitignore vendored
View file

@ -8,7 +8,6 @@ yarn-error.log*
lib/ lib/
dist/ dist/
*.tsbuildinfo *.tsbuildinfo
src/
# Coverage directory used by tools like istanbul # Coverage directory used by tools like istanbul
coverage/ coverage/

1086
bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,210 +0,0 @@
#!/usr/bin/env ts-node
/**
* Bulk email sending example using Nodemailer transport
*/
import nodemailer from 'nodemailer';
import { createTransport } from '../src/index';
async function bulkEmailExample() {
console.log('Bulk Email Example using SMTP WebSocket Transport\n');
// Create the WebSocket transport
const transport = createTransport({
host: 'localhost',
port: 3000,
auth: {
user: 'your-api-key-here'
},
maxQueueSize: 1000, // Handle large queues
debug: false // Disable debug for bulk operations
});
const transporter = nodemailer.createTransporter(transport);
// Sample recipient list
const recipients = [
{ email: 'user1@example.com', name: 'User One' },
{ email: 'user2@example.com', name: 'User Two' },
{ email: 'user3@example.com', name: 'User Three' },
{ email: 'user4@example.com', name: 'User Four' },
{ email: 'user5@example.com', name: 'User Five' }
];
console.log(`Sending emails to ${recipients.length} recipients...\n`);
const results = [];
const startTime = Date.now();
// Send emails concurrently (transport handles queuing automatically)
const emailPromises = recipients.map(async (recipient, index) => {
try {
const info = await transporter.sendMail({
from: 'newsletter@example.com',
to: recipient.email,
subject: `Newsletter #${index + 1} - ${new Date().toLocaleDateString()}`,
text: `Hello ${recipient.name}!\n\nThis is your personalized newsletter.\n\nBest regards,\nThe Newsletter Team`,
html: `
<h2>Hello ${recipient.name}!</h2>
<p>This is your personalized newsletter for ${new Date().toLocaleDateString()}.</p>
<p>This email was delivered via our SMTP WebSocket transport system.</p>
<hr>
<p><small>Newsletter #${index + 1} | Sent at ${new Date().toLocaleTimeString()}</small></p>
`
});
console.log(`Email ${index + 1}/${recipients.length} sent to ${recipient.email}`);
return {
success: true,
recipient: recipient.email,
messageId: info.messageId,
response: info.response
};
} catch (error) {
console.error(`Failed to send email ${index + 1} to ${recipient.email}:`, (error as Error).message);
return {
success: false,
recipient: recipient.email,
error: (error as Error).message
};
}
});
// Wait for all emails to complete
const emailResults = await Promise.allSettled(emailPromises);
const duration = Date.now() - startTime;
// Process results
let successful = 0;
let failed = 0;
emailResults.forEach((result) => {
if (result.status === 'fulfilled') {
results.push(result.value);
if (result.value.success) {
successful++;
} else {
failed++;
}
} else {
failed++;
results.push({
success: false,
error: result.reason.message
});
}
});
// Display summary
console.log('\n--- Bulk Email Results ---');
console.log(`Total emails: ${recipients.length}`);
console.log(`Successful: ${successful}`);
console.log(`Failed: ${failed}`);
console.log(`Duration: ${(duration / 1000).toFixed(2)} seconds`);
console.log(`Average time per email: ${(duration / recipients.length).toFixed(0)}ms`);
// Display failed emails if any
if (failed > 0) {
console.log('\nFailed emails:');
results.forEach((result, index) => {
if (!result.success) {
console.log(` ${index + 1}. ${result.recipient || 'Unknown'}: ${result.error}`);
}
});
}
// Close transport
await transport.close();
console.log('\nTransport closed');
}
// Advanced bulk email with throttling
async function throttledBulkEmail() {
console.log('\nThrottled Bulk Email Example\n');
const transport = createTransport({
host: 'localhost',
port: 3000,
auth: {
user: 'your-api-key-here'
}
});
const transporter = nodemailer.createTransporter(transport);
// Generate larger recipient list
const recipients = Array.from({ length: 20 }, (_, i) => ({
email: `user${i + 1}@example.com`,
name: `User ${i + 1}`
}));
console.log(`Sending throttled emails to ${recipients.length} recipients...`);
console.log('Processing 5 emails at a time with 1 second delay between batches\n');
const batchSize = 5;
const batches = [];
for (let i = 0; i < recipients.length; i += batchSize) {
batches.push(recipients.slice(i, i + batchSize));
}
let totalSuccessful = 0;
let totalFailed = 0;
for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
const batch = batches[batchIndex];
console.log(`Processing batch ${batchIndex + 1}/${batches.length} (${batch.length} emails)...`);
const batchPromises = batch.map(async (recipient) => {
try {
await transporter.sendMail({
from: 'batch@example.com',
to: recipient.email,
subject: `Batch Email - ${recipient.name}`,
text: `Hello ${recipient.name}, this is a batch email.`
});
return { success: true, email: recipient.email };
} catch (error) {
return { success: false, email: recipient.email, error: (error as Error).message };
}
});
const batchResults = await Promise.all(batchPromises);
const batchSuccessful = batchResults.filter(r => r.success).length;
const batchFailed = batchResults.filter(r => !r.success).length;
totalSuccessful += batchSuccessful;
totalFailed += batchFailed;
console.log(`Batch ${batchIndex + 1} complete: ${batchSuccessful} successful, ${batchFailed} failed`);
// Wait between batches (except for the last one)
if (batchIndex < batches.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
console.log(`\nThrottled bulk email complete: ${totalSuccessful} successful, ${totalFailed} failed`);
await transport.close();
}
// Run the examples
if (require.main === module) {
(async () => {
try {
await bulkEmailExample();
await throttledBulkEmail();
console.log('\nBulk email examples completed successfully');
} catch (error) {
console.error('\nExamples failed:', error);
} finally {
process.exit(0);
}
})();
}
export { bulkEmailExample, throttledBulkEmail };

View file

@ -13,12 +13,10 @@ async function nodemailerTransportExample() {
// Create the WebSocket transport // Create the WebSocket transport
const transport = createTransport({ const transport = createTransport({
host: '192.168.0.62', host: '192.168.0.62',
apiKey: 'cebc9a7f-4e0c-4fda-9dd0-85f48c02800c',
port: 80, port: 80,
secure: false, // Set to true for wss:// secure: false, // Set to true for wss://
auth: { debug: false
user: 'cebc9a7f-4e0c-4fda-9dd0-85f48c02800c' // Your SMTP relay API key
},
debug: true
}); });
// Create Nodemailer transporter // Create Nodemailer transporter
@ -39,8 +37,8 @@ async function nodemailerTransportExample() {
console.log('Sending test email...'); console.log('Sending test email...');
const info = await transporter.sendMail({ const info = await transporter.sendMail({
from: 'sender@example.com', from: 'cudconnex@satitm.chula.ac.th',
to: 'recipient@example.com', to: 'siwat.s@chula.ac.th',
subject: 'Test Email via SMTP WebSocket', subject: 'Test Email via SMTP WebSocket',
text: 'This email was sent using the SMTP WebSocket transport!', text: 'This email was sent using the SMTP WebSocket transport!',
html: ` html: `

View file

@ -1,23 +0,0 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/index.ts',
'!**/node_modules/**',
'!**/examples/**',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
testTimeout: 30000,
verbose: true,
clearMocks: true,
restoreMocks: true,
};

View file

@ -24,19 +24,16 @@
} }
}, },
"scripts": { "scripts": {
"build": "npm run build:cjs && npm run build:esm && npm run build:types", "build": "bun run build:cjs && bun run build:esm && bun run build:types",
"build:cjs": "tsc -p tsconfig.cjs.json", "build:cjs": "tsc -p tsconfig.cjs.json",
"build:esm": "echo 'ESM build disabled - use CommonJS for now'", "build:esm": "echo 'ESM build disabled - use CommonJS for now'",
"build:types": "tsc -p tsconfig.types.json", "build:types": "tsc -p tsconfig.types.json",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"clean": "rimraf lib", "clean": "rimraf lib",
"prepublishOnly": "npm run clean && npm run build", "prepublishOnly": "npm run clean && npm run build",
"test": "jest", "lint": "eslint src/**/*.ts && tsc --noEmit",
"test:watch": "jest --watch", "lint:tsc": "tsc --noEmit",
"test:coverage": "jest --coverage", "lint:eslint": "eslint src/**/*.ts",
"lint": "eslint src/**/*.ts", "lint:eslint:fix": "eslint src/**/*.ts --fix",
"lint:fix": "eslint src/**/*.ts --fix",
"format": "prettier --write 'src/**/*.ts'", "format": "prettier --write 'src/**/*.ts'",
"docs": "typedoc src/index.ts", "docs": "typedoc src/index.ts",
"example:basic": "ts-node examples/basic-usage.ts", "example:basic": "ts-node examples/basic-usage.ts",
@ -89,11 +86,8 @@
"eslint": "^8.49.0", "eslint": "^8.49.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.0.0",
"jest": "^29.7.0",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"typedoc": "^0.25.1", "typedoc": "^0.25.1",
"typescript": "^5.2.2" "typescript": "^5.2.2"
}, },

1006
src/client.ts Normal file

File diff suppressed because it is too large Load diff

207
src/errors.ts Normal file
View 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
View 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;

347
src/transport.ts Normal file
View file

@ -0,0 +1,347 @@
/**
* @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 */
apiKey: string;
/** 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.apiKey,
...(options.debug !== undefined && { debug: options.debug }),
...(options.maxQueueSize !== undefined && { maxQueueSize: options.maxQueueSize }),
...(options.reconnectInterval !== undefined && { reconnectInterval: options.reconnectInterval }),
...(options.maxReconnectAttempts !== undefined && { maxReconnectAttempts: options.maxReconnectAttempts }),
...(options.authTimeout !== undefined && { authTimeout: options.authTimeout }),
...(options.channelTimeout !== undefined && { channelTimeout: options.channelTimeout }),
...(options.messageTimeout !== undefined && { messageTimeout: options.messageTimeout }),
...(options.maxConcurrentMessages !== undefined && { maxConcurrentMessages: options.maxConcurrentMessages }),
...(options.logger !== undefined && { logger: options.logger }),
...(options.heartbeatInterval !== undefined && { heartbeatInterval: options.heartbeatInterval })
};
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 = this.extractEnvelope(mail);
const raw = mail.message._raw;
const messageId = this.generateMessageId();
// Build complete SMTP transaction
let smtpTransaction = '';
// MAIL FROM
smtpTransaction += `MAIL FROM: <${envelope.from}>\r\n`;
// RCPT TO for each recipient
for (const recipient of envelope.to) {
smtpTransaction += `RCPT TO: <${recipient}>\r\n`;
}
// DATA command
smtpTransaction += 'DATA\r\n';
// Message content
const messageData = this.prepareMessageData(raw);
smtpTransaction += messageData;
// QUIT
smtpTransaction += 'QUIT\r\n';
// Send complete SMTP transaction in one session
const response = await this.client.sendSMTPCommand(smtpTransaction);
return {
envelope,
messageId,
accepted: [...envelope.to],
rejected: [],
pending: [],
response
};
}
/**
* Extract envelope information from mail data
*/
private extractEnvelope(mail: MailMessage): Envelope {
// Try to get envelope from message first (if already set by nodemailer)
if (mail.message._envelope && mail.message._envelope.from && mail.message._envelope.to) {
return mail.message._envelope;
}
// Extract from mail data if envelope is not properly set
let from: string;
let to: string[] = [];
// Extract from address
if (mail.data.from) {
from = typeof mail.data.from === 'string' ? mail.data.from : mail.data.from.address;
} else if (mail.data.sender) {
from = typeof mail.data.sender === 'string' ? mail.data.sender : mail.data.sender.address;
} else {
throw new Error('No sender address specified');
}
// Extract to addresses
const addAddresses = (field: any) => {
if (!field) return;
if (typeof field === 'string') {
to.push(field);
} else if (Array.isArray(field)) {
field.forEach(addr => {
if (typeof addr === 'string') {
to.push(addr);
} else if (addr.address) {
to.push(addr.address);
}
});
} else if (field.address) {
to.push(field.address);
}
};
addAddresses(mail.data.to);
addAddresses(mail.data.cc);
addAddresses(mail.data.bcc);
if (to.length === 0) {
throw new Error('No recipient addresses specified');
}
return { from, to };
}
/**
* 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);
});
}
/**
* Prepare message data for transmission
*/
private prepareMessageData(raw: string | Buffer): string {
let messageData = raw.toString();
// Escape lines that start with a dot
messageData = messageData.replace(/\n\./g, '\n..');
// Ensure message ends with CRLF.CRLF
if (!messageData.endsWith('\r\n')) {
messageData += '\r\n';
}
messageData += '.\r\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
View 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;
}

View file

@ -1,131 +0,0 @@
import { SMTPOverWSClient, ConnectionState, MessagePriority } from '../src/index';
describe('SMTPOverWSClient', () => {
let client: SMTPOverWSClient;
beforeEach(() => {
client = new SMTPOverWSClient({
url: 'ws://localhost:3000/smtp',
apiKey: 'test-api-key'
});
});
afterEach(async () => {
if (client) {
await client.shutdown();
}
});
describe('constructor', () => {
it('should create client with default configuration', () => {
expect(client.getConnectionState()).toBe(ConnectionState.DISCONNECTED);
expect(client.getQueueSize()).toBe(0);
});
it('should throw error for missing URL', () => {
expect(() => {
new SMTPOverWSClient({
url: '',
apiKey: 'test-key'
});
}).toThrow('URL is required');
});
it('should throw error for missing API key', () => {
expect(() => {
new SMTPOverWSClient({
url: 'ws://localhost:3000',
apiKey: ''
});
}).toThrow('API key is required');
});
});
describe('sendSMTPCommand', () => {
it('should queue message and return promise', async () => {
const promise = client.sendSMTPCommand('EHLO example.com\\r\\n');
expect(client.getQueueSize()).toBe(1);
expect(promise).toBeInstanceOf(Promise);
// Clean up
client.clearQueue();
});
it('should respect priority ordering', async () => {
// Queue messages with different priorities
const lowPromise = client.sendSMTPCommand('LOW', { priority: MessagePriority.LOW });
const highPromise = client.sendSMTPCommand('HIGH', { priority: MessagePriority.HIGH });
const normalPromise = client.sendSMTPCommand('NORMAL', { priority: MessagePriority.NORMAL });
expect(client.getQueueSize()).toBe(3);
// Clean up
client.clearQueue();
});
it('should reject when client is shutting down', async () => {
const shutdownPromise = client.shutdown();
await expect(client.sendSMTPCommand('TEST')).rejects.toThrow('Client is shutting down');
await shutdownPromise;
});
it('should reject when queue is full', async () => {
const smallQueueClient = new SMTPOverWSClient({
url: 'ws://localhost:3000/smtp',
apiKey: 'test-key',
maxQueueSize: 2
});
// Fill queue
smallQueueClient.sendSMTPCommand('MSG1');
smallQueueClient.sendSMTPCommand('MSG2');
// This should fail
await expect(smallQueueClient.sendSMTPCommand('MSG3')).rejects.toThrow('Queue is full');
await smallQueueClient.shutdown();
});
});
describe('statistics', () => {
it('should provide initial statistics', () => {
const stats = client.getStats();
expect(stats).toEqual({
messagesQueued: 0,
messagesProcessed: 0,
messagesFailed: 0,
reconnectionAttempts: 0,
totalConnections: 0,
averageResponseTime: 0,
queueSize: 0,
connectionUptime: 0
});
});
});
describe('queue management', () => {
it('should clear queue', () => {
client.sendSMTPCommand('MSG1');
client.sendSMTPCommand('MSG2');
expect(client.getQueueSize()).toBe(2);
client.clearQueue();
expect(client.getQueueSize()).toBe(0);
});
});
describe('shutdown', () => {
it('should shutdown gracefully', async () => {
await expect(client.shutdown()).resolves.toBeUndefined();
expect(client.getConnectionState()).toBe(ConnectionState.DISCONNECTED);
});
it('should timeout if shutdown takes too long', async () => {
await expect(client.shutdown(100)).resolves.toBeUndefined();
});
});
});

View file

@ -1,15 +0,0 @@
/**
* Jest test setup
*/
// Extend Jest timeout for integration tests
jest.setTimeout(30000);
// Mock WebSocket for tests
(global as any).WebSocket = jest.fn().mockImplementation(() => ({
send: jest.fn(),
close: jest.fn(),
terminate: jest.fn(),
on: jest.fn(),
readyState: 1, // OPEN
}));

View file

@ -1,223 +0,0 @@
import nodemailer from 'nodemailer';
import { SMTPWSTransport, createTransport } from '../src/transport';
describe('SMTPWSTransport', () => {
let transport: SMTPWSTransport;
beforeEach(() => {
transport = createTransport({
host: 'localhost',
port: 3000,
auth: {
user: 'test-api-key'
},
debug: false
});
});
afterEach(async () => {
await transport.close();
});
describe('constructor', () => {
it('should create transport with correct configuration', () => {
expect(transport.name).toBe('SMTPWS');
expect(transport.version).toBe('1.0.0');
});
it('should handle secure connection configuration', () => {
const secureTransport = createTransport({
host: 'localhost',
port: 443,
secure: true,
auth: {
user: 'test-key'
},
debug: false
});
const info = secureTransport.getTransportInfo();
expect(info.secure).toBe(true);
expect(info.port).toBe(443);
});
});
describe('getTransportInfo', () => {
it('should return transport information', () => {
const info = transport.getTransportInfo();
expect(info).toMatchObject({
name: 'SMTPWS',
version: '1.0.0',
host: 'localhost',
port: 3000,
secure: false
});
});
});
describe('send', () => {
it('should send mail message', async () => {
const mockMail = {
data: {
envelope: {
from: 'test@example.com',
to: ['recipient@example.com']
},
raw: 'Subject: Test\r\n\r\nTest message'
}
};
// Mock the internal client
const mockSendCommand = jest.fn()
.mockResolvedValueOnce('250 Hello') // EHLO
.mockResolvedValueOnce('250 OK') // MAIL FROM
.mockResolvedValueOnce('250 OK') // RCPT TO
.mockResolvedValueOnce('354 Start mail input') // DATA
.mockResolvedValueOnce('250 Message accepted') // Message content
.mockResolvedValueOnce('221 Bye'); // QUIT
(transport as any).client.sendSMTPCommand = mockSendCommand;
const result = await transport.send(mockMail);
expect(result).toMatchObject({
envelope: mockMail.data.envelope,
accepted: ['recipient@example.com'],
rejected: [],
pending: []
});
expect(mockSendCommand).toHaveBeenCalledTimes(6);
});
it('should handle rejected recipients', async () => {
const mockMail = {
data: {
envelope: {
from: 'test@example.com',
to: ['good@example.com', 'bad@example.com']
},
raw: 'Subject: Test\r\n\r\nTest message'
}
};
const mockSendCommand = jest.fn()
.mockResolvedValueOnce('250 Hello') // EHLO
.mockResolvedValueOnce('250 OK') // MAIL FROM
.mockResolvedValueOnce('250 OK') // RCPT TO (good)
.mockResolvedValueOnce('550 No such user') // RCPT TO (bad)
.mockResolvedValueOnce('354 Start mail input') // DATA
.mockResolvedValueOnce('250 Message accepted') // Message content
.mockResolvedValueOnce('221 Bye'); // QUIT
(transport as any).client.sendSMTPCommand = mockSendCommand;
const result = await transport.send(mockMail);
expect(result.accepted).toEqual(['good@example.com']);
expect(result.rejected).toEqual(['bad@example.com']);
});
it('should call callback on success', (done) => {
const mockMail = {
data: {
envelope: {
from: 'test@example.com',
to: ['recipient@example.com']
},
raw: 'Test message'
}
};
const mockSendCommand = jest.fn()
.mockResolvedValueOnce('250 Hello')
.mockResolvedValueOnce('250 OK')
.mockResolvedValueOnce('250 OK')
.mockResolvedValueOnce('354 Start mail input')
.mockResolvedValueOnce('250 Message accepted')
.mockResolvedValueOnce('221 Bye');
(transport as any).client.sendSMTPCommand = mockSendCommand;
transport.send(mockMail, (err, info) => {
expect(err).toBeNull();
expect(info).toBeDefined();
expect(info?.accepted).toEqual(['recipient@example.com']);
done();
});
});
it('should call callback on error', (done) => {
const mockMail = {
envelope: {
from: 'test@example.com',
to: ['recipient@example.com']
},
raw: 'Test message'
};
const mockSendCommand = jest.fn()
.mockRejectedValueOnce(new Error('Connection failed'));
(transport as any).client.sendSMTPCommand = mockSendCommand;
transport.send(mockMail, (err, info) => {
expect(err).toBeDefined();
expect(err?.message).toBe('Connection failed');
expect(info).toBeUndefined();
done();
});
});
});
describe('verify', () => {
it('should verify transport connectivity', async () => {
const mockSendCommand = jest.fn()
.mockResolvedValueOnce('250 Hello');
(transport as any).client.sendSMTPCommand = mockSendCommand;
const result = await transport.verify();
expect(result).toBe(true);
expect(mockSendCommand).toHaveBeenCalledWith('EHLO transport-verify\r\n');
});
it('should throw error on verification failure', async () => {
const mockSendCommand = jest.fn()
.mockRejectedValueOnce(new Error('Connection refused'));
(transport as any).client.sendSMTPCommand = mockSendCommand;
await expect(transport.verify()).rejects.toThrow('Transport verification failed');
});
it('should throw error on missing API key during construction', async () => {
expect(() => createTransport({
host: 'localhost',
auth: { user: '' },
debug: false
})).toThrow('API key is required');
});
});
describe('isIdle', () => {
it('should return true when transport is idle', () => {
expect(transport.isIdle()).toBe(true);
});
it('should return false when transport has queued messages', () => {
// Mock queue size
(transport as any).client.getQueueSize = jest.fn().mockReturnValue(5);
expect(transport.isIdle()).toBe(false);
});
});
describe('nodemailer integration', () => {
it('should work as nodemailer transport', async () => {
const transporter = nodemailer.createTransport(transport);
expect(transporter).toBeDefined();
});
});
});