fix(email): relay server-first SMTP banners for generated email routes

This commit is contained in:
2026-06-04 12:06:09 +00:00
parent 2ec647cd6c
commit 17bb63f129
3 changed files with 268 additions and 1 deletions
+7
View File
@@ -8,6 +8,13 @@
### Fixes
- relay server-first SMTP banners for generated email routes (email)
- Convert generated plaintext email forward routes to runtime socket handlers for SmartProxy bootstrap.
- Hydrate DB-backed generated email routes to the same runtime handlers when their email system keys match.
- Add bidirectional socket proxy cleanup and tests for route hydration and SMTP banner relay.
## 2026-06-04 - 13.44.1
### Fixes
+148
View File
@@ -2,6 +2,7 @@ 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';
@@ -18,6 +19,68 @@ async function getFreePort(): Promise<number> {
});
}
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> = {
@@ -109,6 +172,8 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
});
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();
@@ -127,6 +192,89 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
}
});
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],
+113 -1
View File
@@ -1172,7 +1172,7 @@ export class DcRouter {
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
let routes: plugins.smartproxy.IRouteConfig[] = [
...this.seedConfigRoutes,
...this.seedEmailRoutes,
...this.getRuntimeEmailRoutes(this.seedEmailRoutes as IDcRouterRouteConfig[]),
...this.runtimeDnsRoutes,
];
@@ -1715,6 +1715,115 @@ export class DcRouter {
return dnsRoutes;
}
private getRuntimeEmailRoutes(emailRoutes: IDcRouterRouteConfig[]): plugins.smartproxy.IRouteConfig[] {
return emailRoutes.map((route) => this.createServerFirstEmailRuntimeRoute(route) || route);
}
private getCurrentGeneratedEmailRouteNames(): Set<string> {
const sourceRoutes = this.seedEmailRoutes.length > 0
? this.seedEmailRoutes
: this.options.emailConfig
? this.generateEmailRoutes(this.options.emailConfig)
: [];
return new Set(sourceRoutes.map((route) => route.name).filter(Boolean) as string[]);
}
private shouldHydrateGeneratedEmailRoute(storedRoute: IRoute): boolean {
if (storedRoute.origin !== 'email') {
return false;
}
const routeName = storedRoute.route.name;
if (!routeName || !this.getCurrentGeneratedEmailRouteNames().has(routeName)) {
return false;
}
const expectedSystemKey = `email:${routeName}`;
return !storedRoute.systemKey || storedRoute.systemKey === expectedSystemKey;
}
private createServerFirstEmailRuntimeRoute(
route: plugins.smartproxy.IRouteConfig,
): plugins.smartproxy.IRouteConfig | undefined {
const action = route.action as any;
if (action?.type !== 'forward') {
return undefined;
}
const tlsMode = action.tls?.mode;
if (tlsMode === 'terminate' || tlsMode === 'terminate-and-reencrypt') {
return undefined;
}
const routePorts = plugins.smartproxy.expandPortRange(route.match?.ports as any) as number[];
if (routePorts.length !== 1) {
return undefined;
}
const target = action.targets?.[0];
if (!target || action.targets.length !== 1 || typeof target.port !== 'number') {
return undefined;
}
if (typeof target.host !== 'string') {
return undefined;
}
const targetHost = target.host === 'localhost' ? '127.0.0.1' : target.host;
return {
...route,
action: {
type: 'socket-handler' as any,
socketHandler: this.createEmailSocketProxyHandler(targetHost, target.port),
} as any,
};
}
private createEmailSocketProxyHandler(
targetHost: string,
targetPort: number,
): NonNullable<plugins.smartproxy.IRouteConfig['action']['socketHandler']> {
return (clientSocket) => {
let backendSocket: plugins.net.Socket | undefined;
let connectTimeout: ReturnType<typeof setTimeout> & { unref?: () => void };
let cleanupDone = false;
const cleanup = () => {
if (cleanupDone) return;
cleanupDone = true;
clearTimeout(connectTimeout);
clientSocket.removeListener('timeout', cleanup);
clientSocket.removeListener('error', cleanup);
clientSocket.removeListener('end', cleanup);
clientSocket.removeListener('close', cleanup);
backendSocket?.removeListener('timeout', cleanup);
backendSocket?.removeListener('error', cleanup);
backendSocket?.removeListener('end', cleanup);
backendSocket?.removeListener('close', cleanup);
clientSocket.destroy();
backendSocket?.destroy();
};
connectTimeout = setTimeout(() => {
cleanup();
}, 30_000);
connectTimeout.unref?.();
clientSocket.setTimeout(300_000);
clientSocket.on('timeout', cleanup);
clientSocket.on('error', cleanup);
clientSocket.on('end', cleanup);
clientSocket.on('close', cleanup);
backendSocket = plugins.net.connect(targetPort, targetHost, () => {
clearTimeout(connectTimeout);
backendSocket?.setTimeout(300_000);
clientSocket.pipe(backendSocket!);
backendSocket!.pipe(clientSocket);
});
backendSocket.setTimeout(30_000);
backendSocket.on('timeout', cleanup);
backendSocket.on('error', cleanup);
backendSocket.on('end', cleanup);
backendSocket.on('close', cleanup);
};
}
private hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined {
const routeName = storedRoute.route.name || '';
const isDohRoute = storedRoute.origin === 'dns'
@@ -1722,6 +1831,9 @@ export class DcRouter {
&& routeName.startsWith('dns-over-https-');
if (!isDohRoute) {
if (this.shouldHydrateGeneratedEmailRoute(storedRoute)) {
return this.createServerFirstEmailRuntimeRoute(storedRoute.route);
}
return undefined;
}