Files
mailer/test/helpers/utils.ts
Juergen Kunz 6523c55516 feat: Implement Deno-native STARTTLS handler and connection wrapper
- Refactored STARTTLS implementation to use Deno's native TLS via Deno.startTls().
- Introduced ConnectionWrapper to provide a Node.js net.Socket-compatible interface for Deno.Conn and Deno.TlsConn.
- Updated TlsHandler to utilize the new STARTTLS implementation.
- Added comprehensive SMTP authentication tests for PLAIN and LOGIN mechanisms.
- Implemented rate limiting tests for SMTP server connections and commands.
- Enhanced error handling and logging throughout the STARTTLS and connection upgrade processes.
2025-10-28 18:51:33 +00:00

395 lines
10 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;
}
}
/**
* Read SMTP response without sending a command
*/
export async function readSmtpResponse(
conn: Deno.TcpConn,
expectedCode?: string,
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;
// 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<string> {
// 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<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!;
}
/**
* 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<Deno.TlsConn> {
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;
}