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/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
src/
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
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
|
||||
const transport = createTransport({
|
||||
host: '192.168.0.62',
|
||||
apiKey: 'cebc9a7f-4e0c-4fda-9dd0-85f48c02800c',
|
||||
port: 80,
|
||||
secure: false, // Set to true for wss://
|
||||
auth: {
|
||||
user: 'cebc9a7f-4e0c-4fda-9dd0-85f48c02800c' // Your SMTP relay API key
|
||||
},
|
||||
debug: true
|
||||
debug: false
|
||||
});
|
||||
|
||||
// Create Nodemailer transporter
|
||||
|
@ -39,8 +37,8 @@ async function nodemailerTransportExample() {
|
|||
console.log('Sending test email...');
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
from: 'cudconnex@satitm.chula.ac.th',
|
||||
to: 'siwat.s@chula.ac.th',
|
||||
subject: 'Test Email via SMTP WebSocket',
|
||||
text: 'This email was sent using the SMTP WebSocket transport!',
|
||||
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": {
|
||||
"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:esm": "echo 'ESM build disabled - use CommonJS for now'",
|
||||
"build:types": "tsc -p tsconfig.types.json",
|
||||
"dev": "tsc --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rimraf lib",
|
||||
"prepublishOnly": "npm run clean && npm run build",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"lint:fix": "eslint src/**/*.ts --fix",
|
||||
"lint": "eslint src/**/*.ts && tsc --noEmit",
|
||||
"lint:tsc": "tsc --noEmit",
|
||||
"lint:eslint": "eslint src/**/*.ts",
|
||||
"lint:eslint:fix": "eslint src/**/*.ts --fix",
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"docs": "typedoc src/index.ts",
|
||||
"example:basic": "ts-node examples/basic-usage.ts",
|
||||
|
@ -89,11 +86,8 @@
|
|||
"eslint": "^8.49.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.0.3",
|
||||
"rimraf": "^5.0.1",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typedoc": "^0.25.1",
|
||||
"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