Merge branch 'main' of https://git.siwatsystem.com/siwat/mxrelay-consumer
This commit is contained in:
commit
a89e780165
14 changed files with 2981 additions and 620 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -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/
|
||||||
|
|
|
@ -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 };
|
|
|
@ -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: `
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
16
package.json
16
package.json
|
@ -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
1006
src/client.ts
Normal file
File diff suppressed because it is too large
Load diff
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;
|
347
src/transport.ts
Normal file
347
src/transport.ts
Normal 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
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;
|
||||||
|
}
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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
|
|
||||||
}));
|
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
Loading…
Add table
Add a link
Reference in a new issue