528 lines
16 KiB
TypeScript
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(); |