feat(storage): add comprehensive tests for StorageManager with memory, filesystem, and custom function backends
Some checks failed
CI / Type Check & Lint (push) Failing after 3s
CI / Build Test (Current Platform) (push) Failing after 3s
CI / Build All Platforms (push) Failing after 3s

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
This commit is contained in:
2025-10-28 19:46:17 +00:00
parent 6523c55516
commit 17f5661636
271 changed files with 61736 additions and 6222 deletions

View File

@@ -1,9 +1,4 @@
/**
* SMTP Test Utilities for Deno
* Provides helper functions for testing SMTP protocol implementation
*/
import { net } from '../../ts/plugins.ts';
import * as plugins from '../../ts/plugins.ts';
/**
* Test result interface
@@ -29,144 +24,109 @@ export interface ITestConfig {
}
/**
* Connect to SMTP server
* Connect to SMTP server and get greeting
*/
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',
export async function connectToSmtp(host: string, port: number, timeout: number = 5000): Promise<plugins.net.Socket> {
return new Promise((resolve, reject) => {
const socket = plugins.net.createConnection({ host, port });
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`Connection timeout after ${timeout}ms`));
}, timeout);
socket.once('connect', () => {
clearTimeout(timer);
resolve(socket);
});
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`);
socket.once('error', (error) => {
clearTimeout(timer);
reject(error);
});
});
}
/**
* Send SMTP command and wait for response
*/
export async function sendSmtpCommand(
conn: Deno.TcpConn,
command: string,
socket: plugins.net.Socket,
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);
return new Promise((resolve, reject) => {
let buffer = '';
let timer: NodeJS.Timeout;
const onData = (data: Buffer) => {
buffer += data.toString();
// Check if we have a complete response
if (buffer.includes('\r\n')) {
clearTimeout(timer);
socket.removeListener('data', onData);
if (expectedCode && !buffer.startsWith(expectedCode)) {
reject(new Error(`Expected ${expectedCode}, got: ${buffer.trim()}`));
} else {
resolve(buffer);
}
}
};
timer = setTimeout(() => {
socket.removeListener('data', onData);
reject(new Error(`Command timeout after ${timeout}ms`));
}, timeout);
socket.on('data', onData);
socket.write(command + '\r\n');
});
}
/**
* Wait for SMTP greeting (220 code)
* Wait for SMTP greeting
*/
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`);
export async function waitForGreeting(socket: plugins.net.Socket, timeout: number = 5000): Promise<string> {
return new Promise((resolve, reject) => {
let buffer = '';
let timer: NodeJS.Timeout;
const onData = (data: Buffer) => {
buffer += data.toString();
if (buffer.includes('220')) {
clearTimeout(timer);
socket.removeListener('data', onData);
resolve(buffer);
}
};
timer = setTimeout(() => {
socket.removeListener('data', onData);
reject(new Error(`Greeting timeout after ${timeout}ms`));
}, timeout);
socket.on('data', onData);
});
}
/**
* Perform SMTP handshake and return capabilities
* Perform SMTP handshake
*/
export async function performSmtpHandshake(
conn: Deno.TcpConn,
socket: plugins.net.Socket,
hostname: string = 'test.example.com'
): Promise<string[]> {
const capabilities: string[] = [];
// Wait for greeting
await waitForGreeting(conn);
await waitForGreeting(socket);
// Send EHLO
const ehloResponse = await sendSmtpCommand(conn, `EHLO ${hostname}`, '250');
const ehloResponse = await sendSmtpCommand(socket, `EHLO ${hostname}`, '250');
// Parse capabilities
const lines = ehloResponse.split('\r\n');
for (const line of lines) {
@@ -177,7 +137,7 @@ export async function performSmtpHandshake(
}
}
}
return capabilities;
}
@@ -189,31 +149,27 @@ export async function createConcurrentConnections(
port: number,
count: number,
timeout: number = 5000
): Promise<Deno.TcpConn[]> {
): Promise<plugins.net.Socket[]> {
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> {
export async function closeSmtpConnection(socket: plugins.net.Socket): Promise<void> {
try {
await sendSmtpCommand(conn, 'QUIT', '221');
await sendSmtpCommand(socket, 'QUIT', '221');
} catch {
// Ignore errors during QUIT
}
try {
conn.close();
} catch {
// Ignore close errors
}
socket.destroy();
}
/**
@@ -222,11 +178,11 @@ export async function closeSmtpConnection(conn: Deno.TcpConn): Promise<void> {
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;
}
@@ -243,18 +199,18 @@ export function createMimeMessage(options: {
}): 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`;
@@ -263,7 +219,7 @@ export function createMimeMessage(options: {
message += '\r\n';
message += options.text + '\r\n';
}
// HTML part
if (options.html) {
message += `--${boundary}\r\n`;
@@ -272,41 +228,37 @@ export function createMimeMessage(options: {
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 += Buffer.from(attachment.content).toString('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';
@@ -319,16 +271,14 @@ export function createMimeMessage(options: {
message += '\r\n';
message += options.text || '';
}
return message;
}
/**
* Measure operation time
*/
export async function measureTime<T>(
operation: () => Promise<T>
): Promise<{ result: T; duration: number }> {
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;
@@ -344,7 +294,7 @@ export async function retryOperation<T>(
initialDelay: number = 1000
): Promise<T> {
let lastError: Error;
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
@@ -352,43 +302,10 @@ export async function retryOperation<T>(
lastError = error as Error;
if (i < maxRetries - 1) {
const delay = initialDelay * Math.pow(2, i);
await new Promise((resolve) => setTimeout(resolve, delay));
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;
}
}