398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
import * as plugins from '../ts/plugins.js';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
import { Buffer } from 'node:buffer';
|
|
import * as net from 'node:net';
|
|
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
|
|
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
|
|
|
async function getFreePort(): Promise<number> {
|
|
return await new Promise<number>((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const address = server.address();
|
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
server.close(() => resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function listen(server: net.Server, port: number = 0): Promise<number> {
|
|
return await new Promise<number>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(port, '127.0.0.1', () => {
|
|
server.off('error', reject);
|
|
const address = server.address();
|
|
resolve(typeof address === 'object' && address ? address.port : port);
|
|
});
|
|
});
|
|
}
|
|
|
|
function trackSocket(sockets: Set<net.Socket>, socket: net.Socket): void {
|
|
sockets.add(socket);
|
|
socket.once('close', () => sockets.delete(socket));
|
|
}
|
|
|
|
async function closeServer(server: net.Server, sockets?: Set<net.Socket>): Promise<void> {
|
|
for (const socket of sockets || []) {
|
|
socket.destroy();
|
|
}
|
|
if (!server.listening) {
|
|
return;
|
|
}
|
|
await new Promise<void>((resolve) => {
|
|
server.close(() => resolve());
|
|
});
|
|
}
|
|
|
|
async function readFirstSocketData(port: number): Promise<string> {
|
|
return await new Promise<string>((resolve, reject) => {
|
|
const socket = net.connect({ host: '127.0.0.1', port });
|
|
let settled = false;
|
|
let timeout: ReturnType<typeof setTimeout> & { unref?: () => void };
|
|
const cleanup = () => {
|
|
clearTimeout(timeout);
|
|
socket.removeListener('data', onData);
|
|
socket.removeListener('error', onError);
|
|
socket.removeListener('end', onEnd);
|
|
socket.removeListener('close', onClose);
|
|
};
|
|
const settle = (callback: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
socket.destroy();
|
|
callback();
|
|
};
|
|
timeout = setTimeout(() => {
|
|
settle(() => reject(new Error('Timed out waiting for socket data')));
|
|
}, 5000) as ReturnType<typeof setTimeout> & { unref?: () => void };
|
|
timeout.unref?.();
|
|
const onData = (data: Buffer) => settle(() => resolve(data.toString('utf8')));
|
|
const onError = (error: Error) => settle(() => reject(error));
|
|
const onEnd = () => settle(() => reject(new Error('Socket ended before data')));
|
|
const onClose = () => settle(() => reject(new Error('Socket closed before data')));
|
|
socket.once('data', onData);
|
|
socket.once('error', onError);
|
|
socket.once('end', onEnd);
|
|
socket.once('close', onClose);
|
|
});
|
|
}
|
|
|
|
tap.test('DcRouter class - Custom email port configuration', async () => {
|
|
// Define custom port mapping
|
|
const customPortMapping: Record<number, number> = {
|
|
25: 11025, // Custom SMTP port mapping
|
|
587: 11587, // Custom submission port mapping
|
|
465: 11465, // Custom SMTPS port mapping
|
|
2525: 12525 // Additional custom port
|
|
};
|
|
|
|
// Create a custom email configuration using smartmta interfaces
|
|
const emailConfig: IUnifiedEmailServerOptions = {
|
|
ports: [25, 587, 465, 2525],
|
|
hostname: 'mail.example.com',
|
|
maxMessageSize: 50 * 1024 * 1024, // 50MB
|
|
domains: [
|
|
{
|
|
domain: 'example.com',
|
|
dnsMode: 'external-dns',
|
|
},
|
|
{
|
|
domain: 'example.org',
|
|
dnsMode: 'external-dns',
|
|
}
|
|
],
|
|
routes: [
|
|
{
|
|
name: 'forward-example-com',
|
|
match: {
|
|
recipients: '*@example.com',
|
|
},
|
|
action: {
|
|
type: 'forward',
|
|
forward: {
|
|
host: 'mail1.example.com',
|
|
port: 25,
|
|
}
|
|
}
|
|
},
|
|
{
|
|
name: 'deliver-example-org',
|
|
match: {
|
|
recipients: '*@example.org',
|
|
},
|
|
action: {
|
|
type: 'deliver',
|
|
process: {
|
|
dkim: true,
|
|
}
|
|
}
|
|
}
|
|
]
|
|
};
|
|
|
|
// Create DcRouter options with custom email port configuration
|
|
const options: IDcRouterOptions = {
|
|
emailConfig,
|
|
emailPortConfig: {
|
|
portMapping: customPortMapping,
|
|
portSettings: {
|
|
2525: {
|
|
terminateTls: false,
|
|
routeName: 'custom-smtp-route'
|
|
}
|
|
},
|
|
},
|
|
tls: {
|
|
contactEmail: 'test@example.com'
|
|
}
|
|
};
|
|
|
|
// Create DcRouter instance
|
|
const router = new DcRouter(options);
|
|
|
|
// Verify the options are correctly set
|
|
expect(router.options.emailPortConfig).toBeTruthy();
|
|
expect(router.options.emailPortConfig!.portMapping).toEqual(customPortMapping);
|
|
|
|
// Test the generateEmailRoutes method
|
|
if (typeof (router as any)['generateEmailRoutes'] === 'function') {
|
|
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
|
|
|
// Verify that all ports are configured
|
|
expect(routes.length).toBeGreaterThan(0);
|
|
|
|
// Check the custom port configuration
|
|
const customPortRoute = routes.find((r: any) => {
|
|
const ports = r.match.ports;
|
|
return ports === 2525 || (Array.isArray(ports) && (ports as number[]).includes(2525));
|
|
});
|
|
expect(customPortRoute).toBeTruthy();
|
|
expect(customPortRoute?.name).toEqual('custom-smtp-route');
|
|
expect(customPortRoute?.action.type).toEqual('forward');
|
|
expect(customPortRoute?.action.targets[0].host).toEqual('localhost');
|
|
expect(customPortRoute?.action.targets[0].port).toEqual(12525);
|
|
expect(customPortRoute?.remoteIngress).toBeUndefined();
|
|
|
|
// Check standard port mappings
|
|
const smtpRoute = routes.find((r: any) => {
|
|
const ports = r.match.ports;
|
|
return ports === 25 || (Array.isArray(ports) && (ports as number[]).includes(25));
|
|
});
|
|
expect(smtpRoute?.action.targets[0].port).toEqual(11025);
|
|
|
|
const submissionRoute = routes.find((r: any) => {
|
|
const ports = r.match.ports;
|
|
return ports === 587 || (Array.isArray(ports) && (ports as number[]).includes(587));
|
|
});
|
|
expect(submissionRoute?.action.targets[0].port).toEqual(11587);
|
|
}
|
|
});
|
|
|
|
tap.test('DcRouter class - Generated plaintext email routes hydrate to server-first socket handlers', async () => {
|
|
const emailConfig: IUnifiedEmailServerOptions = {
|
|
ports: [25, 587, 465],
|
|
hostname: 'mail.example.com',
|
|
domains: [],
|
|
routes: [],
|
|
};
|
|
const router = new DcRouter({ emailConfig });
|
|
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
|
const smtpRoute = routes.find((route: any) => route.name === 'smtp-route');
|
|
const submissionRoute = routes.find((route: any) => route.name === 'submission-route');
|
|
const smtpsRoute = routes.find((route: any) => route.name === 'smtps-route');
|
|
|
|
const hydrate = (route: any, origin = 'email') => (router as any)['hydrateStoredRouteForRuntime']({
|
|
id: `${origin}-${route.name}`,
|
|
route,
|
|
enabled: true,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
createdBy: 'system',
|
|
origin,
|
|
systemKey: `${origin}:${route.name}`,
|
|
});
|
|
|
|
const runtimeSmtpRoute = hydrate(smtpRoute);
|
|
expect(runtimeSmtpRoute?.action.type).toEqual('socket-handler');
|
|
expect(typeof runtimeSmtpRoute?.action.socketHandler).toEqual('function');
|
|
|
|
const runtimeSubmissionRoute = hydrate(submissionRoute);
|
|
expect(runtimeSubmissionRoute?.action.type).toEqual('socket-handler');
|
|
expect(typeof runtimeSubmissionRoute?.action.socketHandler).toEqual('function');
|
|
|
|
expect(hydrate(smtpsRoute)).toBeUndefined();
|
|
expect(hydrate(smtpRoute, 'api')).toBeUndefined();
|
|
});
|
|
|
|
tap.test('DcRouter class - Email socket handler relays server-first SMTP banners', async () => {
|
|
const backendSockets = new Set<net.Socket>();
|
|
const backend = net.createServer((socket) => {
|
|
trackSocket(backendSockets, socket);
|
|
socket.write('220 test.example ESMTP Service Ready\r\n');
|
|
});
|
|
const backendPort = await listen(backend);
|
|
const emailConfig: IUnifiedEmailServerOptions = {
|
|
ports: [2525],
|
|
hostname: 'mail.example.com',
|
|
domains: [],
|
|
routes: [],
|
|
};
|
|
const router = new DcRouter({
|
|
emailConfig,
|
|
emailPortConfig: {
|
|
portMapping: { 2525: backendPort },
|
|
},
|
|
});
|
|
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
|
const route = routes.find((routeArg: any) => routeArg.name === 'email-port-2525-route');
|
|
const runtimeRoute = (router as any)['createServerFirstEmailRuntimeRoute'](route);
|
|
expect(runtimeRoute?.action.type).toEqual('socket-handler');
|
|
|
|
const frontendSockets = new Set<net.Socket>();
|
|
const frontend = net.createServer((socket) => {
|
|
trackSocket(frontendSockets, socket);
|
|
runtimeRoute.action.socketHandler(socket, {
|
|
port: 2525,
|
|
clientIp: '127.0.0.1',
|
|
serverIp: '127.0.0.1',
|
|
routeName: route.name,
|
|
timestamp: Date.now(),
|
|
connectionId: 'test-email-proxy',
|
|
});
|
|
});
|
|
const frontendPort = await listen(frontend);
|
|
|
|
try {
|
|
const banner = await readFirstSocketData(frontendPort);
|
|
expect(banner).toEqual('220 test.example ESMTP Service Ready\r\n');
|
|
} finally {
|
|
await closeServer(frontend, frontendSockets);
|
|
await closeServer(backend, backendSockets);
|
|
}
|
|
});
|
|
|
|
tap.test('DcRouter class - Email routes are exposed through RemoteIngress when enabled', async () => {
|
|
const emailConfig: IUnifiedEmailServerOptions = {
|
|
ports: [25, 587, 465],
|
|
hostname: 'mail.example.com',
|
|
domains: [],
|
|
routes: [],
|
|
};
|
|
|
|
const router = new DcRouter({
|
|
emailConfig,
|
|
remoteIngressConfig: {
|
|
enabled: true,
|
|
tunnelPort: 8443,
|
|
hubDomain: 'ingress.example.com',
|
|
},
|
|
});
|
|
|
|
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
|
expect(routes.length).toEqual(3);
|
|
for (const route of routes) {
|
|
expect(route.remoteIngress).toEqual({ enabled: true });
|
|
}
|
|
});
|
|
|
|
tap.test('DcRouter class - Email config with domains and routes', async () => {
|
|
const opsServerPort = await getFreePort();
|
|
// Create a basic email configuration
|
|
const emailConfig: IUnifiedEmailServerOptions = {
|
|
ports: [2525],
|
|
hostname: 'mail.example.com',
|
|
domains: [],
|
|
routes: []
|
|
};
|
|
|
|
// Create DcRouter options
|
|
const options: IDcRouterOptions = {
|
|
emailConfig,
|
|
tls: {
|
|
contactEmail: 'test@example.com'
|
|
},
|
|
opsServerPort,
|
|
dbConfig: {
|
|
enabled: false,
|
|
}
|
|
};
|
|
|
|
// Create DcRouter instance
|
|
const router = new DcRouter(options);
|
|
|
|
// Start the router to initialize email services
|
|
await router.start();
|
|
|
|
// Verify unified email server was initialized
|
|
expect(router.emailServer).toBeTruthy();
|
|
expect((router.emailServer as any).options.hostname).toEqual('mail.example.com');
|
|
expect((router.emailServer as any).options.persistRoutes).toEqual(false);
|
|
expect((router.emailServer as any).options.queue.storageType).toEqual('disk');
|
|
|
|
// Stop the router
|
|
await router.stop();
|
|
});
|
|
|
|
tap.test('DcRouter class - Email config updates are serialized', async () => {
|
|
const router = new DcRouter({
|
|
tls: {
|
|
contactEmail: 'test@example.com',
|
|
},
|
|
});
|
|
const delay = async () => await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
let activeLifecycleSteps = 0;
|
|
let overlapped = false;
|
|
|
|
const enterLifecycleStep = async () => {
|
|
activeLifecycleSteps++;
|
|
if (activeLifecycleSteps > 1) {
|
|
overlapped = true;
|
|
}
|
|
await delay();
|
|
activeLifecycleSteps--;
|
|
};
|
|
|
|
(router as any).stopUnifiedEmailComponents = async () => {
|
|
await enterLifecycleStep();
|
|
};
|
|
(router as any).setupUnifiedEmailHandling = async () => {
|
|
await enterLifecycleStep();
|
|
};
|
|
|
|
const firstConfig: IUnifiedEmailServerOptions = {
|
|
ports: [2525],
|
|
hostname: 'first.mail.example.com',
|
|
domains: [],
|
|
routes: [],
|
|
};
|
|
const secondConfig: IUnifiedEmailServerOptions = {
|
|
ports: [2526],
|
|
hostname: 'second.mail.example.com',
|
|
domains: [],
|
|
routes: [],
|
|
};
|
|
|
|
await Promise.all([
|
|
router.updateEmailConfig(firstConfig),
|
|
router.updateEmailConfig(secondConfig),
|
|
]);
|
|
|
|
expect(overlapped).toEqual(false);
|
|
expect(router.options.emailConfig?.hostname).toEqual('second.mail.example.com');
|
|
});
|
|
|
|
// Final clean-up test
|
|
tap.test('clean up after tests', async () => {
|
|
// No-op
|
|
});
|
|
|
|
tap.test('stop', async () => {
|
|
await tap.stopForcefully();
|
|
});
|
|
|
|
export default tap.start();
|