Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7853ef67b6 | |||
| f7af8c4534 |
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-11 - 4.1.0 - feat(e2e-tests)
|
||||||
|
add Node.js end-to-end tests covering server lifecycle, inbound SMTP handling, outbound delivery and routing actions
|
||||||
|
|
||||||
|
- Adds four end-to-end test files: test.e2e.server-lifecycle.node.ts, test.e2e.inbound-smtp.node.ts, test.e2e.outbound-delivery.node.ts, test.e2e.routing-actions.node.ts
|
||||||
|
- Tests exercise UnifiedEmailServer start/stop, SMTP handshake and transactions, outbound delivery via a mock SMTP server, routing actions (process, deliver, reject, forward), concurrency, and RSET handling mid-session
|
||||||
|
- Introduces a minimal mock SMTP server to avoid IPC deadlock with the Rust SMTP client during outbound delivery tests
|
||||||
|
- Tests will skip when the Rust bridge or server cannot start (binary build required)
|
||||||
|
|
||||||
## 2026-02-11 - 4.0.0 - BREAKING CHANGE(smtp-client)
|
## 2026-02-11 - 4.0.0 - BREAKING CHANGE(smtp-client)
|
||||||
Replace the legacy TypeScript SMTP client with a new Rust-based SMTP client and IPC bridge for outbound delivery
|
Replace the legacy TypeScript SMTP client with a new Rust-based SMTP client and IPC bridge for outbound delivery
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartmta",
|
"name": "@push.rocks/smartmta",
|
||||||
"version": "4.0.0",
|
"version": "4.1.0",
|
||||||
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mta",
|
"mta",
|
||||||
|
|||||||
239
test/test.e2e.inbound-smtp.node.ts
Normal file
239
test/test.e2e.inbound-smtp.node.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||||
|
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
import {
|
||||||
|
connectToSmtp,
|
||||||
|
waitForGreeting,
|
||||||
|
sendSmtpCommand,
|
||||||
|
performSmtpHandshake,
|
||||||
|
createConcurrentConnections,
|
||||||
|
createMimeMessage,
|
||||||
|
} from './helpers/utils.js';
|
||||||
|
|
||||||
|
const storageMap = new Map<string, string>();
|
||||||
|
const mockDcRouter = {
|
||||||
|
storageManager: {
|
||||||
|
get: async (key: string) => storageMap.get(key) || null,
|
||||||
|
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: UnifiedEmailServer;
|
||||||
|
let bridge: RustSecurityBridge;
|
||||||
|
let bridgeAvailable = false;
|
||||||
|
|
||||||
|
tap.test('setup - start server on port 10125', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
bridge = RustSecurityBridge.getInstance();
|
||||||
|
|
||||||
|
server = new UnifiedEmailServer(mockDcRouter, {
|
||||||
|
ports: [10125],
|
||||||
|
hostname: 'test.inbound.local',
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
domain: 'testdomain.com',
|
||||||
|
dnsMode: 'forward',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'catch-all',
|
||||||
|
priority: 0,
|
||||||
|
match: {
|
||||||
|
recipients: '*@testdomain.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
bridgeAvailable = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||||
|
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EHLO and capability discovery', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||||
|
const capabilities = await performSmtpHandshake(socket, 'test-client.local');
|
||||||
|
|
||||||
|
// Verify we received capabilities from the EHLO response
|
||||||
|
expect(capabilities.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The server hostname should be in the first capability line
|
||||||
|
const firstLine = capabilities[0];
|
||||||
|
expect(firstLine).toBeTruthy();
|
||||||
|
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('send valid email - full SMTP transaction', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||||
|
await waitForGreeting(socket, 10000);
|
||||||
|
|
||||||
|
// EHLO
|
||||||
|
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
|
||||||
|
|
||||||
|
// MAIL FROM
|
||||||
|
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
|
||||||
|
|
||||||
|
// RCPT TO
|
||||||
|
await sendSmtpCommand(socket, 'RCPT TO:<user@testdomain.com>', '250', 10000);
|
||||||
|
|
||||||
|
// DATA
|
||||||
|
await sendSmtpCommand(socket, 'DATA', '354', 10000);
|
||||||
|
|
||||||
|
// Send MIME message
|
||||||
|
const mimeMessage = createMimeMessage({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'user@testdomain.com',
|
||||||
|
subject: 'E2E Test Email',
|
||||||
|
text: 'This is an end-to-end test email.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send the message data followed by the terminator
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let buffer = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
socket.removeAllListeners('data');
|
||||||
|
reject(new Error('DATA response timeout'));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const onData = (data: Buffer) => {
|
||||||
|
buffer += data.toString();
|
||||||
|
if (buffer.includes('250')) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.removeListener('data', onData);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('data', onData);
|
||||||
|
socket.write(mimeMessage + '\r\n.\r\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
// QUIT
|
||||||
|
try {
|
||||||
|
await sendSmtpCommand(socket, 'QUIT', '221', 5000);
|
||||||
|
} catch {
|
||||||
|
// Ignore QUIT errors
|
||||||
|
}
|
||||||
|
socket.destroy();
|
||||||
|
|
||||||
|
// Verify the email was queued for processing
|
||||||
|
const stats = server.deliveryQueue.getStats();
|
||||||
|
expect(stats.queueSize).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('multiple recipients', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||||
|
await waitForGreeting(socket, 10000);
|
||||||
|
|
||||||
|
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
|
||||||
|
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
|
||||||
|
await sendSmtpCommand(socket, 'RCPT TO:<user1@testdomain.com>', '250', 10000);
|
||||||
|
await sendSmtpCommand(socket, 'RCPT TO:<user2@testdomain.com>', '250', 10000);
|
||||||
|
|
||||||
|
await sendSmtpCommand(socket, 'DATA', '354', 10000);
|
||||||
|
|
||||||
|
const mimeMessage = createMimeMessage({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'user1@testdomain.com',
|
||||||
|
subject: 'Multi-recipient Test',
|
||||||
|
text: 'Testing multiple recipients.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let buffer = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
socket.removeAllListeners('data');
|
||||||
|
reject(new Error('DATA response timeout'));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const onData = (data: Buffer) => {
|
||||||
|
buffer += data.toString();
|
||||||
|
if (buffer.includes('250')) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.removeListener('data', onData);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('data', onData);
|
||||||
|
socket.write(mimeMessage + '\r\n.\r\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('concurrent connections', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sockets = await createConcurrentConnections('127.0.0.1', 10125, 3, 10000);
|
||||||
|
expect(sockets.length).toEqual(3);
|
||||||
|
|
||||||
|
// Perform EHLO on each connection
|
||||||
|
for (const socket of sockets) {
|
||||||
|
await waitForGreeting(socket, 10000);
|
||||||
|
await sendSmtpCommand(socket, 'EHLO concurrent-client.local', '250', 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close all connections
|
||||||
|
for (const socket of sockets) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RSET mid-session', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||||
|
await waitForGreeting(socket, 10000);
|
||||||
|
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
|
||||||
|
|
||||||
|
// Start a transaction
|
||||||
|
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
|
||||||
|
|
||||||
|
// Reset the transaction
|
||||||
|
await sendSmtpCommand(socket, 'RSET', '250', 10000);
|
||||||
|
|
||||||
|
// Start a new transaction after RSET
|
||||||
|
await sendSmtpCommand(socket, 'MAIL FROM:<other@example.com>', '250', 10000);
|
||||||
|
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup - stop server', async () => {
|
||||||
|
if (bridgeAvailable) {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
196
test/test.e2e.outbound-delivery.node.ts
Normal file
196
test/test.e2e.outbound-delivery.node.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||||
|
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
import type { ISmtpPoolStatus } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
import * as net from 'net';
|
||||||
|
|
||||||
|
const storageMap = new Map<string, string>();
|
||||||
|
const mockDcRouter = {
|
||||||
|
storageManager: {
|
||||||
|
get: async (key: string) => storageMap.get(key) || null,
|
||||||
|
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: UnifiedEmailServer;
|
||||||
|
let bridge: RustSecurityBridge;
|
||||||
|
let bridgeAvailable = false;
|
||||||
|
let mockSmtpServer: net.Server;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a minimal mock SMTP server that accepts any email.
|
||||||
|
* This avoids the IPC deadlock that occurs when the Rust SMTP client
|
||||||
|
* sends to the same Rust process's SMTP server (the IPC stdin reader
|
||||||
|
* blocks on the sendEmail command and can't process emailProcessingResult).
|
||||||
|
*/
|
||||||
|
function createMockSmtpServer(port: number): Promise<net.Server> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const srv = net.createServer((socket) => {
|
||||||
|
socket.write('220 mock-smtp.local ESMTP MockServer\r\n');
|
||||||
|
|
||||||
|
let inData = false;
|
||||||
|
let dataBuffer = '';
|
||||||
|
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
const input = chunk.toString();
|
||||||
|
|
||||||
|
if (inData) {
|
||||||
|
dataBuffer += input;
|
||||||
|
if (dataBuffer.includes('\r\n.\r\n')) {
|
||||||
|
inData = false;
|
||||||
|
dataBuffer = '';
|
||||||
|
socket.write('250 2.0.0 Ok: queued\r\n');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process SMTP commands line by line
|
||||||
|
const lines = input.split('\r\n').filter((l: string) => l.length > 0);
|
||||||
|
for (const line of lines) {
|
||||||
|
const cmd = line.toUpperCase();
|
||||||
|
if (cmd.startsWith('EHLO') || cmd.startsWith('HELO')) {
|
||||||
|
socket.write(`250-mock-smtp.local\r\n250-SIZE 10485760\r\n250 OK\r\n`);
|
||||||
|
} else if (cmd.startsWith('MAIL FROM')) {
|
||||||
|
socket.write('250 2.1.0 Ok\r\n');
|
||||||
|
} else if (cmd.startsWith('RCPT TO')) {
|
||||||
|
socket.write('250 2.1.5 Ok\r\n');
|
||||||
|
} else if (cmd === 'DATA') {
|
||||||
|
inData = true;
|
||||||
|
dataBuffer = '';
|
||||||
|
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
||||||
|
} else if (cmd === 'QUIT') {
|
||||||
|
socket.write('221 2.0.0 Bye\r\n');
|
||||||
|
socket.end();
|
||||||
|
} else if (cmd === 'RSET') {
|
||||||
|
socket.write('250 2.0.0 Ok\r\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
srv.listen(port, '127.0.0.1', () => {
|
||||||
|
resolve(srv);
|
||||||
|
});
|
||||||
|
|
||||||
|
srv.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('setup - start bridge and mock SMTP server', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
bridge = RustSecurityBridge.getInstance();
|
||||||
|
|
||||||
|
server = new UnifiedEmailServer(mockDcRouter, {
|
||||||
|
ports: [10325],
|
||||||
|
hostname: 'test.outbound.local',
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
domain: 'outbound-test.com',
|
||||||
|
dnsMode: 'forward',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'catch-all',
|
||||||
|
priority: 0,
|
||||||
|
match: {
|
||||||
|
recipients: '*',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
bridgeAvailable = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||||
|
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a mock SMTP server on a separate port for outbound delivery tests
|
||||||
|
mockSmtpServer = await createMockSmtpServer(10326);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('send email to mock SMTP receiver', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@outbound-test.com',
|
||||||
|
to: 'recipient@outbound-test.com',
|
||||||
|
subject: 'Outbound E2E Test',
|
||||||
|
text: 'Testing outbound delivery to the mock SMTP server.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to the mock SMTP server (port 10326), not the Rust SMTP server (port 10325)
|
||||||
|
const result = await server.sendOutboundEmail('127.0.0.1', 10326, email);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.accepted).toBeTruthy();
|
||||||
|
expect(result.accepted.length).toBeGreaterThan(0);
|
||||||
|
expect(result.response).toBeTruthy();
|
||||||
|
// Rust SMTP client returns enhanced status code without the 250 prefix
|
||||||
|
expect(result.response).toInclude('2.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('send email - connection refused', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@outbound-test.com',
|
||||||
|
to: 'recipient@outbound-test.com',
|
||||||
|
subject: 'Connection Refused Test',
|
||||||
|
text: 'This should fail — no server at port 59888.',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.sendOutboundEmail('127.0.0.1', 59888, email);
|
||||||
|
throw new Error('Expected sendOutboundEmail to fail on connection refused');
|
||||||
|
} catch (err: any) {
|
||||||
|
expect(err).toBeTruthy();
|
||||||
|
expect(err.message.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SMTP pool status and cleanup', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status: ISmtpPoolStatus = await bridge.getSmtpPoolStatus();
|
||||||
|
expect(status).toBeTruthy();
|
||||||
|
expect(status.pools).toBeTruthy();
|
||||||
|
expect(typeof status.pools).toEqual('object');
|
||||||
|
|
||||||
|
// Close all pools
|
||||||
|
await bridge.closeSmtpPool();
|
||||||
|
|
||||||
|
// Verify pools are empty
|
||||||
|
const statusAfter = await bridge.getSmtpPoolStatus();
|
||||||
|
const poolKeys = Object.keys(statusAfter.pools);
|
||||||
|
expect(poolKeys.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup - stop server and mock', async () => {
|
||||||
|
if (mockSmtpServer) {
|
||||||
|
await new Promise<void>((resolve) => mockSmtpServer.close(() => resolve()));
|
||||||
|
}
|
||||||
|
if (bridgeAvailable) {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
239
test/test.e2e.routing-actions.node.ts
Normal file
239
test/test.e2e.routing-actions.node.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||||
|
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
import { SmtpState } from '../ts/mail/delivery/interfaces.js';
|
||||||
|
|
||||||
|
const storageMap = new Map<string, string>();
|
||||||
|
const mockDcRouter = {
|
||||||
|
storageManager: {
|
||||||
|
get: async (key: string) => storageMap.get(key) || null,
|
||||||
|
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: UnifiedEmailServer;
|
||||||
|
let bridge: RustSecurityBridge;
|
||||||
|
let bridgeAvailable = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal SMTP session object for processEmailByMode().
|
||||||
|
*/
|
||||||
|
function buildSession(email: Email): any {
|
||||||
|
return {
|
||||||
|
id: `test-${Date.now()}-${Math.random().toString(36).substring(2)}`,
|
||||||
|
state: SmtpState.FINISHED,
|
||||||
|
mailFrom: email.from,
|
||||||
|
rcptTo: email.to,
|
||||||
|
emailData: '',
|
||||||
|
useTLS: false,
|
||||||
|
connectionEnded: false,
|
||||||
|
remoteAddress: '127.0.0.1',
|
||||||
|
clientHostname: 'test-client.local',
|
||||||
|
secure: false,
|
||||||
|
authenticated: false,
|
||||||
|
envelope: {
|
||||||
|
mailFrom: { address: email.from, args: {} },
|
||||||
|
rcptTo: email.to.map((addr: string) => ({ address: addr, args: {} })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('setup - start server with routing rules on port 10225', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
bridge = RustSecurityBridge.getInstance();
|
||||||
|
|
||||||
|
server = new UnifiedEmailServer(mockDcRouter, {
|
||||||
|
ports: [10225],
|
||||||
|
hostname: 'test.routing.local',
|
||||||
|
domains: [
|
||||||
|
{ domain: 'process.com', dnsMode: 'forward' },
|
||||||
|
{ domain: 'local.com', dnsMode: 'forward' },
|
||||||
|
{ domain: 'external.com', dnsMode: 'forward' },
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'reject-route',
|
||||||
|
priority: 40,
|
||||||
|
match: {
|
||||||
|
senders: '*@spammer.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'reject',
|
||||||
|
reject: {
|
||||||
|
code: 550,
|
||||||
|
message: 'Spam rejected',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'process-route',
|
||||||
|
priority: 30,
|
||||||
|
match: {
|
||||||
|
recipients: '*@process.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
process: {
|
||||||
|
scan: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deliver-route',
|
||||||
|
priority: 20,
|
||||||
|
match: {
|
||||||
|
recipients: '*@local.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'deliver',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'forward-route',
|
||||||
|
priority: 10,
|
||||||
|
match: {
|
||||||
|
recipients: '*@external.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
forward: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 59999, // No server listening — expected failure
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
bridgeAvailable = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||||
|
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('process action - queues email for processing', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'user@process.com',
|
||||||
|
subject: 'Process test',
|
||||||
|
text: 'This email should be queued for processing.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = buildSession(email);
|
||||||
|
const result = await server.processEmailByMode(email, session);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
|
||||||
|
const stats = server.deliveryQueue.getStats();
|
||||||
|
expect(stats.modes.process).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deliver action - queues email for MTA delivery', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'user@local.com',
|
||||||
|
subject: 'Deliver test',
|
||||||
|
text: 'This email should be queued for local delivery.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = buildSession(email);
|
||||||
|
const result = await server.processEmailByMode(email, session);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
|
||||||
|
const stats = server.deliveryQueue.getStats();
|
||||||
|
expect(stats.modes.mta).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reject action - throws with correct code', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'bad@spammer.com',
|
||||||
|
to: 'user@process.com',
|
||||||
|
subject: 'Spam attempt',
|
||||||
|
text: 'This should be rejected.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = buildSession(email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.processEmailByMode(email, session);
|
||||||
|
throw new Error('Expected processEmailByMode to throw for rejected email');
|
||||||
|
} catch (err: any) {
|
||||||
|
expect(err.responseCode).toEqual(550);
|
||||||
|
expect(err.message).toInclude('Spam rejected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('forward action - fails to unreachable host', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'user@external.com',
|
||||||
|
subject: 'Forward test',
|
||||||
|
text: 'This forward should fail — no server at port 59999.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = buildSession(email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.processEmailByMode(email, session);
|
||||||
|
throw new Error('Expected processEmailByMode to throw for unreachable forward host');
|
||||||
|
} catch (err: any) {
|
||||||
|
// We expect an error from the failed SMTP connection
|
||||||
|
expect(err).toBeTruthy();
|
||||||
|
expect(err.message).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('no matching route - throws error', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'nobody@unmatched.com',
|
||||||
|
subject: 'Unmatched route test',
|
||||||
|
text: 'No route matches this recipient.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = buildSession(email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.processEmailByMode(email, session);
|
||||||
|
throw new Error('Expected processEmailByMode to throw for no matching route');
|
||||||
|
} catch (err: any) {
|
||||||
|
expect(err.message).toInclude('No matching route');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup - stop server', async () => {
|
||||||
|
if (bridgeAvailable) {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
118
test/test.e2e.server-lifecycle.node.ts
Normal file
118
test/test.e2e.server-lifecycle.node.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||||
|
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
import { connectToSmtp, waitForGreeting } from './helpers/utils.js';
|
||||||
|
import * as net from 'net';
|
||||||
|
|
||||||
|
// Common mock pattern for dcRouter dependency
|
||||||
|
const storageMap = new Map<string, string>();
|
||||||
|
const mockDcRouter = {
|
||||||
|
storageManager: {
|
||||||
|
get: async (key: string) => storageMap.get(key) || null,
|
||||||
|
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: UnifiedEmailServer;
|
||||||
|
let bridge: RustSecurityBridge;
|
||||||
|
|
||||||
|
tap.test('setup - reset bridge singleton', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
bridge = RustSecurityBridge.getInstance();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('construct server - should create UnifiedEmailServer', async () => {
|
||||||
|
server = new UnifiedEmailServer(mockDcRouter, {
|
||||||
|
ports: [10025, 10587],
|
||||||
|
hostname: 'test.e2e.local',
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
domain: 'e2e-test.com',
|
||||||
|
dnsMode: 'forward',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'catch-all',
|
||||||
|
priority: 0,
|
||||||
|
match: {
|
||||||
|
recipients: '*@e2e-test.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(server).toBeTruthy();
|
||||||
|
expect(server).toBeInstanceOf(UnifiedEmailServer);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('start server - should start and accept SMTP connections', async () => {
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||||
|
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(bridge.running).toBeTrue();
|
||||||
|
|
||||||
|
// Connect to port 10025 and verify we get a 220 greeting
|
||||||
|
const socket = await connectToSmtp('127.0.0.1', 10025, 10000);
|
||||||
|
const greeting = await waitForGreeting(socket, 10000);
|
||||||
|
expect(greeting).toInclude('220');
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('get stats - should return server statistics', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = server.getStats();
|
||||||
|
expect(stats).toBeTruthy();
|
||||||
|
expect(stats.startTime).toBeInstanceOf(Date);
|
||||||
|
expect(stats.connections).toBeTruthy();
|
||||||
|
expect(typeof stats.connections.current).toEqual('number');
|
||||||
|
expect(typeof stats.connections.total).toEqual('number');
|
||||||
|
expect(stats.messages).toBeTruthy();
|
||||||
|
expect(typeof stats.messages.processed).toEqual('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop server - should stop and refuse connections', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await server.stop();
|
||||||
|
|
||||||
|
// Verify connection is refused after stop
|
||||||
|
try {
|
||||||
|
const socket = await connectToSmtp('127.0.0.1', 10025, 3000);
|
||||||
|
socket.destroy();
|
||||||
|
// If we get here, the connection was accepted — that's unexpected
|
||||||
|
throw new Error('Expected connection to be refused after server stop');
|
||||||
|
} catch (err) {
|
||||||
|
// Connection refused or timeout is expected
|
||||||
|
const msg = (err as Error).message;
|
||||||
|
expect(
|
||||||
|
msg.includes('ECONNREFUSED') || msg.includes('timeout') || msg.includes('refused')
|
||||||
|
).toBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
// Clean up if not already stopped
|
||||||
|
if (bridge.running) {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartmta',
|
name: '@push.rocks/smartmta',
|
||||||
version: '4.0.0',
|
version: '4.1.0',
|
||||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user