initial commit

This commit is contained in:
Siwat Sirichai 2025-08-18 22:22:04 +07:00
commit 619cb97fa3
23 changed files with 9242 additions and 0 deletions

View file

@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(mv:*)",
"Bash(npm install)",
"Bash(npm test:*)",
"Bash(npm install:*)",
"Bash(npm run build:*)",
"Bash(bun:*)"
],
"deny": [],
"ask": []
}
}

23
.eslintrc.js Normal file
View file

@ -0,0 +1,23 @@
module.exports = {
parser: '@typescript-eslint/parser',
extends: [
'eslint:recommended',
'@typescript-eslint/recommended',
'prettier',
],
plugins: ['@typescript-eslint', 'prettier'],
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
project: './tsconfig.json',
},
rules: {
'prettier/prettier': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn',
},
ignorePatterns: ['lib/', 'node_modules/', '*.js'],
};

55
.gitignore vendored Normal file
View file

@ -0,0 +1,55 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
lib/
dist/
*.tsbuildinfo
src/
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Logs
logs
*.log
# Dependency directories
.npm
.yarn/
.pnp.*
# Optional eslint cache
.eslintcache
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

8
.prettierrc Normal file
View file

@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}

112
CLAUDE.md Normal file
View file

@ -0,0 +1,112 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is **@siwatsystem/mxrelay-consumer**, a TypeScript client library for SMTP over WebSocket protocol. It provides intelligent queue management, automatic reconnection, and comprehensive error handling for sending emails through WebSocket-based SMTP relay servers.
## Development Commands
### Build Commands
- `npm run build` - Build all formats (CommonJS, ESM, and type declarations)
- `npm run build:cjs` - Build CommonJS format only
- `npm run build:esm` - Build ES modules format only
- `npm run build:types` - Build TypeScript declarations only
- `npm run dev` - Watch mode for development
- `npm run clean` - Remove build artifacts
### Testing Commands
- `npm test` - Run all tests
- `npm run test:watch` - Run tests in watch mode
- `npm run test:coverage` - Run tests with coverage report
### Code Quality Commands
- `npm run lint` - Lint TypeScript files
- `npm run lint:fix` - Lint and auto-fix issues
- `npm run format` - Format code with Prettier
### Example Commands
- `npm run example:basic` - Run basic usage example
- `npm run example:queue` - Run queue management example
## Architecture
### Core Components
**SMTPOverWSClient (`src/client.ts`)** - Main client class that:
- Manages WebSocket connections to SMTP relay servers
- Implements intelligent message queuing with priority support
- Handles automatic reconnection with exponential backoff
- Provides comprehensive error handling and recovery
- Supports connection state management and event emission
- Implements SMTP channel lifecycle management
**Message Queue System** - Priority-based queue that:
- Automatically connects when messages are queued
- Processes messages sequentially with retry logic
- Disconnects when queue is empty to optimize resources
- Supports HIGH/NORMAL/LOW/CRITICAL priority levels
**Connection State Machine** - Manages states:
- DISCONNECTED → CONNECTING → CONNECTED → AUTHENTICATING → AUTHENTICATED
- CHANNEL_OPENING → CHANNEL_READY → CHANNEL_CLOSED
- RECONNECTING → FAILED (on max retry exhaustion)
**SMTPWSTransport (`src/transport.ts`)** - Nodemailer-compatible transport that:
- Converts Nodemailer mail objects to SMTP commands
- Implements standard SMTP protocol flow (EHLO, MAIL FROM, RCPT TO, DATA, QUIT)
- Handles recipient validation and error reporting
- Provides transport verification and lifecycle management
**Error Hierarchy (`src/errors.ts`)** - Structured errors:
- `SMTPWSError` (base) → `ConnectionError`, `AuthenticationError`, `ChannelError`
- `TimeoutError`, `QueueError`, `MessageError`, `ShutdownError`
- `NetworkError`, `ProtocolError`, `ConfigurationError`
- `ErrorFactory` for creating appropriate error instances
### Protocol Implementation
**WebSocket Message Types**:
- Authentication: `AUTHENTICATE``AUTHENTICATE_RESPONSE`
- Channel lifecycle: `SMTP_CHANNEL_OPEN``SMTP_CHANNEL_READY``SMTP_CHANNEL_CLOSED`
- Data transfer: `SMTP_TO_SERVER``SMTP_FROM_SERVER`
- Error handling: `SMTP_CHANNEL_ERROR`
**Queue Processing**:
1. Messages queued by priority (CRITICAL > HIGH > NORMAL > LOW)
2. Auto-connect when queue has messages
3. Sequential processing with retry logic (configurable retry count)
4. Auto-disconnect when queue empty
5. Comprehensive timeout handling at all levels
### Key Configuration Options
- `url`: WebSocket server endpoint
- `apiKey`: Authentication credential
- `debug`: Enable console logging (default: false, library is silent by default)
- `maxQueueSize`: Queue capacity limit (default: 1000)
- `reconnectInterval`: Delay between reconnect attempts (default: 5000ms)
- `maxReconnectAttempts`: Max retry count (default: 10)
- `messageTimeout`: Per-message timeout (default: 60000ms)
- `heartbeatInterval`: Keep-alive interval (default: 30000ms)
### Build System
The project uses multiple TypeScript configurations:
- `tsconfig.json` - Main config for CommonJS build
- `tsconfig.esm.json` - ES modules build
- `tsconfig.types.json` - Type declarations build
- `tsconfig.cjs.json` - CommonJS specific build
Output formats:
- CommonJS: `lib/index.js`
- ES Modules: `lib/index.esm.js`
- Type Declarations: `lib/index.d.ts`
### Testing
- Jest with TypeScript support
- WebSocket mocking in `tests/setup.ts`
- 30-second test timeout for integration tests
- Coverage collection from all source files except index.ts

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 SiwatSystem
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

458
README.md Normal file
View file

@ -0,0 +1,458 @@
# @siwatsystem/smtp-ws-relay-client
[![npm version](https://badge.fury.io/js/@siwatsystem%2Fsmtp-ws-relay-client.svg)](https://badge.fury.io/js/@siwatsystem%2Fsmtp-ws-relay-client)
[![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
A production-ready TypeScript client library for SMTP over WebSocket protocol with intelligent queue management, automatic reconnection, and comprehensive error handling.
## Features
🚀 **Intelligent Queue Management**
- Automatic WebSocket connection when messages are queued
- Priority-based message processing
- Configurable batch processing
- Queue size limits and overflow protection
🔄 **Robust Connection Handling**
- Automatic reconnection with exponential backoff
- Connection state management
- Heartbeat monitoring
- Graceful connection lifecycle
⚡ **High Performance**
- Efficient SMTP channel cycling
- Concurrent message processing support
- Optimized resource usage
- Minimal memory footprint
🛡️ **Enterprise-Grade Reliability**
- Comprehensive error handling and classification
- Timeout management for all operations
- Retry logic with configurable attempts
- Graceful degradation and recovery
📊 **Monitoring & Observability**
- Real-time statistics and metrics
- Structured logging with configurable levels
- Event-driven architecture for monitoring
- Performance tracking and analytics
## Installation
```bash
npm install @siwatsystem/smtp-ws-relay-client
```
## Quick Start
```typescript
import { SMTPOverWSClient, MessagePriority } from '@siwatsystem/smtp-ws-relay-client';
// Create client instance
const client = new SMTPOverWSClient({
url: 'ws://your-smtp-relay-server:3000/smtp',
apiKey: 'your-api-key',
debug: true
});
// Send SMTP commands
try {
const response1 = await client.sendSMTPCommand('EHLO example.com\\r\\n');
const response2 = await client.sendSMTPCommand('MAIL FROM: <test@example.com>\\r\\n');
const response3 = await client.sendSMTPCommand('RCPT TO: <recipient@example.com>\\r\\n');
console.log('SMTP responses:', [response1, response2, response3]);
} catch (error) {
console.error('SMTP error:', error);
}
// Graceful shutdown
await client.shutdown();
```
## Configuration Options
```typescript
interface SMTPClientConfig {
/** WebSocket server URL */
url: string;
/** API key for authentication */
apiKey: string;
/** Interval between reconnection attempts (default: 5000ms) */
reconnectInterval?: number;
/** Maximum reconnection attempts (default: 10) */
maxReconnectAttempts?: number;
/** Authentication timeout (default: 30000ms) */
authTimeout?: number;
/** Channel operation timeout (default: 10000ms) */
channelTimeout?: number;
/** Message timeout (default: 60000ms) */
messageTimeout?: number;
/** Maximum concurrent messages (default: 1) */
maxConcurrentMessages?: number;
/** Enable debug logging (default: false) */
debug?: boolean;
/** Custom logger implementation */
logger?: Logger;
/** Connection heartbeat interval (default: 30000ms) */
heartbeatInterval?: number;
/** Queue processing batch size (default: 10) */
batchSize?: number;
/** Maximum queue size (default: 1000) */
maxQueueSize?: number;
}
```
## Advanced Usage
### Priority-Based Message Queuing
```typescript
import { MessagePriority } from '@siwatsystem/smtp-ws-relay-client';
// High priority message (processed first)
await client.sendSMTPCommand('URGENT EMAIL DATA\\r\\n', {
priority: MessagePriority.HIGH,
timeout: 30000
});
// Normal priority message
await client.sendSMTPCommand('NORMAL EMAIL DATA\\r\\n', {
priority: MessagePriority.NORMAL
});
// Low priority message (processed last)
await client.sendSMTPCommand('NEWSLETTER DATA\\r\\n', {
priority: MessagePriority.LOW
});
```
### Event Monitoring
```typescript
// Connection events
client.on('connected', () => {
console.log('WebSocket connected');
});
client.on('authenticated', () => {
console.log('Authentication successful');
});
client.on('disconnected', (reason) => {
console.log('Disconnected:', reason);
});
// Queue events
client.on('messageQueued', (messageId, queueSize) => {
console.log(`Message ${messageId} queued. Queue size: ${queueSize}`);
});
client.on('messageProcessed', (messageId, responseTime) => {
console.log(`Message ${messageId} processed in ${responseTime}ms`);
});
client.on('queueProcessingStarted', (queueSize) => {
console.log(`Processing ${queueSize} queued messages`);
});
// Error events
client.on('error', (error) => {
console.error('Client error:', error);
});
client.on('messageFailed', (messageId, error) => {
console.error(`Message ${messageId} failed:`, error);
});
```
### Statistics and Monitoring
```typescript
// Get real-time statistics
const stats = client.getStats();
console.log('Client Statistics:', {
messagesQueued: stats.messagesQueued,
messagesProcessed: stats.messagesProcessed,
messagesFailed: stats.messagesFailed,
averageResponseTime: stats.averageResponseTime,
connectionUptime: stats.connectionUptime,
queueSize: stats.queueSize
});
// Monitor connection state
console.log('Current state:', client.getConnectionState());
console.log('Queue size:', client.getQueueSize());
```
### Custom Logger
```typescript
import { Logger } from '@siwatsystem/smtp-ws-relay-client';
class CustomLogger implements Logger {
debug(message: string, ...args: any[]): void {
// Send to your logging service
console.debug(`[DEBUG] ${message}`, ...args);
}
info(message: string, ...args: any[]): void {
console.info(`[INFO] ${message}`, ...args);
}
warn(message: string, ...args: any[]): void {
console.warn(`[WARN] ${message}`, ...args);
}
error(message: string, ...args: any[]): void {
console.error(`[ERROR] ${message}`, ...args);
}
}
const client = new SMTPOverWSClient({
url: 'ws://localhost:3000/smtp',
apiKey: 'your-api-key',
logger: new CustomLogger()
});
```
## Error Handling
The library provides comprehensive error classification:
```typescript
import {
ConnectionError,
AuthenticationError,
ChannelError,
TimeoutError,
QueueError,
MessageError
} from '@siwatsystem/smtp-ws-relay-client';
try {
await client.sendSMTPCommand('EHLO example.com\\r\\n');
} catch (error) {
if (error instanceof ConnectionError) {
console.error('Connection failed:', error.message);
} else if (error instanceof AuthenticationError) {
console.error('Authentication failed:', error.message);
} else if (error instanceof TimeoutError) {
console.error('Operation timed out:', error.message);
} else if (error instanceof QueueError) {
console.error('Queue error:', error.message);
} else if (error instanceof MessageError) {
console.error('Message processing failed:', error.message);
}
}
```
## Connection States
The client manages several connection states:
- `DISCONNECTED` - No connection to server
- `CONNECTING` - Establishing WebSocket connection
- `CONNECTED` - WebSocket connected, authentication pending
- `AUTHENTICATING` - Sending authentication credentials
- `AUTHENTICATED` - Ready to open SMTP channels
- `CHANNEL_OPENING` - Opening SMTP channel
- `CHANNEL_READY` - SMTP channel active, ready for data
- `CHANNEL_CLOSED` - SMTP channel closed
- `CHANNEL_ERROR` - SMTP channel error occurred
- `RECONNECTING` - Attempting to reconnect
- `FAILED` - Connection failed, max retries reached
## Protocol Support
This client implements the SMTP over WebSocket protocol specification:
### Message Types
- `AUTHENTICATE` / `AUTHENTICATE_RESPONSE` - Authentication flow
- `SMTP_CHANNEL_OPEN` / `SMTP_CHANNEL_READY` - Channel management
- `SMTP_CHANNEL_CLOSED` / `SMTP_CHANNEL_ERROR` - Channel lifecycle
- `SMTP_TO_SERVER` / `SMTP_FROM_SERVER` - Data transfer
### Connection Flow
1. **WebSocket Connection** - Establish WebSocket to relay server
2. **Authentication** - Authenticate using API key
3. **Channel Management** - Open/close SMTP channels per message
4. **Data Transfer** - Exchange SMTP commands and responses
5. **Cleanup** - Close channels and connection when queue empty
## Performance Considerations
### Queue Management
- Messages are processed in priority order
- Batch processing reduces connection overhead
- Automatic queue size management prevents memory issues
### Connection Efficiency
- Single WebSocket connection handles multiple SMTP sessions
- Intelligent connect/disconnect based on queue state
- Connection pooling and reuse optimization
### Memory Usage
- Efficient message queuing with cleanup
- Automatic resource management
- Configurable limits and timeouts
## Best Practices
### Production Configuration
```typescript
const client = new SMTPOverWSClient({
url: 'ws://your-production-server/smtp',
apiKey: process.env.SMTP_RELAY_API_KEY!,
// Connection settings
reconnectInterval: 10000,
maxReconnectAttempts: 5,
heartbeatInterval: 30000,
// Timeouts
authTimeout: 30000,
channelTimeout: 15000,
messageTimeout: 120000,
// Queue settings
maxQueueSize: 5000,
batchSize: 50,
// Monitoring
debug: false,
logger: new ProductionLogger()
});
```
### Error Recovery
```typescript
client.on('error', async (error) => {
// Log error to monitoring system
logger.error('SMTP client error', { error: error.toJSON() });
// Implement circuit breaker pattern
if (error instanceof ConnectionError && consecutiveErrors > 10) {
await client.shutdown();
// Implement fallback mechanism
}
});
```
### Graceful Shutdown
```typescript
process.on('SIGTERM', async () => {
console.log('Shutting down SMTP client...');
try {
await client.shutdown(30000); // 30 second timeout
console.log('SMTP client shutdown complete');
} catch (error) {
console.error('Forced shutdown due to timeout');
}
process.exit(0);
});
```
## Development
### Building from Source
```bash
# Clone repository
git clone https://github.com/siwatsystem/smtp-ws-relay-client.git
cd smtp-ws-relay-client
# Install dependencies
npm install
# Build library
npm run build
# Run tests
npm test
# Run examples
npm run example:basic
```
### Testing
```bash
# Run all tests
npm test
# Run with coverage
npm run test:coverage
# Watch mode
npm run test:watch
```
## API Reference
### SMTPOverWSClient
#### Constructor
- `new SMTPOverWSClient(config: SMTPClientConfig)`
#### Methods
- `sendSMTPCommand(data: string, options?: SendOptions): Promise<string>`
- `getStats(): ClientStats`
- `getConnectionState(): ConnectionState`
- `getQueueSize(): number`
- `clearQueue(): void`
- `shutdown(timeout?: number): Promise<void>`
#### Events
- `connecting`, `connected`, `authenticated`, `disconnected`
- `reconnecting`, `reconnected`, `error`
- `messageQueued`, `messageProcessed`, `messageFailed`
- `queueProcessingStarted`, `queueProcessingCompleted`
- `channelOpened`, `channelClosed`, `channelError`
- `stateChanged`
## Contributing
We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details.
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Support
- 📧 Email: [support@siwatsystem.com](mailto:support@siwatsystem.com)
- 🐛 Issues: [GitHub Issues](https://github.com/siwatsystem/smtp-ws-relay-client/issues)
- 📖 Documentation: [API Docs](https://siwatsystem.github.io/smtp-ws-relay-client/)
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for version history and updates.
---
Built with ❤️ by [SiwatSystem](https://siwatsystem.com)

103
examples/basic-usage.ts Normal file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env ts-node
/**
* Basic usage example for SMTP over WebSocket client
*/
import { SMTPOverWSClient, MessagePriority } from '../src/index';
async function basicUsageExample() {
console.log('Starting SMTP WebSocket Client Basic Example\n');
// Create client with basic configuration
const client = new SMTPOverWSClient({
url: 'ws://localhost:3000/smtp', // Replace with your server URL
apiKey: 'your-api-key-here', // Replace with your API key
debug: true,
reconnectInterval: 5000,
maxReconnectAttempts: 3
});
// Set up event listeners
client.on('connected', () => {
console.log('Connected to WebSocket server');
});
client.on('authenticated', () => {
console.log('Authentication successful');
});
client.on('disconnected', (reason) => {
console.log(`Disconnected: ${reason || 'Unknown reason'}`);
});
client.on('error', (error) => {
console.error('Client error:', error.message);
});
client.on('messageQueued', (messageId, queueSize) => {
console.log(`Message ${messageId} queued (Queue size: ${queueSize})`);
});
client.on('messageProcessed', (messageId, responseTime) => {
console.log(`Message ${messageId} processed in ${responseTime}ms`);
});
try {
console.log('Sending SMTP commands...\n');
// Send basic SMTP sequence
const ehloResponse = await client.sendSMTPCommand('EHLO example.com\r\n');
console.log('EHLO Response:', ehloResponse.trim());
const mailFromResponse = await client.sendSMTPCommand('MAIL FROM: <sender@example.com>\r\n');
console.log('MAIL FROM Response:', mailFromResponse.trim());
const rcptToResponse = await client.sendSMTPCommand('RCPT TO: <recipient@example.com>\r\n');
console.log('RCPT TO Response:', rcptToResponse.trim());
const dataResponse = await client.sendSMTPCommand('DATA\r\n');
console.log('DATA Response:', dataResponse.trim());
// Send email content
const emailContent = `From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Test Email\r\n\r\nThis is a test email.\r\n.\r\n`;
const contentResponse = await client.sendSMTPCommand(emailContent);
console.log('Content Response:', contentResponse.trim());
const quitResponse = await client.sendSMTPCommand('QUIT\r\n');
console.log('QUIT Response:', quitResponse.trim());
console.log('\nAll SMTP commands sent successfully!');
// Display statistics
const stats = client.getStats();
console.log('\nClient Statistics:');
console.log(` Messages Queued: ${stats.messagesQueued}`);
console.log(` Messages Processed: ${stats.messagesProcessed}`);
console.log(` Messages Failed: ${stats.messagesFailed}`);
console.log(` Average Response Time: ${stats.averageResponseTime.toFixed(2)}ms`);
console.log(` Connection Uptime: ${stats.connectionUptime}ms`);
} catch (error) {
console.error('Error during SMTP communication:', error);
} finally {
console.log('\nShutting down client...');
await client.shutdown();
console.log('Client shutdown complete');
}
}
// Run the example
if (require.main === module) {
basicUsageExample()
.then(() => {
console.log('\nBasic usage example completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\nExample failed:', error);
process.exit(1);
});
}
export { basicUsageExample };

210
examples/bulk-email.ts Normal file
View file

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

View file

@ -0,0 +1,88 @@
#!/usr/bin/env ts-node
/**
* Nodemailer transport example for SMTP over WebSocket
*/
import nodemailer from 'nodemailer';
import { createTransport } from '../src/index';
async function nodemailerTransportExample() {
console.log('Nodemailer SMTP WebSocket Transport Example\n');
// Create the WebSocket transport
const transport = createTransport({
host: '192.168.0.62',
port: 80,
secure: false, // Set to true for wss://
auth: {
user: 'cebc9a7f-4e0c-4fda-9dd0-85f48c02800c' // Your SMTP relay API key
},
debug: true
});
// Create Nodemailer transporter
const transporter = nodemailer.createTransport(transport);
// Verify the transport configuration
try {
console.log('Verifying transport configuration...');
await transporter.verify();
console.log('Transport verification successful\n');
} catch (error) {
console.error('Transport verification failed:', error);
return;
}
// Send a test email
try {
console.log('Sending test email...');
const info = await transporter.sendMail({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Test Email via SMTP WebSocket',
text: 'This email was sent using the SMTP WebSocket transport!',
html: `
<h1>Test Email</h1>
<p>This email was sent using the <strong>SMTP WebSocket transport</strong>!</p>
<p>Features:</p>
<ul>
<li>Automatic connection management</li>
<li>Queue-based message handling</li>
<li>Nodemailer compatibility</li>
<li>WebSocket-based SMTP relay</li>
</ul>
`
});
console.log('Email sent successfully!');
console.log('Message ID:', info.messageId);
console.log('Accepted recipients:', info.accepted);
console.log('Rejected recipients:', info.rejected);
console.log('Response:', info.response);
} catch (error) {
console.error('Failed to send email:', error);
} finally {
// Close the transport
console.log('\nClosing transport...');
await transport.close();
console.log('Transport closed');
}
}
// Run the example
if (require.main === module) {
nodemailerTransportExample()
.then(() => {
console.log('\nNodemailer transport example completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\nExample failed:', error);
process.exit(1);
});
}
export { nodemailerTransportExample };

View file

@ -0,0 +1,226 @@
#!/usr/bin/env ts-node
/**
* Queue management and priority example for SMTP over WebSocket client
*/
import { SMTPOverWSClient, MessagePriority } from '../src/index';
async function queueManagementExample() {
console.log('Starting SMTP WebSocket Client Queue Management Example\n');
// Create client - queue management is automatic
const client = new SMTPOverWSClient({
url: 'ws://localhost:3000/smtp',
apiKey: 'your-api-key-here',
debug: true,
maxQueueSize: 100,
reconnectInterval: 2000,
maxReconnectAttempts: 5
});
// Set up comprehensive event monitoring
client.on('connecting', () => {
console.log('Connecting to server...');
});
client.on('connected', () => {
console.log('Connected to WebSocket server');
});
client.on('authenticated', () => {
console.log('Authentication successful');
});
client.on('queueProcessingStarted', (queueSize) => {
console.log(`Queue processing started with ${queueSize} messages`);
});
client.on('queueProcessingCompleted', (processed, failed) => {
console.log(`Queue processing completed: ${processed} processed, ${failed} failed`);
});
client.on('messageQueued', (messageId, queueSize) => {
console.log(`Message ${messageId} queued (Queue: ${queueSize})`);
});
client.on('messageProcessed', (messageId, responseTime) => {
console.log(`Message ${messageId} processed in ${responseTime}ms`);
});
client.on('messageFailed', (messageId, error) => {
console.log(`Message ${messageId} failed: ${error.message}`);
});
client.on('stateChanged', (oldState, newState) => {
console.log(`State changed: ${oldState}${newState}`);
});
try {
console.log('Demonstrating priority-based queue management...\n');
// Queue multiple messages with different priorities
const messagePromises: Promise<string>[] = [];
// Low priority messages (will be processed last)
console.log('Queuing low priority messages...');
for (let i = 1; i <= 3; i++) {
const promise = client.sendSMTPCommand(`LOW PRIORITY ${i}\r\n`, {
priority: MessagePriority.LOW,
timeout: 30000
});
messagePromises.push(promise);
}
// Normal priority messages
console.log('Queuing normal priority messages...');
for (let i = 1; i <= 5; i++) {
const promise = client.sendSMTPCommand(`NORMAL PRIORITY ${i}\r\n`, {
priority: MessagePriority.NORMAL,
timeout: 30000
});
messagePromises.push(promise);
}
// High priority messages (will be processed first)
console.log('Queuing high priority messages...');
for (let i = 1; i <= 2; i++) {
const promise = client.sendSMTPCommand(`HIGH PRIORITY ${i}\r\n`, {
priority: MessagePriority.HIGH,
timeout: 30000
});
messagePromises.push(promise);
}
// Critical priority message (highest priority)
console.log('Queuing critical priority message...');
const criticalPromise = client.sendSMTPCommand('CRITICAL PRIORITY MESSAGE\r\n', {
priority: MessagePriority.CRITICAL,
timeout: 30000
});
messagePromises.push(criticalPromise);
console.log(`\nTotal messages queued: ${messagePromises.length}`);
console.log(`Current queue size: ${client.getQueueSize()}`);
// Add some messages after a delay to show dynamic queuing
setTimeout(() => {
console.log('\nAdding more messages to active queue...');
const additionalPromises = [
client.sendSMTPCommand('LATE HIGH PRIORITY\r\n', {
priority: MessagePriority.HIGH
}),
client.sendSMTPCommand('LATE NORMAL PRIORITY\r\n', {
priority: MessagePriority.NORMAL
})
];
messagePromises.push(...additionalPromises);
}, 2000);
// Wait for all messages to be processed
console.log('\nWaiting for all messages to be processed...\n');
const responses = await Promise.allSettled(messagePromises);
// Analyze results
console.log('\nProcessing Results:');
let successful = 0;
let failed = 0;
responses.forEach((result, index) => {
if (result.status === 'fulfilled') {
successful++;
console.log(`Message ${index + 1}: ${result.value.trim()}`);
} else {
failed++;
console.log(`Message ${index + 1}: ${result.reason.message}`);
}
});
console.log(`\nSummary: ${successful} successful, ${failed} failed`);
// Display final statistics
const stats = client.getStats();
console.log('\nFinal Client Statistics:');
console.log(` Messages Queued: ${stats.messagesQueued}`);
console.log(` Messages Processed: ${stats.messagesProcessed}`);
console.log(` Messages Failed: ${stats.messagesFailed}`);
console.log(` Average Response Time: ${stats.averageResponseTime.toFixed(2)}ms`);
console.log(` Total Connections: ${stats.totalConnections}`);
console.log(` Reconnection Attempts: ${stats.reconnectionAttempts}`);
console.log(` Connection Uptime: ${(stats.connectionUptime / 1000).toFixed(2)}s`);
} catch (error) {
console.error('Error during queue management demo:', error);
} finally {
console.log('\nShutting down client...');
await client.shutdown(10000); // 10 second timeout
console.log('Client shutdown complete');
}
}
// Simulate network issues for demonstration
async function resilenceExample() {
console.log('\nStarting resilience demonstration...\n');
const client = new SMTPOverWSClient({
url: 'ws://localhost:3000/smtp',
apiKey: 'your-api-key-here',
debug: true,
reconnectInterval: 1000,
maxReconnectAttempts: 3,
messageTimeout: 5000
});
client.on('reconnecting', (attempt, maxAttempts) => {
console.log(`Reconnection attempt ${attempt}/${maxAttempts}`);
});
client.on('reconnected', () => {
console.log('Successfully reconnected');
});
client.on('error', (error) => {
console.log(`Error handled: ${error.message}`);
});
try {
// Queue messages that will trigger reconnection scenarios
const promises = [];
for (let i = 1; i <= 5; i++) {
promises.push(
client.sendSMTPCommand(`RESILIENCE TEST ${i}\r\n`, {
retries: 2,
timeout: 10000
}).catch(error => {
console.log(`Message ${i} failed: ${error.message}`);
return `FAILED: ${error.message}`;
})
);
}
await Promise.all(promises);
} finally {
await client.shutdown();
}
}
// Run the examples
if (require.main === module) {
(async () => {
try {
await queueManagementExample();
await resilenceExample();
console.log('\nQueue management examples completed successfully');
} catch (error) {
console.error('\nExamples failed:', error);
} finally {
process.exit(0);
}
})();
}
export { queueManagementExample, resilenceExample };

61
examples/simple-usage.ts Normal file
View file

@ -0,0 +1,61 @@
#!/usr/bin/env ts-node
/**
* Simple usage example for SMTP over WebSocket client
*/
import { SMTPOverWSClient } from '../src/index';
async function simpleUsageExample() {
console.log('Simple SMTP WebSocket Client Example\n');
// Create client - that's it, everything else is automatic
const client = new SMTPOverWSClient({
url: 'ws://localhost:3000/smtp',
apiKey: 'your-api-key-here',
debug: true
});
try {
// Just send commands - client handles connection, queuing, everything automatically
console.log('Sending SMTP commands...');
const response1 = await client.sendSMTPCommand('EHLO example.com\r\n');
console.log('EHLO:', response1.trim());
const response2 = await client.sendSMTPCommand('MAIL FROM: <test@example.com>\r\n');
console.log('MAIL FROM:', response2.trim());
const response3 = await client.sendSMTPCommand('RCPT TO: <recipient@example.com>\r\n');
console.log('RCPT TO:', response3.trim());
const response4 = await client.sendSMTPCommand('DATA\r\n');
console.log('DATA:', response4.trim());
const response5 = await client.sendSMTPCommand('Subject: Test\r\n\r\nHello World!\r\n.\r\n');
console.log('Message:', response5.trim());
const response6 = await client.sendSMTPCommand('QUIT\r\n');
console.log('QUIT:', response6.trim());
console.log('\nAll done! Client automatically handled connection and cleanup.');
} catch (error) {
console.error('Error:', error);
} finally {
// Optional - client will clean up automatically when process exits
await client.shutdown();
}
}
// Run the example
if (require.main === module) {
simpleUsageExample()
.then(() => process.exit(0))
.catch((error) => {
console.error('Example failed:', error);
process.exit(1);
});
}
export { simpleUsageExample };

23
jest.config.js Normal file
View file

@ -0,0 +1,23 @@
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,
};

7229
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

104
package.json Normal file
View file

@ -0,0 +1,104 @@
{
"name": "@siwats/mxrelay-consumer",
"version": "1.0.0",
"description": "An internal TypeScript client library for transporting SMTP messages",
"main": "lib/index.js",
"module": "lib/index.esm.js",
"types": "lib/index.d.ts",
"files": [
"lib/**/*",
"src/**/*",
"README.md",
"LICENSE"
],
"exports": {
".": {
"types": "./lib/index.d.ts",
"import": "./lib/index.esm.js",
"require": "./lib/index.js"
},
"./types": {
"types": "./lib/types.d.ts",
"import": "./lib/types.esm.js",
"require": "./lib/types.js"
}
},
"scripts": {
"build": "npm run build:cjs && npm run build:esm && npm 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",
"format": "prettier --write 'src/**/*.ts'",
"docs": "typedoc src/index.ts",
"example:basic": "ts-node examples/basic-usage.ts",
"example:queue": "ts-node examples/queue-management.ts"
},
"keywords": [
"smtp",
"websocket",
"email",
"relay",
"queue",
"client",
"typescript",
"async",
"promise",
"reconnection",
"siwats"
],
"author": {
"name": "Siwat Sirichai",
"email": "siwat@siwatinc.com",
"url": "https://siwatsystem.com"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/siwats/smtp-ws-relay-client.git"
},
"bugs": {
"url": "https://github.com/siwats/smtp-ws-relay-client/issues"
},
"homepage": "https://github.com/siwats/smtp-ws-relay-client#readme",
"engines": {
"node": ">=16.0.0"
},
"dependencies": {
"nodemailer": "^7.0.5",
"ws": "^8.14.2"
},
"peerDependencies": {
"@types/node": ">=16"
},
"devDependencies": {
"@types/jest": "^29.5.5",
"@types/node": "^20.6.3",
"@types/nodemailer": "^6.4.8",
"@types/ws": "^8.5.6",
"@typescript-eslint/eslint-plugin": "^6.7.2",
"@typescript-eslint/parser": "^6.7.2",
"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"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}

131
tests/client.test.ts Normal file
View file

@ -0,0 +1,131 @@
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();
});
});
});

15
tests/setup.ts Normal file
View file

@ -0,0 +1,15 @@
/**
* 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
}));

223
tests/transport.test.ts Normal file
View file

@ -0,0 +1,223 @@
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();
});
});
});

10
tsconfig.cjs.json Normal file
View file

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"target": "ES2018",
"outDir": "./lib",
"declaration": false,
"declarationMap": false
}
}

10
tsconfig.esm.json Normal file
View file

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ES2020",
"target": "ES2020",
"outDir": "./lib-esm",
"declaration": false,
"declarationMap": false
}
}

37
tsconfig.json Normal file
View file

@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "CommonJS",
"moduleResolution": "node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"allowJs": true,
"sourceMap": true,
"outDir": "./lib",
"rootDir": "./src",
"removeComments": false,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"declaration": true,
"declarationMap": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"lib",
"tests",
"examples"
]
}

9
tsconfig.types.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "./lib"
}
}

71
workflows/ci.yml Normal file
View file

@ -0,0 +1,71 @@
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16, 18, 20]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
- name: Run build
run: npm run build
- name: Upload coverage to Codecov
if: matrix.node-version == 18
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
flags: unittests
name: codecov-umbrella
publish:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Use Node.js 18
uses: actions/setup-node@v4
with:
node-version: 18
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Publish to NPM
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}