feat(mailer-smtp): add in-process security pipeline for SMTP delivery (DKIM/SPF/DMARC, content scanning, IP reputation)
This commit is contained in:
@@ -1,8 +1,4 @@
|
||||
import * as plugins from '../../ts/plugins.js';
|
||||
import { UnifiedEmailServer } from '../../ts/mail/routing/classes.unified.email.server.js';
|
||||
import { createSmtpServer } from '../../ts/mail/delivery/smtpserver/index.js';
|
||||
import type { ISmtpServerOptions } from '../../ts/mail/delivery/smtpserver/interfaces.js';
|
||||
import type { net } from '../../ts/plugins.js';
|
||||
|
||||
export interface ITestServerConfig {
|
||||
port: number;
|
||||
@@ -27,165 +23,18 @@ export interface ITestServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a test SMTP server with the given configuration
|
||||
* Starts a test SMTP server with the given configuration.
|
||||
*
|
||||
* NOTE: The TS SMTP server implementation was removed in Phase 7B
|
||||
* (replaced by the Rust SMTP server). This stub preserves the interface
|
||||
* for smtpclient tests that import it, but those tests require `node-forge`
|
||||
* which is not installed (pre-existing issue).
|
||||
*/
|
||||
export async function startTestServer(config: ITestServerConfig): Promise<ITestServer> {
|
||||
// Find a free port if one wasn't specified
|
||||
// Using smartnetwork to find an available port in the range 10000-60000
|
||||
let port = config.port;
|
||||
if (port === undefined || port === 0) {
|
||||
const network = new plugins.smartnetwork.Network();
|
||||
port = await network.findFreePort(10000, 60000, { randomize: true });
|
||||
if (!port) {
|
||||
throw new Error('No free ports available in range 10000-60000');
|
||||
}
|
||||
}
|
||||
|
||||
const serverConfig = {
|
||||
port: port, // Use the found free port
|
||||
hostname: config.hostname || 'localhost',
|
||||
tlsEnabled: config.tlsEnabled || false,
|
||||
authRequired: config.authRequired || false,
|
||||
timeout: config.timeout || 30000,
|
||||
maxConnections: config.maxConnections || 100,
|
||||
size: config.size || 10 * 1024 * 1024, // 10MB default
|
||||
maxRecipients: config.maxRecipients || 100
|
||||
};
|
||||
|
||||
// Create a mock email server for testing
|
||||
const mockEmailServer = {
|
||||
processEmailByMode: async (emailData: any) => {
|
||||
console.log(`📧 [Test Server] Processing email:`, emailData.subject || 'No subject');
|
||||
return emailData;
|
||||
},
|
||||
getRateLimiter: () => {
|
||||
// Return a mock rate limiter for testing
|
||||
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 }),
|
||||
checkRecipientLimit: async (_session: any) => ({ allowed: true, remaining: 50 }),
|
||||
recordAuthenticationFailure: async (_ip: string) => {},
|
||||
recordSyntaxError: async (_ip: string) => {},
|
||||
recordCommandError: async (_ip: string) => {},
|
||||
recordError: (_key: string) => false, // Return false to not block during tests
|
||||
isBlocked: async (_ip: string) => false,
|
||||
cleanup: async () => {}
|
||||
};
|
||||
}
|
||||
} as any;
|
||||
|
||||
// Load test certificates
|
||||
let key: string;
|
||||
let cert: string;
|
||||
|
||||
if (serverConfig.tlsEnabled) {
|
||||
try {
|
||||
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, 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 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
|
||||
const smtpOptions: ISmtpServerOptions = {
|
||||
port: serverConfig.port,
|
||||
hostname: serverConfig.hostname,
|
||||
key: key,
|
||||
cert: cert,
|
||||
maxConnections: serverConfig.maxConnections,
|
||||
size: serverConfig.size,
|
||||
maxRecipients: serverConfig.maxRecipients,
|
||||
socketTimeout: serverConfig.timeout,
|
||||
connectionTimeout: serverConfig.timeout * 2,
|
||||
cleanupInterval: 300000,
|
||||
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}`);
|
||||
|
||||
return {
|
||||
server: mockEmailServer,
|
||||
smtpServer: smtpServer,
|
||||
port: serverConfig.port, // Return the port we already know
|
||||
hostname: serverConfig.hostname,
|
||||
config: serverConfig,
|
||||
startTime: Date.now()
|
||||
};
|
||||
export async function startTestServer(_config: ITestServerConfig): Promise<ITestServer> {
|
||||
throw new Error(
|
||||
'startTestServer is no longer available — the TS SMTP server was removed in Phase 7B. ' +
|
||||
'Use the Rust SMTP server (via UnifiedEmailServer) for integration testing.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,94 +42,19 @@ export async function startTestServer(config: ITestServerConfig): Promise<ITestS
|
||||
*/
|
||||
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);
|
||||
console.error('Error stopping test server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for server to be ready to accept connections
|
||||
*/
|
||||
async function waitForServerReady(hostname: string, port: number, timeout: number = 10000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Server did not become ready within ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for port to be free
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
console.warn(`⚠️ Port ${port} still in use after ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is free
|
||||
*/
|
||||
async function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = plugins.net.createServer();
|
||||
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
|
||||
server.on('error', () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an available port for testing
|
||||
*/
|
||||
@@ -293,6 +67,21 @@ export async function getAvailablePort(startPort: number = 25000): Promise<numbe
|
||||
throw new Error(`No available ports found starting from ${startPort}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is free
|
||||
*/
|
||||
async function isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = plugins.net.createServer();
|
||||
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
|
||||
server.on('error', () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test email data
|
||||
*/
|
||||
@@ -332,7 +121,7 @@ export async function createTestServer(options: {
|
||||
}): 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);
|
||||
@@ -344,7 +133,7 @@ export async function createTestServer(options: {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.listen(port, hostname, () => {
|
||||
resolve({
|
||||
@@ -353,7 +142,7 @@ export async function createTestServer(options: {
|
||||
port
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user