/** * SMTP Test Utilities for Deno * Provides helper functions for testing SMTP protocol implementation */ import { net } from '../../ts/plugins.ts'; /** * Test result interface */ export interface ITestResult { success: boolean; duration: number; message?: string; error?: string; details?: any; } /** * Test configuration interface */ export interface ITestConfig { host: string; port: number; timeout: number; fromAddress?: string; toAddress?: string; [key: string]: any; } /** * Connect to SMTP server */ export async function connectToSmtp( host: string, port: number, timeout: number = 5000 ): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const conn = await Deno.connect({ hostname: host, port, transport: 'tcp', }); clearTimeout(timeoutId); return conn; } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { throw new Error(`Connection timeout after ${timeout}ms`); } throw error; } } /** * Read data from TCP connection with timeout */ async function readWithTimeout( conn: Deno.TcpConn, timeout: number ): Promise { const buffer = new Uint8Array(4096); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const n = await conn.read(buffer); clearTimeout(timeoutId); if (n === null) { throw new Error('Connection closed'); } const decoder = new TextDecoder(); return decoder.decode(buffer.subarray(0, n)); } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { throw new Error(`Read timeout after ${timeout}ms`); } throw error; } } /** * Read SMTP response without sending a command */ export async function readSmtpResponse( conn: Deno.TcpConn, expectedCode?: string, timeout: number = 5000 ): Promise { let buffer = ''; const startTime = Date.now(); while (Date.now() - startTime < timeout) { const chunk = await readWithTimeout(conn, timeout - (Date.now() - startTime)); buffer += chunk; // Check if we have a complete response (ends with \r\n) if (buffer.includes('\r\n')) { if (expectedCode && !buffer.startsWith(expectedCode)) { throw new Error(`Expected ${expectedCode}, got: ${buffer.trim()}`); } return buffer; } } throw new Error(`Response timeout after ${timeout}ms`); } /** * Send SMTP command and wait for response */ export async function sendSmtpCommand( conn: Deno.TcpConn, command: string, expectedCode?: string, timeout: number = 5000 ): Promise { // Send command const encoder = new TextEncoder(); await conn.write(encoder.encode(command + '\r\n')); // Read response using the dedicated function return await readSmtpResponse(conn, expectedCode, timeout); } /** * Wait for SMTP greeting (220 code) */ export async function waitForGreeting( conn: Deno.TcpConn, timeout: number = 5000 ): Promise { let buffer = ''; const startTime = Date.now(); while (Date.now() - startTime < timeout) { const chunk = await readWithTimeout(conn, timeout - (Date.now() - startTime)); buffer += chunk; if (buffer.includes('220')) { return buffer; } } throw new Error(`Greeting timeout after ${timeout}ms`); } /** * Perform SMTP handshake and return capabilities */ export async function performSmtpHandshake( conn: Deno.TcpConn, hostname: string = 'test.example.com' ): Promise { const capabilities: string[] = []; // Wait for greeting await waitForGreeting(conn); // Send EHLO const ehloResponse = await sendSmtpCommand(conn, `EHLO ${hostname}`, '250'); // Parse capabilities const lines = ehloResponse.split('\r\n'); for (const line of lines) { if (line.startsWith('250-') || line.startsWith('250 ')) { const capability = line.substring(4).trim(); if (capability) { capabilities.push(capability); } } } return capabilities; } /** * Create multiple concurrent connections */ export async function createConcurrentConnections( host: string, port: number, count: number, timeout: number = 5000 ): Promise { const connectionPromises = []; for (let i = 0; i < count; i++) { connectionPromises.push(connectToSmtp(host, port, timeout)); } return Promise.all(connectionPromises); } /** * Close SMTP connection gracefully */ export async function closeSmtpConnection(conn: Deno.TcpConn): Promise { try { await sendSmtpCommand(conn, 'QUIT', '221'); } catch { // Ignore errors during QUIT } try { conn.close(); } catch { // Ignore close errors } } /** * Generate random email content */ export function generateRandomEmail(size: number = 1024): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 \r\n'; let content = ''; for (let i = 0; i < size; i++) { content += chars.charAt(Math.floor(Math.random() * chars.length)); } return content; } /** * Create MIME message */ export function createMimeMessage(options: { from: string; to: string; subject: string; text?: string; html?: string; attachments?: Array<{ filename: string; content: string; contentType: string }>; }): string { const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2)}`; const date = new Date().toUTCString(); let message = ''; message += `From: ${options.from}\r\n`; message += `To: ${options.to}\r\n`; message += `Subject: ${options.subject}\r\n`; message += `Date: ${date}\r\n`; message += `MIME-Version: 1.0\r\n`; if (options.attachments && options.attachments.length > 0) { message += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`; message += '\r\n'; // Text part if (options.text) { message += `--${boundary}\r\n`; message += 'Content-Type: text/plain; charset=utf-8\r\n'; message += 'Content-Transfer-Encoding: 8bit\r\n'; message += '\r\n'; message += options.text + '\r\n'; } // HTML part if (options.html) { message += `--${boundary}\r\n`; message += 'Content-Type: text/html; charset=utf-8\r\n'; message += 'Content-Transfer-Encoding: 8bit\r\n'; message += '\r\n'; message += options.html + '\r\n'; } // Attachments const encoder = new TextEncoder(); for (const attachment of options.attachments) { message += `--${boundary}\r\n`; message += `Content-Type: ${attachment.contentType}\r\n`; message += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`; message += 'Content-Transfer-Encoding: base64\r\n'; message += '\r\n'; // Convert to base64 const bytes = encoder.encode(attachment.content); const base64 = btoa(String.fromCharCode(...bytes)); message += base64 + '\r\n'; } message += `--${boundary}--\r\n`; } else if (options.html && options.text) { const altBoundary = `----=_Alt_${Date.now()}_${Math.random().toString(36).substring(2)}`; message += `Content-Type: multipart/alternative; boundary="${altBoundary}"\r\n`; message += '\r\n'; // Text part message += `--${altBoundary}\r\n`; message += 'Content-Type: text/plain; charset=utf-8\r\n'; message += 'Content-Transfer-Encoding: 8bit\r\n'; message += '\r\n'; message += options.text + '\r\n'; // HTML part message += `--${altBoundary}\r\n`; message += 'Content-Type: text/html; charset=utf-8\r\n'; message += 'Content-Transfer-Encoding: 8bit\r\n'; message += '\r\n'; message += options.html + '\r\n'; message += `--${altBoundary}--\r\n`; } else if (options.html) { message += 'Content-Type: text/html; charset=utf-8\r\n'; message += 'Content-Transfer-Encoding: 8bit\r\n'; message += '\r\n'; message += options.html; } else { message += 'Content-Type: text/plain; charset=utf-8\r\n'; message += 'Content-Transfer-Encoding: 8bit\r\n'; message += '\r\n'; message += options.text || ''; } return message; } /** * Measure operation time */ export async function measureTime( operation: () => Promise ): Promise<{ result: T; duration: number }> { const startTime = Date.now(); const result = await operation(); const duration = Date.now() - startTime; return { result, duration }; } /** * Retry operation with exponential backoff */ export async function retryOperation( operation: () => Promise, maxRetries: number = 3, initialDelay: number = 1000 ): Promise { let lastError: Error; for (let i = 0; i < maxRetries; i++) { try { return await operation(); } catch (error) { lastError = error as Error; if (i < maxRetries - 1) { const delay = initialDelay * Math.pow(2, i); await new Promise((resolve) => setTimeout(resolve, delay)); } } } throw lastError!; } /** * Upgrade SMTP connection to TLS using STARTTLS command * @param conn - Active SMTP connection * @param hostname - Server hostname for TLS verification * @returns Upgraded TLS connection */ export async function upgradeToTls(conn: Deno.Conn, hostname: string = 'localhost'): Promise { const encoder = new TextEncoder(); // Send STARTTLS command await conn.write(encoder.encode('STARTTLS\r\n')); // Read response const response = await readSmtpResponse(conn); // Check for 220 Ready to start TLS if (!response.startsWith('220')) { throw new Error(`STARTTLS failed: ${response}`); } // Read test certificate for self-signed cert validation const certPath = new URL('../../test/fixtures/test-cert.pem', import.meta.url).pathname; const certPem = await Deno.readTextFile(certPath); // Upgrade connection to TLS with certificate options const tlsConn = await Deno.startTls(conn, { hostname, caCerts: [certPem], // Accept self-signed test certificate }); return tlsConn; }