import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as net from 'net'; import * as path from 'path'; import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; import type { SmtpServer } from '../../../ts/mail/delivery/smtpserver/index.js'; // Test configuration const TEST_PORT = 2525; const TEST_TIMEOUT = 60000; // Increased for large email handling let testServer: SmtpServer; // Setup tap.test('setup - start SMTP server', async () => { testServer = await startTestServer(); await new Promise(resolve => setTimeout(resolve, 1000)); }); // 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:\r\n'); } else if (currentStep === 'mail_from' && receivedData.includes('250')) { currentStep = 'rcpt_to'; socket.write('RCPT TO:\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:\r\n'); } else if (currentStep === 'mail_from' && receivedData.includes('250')) { currentStep = 'rcpt_to'; socket.write('RCPT TO:\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: 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:\r\n'); } else if (currentStep === 'mail_from' && receivedData.includes('250')) { currentStep = 'rcpt_to'; socket.write('RCPT TO:\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') { const responseCode = receivedData.match(/(\d{3})/)?.[1]; if (responseCode && !completed) { completed = true; 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:\r\n'); } else if (currentStep === 'mail_from' && receivedData.includes('250')) { currentStep = 'rcpt_to'; socket.write('RCPT TO:\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:\r\n'); } else if (currentStep === 'mail_from' && receivedData.includes('250')) { currentStep = 'rcpt_to'; socket.write('RCPT TO:\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') { const responseCode = receivedData.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(); } }); // Start the test tap.start();