dcrouter/test/suite/smtpserver_email-processing/test.ep-04.large-email.ts
2025-05-25 19:05:43 +00:00

528 lines
16 KiB
TypeScript

import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import * as path from 'path';
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
// Test configuration
const TEST_PORT = 30048;
const TEST_TIMEOUT = 60000; // Increased for large email handling
let testServer: ITestServer;
// Setup
tap.test('setup - start SMTP server', async () => {
testServer = await startTestServer({ port: TEST_PORT, hostname: 'localhost' });
expect(testServer).toBeDefined();
});
// Test: Moderately large email (1MB)
tap.test('Large Email - should handle 1MB email', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
let receivedData = '';
let currentStep = 'connecting';
let completed = false;
// Generate 1MB of content
const largeBody = 'X'.repeat(1024 * 1024); // 1MB
const emailContent = `Subject: 1MB Email Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\n${largeBody}\r\n`;
socket.on('data', (data) => {
receivedData += data.toString();
if (currentStep === 'connecting' && receivedData.includes('220')) {
currentStep = 'ehlo';
socket.write('EHLO test.example.com\r\n');
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
currentStep = 'mail_from';
socket.write('MAIL FROM:<sender@example.com>\r\n');
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
currentStep = 'rcpt_to';
socket.write('RCPT TO:<recipient@example.com>\r\n');
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
currentStep = 'data';
socket.write('DATA\r\n');
} else if (currentStep === 'data' && receivedData.includes('354')) {
currentStep = 'sending_large_email';
// Send in chunks to avoid overwhelming
const chunkSize = 64 * 1024; // 64KB chunks
let sent = 0;
const sendChunk = () => {
if (sent < emailContent.length) {
const chunk = emailContent.slice(sent, sent + chunkSize);
socket.write(chunk);
sent += chunk.length;
// Small delay between chunks
if (sent < emailContent.length) {
setTimeout(sendChunk, 10);
} else {
// End of data
socket.write('.\r\n');
currentStep = 'sent';
}
}
};
sendChunk();
} else if (currentStep === 'sent' && (receivedData.includes('250') || receivedData.includes('552'))) {
if (!completed) {
completed = true;
socket.write('QUIT\r\n');
setTimeout(() => {
socket.destroy();
// Either accepted (250) or size exceeded (552)
expect(receivedData).toMatch(/250|552/);
done.resolve();
}, 100);
}
}
});
socket.on('error', (error) => {
done.reject(error);
});
socket.on('timeout', () => {
socket.destroy();
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
});
await done.promise;
});
// Test: Large email with MIME attachments
tap.test('Large Email - should handle multi-part MIME message', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
let receivedData = '';
let currentStep = 'connecting';
let completed = false;
const boundary = '----=_Part_0_123456789';
const attachment1 = 'A'.repeat(500 * 1024); // 500KB
const attachment2 = 'B'.repeat(300 * 1024); // 300KB
const emailContent = [
'Subject: Large MIME Email Test',
'From: sender@example.com',
'To: recipient@example.com',
'MIME-Version: 1.0',
`Content-Type: multipart/mixed; boundary="${boundary}"`,
'',
'This is a multi-part message in MIME format.',
'',
`--${boundary}`,
'Content-Type: text/plain; charset=utf-8',
'',
'This email contains large attachments.',
'',
`--${boundary}`,
'Content-Type: text/plain; charset=utf-8',
'Content-Disposition: attachment; filename="file1.txt"',
'',
attachment1,
'',
`--${boundary}`,
'Content-Type: application/octet-stream',
'Content-Disposition: attachment; filename="file2.bin"',
'Content-Transfer-Encoding: base64',
'',
Buffer.from(attachment2).toString('base64'),
'',
`--${boundary}--`,
''
].join('\r\n');
socket.on('data', (data) => {
receivedData += data.toString();
if (currentStep === 'connecting' && receivedData.includes('220')) {
currentStep = 'ehlo';
socket.write('EHLO test.example.com\r\n');
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
currentStep = 'mail_from';
socket.write('MAIL FROM:<sender@example.com>\r\n');
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
currentStep = 'rcpt_to';
socket.write('RCPT TO:<recipient@example.com>\r\n');
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
currentStep = 'data';
socket.write('DATA\r\n');
} else if (currentStep === 'data' && receivedData.includes('354')) {
currentStep = 'sending_mime';
socket.write(emailContent);
socket.write('\r\n.\r\n');
currentStep = 'sent';
} else if (currentStep === 'sent' && (receivedData.includes('250') || receivedData.includes('552'))) {
if (!completed) {
completed = true;
socket.write('QUIT\r\n');
setTimeout(() => {
socket.destroy();
expect(receivedData).toMatch(/250|552/);
done.resolve();
}, 100);
}
}
});
socket.on('error', (error) => {
done.reject(error);
});
socket.on('timeout', () => {
socket.destroy();
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
});
await done.promise;
});
// Test: Email size limits with SIZE extension
tap.test('Large Email - should respect SIZE limits if advertised', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
let receivedData = '';
let currentStep = 'connecting';
let maxSize: number | null = null;
socket.on('data', (data) => {
receivedData += data.toString();
if (currentStep === 'connecting' && receivedData.includes('220')) {
currentStep = 'ehlo';
socket.write('EHLO test.example.com\r\n');
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
// Check for SIZE extension
const sizeMatch = receivedData.match(/SIZE\s+(\d+)/);
if (sizeMatch) {
maxSize = parseInt(sizeMatch[1]);
console.log(`Server advertises max size: ${maxSize} bytes`);
}
currentStep = 'mail_from';
const emailSize = maxSize ? maxSize + 1000 : 5000000; // Over limit or 5MB
socket.write(`MAIL FROM:<sender@example.com> SIZE=${emailSize}\r\n`);
} else if (currentStep === 'mail_from') {
if (maxSize && receivedData.includes('552')) {
// Size rejected - expected
socket.write('QUIT\r\n');
setTimeout(() => {
socket.destroy();
expect(receivedData).toInclude('552');
done.resolve();
}, 100);
} else if (receivedData.includes('250')) {
// Size accepted or no limit
socket.write('QUIT\r\n');
setTimeout(() => {
socket.destroy();
done.resolve();
}, 100);
}
}
});
socket.on('error', (error) => {
done.reject(error);
});
socket.on('timeout', () => {
socket.destroy();
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
});
await done.promise;
});
// Test: Very large email handling (5MB)
tap.test('Large Email - should handle or reject very large emails gracefully', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
let receivedData = '';
let currentStep = 'connecting';
let completed = false;
// Generate 5MB email
const largeContent = 'X'.repeat(5 * 1024 * 1024); // 5MB
const emailContent = `Subject: 5MB Email Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\n${largeContent}\r\n`;
socket.on('data', (data) => {
receivedData += data.toString();
if (currentStep === 'connecting' && receivedData.includes('220')) {
currentStep = 'ehlo';
socket.write('EHLO test.example.com\r\n');
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
currentStep = 'mail_from';
socket.write('MAIL FROM:<sender@example.com>\r\n');
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
currentStep = 'rcpt_to';
socket.write('RCPT TO:<recipient@example.com>\r\n');
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
currentStep = 'data';
socket.write('DATA\r\n');
} else if (currentStep === 'data' && receivedData.includes('354')) {
currentStep = 'sending_5mb';
console.log('Sending 5MB email...');
// Send in larger chunks for efficiency
const chunkSize = 256 * 1024; // 256KB chunks
let sent = 0;
const sendChunk = () => {
if (sent < emailContent.length) {
const chunk = emailContent.slice(sent, sent + chunkSize);
socket.write(chunk);
sent += chunk.length;
if (sent < emailContent.length) {
setImmediate(sendChunk); // Use setImmediate for better performance
} else {
socket.write('.\r\n');
currentStep = 'sent';
}
}
};
sendChunk();
} else if (currentStep === 'sent' && receivedData.match(/[245]\d{2}/)) {
if (!completed) {
completed = true;
// Extract the last response code
const lines = receivedData.split('\r\n');
let responseCode = '';
// Look for the most recent response code
for (let i = lines.length - 1; i >= 0; i--) {
const match = lines[i].match(/^([245]\d{2})[\s-]/);
if (match) {
responseCode = match[1];
break;
}
}
// If we couldn't extract, but we know there's a response, default to 250
if (!responseCode && receivedData.includes('250 OK message queued')) {
responseCode = '250';
}
socket.write('QUIT\r\n');
setTimeout(() => {
socket.destroy();
// Accept various responses: 250 (accepted), 552 (size exceeded), 554 (failed)
expect(responseCode).toMatch(/^(250|552|554|451|452)$/);
done.resolve();
}, 100);
}
}
});
socket.on('error', (error) => {
// Connection errors during large transfers are acceptable
if (currentStep === 'sending_5mb' || currentStep === 'sent') {
done.resolve();
} else {
done.reject(error);
}
});
socket.on('timeout', () => {
socket.destroy();
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
});
await done.promise;
});
// Test: Chunked transfer handling
tap.test('Large Email - should handle chunked transfers properly', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
let receivedData = '';
let currentStep = 'connecting';
let chunksSent = 0;
let completed = false;
socket.on('data', (data) => {
receivedData += data.toString();
if (currentStep === 'connecting' && receivedData.includes('220')) {
currentStep = 'ehlo';
socket.write('EHLO test.example.com\r\n');
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
currentStep = 'mail_from';
socket.write('MAIL FROM:<sender@example.com>\r\n');
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
currentStep = 'rcpt_to';
socket.write('RCPT TO:<recipient@example.com>\r\n');
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
currentStep = 'data';
socket.write('DATA\r\n');
} else if (currentStep === 'data' && receivedData.includes('354')) {
currentStep = 'chunked_sending';
// Send headers
socket.write('Subject: Chunked Transfer Test\r\n');
socket.write('From: sender@example.com\r\n');
socket.write('To: recipient@example.com\r\n');
socket.write('\r\n');
// Send body in multiple chunks with delays
const chunks = [
'First chunk of data\r\n',
'Second chunk of data\r\n',
'Third chunk of data\r\n',
'Fourth chunk of data\r\n',
'Final chunk of data\r\n'
];
const sendNextChunk = () => {
if (chunksSent < chunks.length) {
socket.write(chunks[chunksSent]);
chunksSent++;
setTimeout(sendNextChunk, 100); // 100ms delay between chunks
} else {
socket.write('.\r\n');
}
};
sendNextChunk();
} else if (currentStep === 'chunked_sending' && receivedData.includes('250')) {
if (!completed) {
completed = true;
socket.write('QUIT\r\n');
setTimeout(() => {
socket.destroy();
expect(chunksSent).toEqual(5);
done.resolve();
}, 100);
}
}
});
socket.on('error', (error) => {
done.reject(error);
});
socket.on('timeout', () => {
socket.destroy();
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
});
await done.promise;
});
// Test: Email with very long lines
tap.test('Large Email - should handle emails with very long lines', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
let receivedData = '';
let currentStep = 'connecting';
let completed = false;
// Create a very long line (10KB)
const veryLongLine = 'A'.repeat(10 * 1024);
const emailContent = `Subject: Long Line Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\n${veryLongLine}\r\nNormal line after long line.\r\n`;
socket.on('data', (data) => {
receivedData += data.toString();
if (currentStep === 'connecting' && receivedData.includes('220')) {
currentStep = 'ehlo';
socket.write('EHLO test.example.com\r\n');
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
currentStep = 'mail_from';
socket.write('MAIL FROM:<sender@example.com>\r\n');
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
currentStep = 'rcpt_to';
socket.write('RCPT TO:<recipient@example.com>\r\n');
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
currentStep = 'data';
socket.write('DATA\r\n');
} else if (currentStep === 'data' && receivedData.includes('354')) {
currentStep = 'long_line';
socket.write(emailContent);
socket.write('.\r\n');
currentStep = 'sent';
} else if (currentStep === 'sent') {
// Extract the last response code from the received data
// Look for response codes that are at the beginning of a line
const responseMatches = receivedData.split('\r\n').filter(line => /^\d{3}\s/.test(line));
const lastResponseLine = responseMatches[responseMatches.length - 1];
const responseCode = lastResponseLine?.match(/^(\d{3})/)?.[1];
if (responseCode && !completed) {
completed = true;
socket.write('QUIT\r\n');
setTimeout(() => {
socket.destroy();
// May accept or reject based on line length limits
expect(responseCode).toMatch(/^(250|500|501|552)$/);
done.resolve();
}, 100);
}
}
});
socket.on('error', (error) => {
done.reject(error);
});
socket.on('timeout', () => {
socket.destroy();
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
});
await done.promise;
});
// Teardown
tap.test('teardown - stop SMTP server', async () => {
if (testServer) {
await stopTestServer(testServer);
}
expect(true).toEqual(true);
});
// Start the test
export default tap.start();