This commit is contained in:
2025-05-24 17:00:59 +00:00
parent 4e4c7df558
commit 11a2ae6b27
16 changed files with 8733 additions and 0 deletions

View File

@ -0,0 +1,562 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestSmtpServer } from '../../helpers/server.loader.js';
import { createSmtpClient } from '../../helpers/smtp.client.js';
import { Email } from '../../../ts/mail/core/classes.email.js';
import * as net from 'net';
let testServer: any;
tap.test('setup test SMTP server', async () => {
testServer = await startTestSmtpServer();
expect(testServer).toBeTruthy();
expect(testServer.port).toBeGreaterThan(0);
});
tap.test('CERR-07: SIZE extension detection', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
await smtpClient.connect();
// Check for SIZE extension
const ehloResponse = await smtpClient.sendCommand('EHLO testclient.example.com');
console.log('\nChecking SIZE extension support...');
const sizeMatch = ehloResponse.match(/250[- ]SIZE (\d+)/);
if (sizeMatch) {
const maxSize = parseInt(sizeMatch[1]);
console.log(`Server advertises SIZE extension: ${maxSize} bytes`);
console.log(` Human readable: ${(maxSize / 1024 / 1024).toFixed(2)} MB`);
// Common size limits
const commonLimits = [
{ size: 10 * 1024 * 1024, name: '10 MB' },
{ size: 25 * 1024 * 1024, name: '25 MB' },
{ size: 50 * 1024 * 1024, name: '50 MB' },
{ size: 100 * 1024 * 1024, name: '100 MB' }
];
const closestLimit = commonLimits.find(limit => Math.abs(limit.size - maxSize) < 1024 * 1024);
if (closestLimit) {
console.log(` Appears to be standard ${closestLimit.name} limit`);
}
} else {
console.log('Server does not advertise SIZE extension');
}
await smtpClient.close();
});
tap.test('CERR-07: Message size calculation', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
await smtpClient.connect();
// Test different message components and their size impact
console.log('\nMessage size calculation tests:');
const sizeTests = [
{
name: 'Plain text only',
email: new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Size test',
text: 'x'.repeat(1000)
}),
expectedSize: 1200 // Approximate with headers
},
{
name: 'HTML content',
email: new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'HTML size test',
html: '<html><body>' + 'x'.repeat(1000) + '</body></html>',
text: 'x'.repeat(1000)
}),
expectedSize: 2500 // Multipart adds overhead
},
{
name: 'With attachment',
email: new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Attachment test',
text: 'See attachment',
attachments: [{
filename: 'test.txt',
content: Buffer.from('x'.repeat(10000)),
contentType: 'text/plain'
}]
}),
expectedSize: 14000 // Base64 encoding adds ~33%
}
];
for (const test of sizeTests) {
// Calculate actual message size
let messageSize = 0;
const originalSendCommand = smtpClient.sendCommand.bind(smtpClient);
smtpClient.sendCommand = async (command: string) => {
messageSize += Buffer.byteLength(command, 'utf8');
// Check SIZE parameter in MAIL FROM
if (command.startsWith('MAIL FROM') && command.includes('SIZE=')) {
const sizeMatch = command.match(/SIZE=(\d+)/);
if (sizeMatch) {
console.log(`\n${test.name}:`);
console.log(` SIZE parameter: ${sizeMatch[1]} bytes`);
}
}
return originalSendCommand(command);
};
try {
await smtpClient.sendMail(test.email);
console.log(` Actual transmitted: ${messageSize} bytes`);
console.log(` Expected (approx): ${test.expectedSize} bytes`);
} catch (error) {
console.log(` Error: ${error.message}`);
}
}
await smtpClient.close();
});
tap.test('CERR-07: Exceeding size limits', async () => {
// Create server with size limit
const sizeLimitServer = net.createServer((socket) => {
const maxSize = 1024 * 1024; // 1 MB limit
let currentMailSize = 0;
let inData = false;
socket.write('220 Size Limit Test Server\r\n');
socket.on('data', (data) => {
const command = data.toString();
if (command.trim().startsWith('EHLO')) {
socket.write(`250-sizelimit.example.com\r\n`);
socket.write(`250-SIZE ${maxSize}\r\n`);
socket.write('250 OK\r\n');
} else if (command.trim().startsWith('MAIL FROM')) {
// Check SIZE parameter
const sizeMatch = command.match(/SIZE=(\d+)/);
if (sizeMatch) {
const declaredSize = parseInt(sizeMatch[1]);
if (declaredSize > maxSize) {
socket.write(`552 5.3.4 Message size exceeds fixed maximum message size (${maxSize})\r\n`);
return;
}
}
currentMailSize = 0;
socket.write('250 OK\r\n');
} else if (command.trim().startsWith('RCPT TO')) {
socket.write('250 OK\r\n');
} else if (command.trim() === 'DATA') {
inData = true;
socket.write('354 Send data\r\n');
} else if (inData) {
currentMailSize += Buffer.byteLength(command, 'utf8');
if (command.trim() === '.') {
inData = false;
if (currentMailSize > maxSize) {
socket.write(`552 5.3.4 Message too big (${currentMailSize} bytes, limit is ${maxSize})\r\n`);
} else {
socket.write('250 OK\r\n');
}
}
} else if (command.trim() === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
await new Promise<void>((resolve) => {
sizeLimitServer.listen(0, '127.0.0.1', () => resolve());
});
const sizeLimitPort = (sizeLimitServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: sizeLimitPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
console.log('\nTesting size limit enforcement (1 MB limit)...');
await smtpClient.connect();
// Test messages of different sizes
const sizes = [
{ size: 500 * 1024, name: '500 KB', shouldSucceed: true },
{ size: 900 * 1024, name: '900 KB', shouldSucceed: true },
{ size: 1.5 * 1024 * 1024, name: '1.5 MB', shouldSucceed: false },
{ size: 5 * 1024 * 1024, name: '5 MB', shouldSucceed: false }
];
for (const test of sizes) {
console.log(`\nTesting ${test.name} message...`);
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: `Size test: ${test.name}`,
text: 'x'.repeat(test.size)
});
try {
await smtpClient.sendMail(email);
if (test.shouldSucceed) {
console.log(' ✓ Accepted as expected');
} else {
console.log(' ✗ Unexpectedly accepted');
}
} catch (error) {
if (!test.shouldSucceed) {
console.log(' ✓ Rejected as expected:', error.message);
expect(error.message).toMatch(/552|size|big|large|exceed/i);
} else {
console.log(' ✗ Unexpectedly rejected:', error.message);
}
}
}
await smtpClient.close();
sizeLimitServer.close();
});
tap.test('CERR-07: Size rejection at different stages', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
await smtpClient.connect();
console.log('\nSize rejection can occur at different stages:');
// 1. MAIL FROM with SIZE parameter
console.log('\n1. During MAIL FROM (with SIZE parameter):');
try {
await smtpClient.sendCommand('MAIL FROM:<sender@example.com> SIZE=999999999');
console.log(' Large SIZE accepted in MAIL FROM');
} catch (error) {
console.log(' Rejected at MAIL FROM:', error.message);
}
await smtpClient.sendCommand('RSET');
// 2. After DATA command
console.log('\n2. After receiving message data:');
const largeEmail = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Large message',
text: 'x'.repeat(10 * 1024 * 1024) // 10 MB
});
try {
await smtpClient.sendMail(largeEmail);
console.log(' Large message accepted');
} catch (error) {
console.log(' Rejected after DATA:', error.message);
}
await smtpClient.close();
});
tap.test('CERR-07: Attachment encoding overhead', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
await smtpClient.connect();
console.log('\nTesting attachment encoding overhead:');
// Test how different content types affect size
const attachmentTests = [
{
name: 'Binary file (base64)',
content: Buffer.from(Array(1000).fill(0xFF)),
encoding: 'base64',
overhead: 1.33 // ~33% overhead
},
{
name: 'Text file (quoted-printable)',
content: Buffer.from('This is plain text content.\r\n'.repeat(100)),
encoding: 'quoted-printable',
overhead: 1.1 // ~10% overhead for mostly ASCII
},
{
name: 'Already base64',
content: Buffer.from('SGVsbG8gV29ybGQh'.repeat(100)),
encoding: '7bit',
overhead: 1.0 // No additional encoding
}
];
for (const test of attachmentTests) {
const originalSize = test.content.length;
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: `Encoding test: ${test.name}`,
text: 'See attachment',
attachments: [{
filename: 'test.dat',
content: test.content,
encoding: test.encoding as any
}]
});
// Monitor actual transmitted size
let transmittedSize = 0;
const originalSendCommand = smtpClient.sendCommand.bind(smtpClient);
smtpClient.sendCommand = async (command: string) => {
transmittedSize += Buffer.byteLength(command, 'utf8');
return originalSendCommand(command);
};
await smtpClient.sendMail(email);
const attachmentSize = transmittedSize - 1000; // Rough estimate minus headers
const actualOverhead = attachmentSize / originalSize;
console.log(`\n${test.name}:`);
console.log(` Original size: ${originalSize} bytes`);
console.log(` Transmitted size: ~${attachmentSize} bytes`);
console.log(` Actual overhead: ${(actualOverhead * 100 - 100).toFixed(1)}%`);
console.log(` Expected overhead: ${(test.overhead * 100 - 100).toFixed(1)}%`);
}
await smtpClient.close();
});
tap.test('CERR-07: Chunked transfer for large messages', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 30000,
chunkSize: 64 * 1024, // 64KB chunks
debug: true
});
await smtpClient.connect();
console.log('\nTesting chunked transfer for large message...');
// Create a large message
const chunkSize = 64 * 1024;
const totalSize = 2 * 1024 * 1024; // 2 MB
const chunks = Math.ceil(totalSize / chunkSize);
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Chunked transfer test',
text: 'x'.repeat(totalSize)
});
// Monitor chunk transmission
let chunkCount = 0;
let bytesSent = 0;
const startTime = Date.now();
const originalSendCommand = smtpClient.sendCommand.bind(smtpClient);
smtpClient.sendCommand = async (command: string) => {
const commandSize = Buffer.byteLength(command, 'utf8');
bytesSent += commandSize;
// Detect chunk boundaries (simplified)
if (commandSize > 1000 && commandSize <= chunkSize + 100) {
chunkCount++;
const progress = (bytesSent / totalSize * 100).toFixed(1);
console.log(` Chunk ${chunkCount}: ${commandSize} bytes (${progress}% complete)`);
}
return originalSendCommand(command);
};
await smtpClient.sendMail(email);
const elapsed = Date.now() - startTime;
const throughput = (bytesSent / elapsed * 1000 / 1024).toFixed(2);
console.log(`\nTransfer complete:`);
console.log(` Total chunks: ${chunkCount}`);
console.log(` Total bytes: ${bytesSent}`);
console.log(` Time: ${elapsed}ms`);
console.log(` Throughput: ${throughput} KB/s`);
await smtpClient.close();
});
tap.test('CERR-07: Size limit error recovery', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
autoShrinkAttachments: true, // Automatically compress/resize attachments
maxMessageSize: 5 * 1024 * 1024, // 5 MB client-side limit
debug: true
});
await smtpClient.connect();
console.log('\nTesting size limit error recovery...');
// Create oversized email
const largeImage = Buffer.alloc(10 * 1024 * 1024); // 10 MB "image"
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Large attachment',
text: 'See attached image',
attachments: [{
filename: 'large-image.jpg',
content: largeImage,
contentType: 'image/jpeg'
}]
});
// Monitor size reduction attempts
smtpClient.on('attachment-resize', (info) => {
console.log(`\nAttempting to reduce attachment size:`);
console.log(` Original: ${info.originalSize} bytes`);
console.log(` Target: ${info.targetSize} bytes`);
console.log(` Method: ${info.method}`);
});
try {
const result = await smtpClient.sendMail(email);
console.log('\nEmail sent after size reduction');
if (result.modifications) {
console.log('Modifications made:');
result.modifications.forEach(mod => {
console.log(` - ${mod}`);
});
}
} catch (error) {
console.log('\nFailed even after size reduction:', error.message);
}
await smtpClient.close();
});
tap.test('CERR-07: Multiple size limits', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
await smtpClient.connect();
console.log('\nDifferent types of size limits:');
const sizeLimits = [
{
type: 'Total message size',
limit: '25 MB',
description: 'Complete MIME message including all parts'
},
{
type: 'Individual attachment',
limit: '10 MB',
description: 'Per-attachment limit'
},
{
type: 'Text content',
limit: '1 MB',
description: 'Plain text or HTML body'
},
{
type: 'Header size',
limit: '100 KB',
description: 'Total size of all headers'
},
{
type: 'Recipient count',
limit: '100',
description: 'Affects total message size with BCC expansion'
}
];
sizeLimits.forEach(limit => {
console.log(`\n${limit.type}:`);
console.log(` Typical limit: ${limit.limit}`);
console.log(` Description: ${limit.description}`);
});
// Test cumulative size with multiple attachments
console.log('\n\nTesting cumulative attachment size...');
const attachments = Array.from({ length: 5 }, (_, i) => ({
filename: `file${i + 1}.dat`,
content: Buffer.alloc(2 * 1024 * 1024), // 2 MB each
contentType: 'application/octet-stream'
}));
const multiAttachEmail = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Multiple attachments',
text: 'Testing cumulative size',
attachments: attachments
});
console.log(`Total attachment size: ${attachments.length * 2} MB`);
try {
await smtpClient.sendMail(multiAttachEmail);
console.log('Multiple attachments accepted');
} catch (error) {
console.log('Rejected due to cumulative size:', error.message);
}
await smtpClient.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await testServer.stop();
}
});
export default tap.start();

View File

@ -0,0 +1,573 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestSmtpServer } from '../../helpers/server.loader.js';
import { createSmtpClient } from '../../helpers/smtp.client.js';
import { Email } from '../../../ts/mail/core/classes.email.js';
import * as net from 'net';
let testServer: any;
tap.test('setup test SMTP server', async () => {
testServer = await startTestSmtpServer();
expect(testServer).toBeTruthy();
expect(testServer.port).toBeGreaterThan(0);
});
tap.test('CERR-08: Connection rate limiting', async () => {
// Create server with connection rate limiting
let connectionCount = 0;
let connectionTimes: number[] = [];
const maxConnectionsPerMinute = 10;
const rateLimitServer = net.createServer((socket) => {
const now = Date.now();
connectionTimes.push(now);
connectionCount++;
// Remove old connection times (older than 1 minute)
connectionTimes = connectionTimes.filter(time => now - time < 60000);
if (connectionTimes.length > maxConnectionsPerMinute) {
socket.write('421 4.7.0 Too many connections, please try again later\r\n');
socket.end();
return;
}
socket.write('220 Rate Limit Test Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('250 OK\r\n');
}
});
});
await new Promise<void>((resolve) => {
rateLimitServer.listen(0, '127.0.0.1', () => resolve());
});
const rateLimitPort = (rateLimitServer.address() as net.AddressInfo).port;
console.log('\nTesting connection rate limiting...');
console.log(`Server limit: ${maxConnectionsPerMinute} connections per minute`);
// Try to make many connections rapidly
const connections: any[] = [];
let accepted = 0;
let rejected = 0;
for (let i = 0; i < 15; i++) {
try {
const client = createSmtpClient({
host: '127.0.0.1',
port: rateLimitPort,
secure: false,
connectionTimeout: 2000,
debug: false
});
await client.connect();
accepted++;
connections.push(client);
console.log(` Connection ${i + 1}: Accepted`);
} catch (error) {
rejected++;
console.log(` Connection ${i + 1}: Rejected - ${error.message}`);
expect(error.message).toMatch(/421|too many|rate/i);
}
}
console.log(`\nResults: ${accepted} accepted, ${rejected} rejected`);
expect(rejected).toBeGreaterThan(0); // Some should be rate limited
// Clean up connections
for (const client of connections) {
await client.close();
}
rateLimitServer.close();
});
tap.test('CERR-08: Message rate limiting', async () => {
// Create server with message rate limiting
const messageRateLimits: { [key: string]: { count: number; resetTime: number } } = {};
const messagesPerHour = 100;
const messageRateLimitServer = net.createServer((socket) => {
let senderAddress = '';
socket.write('220 Message Rate Limit Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
const match = command.match(/<([^>]+)>/);
if (match) {
senderAddress = match[1];
const now = Date.now();
if (!messageRateLimits[senderAddress]) {
messageRateLimits[senderAddress] = { count: 0, resetTime: now + 3600000 };
}
// Reset if hour has passed
if (now > messageRateLimits[senderAddress].resetTime) {
messageRateLimits[senderAddress] = { count: 0, resetTime: now + 3600000 };
}
messageRateLimits[senderAddress].count++;
if (messageRateLimits[senderAddress].count > messagesPerHour) {
socket.write(`421 4.7.0 Message rate limit exceeded (${messagesPerHour}/hour)\r\n`);
return;
}
}
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
socket.write('250 OK\r\n');
} else if (command === 'DATA') {
socket.write('354 Send data\r\n');
} else if (command === '.') {
const remaining = messagesPerHour - messageRateLimits[senderAddress].count;
socket.write(`250 OK (${remaining} messages remaining this hour)\r\n`);
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
await new Promise<void>((resolve) => {
messageRateLimitServer.listen(0, '127.0.0.1', () => resolve());
});
const messageRateLimitPort = (messageRateLimitServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: messageRateLimitPort,
secure: false,
connectionTimeout: 5000,
debug: true
});
console.log('\nTesting message rate limiting...');
console.log(`Server limit: ${messagesPerHour} messages per hour per sender`);
await smtpClient.connect();
// Simulate sending many messages
const testMessageCount = 10;
const sender = 'bulk-sender@example.com';
for (let i = 0; i < testMessageCount; i++) {
const email = new Email({
from: sender,
to: [`recipient${i}@example.com`],
subject: `Test message ${i + 1}`,
text: 'Testing message rate limits'
});
try {
const result = await smtpClient.sendMail(email);
// Extract remaining count from response
const remainingMatch = result.response?.match(/(\d+) messages remaining/);
if (remainingMatch) {
console.log(` Message ${i + 1}: Sent (${remainingMatch[1]} remaining)`);
} else {
console.log(` Message ${i + 1}: Sent`);
}
} catch (error) {
console.log(` Message ${i + 1}: Rate limited - ${error.message}`);
}
}
await smtpClient.close();
messageRateLimitServer.close();
});
tap.test('CERR-08: Recipient rate limiting', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
await smtpClient.connect();
console.log('\nTesting recipient rate limiting...');
// Test different recipient rate limit scenarios
const recipientTests = [
{
name: 'Many recipients in single message',
recipients: Array.from({ length: 200 }, (_, i) => `user${i}@example.com`),
expectedLimit: 100
},
{
name: 'Rapid sequential messages',
recipients: Array.from({ length: 50 }, (_, i) => `rapid${i}@example.com`),
delay: 0
}
];
for (const test of recipientTests) {
console.log(`\n${test.name}:`);
const email = new Email({
from: 'sender@example.com',
to: test.recipients,
subject: test.name,
text: 'Testing recipient limits'
});
let acceptedCount = 0;
let rejectedCount = 0;
// Monitor RCPT TO responses
const originalSendCommand = smtpClient.sendCommand.bind(smtpClient);
smtpClient.sendCommand = async (command: string) => {
const response = await originalSendCommand(command);
if (command.startsWith('RCPT TO')) {
if (response.startsWith('250')) {
acceptedCount++;
} else if (response.match(/^[45]/)) {
rejectedCount++;
if (response.match(/rate|limit|too many|slow down/i)) {
console.log(` Rate limit hit after ${acceptedCount} recipients`);
}
}
}
return response;
};
try {
await smtpClient.sendMail(email);
console.log(` All ${acceptedCount} recipients accepted`);
} catch (error) {
console.log(` Accepted: ${acceptedCount}, Rejected: ${rejectedCount}`);
console.log(` Error: ${error.message}`);
}
await smtpClient.sendCommand('RSET');
}
await smtpClient.close();
});
tap.test('CERR-08: Rate limit response codes', async () => {
console.log('\nCommon rate limiting response codes:');
const rateLimitCodes = [
{
code: '421 4.7.0',
message: 'Too many connections',
type: 'Connection rate limit',
action: 'Close connection, retry later'
},
{
code: '450 4.7.1',
message: 'Rate limit exceeded, try again later',
type: 'Command rate limit',
action: 'Temporary failure, queue and retry'
},
{
code: '451 4.7.1',
message: 'Please slow down',
type: 'Throttling request',
action: 'Add delay before next command'
},
{
code: '452 4.5.3',
message: 'Too many recipients',
type: 'Recipient limit',
action: 'Split into multiple messages'
},
{
code: '454 4.7.0',
message: 'Temporary authentication failure',
type: 'Auth rate limit',
action: 'Delay and retry authentication'
},
{
code: '550 5.7.1',
message: 'Daily sending quota exceeded',
type: 'Hard quota limit',
action: 'Stop sending until quota resets'
}
];
rateLimitCodes.forEach(limit => {
console.log(`\n${limit.code} ${limit.message}`);
console.log(` Type: ${limit.type}`);
console.log(` Action: ${limit.action}`);
});
});
tap.test('CERR-08: Adaptive rate limiting', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
adaptiveRateLimit: true,
initialDelay: 100, // Start with 100ms between commands
maxDelay: 5000, // Max 5 seconds between commands
debug: true
});
await smtpClient.connect();
console.log('\nTesting adaptive rate limiting...');
// Track delays
const delays: number[] = [];
let lastCommandTime = Date.now();
const originalSendCommand = smtpClient.sendCommand.bind(smtpClient);
smtpClient.sendCommand = async (command: string) => {
const now = Date.now();
const delay = now - lastCommandTime;
delays.push(delay);
lastCommandTime = now;
return originalSendCommand(command);
};
// Send multiple emails and observe delay adaptation
for (let i = 0; i < 5; i++) {
const email = new Email({
from: 'sender@example.com',
to: [`recipient${i}@example.com`],
subject: `Adaptive test ${i + 1}`,
text: 'Testing adaptive rate limiting'
});
try {
await smtpClient.sendMail(email);
console.log(` Email ${i + 1}: Sent with ${delays[delays.length - 1]}ms delay`);
} catch (error) {
console.log(` Email ${i + 1}: Failed - ${error.message}`);
// Check if delay increased
if (delays.length > 1) {
const lastDelay = delays[delays.length - 1];
const previousDelay = delays[delays.length - 2];
if (lastDelay > previousDelay) {
console.log(` Delay increased from ${previousDelay}ms to ${lastDelay}ms`);
}
}
}
}
await smtpClient.close();
});
tap.test('CERR-08: Rate limit headers', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
await smtpClient.connect();
console.log('\nChecking for rate limit information in responses...');
// Send email and monitor for rate limit headers
const email = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Rate limit header test',
text: 'Checking for rate limit information'
});
// Monitor responses for rate limit info
const rateLimitInfo: string[] = [];
const originalSendCommand = smtpClient.sendCommand.bind(smtpClient);
smtpClient.sendCommand = async (command: string) => {
const response = await originalSendCommand(command);
// Look for rate limit information in responses
const patterns = [
/X-RateLimit-Limit: (\d+)/i,
/X-RateLimit-Remaining: (\d+)/i,
/X-RateLimit-Reset: (\d+)/i,
/(\d+) requests? remaining/i,
/limit.* (\d+) per/i,
/retry.* (\d+) seconds?/i
];
patterns.forEach(pattern => {
const match = response.match(pattern);
if (match) {
rateLimitInfo.push(match[0]);
}
});
return response;
};
await smtpClient.sendMail(email);
if (rateLimitInfo.length > 0) {
console.log('Rate limit information found:');
rateLimitInfo.forEach(info => console.log(` ${info}`));
} else {
console.log('No rate limit information in responses');
}
await smtpClient.close();
});
tap.test('CERR-08: Distributed rate limiting', async () => {
console.log('\nDistributed rate limiting strategies:');
const strategies = [
{
name: 'Token bucket',
description: 'Fixed number of tokens replenished at constant rate',
pros: 'Allows bursts, smooth rate control',
cons: 'Can be complex to implement distributed'
},
{
name: 'Sliding window',
description: 'Count requests in moving time window',
pros: 'More accurate than fixed windows',
cons: 'Higher memory usage'
},
{
name: 'Fixed window',
description: 'Reset counter at fixed intervals',
pros: 'Simple to implement',
cons: 'Can allow 2x rate at window boundaries'
},
{
name: 'Leaky bucket',
description: 'Queue with constant drain rate',
pros: 'Smooth output rate',
cons: 'Can drop messages if bucket overflows'
}
];
strategies.forEach(strategy => {
console.log(`\n${strategy.name}:`);
console.log(` Description: ${strategy.description}`);
console.log(` Pros: ${strategy.pros}`);
console.log(` Cons: ${strategy.cons}`);
});
// Simulate distributed rate limiting
const distributedLimiter = {
nodes: ['server1', 'server2', 'server3'],
globalLimit: 1000, // 1000 messages per minute globally
perNodeLimit: 400, // Each node can handle 400/min
currentCounts: { server1: 0, server2: 0, server3: 0 }
};
console.log('\n\nSimulating distributed rate limiting:');
console.log(`Global limit: ${distributedLimiter.globalLimit}/min`);
console.log(`Per-node limit: ${distributedLimiter.perNodeLimit}/min`);
// Simulate load distribution
for (let i = 0; i < 20; i++) {
// Pick least loaded node
const node = distributedLimiter.nodes.reduce((min, node) =>
distributedLimiter.currentCounts[node] < distributedLimiter.currentCounts[min] ? node : min
);
distributedLimiter.currentCounts[node]++;
if (i % 5 === 4) {
console.log(`\nAfter ${i + 1} messages:`);
distributedLimiter.nodes.forEach(n => {
console.log(` ${n}: ${distributedLimiter.currentCounts[n]} messages`);
});
}
}
});
tap.test('CERR-08: Rate limit bypass strategies', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
await smtpClient.connect();
console.log('\nLegitimate rate limit management strategies:');
// 1. Message batching
console.log('\n1. Message batching:');
const recipients = Array.from({ length: 50 }, (_, i) => `user${i}@example.com`);
const batchSize = 10;
for (let i = 0; i < recipients.length; i += batchSize) {
const batch = recipients.slice(i, i + batchSize);
const email = new Email({
from: 'sender@example.com',
to: batch,
subject: 'Batched message',
text: 'Sending in batches to respect rate limits'
});
console.log(` Batch ${Math.floor(i/batchSize) + 1}: ${batch.length} recipients`);
try {
await smtpClient.sendMail(email);
// Add delay between batches
if (i + batchSize < recipients.length) {
console.log(' Waiting 2 seconds before next batch...');
await new Promise(resolve => setTimeout(resolve, 2000));
}
} catch (error) {
console.log(` Batch failed: ${error.message}`);
}
}
// 2. Connection pooling with limits
console.log('\n2. Connection pooling:');
console.log(' Using multiple connections with per-connection limits');
console.log(' Example: 5 connections × 20 msg/min = 100 msg/min total');
// 3. Retry with backoff
console.log('\n3. Exponential backoff on rate limits:');
const backoffDelays = [1, 2, 4, 8, 16, 32];
backoffDelays.forEach((delay, attempt) => {
console.log(` Attempt ${attempt + 1}: Wait ${delay} seconds`);
});
await smtpClient.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await testServer.stop();
}
});
export default tap.start();

View File

@ -0,0 +1,616 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestSmtpServer } from '../../helpers/server.loader.js';
import { createSmtpClient } from '../../helpers/smtp.client.js';
import { Email } from '../../../ts/mail/core/classes.email.js';
import * as net from 'net';
let testServer: any;
tap.test('setup test SMTP server', async () => {
testServer = await startTestSmtpServer();
expect(testServer).toBeTruthy();
expect(testServer.port).toBeGreaterThan(0);
});
tap.test('CERR-09: Pool exhaustion', async () => {
const pooledClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
pool: true,
maxConnections: 3,
maxMessages: 100,
connectionTimeout: 5000,
debug: true
});
console.log('Testing connection pool exhaustion...');
console.log('Pool configuration: maxConnections=3');
// Track pool state
const poolStats = {
active: 0,
idle: 0,
pending: 0,
created: 0,
destroyed: 0
};
pooledClient.on('pool-connection-create', () => {
poolStats.created++;
console.log(` Pool: Connection created (total: ${poolStats.created})`);
});
pooledClient.on('pool-connection-close', () => {
poolStats.destroyed++;
console.log(` Pool: Connection closed (total: ${poolStats.destroyed})`);
});
// Send more concurrent messages than pool size
const messageCount = 10;
const emails = Array.from({ length: messageCount }, (_, i) => new Email({
from: 'sender@example.com',
to: [`recipient${i}@example.com`],
subject: `Pool test ${i}`,
text: 'Testing connection pool exhaustion'
}));
console.log(`\nSending ${messageCount} concurrent messages...`);
const startTime = Date.now();
const results = await Promise.allSettled(
emails.map((email, i) => {
return pooledClient.sendMail(email).then(() => {
console.log(` Message ${i}: Sent`);
return { index: i, status: 'sent' };
}).catch(error => {
console.log(` Message ${i}: Failed - ${error.message}`);
return { index: i, status: 'failed', error: error.message };
});
})
);
const elapsed = Date.now() - startTime;
const successful = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
console.log(`\nResults after ${elapsed}ms:`);
console.log(` Successful: ${successful}/${messageCount}`);
console.log(` Failed: ${failed}/${messageCount}`);
console.log(` Connections created: ${poolStats.created}`);
console.log(` Connections destroyed: ${poolStats.destroyed}`);
// Pool should limit concurrent connections
expect(poolStats.created).toBeLessThanOrEqual(3);
await pooledClient.close();
});
tap.test('CERR-09: Connection pool timeouts', async () => {
// Create slow server
const slowServer = net.createServer((socket) => {
socket.write('220 Slow Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
// Add delays to simulate slow responses
setTimeout(() => {
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
// Slow response for other commands
setTimeout(() => {
socket.write('250 OK\r\n');
}, 3000); // 3 second delay
}
}, 1000);
});
});
await new Promise<void>((resolve) => {
slowServer.listen(0, '127.0.0.1', () => resolve());
});
const slowPort = (slowServer.address() as net.AddressInfo).port;
const pooledClient = createSmtpClient({
host: '127.0.0.1',
port: slowPort,
secure: false,
pool: true,
maxConnections: 2,
poolTimeout: 2000, // 2 second timeout for getting connection from pool
commandTimeout: 4000,
debug: true
});
console.log('\nTesting connection pool timeouts...');
console.log('Pool timeout: 2 seconds');
// Send multiple messages to trigger pool timeout
const emails = Array.from({ length: 5 }, (_, i) => new Email({
from: 'sender@example.com',
to: [`recipient${i}@example.com`],
subject: `Timeout test ${i}`,
text: 'Testing pool timeout'
}));
const timeoutErrors = [];
await Promise.allSettled(
emails.map(async (email, i) => {
try {
console.log(` Message ${i}: Attempting to send...`);
await pooledClient.sendMail(email);
console.log(` Message ${i}: Sent successfully`);
} catch (error) {
console.log(` Message ${i}: ${error.message}`);
if (error.message.includes('timeout')) {
timeoutErrors.push(error);
}
}
})
);
console.log(`\nTimeout errors: ${timeoutErrors.length}`);
expect(timeoutErrors.length).toBeGreaterThan(0);
await pooledClient.close();
slowServer.close();
});
tap.test('CERR-09: Dead connection detection', async () => {
const pooledClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
pool: true,
maxConnections: 3,
poolIdleTimeout: 5000, // Connections idle for 5s are closed
poolPingInterval: 2000, // Ping idle connections every 2s
debug: true
});
console.log('\nTesting dead connection detection...');
// Track connection health checks
let pingCount = 0;
let deadConnections = 0;
pooledClient.on('pool-connection-ping', (result) => {
pingCount++;
console.log(` Ping ${pingCount}: ${result.alive ? 'Connection alive' : 'Connection dead'}`);
if (!result.alive) {
deadConnections++;
}
});
// Send initial message to create connection
await pooledClient.sendMail(new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Initial message',
text: 'Creating connection'
}));
console.log('Connection created, waiting for health checks...');
// Wait for health checks
await new Promise(resolve => setTimeout(resolve, 6000));
console.log(`\nHealth check results:`);
console.log(` Total pings: ${pingCount}`);
console.log(` Dead connections detected: ${deadConnections}`);
// Send another message to test connection recovery
try {
await pooledClient.sendMail(new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'After idle',
text: 'Testing after idle period'
}));
console.log('Message sent successfully after idle period');
} catch (error) {
console.log('Error after idle:', error.message);
}
await pooledClient.close();
});
tap.test('CERR-09: Pool connection limit per host', async () => {
// Create multiple servers
const servers = [];
for (let i = 0; i < 3; i++) {
const server = net.createServer((socket) => {
socket.write(`220 Server ${i + 1}\r\n`);
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write(`250 server${i + 1}.example.com\r\n`);
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('250 OK\r\n');
}
});
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
servers.push({
server,
port: (server.address() as net.AddressInfo).port
});
}
console.log('\nTesting per-host connection limits...');
// Create pooled client with per-host limits
const pooledClient = createSmtpClient({
pool: true,
maxConnections: 10, // Total pool size
maxConnectionsPerHost: 2, // Per-host limit
debug: true
});
// Track connections per host
const hostConnections: { [key: string]: number } = {};
pooledClient.on('pool-connection-create', (info) => {
const host = info.host || 'unknown';
hostConnections[host] = (hostConnections[host] || 0) + 1;
console.log(` Created connection to ${host} (total: ${hostConnections[host]})`);
});
// Send messages to different servers
const messages = [];
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 4; j++) {
messages.push({
server: i,
email: new Email({
from: 'sender@example.com',
to: [`recipient${j}@server${i}.com`],
subject: `Test ${j} to server ${i}`,
text: 'Testing per-host limits'
})
});
}
}
// Override host/port for each message
await Promise.allSettled(
messages.map(async ({ server, email }) => {
const client = createSmtpClient({
host: '127.0.0.1',
port: servers[server].port,
secure: false,
pool: true,
maxConnections: 10,
maxConnectionsPerHost: 2,
debug: false
});
try {
await client.sendMail(email);
console.log(` Sent to server ${server + 1}`);
} catch (error) {
console.log(` Failed to server ${server + 1}: ${error.message}`);
}
await client.close();
})
);
console.log('\nConnections per host:');
Object.entries(hostConnections).forEach(([host, count]) => {
console.log(` ${host}: ${count} connections`);
expect(count).toBeLessThanOrEqual(2); // Should respect per-host limit
});
// Clean up servers
servers.forEach(s => s.server.close());
});
tap.test('CERR-09: Connection pool recovery', async () => {
// Create unstable server
let shouldFail = true;
let requestCount = 0;
const unstableServer = net.createServer((socket) => {
requestCount++;
if (shouldFail && requestCount <= 3) {
// Abruptly close connection for first 3 requests
socket.destroy();
return;
}
socket.write('220 Unstable Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('250 OK\r\n');
}
});
});
await new Promise<void>((resolve) => {
unstableServer.listen(0, '127.0.0.1', () => resolve());
});
const unstablePort = (unstableServer.address() as net.AddressInfo).port;
const pooledClient = createSmtpClient({
host: '127.0.0.1',
port: unstablePort,
secure: false,
pool: true,
maxConnections: 2,
retryFailedConnections: true,
connectionRetryDelay: 1000,
debug: true
});
console.log('\nTesting connection pool recovery...');
console.log('Server will fail first 3 connection attempts');
// Track recovery attempts
let recoveryAttempts = 0;
pooledClient.on('pool-connection-retry', () => {
recoveryAttempts++;
console.log(` Recovery attempt ${recoveryAttempts}`);
});
// Try to send messages
const results = [];
for (let i = 0; i < 5; i++) {
const email = new Email({
from: 'sender@example.com',
to: [`recipient${i}@example.com`],
subject: `Recovery test ${i}`,
text: 'Testing connection recovery'
});
try {
console.log(`\nMessage ${i}: Attempting...`);
await pooledClient.sendMail(email);
console.log(`Message ${i}: Success`);
results.push('success');
} catch (error) {
console.log(`Message ${i}: Failed - ${error.message}`);
results.push('failed');
// After some failures, allow connections
if (i === 2) {
shouldFail = false;
console.log(' Server stabilized, connections should succeed now');
}
}
// Small delay between attempts
await new Promise(resolve => setTimeout(resolve, 500));
}
console.log('\nFinal results:', results);
const successCount = results.filter(r => r === 'success').length;
expect(successCount).toBeGreaterThan(0); // Should recover eventually
await pooledClient.close();
unstableServer.close();
});
tap.test('CERR-09: Pool metrics and monitoring', async () => {
const pooledClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
pool: true,
maxConnections: 5,
poolMetrics: true,
debug: true
});
console.log('\nTesting pool metrics collection...');
// Collect metrics
const metrics = {
connectionsCreated: 0,
connectionsDestroyed: 0,
messagesQueued: 0,
messagesSent: 0,
errors: 0,
avgWaitTime: 0,
waitTimes: [] as number[]
};
pooledClient.on('pool-metrics', (data) => {
Object.assign(metrics, data);
});
pooledClient.on('message-queued', () => {
metrics.messagesQueued++;
});
pooledClient.on('message-sent', (info) => {
metrics.messagesSent++;
if (info.waitTime) {
metrics.waitTimes.push(info.waitTime);
}
});
// Send batch of messages
const messageCount = 20;
const startTime = Date.now();
await Promise.allSettled(
Array.from({ length: messageCount }, (_, i) =>
pooledClient.sendMail(new Email({
from: 'sender@example.com',
to: [`recipient${i}@example.com`],
subject: `Metrics test ${i}`,
text: 'Testing pool metrics'
}))
)
);
const totalTime = Date.now() - startTime;
// Calculate average wait time
if (metrics.waitTimes.length > 0) {
metrics.avgWaitTime = metrics.waitTimes.reduce((a, b) => a + b, 0) / metrics.waitTimes.length;
}
// Get final pool status
const poolStatus = pooledClient.getPoolStatus();
console.log('\nPool Metrics:');
console.log(` Messages queued: ${metrics.messagesQueued}`);
console.log(` Messages sent: ${metrics.messagesSent}`);
console.log(` Average wait time: ${metrics.avgWaitTime.toFixed(2)}ms`);
console.log(` Total time: ${totalTime}ms`);
console.log(` Throughput: ${(messageCount / totalTime * 1000).toFixed(2)} msg/sec`);
console.log('\nPool Status:');
console.log(` Active connections: ${poolStatus.active}`);
console.log(` Idle connections: ${poolStatus.idle}`);
console.log(` Total connections: ${poolStatus.total}`);
await pooledClient.close();
});
tap.test('CERR-09: Connection affinity', async () => {
const pooledClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
pool: true,
maxConnections: 3,
connectionAffinity: 'sender', // Reuse same connection for same sender
debug: true
});
console.log('\nTesting connection affinity...');
// Track which connection handles which sender
const senderConnections: { [sender: string]: string } = {};
pooledClient.on('connection-assigned', (info) => {
senderConnections[info.sender] = info.connectionId;
console.log(` Sender ${info.sender} assigned to connection ${info.connectionId}`);
});
// Send messages from different senders
const senders = ['alice@example.com', 'bob@example.com', 'alice@example.com', 'charlie@example.com', 'bob@example.com'];
for (const sender of senders) {
const email = new Email({
from: sender,
to: ['recipient@example.com'],
subject: `From ${sender}`,
text: 'Testing connection affinity'
});
await pooledClient.sendMail(email);
const connectionId = senderConnections[sender];
console.log(` Message from ${sender} sent via connection ${connectionId}`);
}
// Verify affinity
console.log('\nConnection affinity results:');
const uniqueSenders = [...new Set(senders)];
uniqueSenders.forEach(sender => {
const messages = senders.filter(s => s === sender).length;
console.log(` ${sender}: ${messages} messages, connection ${senderConnections[sender]}`);
});
await pooledClient.close();
});
tap.test('CERR-09: Pool resource cleanup', async () => {
const pooledClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
pool: true,
maxConnections: 3,
poolCleanupInterval: 1000, // Clean up every second
debug: true
});
console.log('\nTesting pool resource cleanup...');
// Track cleanup events
const cleanupStats = {
idleClosed: 0,
staleClosed: 0,
errorClosed: 0
};
pooledClient.on('pool-connection-cleanup', (reason) => {
switch (reason.type) {
case 'idle':
cleanupStats.idleClosed++;
console.log(` Closed idle connection: ${reason.connectionId}`);
break;
case 'stale':
cleanupStats.staleClosed++;
console.log(` Closed stale connection: ${reason.connectionId}`);
break;
case 'error':
cleanupStats.errorClosed++;
console.log(` Closed errored connection: ${reason.connectionId}`);
break;
}
});
// Send some messages
for (let i = 0; i < 3; i++) {
await pooledClient.sendMail(new Email({
from: 'sender@example.com',
to: [`recipient${i}@example.com`],
subject: `Cleanup test ${i}`,
text: 'Testing cleanup'
}));
}
console.log('Messages sent, waiting for cleanup...');
// Wait for cleanup cycles
await new Promise(resolve => setTimeout(resolve, 5000));
console.log('\nCleanup statistics:');
console.log(` Idle connections closed: ${cleanupStats.idleClosed}`);
console.log(` Stale connections closed: ${cleanupStats.staleClosed}`);
console.log(` Errored connections closed: ${cleanupStats.errorClosed}`);
const finalStatus = pooledClient.getPoolStatus();
console.log(`\nFinal pool status:`);
console.log(` Active: ${finalStatus.active}`);
console.log(` Idle: ${finalStatus.idle}`);
console.log(` Total: ${finalStatus.total}`);
await pooledClient.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await testServer.stop();
}
});
export default tap.start();

View File

@ -0,0 +1,628 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { startTestSmtpServer } from '../../helpers/server.loader.js';
import { createSmtpClient } from '../../helpers/smtp.client.js';
import { Email } from '../../../ts/mail/core/classes.email.js';
import * as net from 'net';
let testServer: any;
tap.test('setup test SMTP server', async () => {
testServer = await startTestSmtpServer();
expect(testServer).toBeTruthy();
expect(testServer.port).toBeGreaterThan(0);
});
tap.test('CERR-10: Partial recipient failure', async () => {
// Create server that accepts some recipients and rejects others
const partialFailureServer = net.createServer((socket) => {
socket.write('220 Partial Failure Test Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.startsWith('RCPT TO')) {
const recipient = command.match(/<([^>]+)>/)?.[1] || '';
// Accept/reject based on recipient
if (recipient.includes('valid')) {
socket.write('250 OK\r\n');
} else if (recipient.includes('invalid')) {
socket.write('550 5.1.1 User unknown\r\n');
} else if (recipient.includes('full')) {
socket.write('452 4.2.2 Mailbox full\r\n');
} else if (recipient.includes('greylisted')) {
socket.write('451 4.7.1 Greylisted, try again later\r\n');
} else {
socket.write('250 OK\r\n');
}
} else if (command === 'DATA') {
socket.write('354 Send data\r\n');
} else if (command === '.') {
socket.write('250 OK - delivered to accepted recipients only\r\n');
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
await new Promise<void>((resolve) => {
partialFailureServer.listen(0, '127.0.0.1', () => resolve());
});
const partialPort = (partialFailureServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: partialPort,
secure: false,
connectionTimeout: 5000,
continueOnRecipientError: true, // Continue even if some recipients fail
debug: true
});
console.log('Testing partial recipient failure...');
await smtpClient.connect();
const email = new Email({
from: 'sender@example.com',
to: [
'valid1@example.com',
'invalid@example.com',
'valid2@example.com',
'full@example.com',
'valid3@example.com',
'greylisted@example.com'
],
subject: 'Partial failure test',
text: 'Testing partial recipient failures'
});
try {
const result = await smtpClient.sendMail(email);
console.log('\nPartial send results:');
console.log(` Total recipients: ${email.to.length}`);
console.log(` Accepted: ${result.accepted?.length || 0}`);
console.log(` Rejected: ${result.rejected?.length || 0}`);
console.log(` Pending: ${result.pending?.length || 0}`);
if (result.accepted && result.accepted.length > 0) {
console.log('\nAccepted recipients:');
result.accepted.forEach(r => console.log(`${r}`));
}
if (result.rejected && result.rejected.length > 0) {
console.log('\nRejected recipients:');
result.rejected.forEach(r => console.log(`${r.recipient}: ${r.reason}`));
}
if (result.pending && result.pending.length > 0) {
console.log('\nPending recipients (temporary failures):');
result.pending.forEach(r => console.log(`${r.recipient}: ${r.reason}`));
}
// Should have partial success
expect(result.accepted?.length).toBeGreaterThan(0);
expect(result.rejected?.length).toBeGreaterThan(0);
} catch (error) {
console.log('Unexpected complete failure:', error.message);
}
await smtpClient.close();
partialFailureServer.close();
});
tap.test('CERR-10: Partial failure policies', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
await smtpClient.connect();
console.log('\nTesting different partial failure policies:');
// Policy configurations
const policies = [
{
name: 'Fail if any recipient fails',
continueOnError: false,
minSuccessRate: 1.0
},
{
name: 'Continue if any recipient succeeds',
continueOnError: true,
minSuccessRate: 0.01
},
{
name: 'Require 50% success rate',
continueOnError: true,
minSuccessRate: 0.5
},
{
name: 'Require at least 2 recipients',
continueOnError: true,
minSuccessCount: 2
}
];
for (const policy of policies) {
console.log(`\n${policy.name}:`);
console.log(` Continue on error: ${policy.continueOnError}`);
if (policy.minSuccessRate !== undefined) {
console.log(` Min success rate: ${(policy.minSuccessRate * 100).toFixed(0)}%`);
}
if (policy.minSuccessCount !== undefined) {
console.log(` Min success count: ${policy.minSuccessCount}`);
}
// Simulate applying policy
const results = {
accepted: ['user1@example.com', 'user2@example.com'],
rejected: ['invalid@example.com'],
total: 3
};
const successRate = results.accepted.length / results.total;
let shouldProceed = policy.continueOnError;
if (policy.minSuccessRate !== undefined) {
shouldProceed = shouldProceed && (successRate >= policy.minSuccessRate);
}
if (policy.minSuccessCount !== undefined) {
shouldProceed = shouldProceed && (results.accepted.length >= policy.minSuccessCount);
}
console.log(` With ${results.accepted.length}/${results.total} success: ${shouldProceed ? 'PROCEED' : 'FAIL'}`);
}
await smtpClient.close();
});
tap.test('CERR-10: Partial data transmission failure', async () => {
// Server that fails during DATA phase
const dataFailureServer = net.createServer((socket) => {
let dataSize = 0;
let inData = false;
socket.write('220 Data Failure Test Server\r\n');
socket.on('data', (data) => {
const command = data.toString();
if (command.trim().startsWith('EHLO')) {
socket.write('250 OK\r\n');
} else if (command.trim().startsWith('MAIL FROM')) {
socket.write('250 OK\r\n');
} else if (command.trim().startsWith('RCPT TO')) {
socket.write('250 OK\r\n');
} else if (command.trim() === 'DATA') {
inData = true;
dataSize = 0;
socket.write('354 Send data\r\n');
} else if (inData) {
dataSize += data.length;
// Fail after receiving 1KB of data
if (dataSize > 1024) {
socket.write('451 4.3.0 Message transmission failed\r\n');
socket.destroy();
return;
}
if (command.includes('\r\n.\r\n')) {
inData = false;
socket.write('250 OK\r\n');
}
} else if (command.trim() === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
}
});
});
await new Promise<void>((resolve) => {
dataFailureServer.listen(0, '127.0.0.1', () => resolve());
});
const dataFailurePort = (dataFailureServer.address() as net.AddressInfo).port;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: dataFailurePort,
secure: false,
connectionTimeout: 5000,
debug: true
});
console.log('\nTesting partial data transmission failure...');
await smtpClient.connect();
// Try to send large message that will fail during transmission
const largeEmail = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Large message test',
text: 'x'.repeat(2048) // 2KB - will fail after 1KB
});
try {
await smtpClient.sendMail(largeEmail);
console.log('Unexpected success');
} catch (error) {
console.log('Data transmission failed as expected:', error.message);
expect(error.message).toMatch(/451|transmission|failed/i);
}
// Try smaller message that should succeed
const smallEmail = new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Small message test',
text: 'This is a small message'
});
// Need new connection after failure
await smtpClient.close();
await smtpClient.connect();
try {
await smtpClient.sendMail(smallEmail);
console.log('Small message sent successfully');
} catch (error) {
console.log('Small message also failed:', error.message);
}
await smtpClient.close();
dataFailureServer.close();
});
tap.test('CERR-10: Partial failure recovery strategies', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
partialFailureStrategy: 'retry-failed',
debug: true
});
await smtpClient.connect();
console.log('\nPartial failure recovery strategies:');
const strategies = [
{
name: 'Retry failed recipients',
description: 'Queue failed recipients for retry',
implementation: async (result: any) => {
if (result.rejected && result.rejected.length > 0) {
console.log(` Queueing ${result.rejected.length} recipients for retry`);
// Would implement retry queue here
}
}
},
{
name: 'Bounce failed recipients',
description: 'Send bounce notifications immediately',
implementation: async (result: any) => {
if (result.rejected && result.rejected.length > 0) {
console.log(` Generating bounce messages for ${result.rejected.length} recipients`);
// Would generate NDR here
}
}
},
{
name: 'Split and retry',
description: 'Split into individual messages',
implementation: async (result: any) => {
if (result.rejected && result.rejected.length > 0) {
console.log(` Splitting into ${result.rejected.length} individual messages`);
// Would send individual messages here
}
}
},
{
name: 'Fallback transport',
description: 'Try alternative delivery method',
implementation: async (result: any) => {
if (result.rejected && result.rejected.length > 0) {
console.log(` Attempting fallback delivery for ${result.rejected.length} recipients`);
// Would try alternative server/route here
}
}
}
];
// Simulate partial failure
const mockResult = {
accepted: ['user1@example.com', 'user2@example.com'],
rejected: [
{ recipient: 'invalid@example.com', reason: '550 User unknown' },
{ recipient: 'full@example.com', reason: '552 Mailbox full' }
],
pending: [
{ recipient: 'greylisted@example.com', reason: '451 Greylisted' }
]
};
for (const strategy of strategies) {
console.log(`\n${strategy.name}:`);
console.log(` Description: ${strategy.description}`);
await strategy.implementation(mockResult);
}
await smtpClient.close();
});
tap.test('CERR-10: Transaction state after partial failure', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
debug: true
});
await smtpClient.connect();
console.log('\nTesting transaction state after partial failure...');
// Start transaction
await smtpClient.sendCommand('MAIL FROM:<sender@example.com>');
// Add recipients with mixed results
const recipients = [
{ email: 'valid@example.com', shouldSucceed: true },
{ email: 'invalid@nonexistent.com', shouldSucceed: false },
{ email: 'another@example.com', shouldSucceed: true }
];
const results = [];
for (const recipient of recipients) {
try {
const response = await smtpClient.sendCommand(`RCPT TO:<${recipient.email}>`);
results.push({
email: recipient.email,
success: response.startsWith('250'),
response: response.trim()
});
} catch (error) {
results.push({
email: recipient.email,
success: false,
response: error.message
});
}
}
console.log('\nRecipient results:');
results.forEach(r => {
console.log(` ${r.email}: ${r.success ? '✓' : '✗'} ${r.response}`);
});
const acceptedCount = results.filter(r => r.success).length;
if (acceptedCount > 0) {
console.log(`\n${acceptedCount} recipients accepted, proceeding with DATA...`);
try {
const dataResponse = await smtpClient.sendCommand('DATA');
console.log('DATA response:', dataResponse.trim());
if (dataResponse.startsWith('354')) {
await smtpClient.sendCommand('Subject: Partial recipient test\r\n\r\nTest message\r\n.');
console.log('Message sent to accepted recipients');
}
} catch (error) {
console.log('DATA phase error:', error.message);
}
} else {
console.log('\nNo recipients accepted, resetting transaction');
await smtpClient.sendCommand('RSET');
}
await smtpClient.close();
});
tap.test('CERR-10: Partial authentication failure', async () => {
// Server with selective authentication
const authFailureServer = net.createServer((socket) => {
socket.write('220 Auth Failure Test Server\r\n');
socket.on('data', (data) => {
const command = data.toString().trim();
if (command.startsWith('EHLO')) {
socket.write('250-authfailure.example.com\r\n');
socket.write('250-AUTH PLAIN LOGIN\r\n');
socket.write('250 OK\r\n');
} else if (command.startsWith('AUTH')) {
// Randomly fail authentication
if (Math.random() > 0.5) {
socket.write('235 2.7.0 Authentication successful\r\n');
} else {
socket.write('535 5.7.8 Authentication credentials invalid\r\n');
}
} else if (command === 'QUIT') {
socket.write('221 Bye\r\n');
socket.end();
} else {
socket.write('250 OK\r\n');
}
});
});
await new Promise<void>((resolve) => {
authFailureServer.listen(0, '127.0.0.1', () => resolve());
});
const authPort = (authFailureServer.address() as net.AddressInfo).port;
console.log('\nTesting partial authentication failure with fallback...');
// Try multiple authentication methods
const authMethods = [
{ method: 'PLAIN', credentials: 'user1:pass1' },
{ method: 'LOGIN', credentials: 'user2:pass2' },
{ method: 'PLAIN', credentials: 'user3:pass3' }
];
let authenticated = false;
let attempts = 0;
for (const auth of authMethods) {
attempts++;
const smtpClient = createSmtpClient({
host: '127.0.0.1',
port: authPort,
secure: false,
auth: {
method: auth.method,
user: auth.credentials.split(':')[0],
pass: auth.credentials.split(':')[1]
},
connectionTimeout: 5000,
debug: true
});
console.log(`\nAttempt ${attempts}: ${auth.method} authentication`);
try {
await smtpClient.connect();
authenticated = true;
console.log('Authentication successful');
// Send test message
await smtpClient.sendMail(new Email({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Auth test',
text: 'Successfully authenticated'
}));
await smtpClient.close();
break;
} catch (error) {
console.log('Authentication failed:', error.message);
await smtpClient.close();
}
}
console.log(`\nAuthentication ${authenticated ? 'succeeded' : 'failed'} after ${attempts} attempts`);
authFailureServer.close();
});
tap.test('CERR-10: Partial failure reporting', async () => {
const smtpClient = createSmtpClient({
host: testServer.hostname,
port: testServer.port,
secure: false,
connectionTimeout: 5000,
generatePartialFailureReport: true,
debug: true
});
await smtpClient.connect();
console.log('\nGenerating partial failure report...');
// Simulate partial failure result
const partialResult = {
messageId: '<123456@example.com>',
timestamp: new Date(),
from: 'sender@example.com',
accepted: [
'user1@example.com',
'user2@example.com',
'user3@example.com'
],
rejected: [
{ recipient: 'invalid@example.com', code: '550', reason: 'User unknown' },
{ recipient: 'full@example.com', code: '552', reason: 'Mailbox full' }
],
pending: [
{ recipient: 'grey@example.com', code: '451', reason: 'Greylisted' }
]
};
// Generate failure report
const report = {
summary: {
total: partialResult.accepted.length + partialResult.rejected.length + partialResult.pending.length,
delivered: partialResult.accepted.length,
failed: partialResult.rejected.length,
deferred: partialResult.pending.length,
successRate: ((partialResult.accepted.length / (partialResult.accepted.length + partialResult.rejected.length + partialResult.pending.length)) * 100).toFixed(1)
},
details: {
messageId: partialResult.messageId,
timestamp: partialResult.timestamp.toISOString(),
from: partialResult.from,
recipients: {
delivered: partialResult.accepted,
failed: partialResult.rejected.map(r => ({
address: r.recipient,
error: `${r.code} ${r.reason}`,
permanent: r.code.startsWith('5')
})),
deferred: partialResult.pending.map(r => ({
address: r.recipient,
error: `${r.code} ${r.reason}`,
retryAfter: new Date(Date.now() + 300000).toISOString() // 5 minutes
}))
}
},
actions: {
failed: 'Generate bounce notifications',
deferred: 'Queue for retry in 5 minutes'
}
};
console.log('\nPartial Failure Report:');
console.log(JSON.stringify(report, null, 2));
// Send notification email about partial failure
const notificationEmail = new Email({
from: 'postmaster@example.com',
to: ['sender@example.com'],
subject: 'Partial delivery failure',
text: `Your message ${partialResult.messageId} was partially delivered.\n\n` +
`Delivered: ${report.summary.delivered}\n` +
`Failed: ${report.summary.failed}\n` +
`Deferred: ${report.summary.deferred}\n` +
`Success rate: ${report.summary.successRate}%`
});
try {
await smtpClient.sendMail(notificationEmail);
console.log('\nPartial failure notification sent');
} catch (error) {
console.log('Failed to send notification:', error.message);
}
await smtpClient.close();
});
tap.test('cleanup test SMTP server', async () => {
if (testServer) {
await testServer.stop();
}
});
export default tap.start();