dcrouter/test/suite/smtpclient_error-handling/test.cerr-10.partial-failure.ts
2025-05-24 17:00:59 +00:00

628 lines
19 KiB
TypeScript

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();