- Implemented SMTP client utilities in `test/helpers/smtp.client.ts` for creating test clients, sending emails, and testing connections. - Developed SMTP protocol test utilities in `test/helpers/utils.ts` for managing TCP connections, sending commands, and handling responses. - Created a detailed README in `test/readme.md` outlining the test framework, infrastructure, organization, and running instructions. - Ported CMD-01: EHLO Command tests in `test/suite/smtpserver_commands/test.cmd-01.ehlo-command.test.ts` with multiple scenarios including valid and invalid hostnames. - Ported CMD-02: MAIL FROM Command tests in `test/suite/smtpserver_commands/test.cmd-02.mail-from.test.ts` covering valid address acceptance, invalid address rejection, SIZE parameter support, and command sequence enforcement.
351 lines
8.7 KiB
TypeScript
351 lines
8.7 KiB
TypeScript
/**
|
|
* 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<Deno.TcpConn> {
|
|
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<string> {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send SMTP command and wait for response
|
|
*/
|
|
export async function sendSmtpCommand(
|
|
conn: Deno.TcpConn,
|
|
command: string,
|
|
expectedCode?: string,
|
|
timeout: number = 5000
|
|
): Promise<string> {
|
|
// Send command
|
|
const encoder = new TextEncoder();
|
|
await conn.write(encoder.encode(command + '\r\n'));
|
|
|
|
// Read response
|
|
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(`Command timeout after ${timeout}ms`);
|
|
}
|
|
|
|
/**
|
|
* Wait for SMTP greeting (220 code)
|
|
*/
|
|
export async function waitForGreeting(
|
|
conn: Deno.TcpConn,
|
|
timeout: number = 5000
|
|
): Promise<string> {
|
|
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<string[]> {
|
|
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<Deno.TcpConn[]> {
|
|
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<void> {
|
|
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<T>(
|
|
operation: () => Promise<T>
|
|
): 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<T>(
|
|
operation: () => Promise<T>,
|
|
maxRetries: number = 3,
|
|
initialDelay: number = 1000
|
|
): Promise<T> {
|
|
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!;
|
|
}
|