import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as plugins from '../../../ts/plugins.js'; import * as net from 'net'; import { startTestServer, stopTestServer } from '../../helpers/server.loader.js' import type { ITestServer } from '../../helpers/server.loader.js'; const TEST_PORT = 2525; let testServer: ITestServer; // Helper function to wait for SMTP response const waitForResponse = (socket: net.Socket, expectedCode?: string, timeout = 5000): Promise => { return new Promise((resolve, reject) => { let buffer = ''; const timer = setTimeout(() => { socket.removeListener('data', handler); reject(new Error(`Timeout waiting for ${expectedCode || 'any'} response`)); }, timeout); const handler = (data: Buffer) => { buffer += data.toString(); const lines = buffer.split('\r\n'); // Check if we have a complete response for (const line of lines) { if (expectedCode) { if (line.startsWith(expectedCode + ' ')) { clearTimeout(timer); socket.removeListener('data', handler); resolve(buffer); return; } } else { // Any complete response line if (line.match(/^\d{3} /)) { clearTimeout(timer); socket.removeListener('data', handler); resolve(buffer); return; } } } }; socket.on('data', handler); }); }; tap.test('setup - start test server', async (toolsArg) => { testServer = await startTestServer({ port: TEST_PORT }); await toolsArg.delayFor(1000); }); tap.test('RFC 5321 - Server greeting format', async (tools) => { const done = tools.defer(); const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: 30000 }); socket.on('error', (err) => { console.error('Socket error:', err); done.reject(err); }); socket.on('connect', async () => { try { // Wait for initial greeting const greeting = await waitForResponse(socket, '220'); console.log('Server greeting:', greeting.trim()); // RFC 5321: Server must provide proper 220 greeting const greetingLine = greeting.trim(); const validGreeting = greetingLine.startsWith('220') && greetingLine.length > 10; expect(validGreeting).toEqual(true); expect(greetingLine).toMatch(/^220\s+\S+/); // Should have hostname after 220 // Send QUIT socket.write('QUIT\r\n'); await waitForResponse(socket, '221'); socket.end(); done.resolve(); } catch (err) { console.error('Test error:', err); socket.end(); done.reject(err); } }); await done.promise; }); tap.test('RFC 5321 - EHLO response format', async (tools) => { const done = tools.defer(); const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: 30000 }); socket.on('error', (err) => { console.error('Socket error:', err); done.reject(err); }); socket.on('connect', async () => { try { // Wait for greeting await waitForResponse(socket, '220'); // Send EHLO socket.write('EHLO testclient\r\n'); const ehloResponse = await waitForResponse(socket, '250'); console.log('Server response:', ehloResponse); // RFC 5321: EHLO must return 250 with hostname and extensions const ehloLines = ehloResponse.split('\r\n').filter(line => line.startsWith('250')); expect(ehloLines.length).toBeGreaterThan(0); expect(ehloLines[0]).toMatch(/^250[\s-]\S+/); // First line should have hostname // Check for common extensions const extensions = ehloLines.slice(1).map(line => line.substring(4).trim()); console.log('Extensions:', extensions); // Send QUIT socket.write('QUIT\r\n'); await waitForResponse(socket, '221'); socket.end(); done.resolve(); } catch (err) { console.error('Test error:', err); socket.end(); done.reject(err); } }); await done.promise; }); tap.test('RFC 5321 - Command case insensitivity', async (tools) => { const done = tools.defer(); const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: 30000 }); socket.on('error', (err) => { console.error('Socket error:', err); done.reject(err); }); socket.on('connect', async () => { try { // Wait for greeting await waitForResponse(socket, '220'); // Test lowercase command socket.write('ehlo testclient\r\n'); await waitForResponse(socket, '250'); // Test mixed case command socket.write('MaIl FrOm:\r\n'); await waitForResponse(socket, '250'); // Test uppercase command socket.write('RCPT TO:\r\n'); await waitForResponse(socket, '250'); // All case variations worked console.log('All case variations accepted'); // Send QUIT socket.write('QUIT\r\n'); await waitForResponse(socket, '221'); socket.end(); done.resolve(); } catch (err) { console.error('Test error:', err); socket.end(); done.reject(err); } }); await done.promise; }); tap.test('RFC 5321 - Line length limits', async (tools) => { const done = tools.defer(); const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: 30000 }); socket.on('error', (err) => { console.error('Socket error:', err); done.reject(err); }); socket.on('connect', async () => { try { // Wait for greeting await waitForResponse(socket, '220'); // Send EHLO socket.write('EHLO testclient\r\n'); await waitForResponse(socket, '250'); // RFC 5321: Command line limit is 512 chars including CRLF // Test with a long MAIL FROM command (but within limit) const longDomain = 'a'.repeat(400); socket.write(`MAIL FROM:\r\n`); const response = await waitForResponse(socket); // Should either accept (if within server limits) or reject gracefully const accepted = response.includes('250'); const rejected = response.includes('501') || response.includes('500'); expect(accepted || rejected).toEqual(true); console.log(`Long line test ${accepted ? 'accepted' : 'rejected'}`); // Send QUIT socket.write('QUIT\r\n'); await waitForResponse(socket, '221'); socket.end(); done.resolve(); } catch (err) { console.error('Test error:', err); socket.end(); done.reject(err); } }); await done.promise; }); tap.test('RFC 5321 - Standard SMTP verb compliance', async (tools) => { const done = tools.defer(); const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: 30000 }); socket.on('error', (err) => { console.error('Socket error:', err); done.reject(err); }); socket.on('connect', async () => { try { const supportedVerbs: string[] = []; // Wait for greeting await waitForResponse(socket, '220'); // Try HELP command to see supported verbs socket.write('HELP\r\n'); const helpResponse = await waitForResponse(socket); // Parse HELP response for supported commands if (helpResponse.includes('214') || helpResponse.includes('502')) { // Either help text or command not implemented } // Test NOOP socket.write('NOOP\r\n'); const noopResponse = await waitForResponse(socket); if (noopResponse.includes('250')) { supportedVerbs.push('NOOP'); } // Test RSET socket.write('RSET\r\n'); const rsetResponse = await waitForResponse(socket); if (rsetResponse.includes('250')) { supportedVerbs.push('RSET'); } // Test VRFY socket.write('VRFY test@example.com\r\n'); const vrfyResponse = await waitForResponse(socket); // VRFY may be disabled for security (252 or 502) if (vrfyResponse.includes('250') || vrfyResponse.includes('252')) { supportedVerbs.push('VRFY'); } // Check minimum required verbs const requiredVerbs = ['NOOP', 'RSET']; const hasRequired = requiredVerbs.every(verb => supportedVerbs.includes(verb) || verb === 'VRFY' // VRFY is optional ); console.log('Supported verbs:', supportedVerbs); expect(hasRequired).toEqual(true); // Send QUIT socket.write('QUIT\r\n'); await waitForResponse(socket, '221'); socket.end(); done.resolve(); } catch (err) { console.error('Test error:', err); socket.end(); done.reject(err); } }); await done.promise; }); tap.test('RFC 5321 - Required minimum extensions', async (tools) => { const done = tools.defer(); const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: 30000 }); socket.on('error', (err) => { console.error('Socket error:', err); done.reject(err); }); socket.on('connect', async () => { try { // Wait for greeting await waitForResponse(socket, '220'); // Send EHLO socket.write('EHLO testclient\r\n'); const ehloResponse = await waitForResponse(socket, '250'); // Check for extensions const lines = ehloResponse.split('\r\n'); const extensions = lines .filter(line => line.startsWith('250-') || (line.startsWith('250 ') && lines.indexOf(line) > 0)) .map(line => line.substring(4).split(' ')[0].toUpperCase()); console.log('Server extensions:', extensions); // RFC 5321 recommends these extensions const recommendedExtensions = ['8BITMIME', 'SIZE', 'PIPELINING']; const hasRecommended = recommendedExtensions.filter(ext => extensions.includes(ext)); console.log('Recommended extensions present:', hasRecommended); // Send QUIT socket.write('QUIT\r\n'); await waitForResponse(socket, '221'); socket.end(); done.resolve(); } catch (err) { console.error('Test error:', err); socket.end(); done.reject(err); } }); await done.promise; }); tap.test('cleanup - stop test server', async () => { await stopTestServer(testServer); }); export default tap.start();