feat(storage): add comprehensive tests for StorageManager with memory, filesystem, and custom function backends
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:
@@ -1,21 +1,14 @@
|
||||
/**
|
||||
* Test SMTP Server Loader for Deno
|
||||
* Manages test server lifecycle and configuration
|
||||
*/
|
||||
|
||||
import * as plugins from '../../ts/plugins.ts';
|
||||
import { UnifiedEmailServer } from '../../ts/mail/routing/classes.unified.email.server.ts';
|
||||
import { createSmtpServer } from '../../ts/mail/delivery/smtpserver/index.ts';
|
||||
import type { ISmtpServerOptions } from '../../ts/mail/delivery/smtpserver/interfaces.ts';
|
||||
import { Email } from '../../ts/mail/core/classes.email.ts';
|
||||
import { net, crypto } from '../../ts/plugins.ts';
|
||||
import type { net } from '../../ts/plugins.ts';
|
||||
|
||||
export interface ITestServerConfig {
|
||||
port: number;
|
||||
hostname?: string;
|
||||
tlsEnabled?: boolean;
|
||||
secure?: boolean; // Direct TLS server (like SMTPS on port 465)
|
||||
authRequired?: boolean;
|
||||
authMethods?: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
|
||||
requireTLS?: boolean; // Whether to require TLS for AUTH (default: true)
|
||||
timeout?: number;
|
||||
testCertPath?: string;
|
||||
testKeyPath?: string;
|
||||
@@ -34,73 +27,7 @@ export interface ITestServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate self-signed certificate for testing
|
||||
* Uses Deno's built-in crypto for key generation
|
||||
*/
|
||||
async function generateSelfSignedCert(hostname: string): Promise<{
|
||||
key: string;
|
||||
cert: string;
|
||||
}> {
|
||||
// For now, return placeholder cert/key that will be replaced with real generation
|
||||
// In production tests, we should either use pre-generated test certs from fixtures
|
||||
// or implement proper cert generation using Deno's crypto API
|
||||
|
||||
// This is a self-signed test certificate - DO NOT use in production
|
||||
const key = `-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKCAQEAxzYIwlfnr7AK2v6E+c2oYD7nAIXIIvDuvVvZ8R9kyxXIzTXB
|
||||
j5D1AgntqKS3bFR1XT8hCVeXjuLKPBvXbhVjG15gXlXxpNiFi1ZcphJvs4zB/Vh7
|
||||
Zv2ALt3anSIwsJ2rZA/R/GqdJPkHvYf/GMTDLw0YllR0YOevErnRIIM5S58Lj2nT
|
||||
Cr5v5hK1Gl9mWwRkFQKkWVl2UXt/JX6C7Z6UyJXMZSnoG0Kw6GQje41K5r0Zdzrh
|
||||
rGfmb9wSDUn9sZGX6il+oMiYz7UgQkPEzGUZEJxKJwxy8ZgPdSgbvYq4WwPwbBUJ
|
||||
lpw0gt5i6HOS7CphRama+zAf5LvfSLoLXSP5JwIDAQABAoIBAQC8C5Ge6wS4LuH9
|
||||
tbZFPwjdGHXL+QT2fOFxPBrE7PkeY8UXD7G5Yei6iqqCxJh8nhLQ3DoayhZM69hO
|
||||
ePOV1Z/LDERCnGel15WKQ1QJ1HZ+JQXnfQrE1Mi9QrXO5bVFtnXIr0mZ+AzwoUmn
|
||||
K5fYCvaL3xDZPDzOYL5kZG2hQKgbywGKZoQx16G0dSEhlAHbK9z6XmPRrbUKGzB8
|
||||
qV7QGbL7BUTQs5JW/8LpkYr5C0q5THtUVb9mHNR3jPf9WTPQ0D3lxcbLS4PQ8jQ/
|
||||
L/GcuHGmsXhe2Unw3w2wpuJKPeHKz4rBNIvaSjIZl9/dIKM88JYQTiIGKErxsC0e
|
||||
kczQMp6BAoGBAO0zUN8H7ynXGNNtK/tJo0lI3qg1ZKgr+0CU2L5eU8Bn1oJ1JkCI
|
||||
WD3p36NdECx5tGexm9U6MN+HzKYUjnQ6LKzbHQGLZqzF5IL5axXgCn8w4BM+6Ixm
|
||||
y8kQgsTKlKRMXIn8RZCmXNnc7v0FhBgpDxPmm7ZUuOPrInd8Ph4mEsePAoGBANb4
|
||||
3/izAHnLEp3/sTOZpfWBnDcvEHCG7/JAX0TDRW1FpXiTHpvDV1j3XU3EvLl7WRJ1
|
||||
B+B8h/Z6kQtUUxQ3I+zxuQIkQYI8qPu+xhQ8gb5AIO5CMX09+xKUgYjQtm7kYs7W
|
||||
L0LD9u3hkGsJk2wfVvMJKb3OSIHeTwRzFCzGX995AoGADkLB8eu/FKAIfwRPCHVE
|
||||
sfwMtqjkj2XJ9FeNcRQ5g/Tf8OGnCGEzBwXb05wJVrXUgXp4dBaqYTdAKj8uLEvd
|
||||
mi9t/LzR+33cGUdAQHItxcKbsMv00TyNRQUvZFZ7ZEY8aBkv5uZfvJHZ5iQ8C7+g
|
||||
HGXNfVGXGPutz/KN6X25CLECgYEAjVLK0MkXzLxCYJRDIhB1TpQVXjpxYUP2Vxls
|
||||
SSxfeYqkJPgNvYiHee33xQ8+TP1y9WzkWh+g2AbGmwTuKKL6CvQS9gKVvqqaFB7y
|
||||
KrkR13MTPJKvHHdQYKGQqQGgHKh0kGFCC0+PoVwtYs/XU1KpZCE16nNgXrOvTYNN
|
||||
HxESa+kCgYB7WOcawTp3WdKP8JbolxIfxax7Kd4QkZhY7dEb4JxBBYXXXpv/NHE9
|
||||
pcJw4eKDyY+QE2AHPu3+fQYzXopaaTGRpB+ynEfYfD2hW+HnOWfWu/lFJbiwBn/S
|
||||
wRsYzSWiLtNplKNFRrsSoMWlh8GOTUpZ7FMLXWhE4rE9NskQBbYq8g==
|
||||
-----END RSA PRIVATE KEY-----`;
|
||||
|
||||
const cert = `-----BEGIN CERTIFICATE-----
|
||||
MIIDazCCAlOgAwIBAgIUcmAewXEYwtzbZmZAJ5inMogKSbowDQYJKoZIhvcNAQEL
|
||||
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
|
||||
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAyMjAwODM4MzRaFw0yNTAy
|
||||
MTkwODM4MzRaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
|
||||
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
|
||||
AQUAA4IBDwAwggEKAoIBAQDHNgjCV+evsAra/oT5zahgPucAhcgi8O69W9nxH2TL
|
||||
FcjNNcGPkPUCCe2opLdsVHVdPyEJV5eO4so8G9duFWMbXmBeVfGk2IWLVlymEm+z
|
||||
jMH9WHtm/YAu3dqdIjCwnatED9H8ap0k+Qd9h/8YxMMvDRiWVHRg568SudEggzlL
|
||||
nwuPadMKvm/mErUaX2ZbBGQVAqRZWXZRe38lfoLtnpTIlcxlKegbQrDoZCN7jUrm
|
||||
vRl3OuGsZ+Zv3BINSf2xkZfqKX6gyJjPtSBCQ8TMZRkQnEonDHLxmA91KBu9irhb
|
||||
A/BsFQmWnDSC3mLoc5LsKmFFqZr7MB/ku99IugtdI/knAgMBAAGjUzBRMB0GA1Ud
|
||||
DgQWBBQryyWLuN22OqU1r9HIt2tMLBk42DAfBgNVHSMEGDAWgBQryyWLuN22OqU1
|
||||
r9HIt2tMLBk42DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAe
|
||||
CeXQZlXJ2xLnDoOoKY3BpodErNmAwygGYxwDCU0xPbpUMPrQhLI80JlZmfy58gT/
|
||||
0ZbULS+srShfEsFnBLmzWLGXDvA/IKCQyTmCQwbPeELGXF6h4URMb+lQL7WL9tY0
|
||||
uUg2dA+7CtYokIrOkGqUitPK3yvVhxugkf51WIgKMACZDibOQSWrV5QO2vHOAaO9
|
||||
ePzRGGl3+Ebmcs3+5w1fI6OLsIZH10lfEnC83C0lO8tIJlGsXMQkCjAcX22rT0rc
|
||||
AcxLm07H4EwMwgOAJUkuDjD3y4+KH91jKWF8bhaLZooFB8lccNnaCRiuZRnXlvmf
|
||||
M7uVlLGwlj5R9iHd+0dP
|
||||
-----END CERTIFICATE-----`;
|
||||
|
||||
return { key, cert };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a test SMTP server with the given configuration
|
||||
* Starts a test SMTP server with the given configuration
|
||||
*/
|
||||
export async function startTestServer(config: ITestServerConfig): Promise<ITestServer> {
|
||||
const serverConfig = {
|
||||
@@ -111,7 +38,7 @@ export async function startTestServer(config: ITestServerConfig): Promise<ITestS
|
||||
timeout: config.timeout || 30000,
|
||||
maxConnections: config.maxConnections || 100,
|
||||
size: config.size || 10 * 1024 * 1024, // 10MB default
|
||||
maxRecipients: config.maxRecipients || 100,
|
||||
maxRecipients: config.maxRecipients || 100
|
||||
};
|
||||
|
||||
// Create a mock email server for testing
|
||||
@@ -125,43 +52,85 @@ export async function startTestServer(config: ITestServerConfig): Promise<ITestS
|
||||
return {
|
||||
recordConnection: (_ip: string) => ({ allowed: true, remaining: 100 }),
|
||||
checkConnectionLimit: async (_ip: string) => ({ allowed: true, remaining: 100 }),
|
||||
checkMessageLimit: (
|
||||
_senderAddress: string,
|
||||
_ip: string,
|
||||
_recipientCount?: number,
|
||||
_pattern?: string,
|
||||
_domain?: string
|
||||
) => ({ allowed: true, remaining: 1000 }),
|
||||
checkMessageLimit: (_senderAddress: string, _ip: string, _recipientCount?: number, _pattern?: string, _domain?: string) => ({ allowed: true, remaining: 1000 }),
|
||||
checkRecipientLimit: async (_session: any) => ({ allowed: true, remaining: 50 }),
|
||||
recordAuthenticationFailure: async (_ip: string) => {},
|
||||
recordSyntaxError: async (_ip: string) => {},
|
||||
recordCommandError: async (_ip: string) => {},
|
||||
recordError: (_ip: string) => false, // Returns false = don't block IP in tests
|
||||
isBlocked: async (_ip: string) => false,
|
||||
cleanup: async () => {},
|
||||
cleanup: async () => {}
|
||||
};
|
||||
},
|
||||
}
|
||||
} as any;
|
||||
|
||||
// Load or generate test certificates
|
||||
// Load test certificates
|
||||
let key: string;
|
||||
let cert: string;
|
||||
|
||||
if (serverConfig.tlsEnabled && config.testCertPath && config.testKeyPath) {
|
||||
|
||||
if (serverConfig.tlsEnabled) {
|
||||
try {
|
||||
key = await Deno.readTextFile(config.testKeyPath);
|
||||
cert = await Deno.readTextFile(config.testCertPath);
|
||||
const certPath = config.testCertPath || './test/fixtures/test-cert.pem';
|
||||
const keyPath = config.testKeyPath || './test/fixtures/test-key.pem';
|
||||
|
||||
cert = await plugins.fs.promises.readFile(certPath, 'utf8');
|
||||
key = await plugins.fs.promises.readFile(keyPath, 'utf8');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to load TLS certificates, generating self-signed', error);
|
||||
const generated = await generateSelfSignedCert(serverConfig.hostname);
|
||||
key = generated.key;
|
||||
cert = generated.cert;
|
||||
console.warn('⚠️ Failed to load TLS certificates, falling back to self-signed');
|
||||
// Generate self-signed certificate for testing
|
||||
const forge = await import('node-forge');
|
||||
const pki = forge.default.pki;
|
||||
|
||||
// Generate key pair
|
||||
const keys = pki.rsa.generateKeyPair(2048);
|
||||
|
||||
// Create certificate
|
||||
const certificate = pki.createCertificate();
|
||||
certificate.publicKey = keys.publicKey;
|
||||
certificate.serialNumber = '01';
|
||||
certificate.validity.notBefore = new Date();
|
||||
certificate.validity.notAfter = new Date();
|
||||
certificate.validity.notAfter.setFullYear(certificate.validity.notBefore.getFullYear() + 1);
|
||||
|
||||
const attrs = [{
|
||||
name: 'commonName',
|
||||
value: serverConfig.hostname
|
||||
}];
|
||||
certificate.setSubject(attrs);
|
||||
certificate.setIssuer(attrs);
|
||||
certificate.sign(keys.privateKey);
|
||||
|
||||
// Convert to PEM
|
||||
cert = pki.certificateToPem(certificate);
|
||||
key = pki.privateKeyToPem(keys.privateKey);
|
||||
}
|
||||
} else {
|
||||
// Always generate a certificate (required by the interface)
|
||||
const generated = await generateSelfSignedCert(serverConfig.hostname);
|
||||
key = generated.key;
|
||||
cert = generated.cert;
|
||||
// Always provide a self-signed certificate for non-TLS servers
|
||||
// This is required by the interface
|
||||
const forge = await import('node-forge');
|
||||
const pki = forge.default.pki;
|
||||
|
||||
// Generate key pair
|
||||
const keys = pki.rsa.generateKeyPair(2048);
|
||||
|
||||
// Create certificate
|
||||
const certificate = pki.createCertificate();
|
||||
certificate.publicKey = keys.publicKey;
|
||||
certificate.serialNumber = '01';
|
||||
certificate.validity.notBefore = new Date();
|
||||
certificate.validity.notAfter = new Date();
|
||||
certificate.validity.notAfter.setFullYear(certificate.validity.notBefore.getFullYear() + 1);
|
||||
|
||||
const attrs = [{
|
||||
name: 'commonName',
|
||||
value: serverConfig.hostname
|
||||
}];
|
||||
certificate.setSubject(attrs);
|
||||
certificate.setIssuer(attrs);
|
||||
certificate.sign(keys.privateKey);
|
||||
|
||||
// Convert to PEM
|
||||
cert = pki.certificateToPem(certificate);
|
||||
key = pki.privateKeyToPem(keys.privateKey);
|
||||
}
|
||||
|
||||
// SMTP server options
|
||||
@@ -176,62 +145,57 @@ export async function startTestServer(config: ITestServerConfig): Promise<ITestS
|
||||
socketTimeout: serverConfig.timeout,
|
||||
connectionTimeout: serverConfig.timeout * 2,
|
||||
cleanupInterval: 300000,
|
||||
auth: serverConfig.authRequired
|
||||
? ({
|
||||
required: true,
|
||||
methods: (serverConfig.authMethods || ['PLAIN', 'LOGIN']) as ('PLAIN' | 'LOGIN' | 'OAUTH2')[],
|
||||
requireTLS: serverConfig.requireTLS !== undefined ? serverConfig.requireTLS : true, // Default: require TLS for AUTH
|
||||
validateUser: async (username: string, password: string) => {
|
||||
// Test server accepts these credentials
|
||||
return username === 'testuser' && password === 'testpass';
|
||||
},
|
||||
} as any)
|
||||
: undefined,
|
||||
auth: serverConfig.authRequired ? ({
|
||||
required: true,
|
||||
methods: ['PLAIN', 'LOGIN'] as ('PLAIN' | 'LOGIN' | 'OAUTH2')[],
|
||||
validateUser: async (username: string, password: string) => {
|
||||
// Test server accepts these credentials
|
||||
return username === 'testuser' && password === 'testpass';
|
||||
}
|
||||
} as any) : undefined
|
||||
};
|
||||
|
||||
// Create SMTP server
|
||||
const smtpServer = await createSmtpServer(mockEmailServer, smtpOptions);
|
||||
|
||||
|
||||
// Start the server
|
||||
await smtpServer.listen();
|
||||
|
||||
|
||||
// Wait for server to be ready
|
||||
await waitForServerReady(serverConfig.hostname, serverConfig.port);
|
||||
|
||||
console.log(
|
||||
`✅ Test SMTP server started on ${serverConfig.hostname}:${serverConfig.port}`
|
||||
);
|
||||
|
||||
|
||||
console.log(`✅ Test SMTP server started on ${serverConfig.hostname}:${serverConfig.port}`);
|
||||
|
||||
return {
|
||||
server: mockEmailServer,
|
||||
smtpServer: smtpServer,
|
||||
port: serverConfig.port,
|
||||
hostname: serverConfig.hostname,
|
||||
config: serverConfig,
|
||||
startTime: Date.now(),
|
||||
startTime: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a test SMTP server
|
||||
* Stops a test SMTP server
|
||||
*/
|
||||
export async function stopTestServer(testServer: ITestServer): Promise<void> {
|
||||
if (!testServer || !testServer.smtpServer) {
|
||||
console.warn('⚠️ No test server to stop');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
console.log(`🛑 Stopping test SMTP server on ${testServer.hostname}:${testServer.port}`);
|
||||
|
||||
|
||||
// Stop the SMTP server
|
||||
if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') {
|
||||
await testServer.smtpServer.close();
|
||||
}
|
||||
|
||||
|
||||
// Wait for port to be free
|
||||
await waitForPortFree(testServer.port);
|
||||
|
||||
|
||||
console.log(`✅ Test SMTP server stopped`);
|
||||
} catch (error) {
|
||||
console.error('❌ Error stopping test server:', error);
|
||||
@@ -242,24 +206,34 @@ export async function stopTestServer(testServer: ITestServer): Promise<void> {
|
||||
/**
|
||||
* Wait for server to be ready to accept connections
|
||||
*/
|
||||
async function waitForServerReady(
|
||||
hostname: string,
|
||||
port: number,
|
||||
timeout: number = 10000
|
||||
): Promise<void> {
|
||||
async function waitForServerReady(hostname: string, port: number, timeout: number = 10000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
const conn = await Deno.connect({ hostname, port, transport: 'tcp' });
|
||||
conn.close();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = plugins.net.createConnection({ port, host: hostname });
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
|
||||
socket.on('error', reject);
|
||||
|
||||
setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error('Connection timeout'));
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
return; // Server is ready
|
||||
} catch {
|
||||
// Server not ready yet, wait and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
throw new Error(`Server did not become ready within ${timeout}ms`);
|
||||
}
|
||||
|
||||
@@ -268,15 +242,15 @@ async function waitForServerReady(
|
||||
*/
|
||||
async function waitForPortFree(port: number, timeout: number = 5000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const isFree = await isPortFree(port);
|
||||
if (isFree) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
|
||||
console.warn(`⚠️ Port ${port} still in use after ${timeout}ms`);
|
||||
}
|
||||
|
||||
@@ -284,13 +258,15 @@ async function waitForPortFree(port: number, timeout: number = 5000): Promise<vo
|
||||
* Check if a port is free
|
||||
*/
|
||||
async function isPortFree(port: number): Promise<boolean> {
|
||||
try {
|
||||
const listener = Deno.listen({ port, transport: 'tcp' });
|
||||
listener.close();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const server = plugins.net.createServer();
|
||||
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
|
||||
server.on('error', () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,16 +284,14 @@ export async function getAvailablePort(startPort: number = 25000): Promise<numbe
|
||||
/**
|
||||
* Create test email data
|
||||
*/
|
||||
export function createTestEmail(
|
||||
options: {
|
||||
from?: string;
|
||||
to?: string | string[];
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
attachments?: any[];
|
||||
} = {}
|
||||
): any {
|
||||
export function createTestEmail(options: {
|
||||
from?: string;
|
||||
to?: string | string[];
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
attachments?: any[];
|
||||
} = {}): any {
|
||||
return {
|
||||
from: options.from || 'test@example.com',
|
||||
to: options.to || 'recipient@example.com',
|
||||
@@ -326,6 +300,48 @@ export function createTestEmail(
|
||||
html: options.html || '<p>This is a test email</p>',
|
||||
attachments: options.attachments || [],
|
||||
date: new Date(),
|
||||
messageId: `<${Date.now()}@test.example.com>`,
|
||||
messageId: `<${Date.now()}@test.example.com>`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple test server for custom protocol testing
|
||||
*/
|
||||
export interface ISimpleTestServer {
|
||||
server: any;
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export async function createTestServer(options: {
|
||||
onConnection?: (socket: any) => void | Promise<void>;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
}): Promise<ISimpleTestServer> {
|
||||
const hostname = options.hostname || 'localhost';
|
||||
const port = options.port || await getAvailablePort();
|
||||
|
||||
const server = plugins.net.createServer((socket) => {
|
||||
if (options.onConnection) {
|
||||
const result = options.onConnection(socket);
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.catch(error => {
|
||||
console.error('Error in onConnection handler:', error);
|
||||
socket.destroy();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.listen(port, hostname, () => {
|
||||
resolve({
|
||||
server,
|
||||
hostname,
|
||||
port
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
/**
|
||||
* Test SMTP Client Utilities for Deno
|
||||
* Provides helpers for creating and testing SMTP client functionality
|
||||
*/
|
||||
|
||||
import { smtpClientMod } from '../../ts/mail/delivery/index.ts';
|
||||
import type { ISmtpClientOptions } from '../../ts/mail/delivery/smtpclient/interfaces.ts';
|
||||
import type { SmtpClient } from '../../ts/mail/delivery/smtpclient/smtp-client.ts';
|
||||
import type { ISmtpClientOptions, SmtpClient } from '../../ts/mail/delivery/smtpclient/index.ts';
|
||||
import { Email } from '../../ts/mail/core/classes.email.ts';
|
||||
|
||||
/**
|
||||
* Create a test SMTP client with sensible defaults
|
||||
* Create a test SMTP client
|
||||
*/
|
||||
export function createTestSmtpClient(options: Partial<ISmtpClientOptions> = {}): SmtpClient {
|
||||
const defaultOptions: ISmtpClientOptions = {
|
||||
@@ -23,11 +17,10 @@ export function createTestSmtpClient(options: Partial<ISmtpClientOptions> = {}):
|
||||
maxMessages: options.maxMessages || 100,
|
||||
debug: options.debug || false,
|
||||
tls: options.tls || {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
pool: options.pool || false,
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return smtpClientMod.createSmtpClient(defaultOptions);
|
||||
}
|
||||
|
||||
@@ -49,17 +42,16 @@ export async function sendTestEmail(
|
||||
to: options.to || 'recipient@example.com',
|
||||
subject: options.subject || 'Test Email',
|
||||
text: options.text || 'This is a test email',
|
||||
html: options.html,
|
||||
html: options.html
|
||||
};
|
||||
|
||||
|
||||
const email = new Email({
|
||||
from: mailOptions.from,
|
||||
to: mailOptions.to,
|
||||
subject: mailOptions.subject,
|
||||
text: mailOptions.text,
|
||||
html: mailOptions.html,
|
||||
html: mailOptions.html
|
||||
});
|
||||
|
||||
return client.sendMail(email);
|
||||
}
|
||||
|
||||
@@ -74,9 +66,9 @@ export async function testClientConnection(
|
||||
const client = createTestSmtpClient({
|
||||
host,
|
||||
port,
|
||||
connectionTimeout: timeout,
|
||||
connectionTimeout: timeout
|
||||
});
|
||||
|
||||
|
||||
try {
|
||||
const result = await client.verify();
|
||||
return result;
|
||||
@@ -105,9 +97,9 @@ export function createAuthenticatedClient(
|
||||
auth: {
|
||||
user: username,
|
||||
pass: password,
|
||||
method: authMethod,
|
||||
method: authMethod
|
||||
},
|
||||
secure: false,
|
||||
secure: false
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,8 +119,8 @@ export function createTlsClient(
|
||||
port,
|
||||
secure: options.secure || false,
|
||||
tls: {
|
||||
rejectUnauthorized: options.rejectUnauthorized || false,
|
||||
},
|
||||
rejectUnauthorized: options.rejectUnauthorized || false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,14 +131,14 @@ export async function testClientPoolStatus(client: SmtpClient): Promise<any> {
|
||||
if (typeof client.getPoolStatus === 'function') {
|
||||
return client.getPoolStatus();
|
||||
}
|
||||
|
||||
|
||||
// Fallback for clients without pool status
|
||||
return {
|
||||
size: 1,
|
||||
available: 1,
|
||||
pending: 0,
|
||||
connecting: 0,
|
||||
active: 0,
|
||||
active: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,16 +156,16 @@ export async function sendConcurrentEmails(
|
||||
} = {}
|
||||
): Promise<any[]> {
|
||||
const promises = [];
|
||||
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
promises.push(
|
||||
sendTestEmail(client, {
|
||||
...emailOptions,
|
||||
subject: `${emailOptions.subject || 'Test Email'} ${i + 1}`,
|
||||
subject: `${emailOptions.subject || 'Test Email'} ${i + 1}`
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
@@ -189,17 +181,12 @@ export async function measureClientThroughput(
|
||||
subject?: string;
|
||||
text?: string;
|
||||
} = {}
|
||||
): Promise<{
|
||||
totalSent: number;
|
||||
successCount: number;
|
||||
errorCount: number;
|
||||
throughput: number;
|
||||
}> {
|
||||
): Promise<{ totalSent: number; successCount: number; errorCount: number; throughput: number }> {
|
||||
const startTime = Date.now();
|
||||
let totalSent = 0;
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
|
||||
while (Date.now() - startTime < duration) {
|
||||
try {
|
||||
await sendTestEmail(client, emailOptions);
|
||||
@@ -209,28 +196,14 @@ export async function measureClientThroughput(
|
||||
}
|
||||
totalSent++;
|
||||
}
|
||||
|
||||
|
||||
const actualDuration = (Date.now() - startTime) / 1000; // in seconds
|
||||
const throughput = totalSent / actualDuration;
|
||||
|
||||
|
||||
return {
|
||||
totalSent,
|
||||
successCount,
|
||||
errorCount,
|
||||
throughput,
|
||||
throughput
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pooled SMTP client for concurrent testing
|
||||
*/
|
||||
export function createPooledTestClient(
|
||||
options: Partial<ISmtpClientOptions> = {}
|
||||
): SmtpClient {
|
||||
return createTestSmtpClient({
|
||||
...options,
|
||||
pool: true,
|
||||
maxConnections: options.maxConnections || 5,
|
||||
maxMessages: options.maxMessages || 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user