feat(email): implement EmailSendJob class for robust email delivery with retry logic and MX record resolution feat(mail): restructure mail module exports for simplified access to core and delivery functionalities
		
			
				
	
	
		
			334 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			334 lines
		
	
	
		
			9.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { tap, expect } from '@git.zone/tstest/tapbundle';
 | |
| import * as net from 'net';
 | |
| import { startTestServer, stopTestServer } from '../../helpers/server.loader.ts';
 | |
| 
 | |
| const TEST_PORT = 2525;
 | |
| 
 | |
| let testServer;
 | |
| const TEST_TIMEOUT = 30000;
 | |
| 
 | |
| tap.test('prepare server', async () => {
 | |
|   testServer = await startTestServer({ port: TEST_PORT });
 | |
|   await new Promise(resolve => setTimeout(resolve, 100));
 | |
| });
 | |
| 
 | |
| tap.test('Command Pipelining - should advertise PIPELINING in EHLO response', async (tools) => {
 | |
|   const done = tools.defer();
 | |
|   
 | |
|   try {
 | |
|     const socket = net.createConnection({
 | |
|       host: 'localhost',
 | |
|       port: TEST_PORT,
 | |
|       timeout: TEST_TIMEOUT
 | |
|     });
 | |
|     
 | |
|     await new Promise<void>((resolve, reject) => {
 | |
|       socket.once('connect', () => resolve());
 | |
|       socket.once('error', reject);
 | |
|     });
 | |
|     
 | |
|     // Get banner
 | |
|     const banner = await new Promise<string>((resolve) => {
 | |
|       socket.once('data', (chunk) => resolve(chunk.toString()));
 | |
|     });
 | |
|     
 | |
|     expect(banner).toInclude('220');
 | |
|     
 | |
|     // Send EHLO
 | |
|     socket.write('EHLO testhost\r\n');
 | |
|     
 | |
|     const ehloResponse = await new Promise<string>((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);
 | |
|     });
 | |
|     
 | |
|     console.log('EHLO response:', ehloResponse);
 | |
|     
 | |
|     // Check if PIPELINING is advertised
 | |
|     const pipeliningAdvertised = ehloResponse.includes('250-PIPELINING') || ehloResponse.includes('250 PIPELINING');
 | |
|     console.log('PIPELINING advertised:', pipeliningAdvertised);
 | |
|     
 | |
|     // Clean up
 | |
|     socket.write('QUIT\r\n');
 | |
|     socket.end();
 | |
|     
 | |
|     // Note: PIPELINING is optional per RFC 2920
 | |
|     expect(ehloResponse).toInclude('250');
 | |
|     
 | |
|   } finally {
 | |
|     done.resolve();
 | |
|   }
 | |
| });
 | |
| 
 | |
| tap.test('Command Pipelining - should handle pipelined MAIL FROM and RCPT TO', async (tools) => {
 | |
|   const done = tools.defer();
 | |
|   
 | |
|   try {
 | |
|     const socket = net.createConnection({
 | |
|       host: 'localhost',
 | |
|       port: TEST_PORT,
 | |
|       timeout: TEST_TIMEOUT
 | |
|     });
 | |
|     
 | |
|     await new Promise<void>((resolve, reject) => {
 | |
|       socket.once('connect', () => resolve());
 | |
|       socket.once('error', reject);
 | |
|     });
 | |
|     
 | |
|     // Get banner
 | |
|     await new Promise<string>((resolve) => {
 | |
|       socket.once('data', (chunk) => resolve(chunk.toString()));
 | |
|     });
 | |
|     
 | |
|     // Send EHLO
 | |
|     socket.write('EHLO testhost\r\n');
 | |
|     
 | |
|     await new Promise<string>((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 pipelined commands (all at once)
 | |
|     const pipelinedCommands = 
 | |
|       'MAIL FROM:<sender@example.com>\r\n' +
 | |
|       'RCPT TO:<recipient@example.com>\r\n';
 | |
|     
 | |
|     console.log('Sending pipelined commands...');
 | |
|     socket.write(pipelinedCommands);
 | |
|     
 | |
|     // Collect responses
 | |
|     const responses = await new Promise<string>((resolve) => {
 | |
|       let data = '';
 | |
|       let responseCount = 0;
 | |
|       const handler = (chunk: Buffer) => {
 | |
|         data += chunk.toString();
 | |
|         const lines = data.split('\r\n').filter(line => line.trim());
 | |
|         
 | |
|         // Count responses that look like complete SMTP responses
 | |
|         const completeResponses = lines.filter(line => /^[0-9]{3}(\s|-)/.test(line));
 | |
|         
 | |
|         // We expect 2 responses (one for MAIL FROM, one for RCPT TO)
 | |
|         if (completeResponses.length >= 2) {
 | |
|           socket.removeListener('data', handler);
 | |
|           resolve(data);
 | |
|         }
 | |
|       };
 | |
|       socket.on('data', handler);
 | |
|       
 | |
|       // Timeout if we don't get responses
 | |
|       setTimeout(() => {
 | |
|         socket.removeListener('data', handler);
 | |
|         resolve(data);
 | |
|       }, 5000);
 | |
|     });
 | |
|     
 | |
|     console.log('Pipelined command responses:', responses);
 | |
|     
 | |
|     // Parse responses
 | |
|     const responseLines = responses.split('\r\n').filter(line => line.trim());
 | |
|     const mailFromResponse = responseLines.find(line => line.match(/^250.*/) && responseLines.indexOf(line) === 0);
 | |
|     const rcptToResponse = responseLines.find(line => line.match(/^250.*/) && responseLines.indexOf(line) === 1);
 | |
|     
 | |
|     // Both commands should succeed
 | |
|     expect(mailFromResponse).toBeDefined();
 | |
|     expect(rcptToResponse).toBeDefined();
 | |
|     
 | |
|     // Clean up
 | |
|     socket.write('QUIT\r\n');
 | |
|     socket.end();
 | |
|     
 | |
|   } finally {
 | |
|     done.resolve();
 | |
|   }
 | |
| });
 | |
| 
 | |
| tap.test('Command Pipelining - should handle pipelined commands with DATA', async (tools) => {
 | |
|   const done = tools.defer();
 | |
|   
 | |
|   try {
 | |
|     const socket = net.createConnection({
 | |
|       host: 'localhost',
 | |
|       port: TEST_PORT,
 | |
|       timeout: TEST_TIMEOUT
 | |
|     });
 | |
|     
 | |
|     await new Promise<void>((resolve, reject) => {
 | |
|       socket.once('connect', () => resolve());
 | |
|       socket.once('error', reject);
 | |
|     });
 | |
|     
 | |
|     // Get banner
 | |
|     await new Promise<string>((resolve) => {
 | |
|       socket.once('data', (chunk) => resolve(chunk.toString()));
 | |
|     });
 | |
|     
 | |
|     // Send EHLO
 | |
|     socket.write('EHLO testhost\r\n');
 | |
|     
 | |
|     await new Promise<string>((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 pipelined MAIL FROM, RCPT TO, and DATA commands
 | |
|     const pipelinedCommands = 
 | |
|       'MAIL FROM:<sender@example.com>\r\n' +
 | |
|       'RCPT TO:<recipient@example.com>\r\n' +
 | |
|       'DATA\r\n';
 | |
|     
 | |
|     console.log('Sending pipelined commands with DATA...');
 | |
|     socket.write(pipelinedCommands);
 | |
|     
 | |
|     // Collect responses
 | |
|     const responses = await new Promise<string>((resolve) => {
 | |
|       let data = '';
 | |
|       const handler = (chunk: Buffer) => {
 | |
|         data += chunk.toString();
 | |
|         
 | |
|         // Look for the DATA prompt (354)
 | |
|         if (data.includes('354')) {
 | |
|           socket.removeListener('data', handler);
 | |
|           resolve(data);
 | |
|         }
 | |
|       };
 | |
|       socket.on('data', handler);
 | |
|       
 | |
|       setTimeout(() => {
 | |
|         socket.removeListener('data', handler);
 | |
|         resolve(data);
 | |
|       }, 5000);
 | |
|     });
 | |
|     
 | |
|     console.log('Responses including DATA:', responses);
 | |
|     
 | |
|     // Should get 250 for MAIL FROM, 250 for RCPT TO, and 354 for DATA
 | |
|     expect(responses).toInclude('250'); // MAIL FROM OK
 | |
|     expect(responses).toInclude('354'); // Start mail input
 | |
|     
 | |
|     // Send email content
 | |
|     const emailContent = 'Subject: Pipelining Test\r\nFrom: sender@example.com\r\nTo: recipient@example.com\r\n\r\nTest email with pipelining.\r\n.\r\n';
 | |
|     socket.write(emailContent);
 | |
|     
 | |
|     // Get final response
 | |
|     const finalResponse = await new Promise<string>((resolve) => {
 | |
|       socket.once('data', (chunk) => resolve(chunk.toString()));
 | |
|     });
 | |
|     
 | |
|     console.log('Final response:', finalResponse);
 | |
|     expect(finalResponse).toInclude('250');
 | |
|     
 | |
|     // Clean up
 | |
|     socket.write('QUIT\r\n');
 | |
|     socket.end();
 | |
|     
 | |
|   } finally {
 | |
|     done.resolve();
 | |
|   }
 | |
| });
 | |
| 
 | |
| tap.test('Command Pipelining - should handle pipelined NOOP commands', async (tools) => {
 | |
|   const done = tools.defer();
 | |
|   
 | |
|   try {
 | |
|     const socket = net.createConnection({
 | |
|       host: 'localhost',
 | |
|       port: TEST_PORT,
 | |
|       timeout: TEST_TIMEOUT
 | |
|     });
 | |
|     
 | |
|     await new Promise<void>((resolve, reject) => {
 | |
|       socket.once('connect', () => resolve());
 | |
|       socket.once('error', reject);
 | |
|     });
 | |
|     
 | |
|     // Get banner
 | |
|     await new Promise<string>((resolve) => {
 | |
|       socket.once('data', (chunk) => resolve(chunk.toString()));
 | |
|     });
 | |
|     
 | |
|     // Send EHLO
 | |
|     socket.write('EHLO testhost\r\n');
 | |
|     
 | |
|     await new Promise<string>((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 multiple pipelined NOOP commands
 | |
|     const pipelinedNoops = 
 | |
|       'NOOP\r\n' +
 | |
|       'NOOP\r\n' +
 | |
|       'NOOP\r\n';
 | |
|     
 | |
|     console.log('Sending pipelined NOOP commands...');
 | |
|     socket.write(pipelinedNoops);
 | |
|     
 | |
|     // Collect responses
 | |
|     const responses = await new Promise<string>((resolve) => {
 | |
|       let data = '';
 | |
|       const handler = (chunk: Buffer) => {
 | |
|         data += chunk.toString();
 | |
|         const responseCount = (data.match(/^250.*OK/gm) || []).length;
 | |
|         
 | |
|         // We expect 3 NOOP responses
 | |
|         if (responseCount >= 3) {
 | |
|           socket.removeListener('data', handler);
 | |
|           resolve(data);
 | |
|         }
 | |
|       };
 | |
|       socket.on('data', handler);
 | |
|       
 | |
|       setTimeout(() => {
 | |
|         socket.removeListener('data', handler);
 | |
|         resolve(data);
 | |
|       }, 5000);
 | |
|     });
 | |
|     
 | |
|     console.log('NOOP responses:', responses);
 | |
|     
 | |
|     // Count OK responses
 | |
|     const okResponses = (responses.match(/^250.*OK/gm) || []).length;
 | |
|     expect(okResponses).toBeGreaterThanOrEqual(3);
 | |
|     
 | |
|     // Clean up
 | |
|     socket.write('QUIT\r\n');
 | |
|     socket.end();
 | |
|     
 | |
|   } finally {
 | |
|     done.resolve();
 | |
|   }
 | |
| });
 | |
| 
 | |
| tap.test('cleanup server', async () => {
 | |
|   await stopTestServer(testServer);
 | |
| });
 | |
| 
 | |
| export default tap.start(); |