import { tap, expect } from '@git.zone/tapbundle'; import * as net from 'net'; import { startTestServer, stopTestServer } from '../../helpers/server.loader.js'; import type { ITestServer } from '../../helpers/server.loader.js'; const TEST_PORT = 30028; const TEST_TIMEOUT = 30000; let testServer: ITestServer; tap.test('setup - start SMTP server for permanent failure tests', async () => { testServer = await startTestServer({ port: TEST_PORT, hostname: 'localhost' }); expect(testServer).toBeInstanceOf(Object); }); tap.test('Permanent Failures - should return 5xx for invalid recipient syntax', async (tools) => { const done = tools.defer(); try { const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Get banner await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Send EHLO socket.write('EHLO testhost\r\n'); await new Promise((resolve) => { let data = ''; const handler = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { socket.removeListener('data', handler); resolve(data); } }; socket.on('data', handler); }); // Send MAIL FROM socket.write('MAIL FROM:\r\n'); const mailResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(mailResponse).toInclude('250'); // Send RCPT TO with invalid syntax (double @) socket.write('RCPT TO:\r\n'); const rcptResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); console.log('Response to invalid recipient:', rcptResponse); // Should get a permanent failure (5xx) const permanentFailureCodes = ['550', '551', '552', '553', '554', '501']; const isPermanentFailure = permanentFailureCodes.some(code => rcptResponse.includes(code)); expect(isPermanentFailure).toBeTrue(); // Clean up socket.write('QUIT\r\n'); socket.end(); } finally { done.resolve(); } }); tap.test('Permanent Failures - should handle non-existent domain', async (tools) => { const done = tools.defer(); try { const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Get banner await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Send EHLO socket.write('EHLO testhost\r\n'); await new Promise((resolve) => { let data = ''; const handler = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { socket.removeListener('data', handler); resolve(data); } }; socket.on('data', handler); }); // Send MAIL FROM socket.write('MAIL FROM:\r\n'); const mailResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(mailResponse).toInclude('250'); // Send RCPT TO with non-existent domain socket.write('RCPT TO:\r\n'); const rcptResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); console.log('Response to non-existent domain:', rcptResponse); // Server might: // 1. Accept it (250) and handle bounces later // 2. Reject with permanent failure (5xx) // Both are valid approaches const acceptedOrRejected = rcptResponse.includes('250') || /^5\d{2}/.test(rcptResponse); expect(acceptedOrRejected).toBeTrue(); if (rcptResponse.includes('250')) { console.log('Server accepts unknown domains (will handle bounces later)'); } else { console.log('Server rejects unknown domains immediately'); } // Clean up socket.write('QUIT\r\n'); socket.end(); } finally { done.resolve(); } }); tap.test('Permanent Failures - should reject oversized messages', async (tools) => { const done = tools.defer(); try { const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Get banner await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Send EHLO socket.write('EHLO testhost\r\n'); const ehloResponse = await new Promise((resolve) => { let data = ''; const handler = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { socket.removeListener('data', handler); resolve(data); } }; socket.on('data', handler); }); // Check if SIZE is advertised const sizeMatch = ehloResponse.match(/250[- ]SIZE\s+(\d+)/); const maxSize = sizeMatch ? parseInt(sizeMatch[1]) : null; console.log('Server max size:', maxSize || 'not advertised'); // Send MAIL FROM with SIZE parameter exceeding limit const oversizeAmount = maxSize ? maxSize + 1000000 : 100000000; // 100MB if no limit advertised socket.write(`MAIL FROM: SIZE=${oversizeAmount}\r\n`); const mailResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); console.log('Response to oversize MAIL FROM:', mailResponse); if (maxSize && oversizeAmount > maxSize) { // Should get permanent failure expect(mailResponse).toMatch(/^5\d{2}/); expect(mailResponse.toLowerCase()).toMatch(/size|too.*large|exceed/); } else { // No size limit advertised, server might accept expect(mailResponse).toMatch(/^[2-5]\d{2}/); } // Clean up socket.write('QUIT\r\n'); socket.end(); } finally { done.resolve(); } }); tap.test('Permanent Failures - should persist after RSET', async (tools) => { const done = tools.defer(); try { const socket = net.createConnection({ host: 'localhost', port: TEST_PORT, timeout: TEST_TIMEOUT }); await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Get banner await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Send EHLO socket.write('EHLO testhost\r\n'); await new Promise((resolve) => { let data = ''; const handler = (chunk: Buffer) => { data += chunk.toString(); if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) { socket.removeListener('data', handler); resolve(data); } }; socket.on('data', handler); }); // First attempt with invalid syntax socket.write('MAIL FROM:\r\n'); const firstMailResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); console.log('First MAIL FROM response:', firstMailResponse); const firstWasRejected = /^5\d{2}/.test(firstMailResponse); if (firstWasRejected) { // Try RSET socket.write('RSET\r\n'); const rsetResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(rsetResponse).toInclude('250'); // Try same invalid syntax again socket.write('MAIL FROM:\r\n'); const secondMailResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); console.log('Second MAIL FROM response after RSET:', secondMailResponse); // Should still get permanent failure expect(secondMailResponse).toMatch(/^5\d{2}/); console.log('Permanent failures persist correctly after RSET'); } else { console.log('Server accepts invalid syntax in MAIL FROM (lenient parsing)'); expect(true).toBeTrue(); } // Clean up socket.write('QUIT\r\n'); socket.end(); } finally { done.resolve(); } }); tap.test('cleanup - stop SMTP server', async () => { await stopTestServer(testServer); expect(true).toBeTrue(); }); tap.start();