240 lines
6.3 KiB
TypeScript
240 lines
6.3 KiB
TypeScript
|
|
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();
|