From f7af8c4534fed899b53d3a7dcbd008fed667e0c7 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 11 Feb 2026 07:31:08 +0000 Subject: [PATCH] feat(e2e-tests): add Node.js end-to-end tests covering server lifecycle, inbound SMTP handling, outbound delivery and routing actions --- changelog.md | 8 + test/test.e2e.inbound-smtp.node.ts | 239 ++++++++++++++++++++++++ test/test.e2e.outbound-delivery.node.ts | 196 +++++++++++++++++++ test/test.e2e.routing-actions.node.ts | 239 ++++++++++++++++++++++++ test/test.e2e.server-lifecycle.node.ts | 118 ++++++++++++ ts/00_commitinfo_data.ts | 2 +- 6 files changed, 801 insertions(+), 1 deletion(-) create mode 100644 test/test.e2e.inbound-smtp.node.ts create mode 100644 test/test.e2e.outbound-delivery.node.ts create mode 100644 test/test.e2e.routing-actions.node.ts create mode 100644 test/test.e2e.server-lifecycle.node.ts diff --git a/changelog.md b/changelog.md index 059dd57..c1b9aab 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # 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) Replace the legacy TypeScript SMTP client with a new Rust-based SMTP client and IPC bridge for outbound delivery diff --git a/test/test.e2e.inbound-smtp.node.ts b/test/test.e2e.inbound-smtp.node.ts new file mode 100644 index 0000000..e6ebe47 --- /dev/null +++ b/test/test.e2e.inbound-smtp.node.ts @@ -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(); +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:', '250', 10000); + + // RCPT TO + await sendSmtpCommand(socket, 'RCPT TO:', '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((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:', '250', 10000); + await sendSmtpCommand(socket, 'RCPT TO:', '250', 10000); + await sendSmtpCommand(socket, 'RCPT TO:', '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((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:', '250', 10000); + + // Reset the transaction + await sendSmtpCommand(socket, 'RSET', '250', 10000); + + // Start a new transaction after RSET + await sendSmtpCommand(socket, 'MAIL FROM:', '250', 10000); + + socket.destroy(); +}); + +tap.test('cleanup - stop server', async () => { + if (bridgeAvailable) { + await server.stop(); + } + await tap.stopForcefully(); +}); + +export default tap.start(); diff --git a/test/test.e2e.outbound-delivery.node.ts b/test/test.e2e.outbound-delivery.node.ts new file mode 100644 index 0000000..24e1985 --- /dev/null +++ b/test/test.e2e.outbound-delivery.node.ts @@ -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(); +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 { + 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 .\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((resolve) => mockSmtpServer.close(() => resolve())); + } + if (bridgeAvailable) { + await server.stop(); + } + await tap.stopForcefully(); +}); + +export default tap.start(); diff --git a/test/test.e2e.routing-actions.node.ts b/test/test.e2e.routing-actions.node.ts new file mode 100644 index 0000000..a0b9a11 --- /dev/null +++ b/test/test.e2e.routing-actions.node.ts @@ -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(); +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(); diff --git a/test/test.e2e.server-lifecycle.node.ts b/test/test.e2e.server-lifecycle.node.ts new file mode 100644 index 0000000..d43bda8 --- /dev/null +++ b/test/test.e2e.server-lifecycle.node.ts @@ -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(); +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(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 66d160f..5bb77a8 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { 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.' }