import { expect, tap } from '@git.zone/tstest/tapbundle'; import { createTestServer } from '../../helpers/server.loader.js'; import { createTestSmtpClient } from '../../helpers/smtp.client.js'; import { Email } from '../../../ts/index.js'; tap.test('CRFC-05: should comply with SMTP state machine (RFC 5321)', async (tools) => { const testId = 'CRFC-05-state-machine'; console.log(`\n${testId}: Testing SMTP state machine compliance...`); let scenarioCount = 0; // Scenario 1: Initial state and greeting await (async () => { scenarioCount++; console.log(`\nScenario ${scenarioCount}: Testing initial state and greeting`); const testServer = await createTestServer({ onConnection: async (socket) => { console.log(' [Server] Client connected - Initial state'); let state = 'initial'; // Send greeting immediately upon connection socket.write('220 statemachine.example.com ESMTP Service ready\r\n'); state = 'greeting-sent'; console.log(' [Server] State: initial -> greeting-sent'); socket.on('data', (data) => { const command = data.toString().trim(); console.log(` [Server] State: ${state}, Received: ${command}`); if (state === 'greeting-sent') { if (command.startsWith('EHLO') || command.startsWith('HELO')) { socket.write('250 statemachine.example.com\r\n'); state = 'ready'; console.log(' [Server] State: greeting-sent -> ready'); } else if (command === 'QUIT') { socket.write('221 Bye\r\n'); socket.end(); } else { socket.write('503 5.5.1 Bad sequence of commands\r\n'); } } else if (state === 'ready') { if (command.startsWith('MAIL FROM:')) { socket.write('250 OK\r\n'); state = 'mail'; console.log(' [Server] State: ready -> mail'); } else if (command.startsWith('EHLO') || command.startsWith('HELO')) { socket.write('250 statemachine.example.com\r\n'); // Stay in ready state } else if (command === 'RSET' || command === 'NOOP') { socket.write('250 OK\r\n'); // Stay in ready state } else if (command === 'QUIT') { socket.write('221 Bye\r\n'); socket.end(); } else { socket.write('503 5.5.1 Bad sequence of commands\r\n'); } } }); } }); const smtpClient = createTestSmtpClient({ host: testServer.hostname, port: testServer.port, secure: false }); // Just establish connection and send EHLO try { await smtpClient.verify(); console.log(' Initial state transition (connect -> EHLO) successful'); } catch (error) { console.log(` Connection/EHLO failed: ${error.message}`); } await testServer.server.close(); })(); // Scenario 2: Transaction state machine await (async () => { scenarioCount++; console.log(`\nScenario ${scenarioCount}: Testing transaction state machine`); const testServer = await createTestServer({ onConnection: async (socket) => { console.log(' [Server] Client connected'); socket.write('220 statemachine.example.com ESMTP\r\n'); let state = 'ready'; socket.on('data', (data) => { const command = data.toString().trim(); console.log(` [Server] State: ${state}, Command: ${command}`); switch (state) { case 'ready': if (command.startsWith('EHLO')) { socket.write('250 statemachine.example.com\r\n'); // Stay in ready } else if (command.startsWith('MAIL FROM:')) { socket.write('250 OK\r\n'); state = 'mail'; console.log(' [Server] State: ready -> mail'); } else if (command === 'QUIT') { socket.write('221 Bye\r\n'); socket.end(); } else { socket.write('503 5.5.1 Bad sequence of commands\r\n'); } break; case 'mail': if (command.startsWith('RCPT TO:')) { socket.write('250 OK\r\n'); state = 'rcpt'; console.log(' [Server] State: mail -> rcpt'); } else if (command === 'RSET') { socket.write('250 OK\r\n'); state = 'ready'; console.log(' [Server] State: mail -> ready (RSET)'); } else if (command === 'QUIT') { socket.write('221 Bye\r\n'); socket.end(); } else { socket.write('503 5.5.1 Bad sequence of commands\r\n'); } break; case 'rcpt': if (command.startsWith('RCPT TO:')) { socket.write('250 OK\r\n'); // Stay in rcpt (can have multiple recipients) } else if (command === 'DATA') { socket.write('354 Start mail input\r\n'); state = 'data'; console.log(' [Server] State: rcpt -> data'); } else if (command === 'RSET') { socket.write('250 OK\r\n'); state = 'ready'; console.log(' [Server] State: rcpt -> ready (RSET)'); } else if (command === 'QUIT') { socket.write('221 Bye\r\n'); socket.end(); } else { socket.write('503 5.5.1 Bad sequence of commands\r\n'); } break; case 'data': if (command === '.') { socket.write('250 OK\r\n'); state = 'ready'; console.log(' [Server] State: data -> ready (message complete)'); } else if (command === 'QUIT') { // QUIT is not allowed during DATA socket.write('503 5.5.1 Bad sequence of commands\r\n'); } // All other input during DATA is message content break; } }); } }); const smtpClient = createTestSmtpClient({ host: testServer.hostname, port: testServer.port, secure: false }); const email = new Email({ from: 'sender@example.com', to: ['recipient1@example.com', 'recipient2@example.com'], subject: 'State machine test', text: 'Testing SMTP transaction state machine' }); const result = await smtpClient.sendMail(email); console.log(' Complete transaction state sequence successful'); expect(result).toBeDefined(); expect(result.messageId).toBeDefined(); await testServer.server.close(); })(); // Scenario 3: Invalid state transitions await (async () => { scenarioCount++; console.log(`\nScenario ${scenarioCount}: Testing invalid state transitions`); const testServer = await createTestServer({ onConnection: async (socket) => { console.log(' [Server] Client connected'); socket.write('220 statemachine.example.com ESMTP\r\n'); let state = 'ready'; socket.on('data', (data) => { const command = data.toString().trim(); console.log(` [Server] State: ${state}, Command: ${command}`); // Strictly enforce state machine switch (state) { case 'ready': if (command.startsWith('EHLO') || command.startsWith('HELO')) { socket.write('250 statemachine.example.com\r\n'); } else if (command.startsWith('MAIL FROM:')) { socket.write('250 OK\r\n'); state = 'mail'; } else if (command === 'RSET' || command === 'NOOP') { socket.write('250 OK\r\n'); } else if (command === 'QUIT') { socket.write('221 Bye\r\n'); socket.end(); } else if (command.startsWith('RCPT TO:')) { console.log(' [Server] RCPT TO without MAIL FROM'); socket.write('503 5.5.1 Need MAIL command first\r\n'); } else if (command === 'DATA') { console.log(' [Server] DATA without MAIL FROM and RCPT TO'); socket.write('503 5.5.1 Need MAIL and RCPT commands first\r\n'); } else { socket.write('503 5.5.1 Bad sequence of commands\r\n'); } break; case 'mail': if (command.startsWith('RCPT TO:')) { socket.write('250 OK\r\n'); state = 'rcpt'; } else if (command.startsWith('MAIL FROM:')) { console.log(' [Server] Second MAIL FROM without RSET'); socket.write('503 5.5.1 Sender already specified\r\n'); } else if (command === 'DATA') { console.log(' [Server] DATA without RCPT TO'); socket.write('503 5.5.1 Need RCPT command first\r\n'); } else if (command === 'RSET') { socket.write('250 OK\r\n'); state = 'ready'; } else if (command === 'QUIT') { socket.write('221 Bye\r\n'); socket.end(); } else { socket.write('503 5.5.1 Bad sequence of commands\r\n'); } break; case 'rcpt': if (command.startsWith('RCPT TO:')) { socket.write('250 OK\r\n'); } else if (command === 'DATA') { socket.write('354 Start mail input\r\n'); state = 'data'; } else if (command.startsWith('MAIL FROM:')) { console.log(' [Server] MAIL FROM after RCPT TO without RSET'); socket.write('503 5.5.1 Sender already specified\r\n'); } else if (command === 'RSET') { socket.write('250 OK\r\n'); state = 'ready'; } else if (command === 'QUIT') { socket.write('221 Bye\r\n'); socket.end(); } else { socket.write('503 5.5.1 Bad sequence of commands\r\n'); } break; case 'data': if (command === '.') { socket.write('250 OK\r\n'); state = 'ready'; } else if (command.startsWith('MAIL FROM:') || command.startsWith('RCPT TO:') || command === 'RSET') { console.log(' [Server] SMTP command during DATA mode'); socket.write('503 5.5.1 Commands not allowed during data transfer\r\n'); } // During DATA, most input is treated as message content break; } }); } }); // We'll create a custom client to send invalid command sequences const testCases = [ { name: 'RCPT without MAIL', commands: ['EHLO client.example.com', 'RCPT TO:'], expectError: true }, { name: 'DATA without RCPT', commands: ['EHLO client.example.com', 'MAIL FROM:', 'DATA'], expectError: true }, { name: 'Double MAIL FROM', commands: ['EHLO client.example.com', 'MAIL FROM:', 'MAIL FROM:'], expectError: true } ]; for (const testCase of testCases) { console.log(` Testing: ${testCase.name}`); try { // Create simple socket connection for manual command testing const net = await import('net'); const client = net.createConnection(testServer.port, testServer.hostname); let responseCount = 0; let errorReceived = false; client.on('data', (data) => { const response = data.toString(); console.log(` Response: ${response.trim()}`); if (response.startsWith('5')) { errorReceived = true; } responseCount++; if (responseCount <= testCase.commands.length) { const command = testCase.commands[responseCount - 1]; if (command) { setTimeout(() => { console.log(` Sending: ${command}`); client.write(command + '\r\n'); }, 100); } } else { client.write('QUIT\r\n'); client.end(); } }); await new Promise((resolve, reject) => { client.on('end', () => { if (testCase.expectError && errorReceived) { console.log(` ✓ Expected error received`); } else if (!testCase.expectError && !errorReceived) { console.log(` ✓ No error as expected`); } else { console.log(` ✗ Unexpected result`); } resolve(void 0); }); client.on('error', reject); // Start with greeting response setTimeout(() => { if (testCase.commands.length > 0) { console.log(` Sending: ${testCase.commands[0]}`); client.write(testCase.commands[0] + '\r\n'); } }, 100); }); } catch (error) { console.log(` Error testing ${testCase.name}: ${error.message}`); } } await testServer.server.close(); })(); // Scenario 4: RSET command state transitions await (async () => { scenarioCount++; console.log(`\nScenario ${scenarioCount}: Testing RSET command state transitions`); const testServer = await createTestServer({ onConnection: async (socket) => { console.log(' [Server] Client connected'); socket.write('220 statemachine.example.com ESMTP\r\n'); let state = 'ready'; socket.on('data', (data) => { const command = data.toString().trim(); console.log(` [Server] State: ${state}, Command: ${command}`); if (command.startsWith('EHLO')) { socket.write('250 statemachine.example.com\r\n'); state = 'ready'; } else if (command.startsWith('MAIL FROM:')) { socket.write('250 OK\r\n'); state = 'mail'; } else if (command.startsWith('RCPT TO:')) { if (state === 'mail' || state === 'rcpt') { socket.write('250 OK\r\n'); state = 'rcpt'; } else { socket.write('503 5.5.1 Bad sequence of commands\r\n'); } } else if (command === 'RSET') { console.log(` [Server] RSET from state: ${state} -> ready`); socket.write('250 OK\r\n'); state = 'ready'; } else if (command === 'DATA') { if (state === 'rcpt') { socket.write('354 Start mail input\r\n'); state = 'data'; } else { socket.write('503 5.5.1 Bad sequence of commands\r\n'); } } else if (command === '.') { if (state === 'data') { socket.write('250 OK\r\n'); state = 'ready'; } } else if (command === 'QUIT') { socket.write('221 Bye\r\n'); socket.end(); } else if (command === 'NOOP') { socket.write('250 OK\r\n'); } }); } }); const smtpClient = createTestSmtpClient({ host: testServer.hostname, port: testServer.port, secure: false }); // Test RSET at various points in transaction console.log(' Testing RSET from different states...'); // We'll manually test RSET behavior const net = await import('net'); const client = net.createConnection(testServer.port, testServer.hostname); const commands = [ 'EHLO client.example.com', // -> ready 'MAIL FROM:', // -> mail 'RSET', // -> ready (reset from mail state) 'MAIL FROM:', // -> mail 'RCPT TO:', // -> rcpt 'RCPT TO:', // -> rcpt (multiple recipients) 'RSET', // -> ready (reset from rcpt state) 'MAIL FROM:', // -> mail (fresh transaction) 'RCPT TO:', // -> rcpt 'DATA', // -> data '.', // -> ready (complete transaction) 'QUIT' ]; let commandIndex = 0; client.on('data', (data) => { const response = data.toString().trim(); console.log(` Response: ${response}`); if (commandIndex < commands.length) { setTimeout(() => { const command = commands[commandIndex]; console.log(` Sending: ${command}`); if (command === 'DATA') { client.write(command + '\r\n'); // Send message content immediately after DATA setTimeout(() => { client.write('Subject: RSET test\r\n\r\nTesting RSET state transitions.\r\n.\r\n'); }, 100); } else { client.write(command + '\r\n'); } commandIndex++; }, 100); } else { client.end(); } }); await new Promise((resolve, reject) => { client.on('end', () => { console.log(' RSET state transitions completed successfully'); resolve(void 0); }); client.on('error', reject); }); await testServer.server.close(); })(); // Scenario 5: Connection state persistence await (async () => { scenarioCount++; console.log(`\nScenario ${scenarioCount}: Testing connection state persistence`); const testServer = await createTestServer({ onConnection: async (socket) => { console.log(' [Server] Client connected'); socket.write('220 statemachine.example.com ESMTP\r\n'); let state = 'ready'; let messageCount = 0; socket.on('data', (data) => { const command = data.toString().trim(); if (command.startsWith('EHLO')) { socket.write('250-statemachine.example.com\r\n'); socket.write('250 PIPELINING\r\n'); state = 'ready'; } else if (command.startsWith('MAIL FROM:')) { if (state === 'ready') { socket.write('250 OK\r\n'); state = 'mail'; } else { socket.write('503 5.5.1 Bad sequence\r\n'); } } else if (command.startsWith('RCPT TO:')) { if (state === 'mail' || state === 'rcpt') { socket.write('250 OK\r\n'); state = 'rcpt'; } else { socket.write('503 5.5.1 Bad sequence\r\n'); } } else if (command === 'DATA') { if (state === 'rcpt') { socket.write('354 Start mail input\r\n'); state = 'data'; } else { socket.write('503 5.5.1 Bad sequence\r\n'); } } else if (command === '.') { if (state === 'data') { messageCount++; console.log(` [Server] Message ${messageCount} completed`); socket.write(`250 OK: Message ${messageCount} accepted\r\n`); state = 'ready'; } } else if (command === 'QUIT') { console.log(` [Server] Session ended after ${messageCount} messages`); socket.write('221 Bye\r\n'); socket.end(); } }); } }); const smtpClient = createTestSmtpClient({ host: testServer.hostname, port: testServer.port, secure: false, pool: true, maxConnections: 1 }); // Send multiple emails through same connection for (let i = 1; i <= 3; i++) { const email = new Email({ from: 'sender@example.com', to: [`recipient${i}@example.com`], subject: `Persistence test ${i}`, text: `Testing connection state persistence - message ${i}` }); const result = await smtpClient.sendMail(email); console.log(` Message ${i} sent successfully`); expect(result).toBeDefined(); expect(result.response).toContain(`Message ${i}`); } // Close the pooled connection await smtpClient.close(); await testServer.server.close(); })(); // Scenario 6: Error state recovery await (async () => { scenarioCount++; console.log(`\nScenario ${scenarioCount}: Testing error state recovery`); const testServer = await createTestServer({ onConnection: async (socket) => { console.log(' [Server] Client connected'); socket.write('220 statemachine.example.com ESMTP\r\n'); let state = 'ready'; let errorCount = 0; socket.on('data', (data) => { const command = data.toString().trim(); console.log(` [Server] State: ${state}, Command: ${command}`); if (command.startsWith('EHLO')) { socket.write('250 statemachine.example.com\r\n'); state = 'ready'; errorCount = 0; // Reset error count on new session } else if (command.startsWith('MAIL FROM:')) { const address = command.match(/<(.+)>/)?.[1] || ''; if (address.includes('error')) { errorCount++; console.log(` [Server] Error ${errorCount} - invalid sender`); socket.write('550 5.1.8 Invalid sender address\r\n'); // State remains ready after error } else { socket.write('250 OK\r\n'); state = 'mail'; } } else if (command.startsWith('RCPT TO:')) { if (state === 'mail' || state === 'rcpt') { const address = command.match(/<(.+)>/)?.[1] || ''; if (address.includes('error')) { errorCount++; console.log(` [Server] Error ${errorCount} - invalid recipient`); socket.write('550 5.1.1 User unknown\r\n'); // State remains the same after recipient error } else { socket.write('250 OK\r\n'); state = 'rcpt'; } } else { socket.write('503 5.5.1 Bad sequence\r\n'); } } else if (command === 'DATA') { if (state === 'rcpt') { socket.write('354 Start mail input\r\n'); state = 'data'; } else { socket.write('503 5.5.1 Bad sequence\r\n'); } } else if (command === '.') { if (state === 'data') { socket.write('250 OK\r\n'); state = 'ready'; } } else if (command === 'RSET') { console.log(` [Server] RSET - recovering from errors (${errorCount} errors so far)`); socket.write('250 OK\r\n'); state = 'ready'; } else if (command === 'QUIT') { console.log(` [Server] Session ended with ${errorCount} total errors`); socket.write('221 Bye\r\n'); socket.end(); } else { socket.write('500 5.5.1 Command not recognized\r\n'); } }); } }); const smtpClient = createTestSmtpClient({ host: testServer.hostname, port: testServer.port, secure: false }); // Test recovery from various errors const testEmails = [ { from: 'error@example.com', // Will cause sender error to: ['valid@example.com'], desc: 'invalid sender' }, { from: 'valid@example.com', to: ['error@example.com', 'valid@example.com'], // Mixed valid/invalid recipients desc: 'mixed recipients' }, { from: 'valid@example.com', to: ['valid@example.com'], desc: 'valid email after errors' } ]; for (const testEmail of testEmails) { console.log(` Testing ${testEmail.desc}...`); const email = new Email({ from: testEmail.from, to: testEmail.to, subject: `Error recovery test: ${testEmail.desc}`, text: `Testing error state recovery with ${testEmail.desc}` }); try { const result = await smtpClient.sendMail(email); console.log(` ${testEmail.desc}: Success`); if (result.rejected && result.rejected.length > 0) { console.log(` Rejected: ${result.rejected.length} recipients`); } } catch (error) { console.log(` ${testEmail.desc}: Failed as expected - ${error.message}`); } } await testServer.server.close(); })(); console.log(`\n${testId}: All ${scenarioCount} state machine scenarios tested ✓`); }); tap.start();