From 36006191fc8b2e33b6ff00dd0f8e43e7c701b090 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 10 Feb 2026 23:23:00 +0000 Subject: [PATCH] BREAKING CHANGE(security): implement resilience and lifecycle management for RustSecurityBridge (auto-restart, health checks, state machine and eventing); remove legacy TS SMTP test helper and DNSManager; remove deliverability IP-warmup/sender-reputation integrations and related types; drop unused dependencies --- changelog.md | 10 + package.json | 2 - pnpm-lock.yaml | 128 -- test/helpers/server.loader.ts | 148 -- ...test.rustsecuritybridge.resilience.node.ts | 177 +++ ts/00_commitinfo_data.ts | 2 +- ts/mail/delivery/classes.emailsendjob.ts | 22 - .../delivery/classes.smtp.client.legacy.ts | 1414 ----------------- ts/mail/delivery/interfaces.ts | 124 -- ts/mail/routing/classes.dnsmanager.ts | 559 ------- .../routing/classes.unified.email.server.ts | 410 +---- ts/security/classes.rustsecuritybridge.ts | 275 +++- ts/security/index.ts | 2 + 13 files changed, 495 insertions(+), 2778 deletions(-) delete mode 100644 test/helpers/server.loader.ts create mode 100644 test/test.rustsecuritybridge.resilience.node.ts delete mode 100644 ts/mail/delivery/classes.smtp.client.legacy.ts delete mode 100644 ts/mail/routing/classes.dnsmanager.ts diff --git a/changelog.md b/changelog.md index a7ebbdd..58d5dbb 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-02-10 - 3.0.0 - BREAKING CHANGE(security) +implement resilience and lifecycle management for RustSecurityBridge (auto-restart, health checks, state machine and eventing); remove legacy TS SMTP test helper and DNSManager; remove deliverability IP-warmup/sender-reputation integrations and related types; drop unused dependencies + +- RustSecurityBridge now extends EventEmitter and includes a BridgeState state machine, IBridgeResilienceConfig with DEFAULT_RESILIENCE_CONFIG, auto-restart with exponential backoff, periodic health checks, restart/restore logic, and descriptive ensureRunning() guards on command methods. +- Added static methods: resetInstance() (test-friendly) and configure(...) to tweak resilience settings at runtime. +- Added stateChange events and logging for lifecycle transitions; new tests added for resilience: test/test.rustsecuritybridge.resilience.node.ts. +- Removed the TypeScript SMTP test helper (test/helpers/server.loader.ts), the DNSManager (ts/mail/routing/classes.dnsmanager.ts), and many deliverability-related interfaces/implementations (IP warmup manager and sender reputation monitor) from unified email server. +- Removed public types ISmtpServerOptions and ISmtpTransactionResult from ts/mail/delivery/interfaces.ts, which is a breaking API change for consumers relying on those types. +- Removed unused dependencies from package.json: ip and mailauth. + ## 2026-02-10 - 2.4.0 - feat(docs) document Rust-side in-process security pipeline and update README to reflect SMTP server behavior and crate/test counts diff --git a/package.json b/package.json index c4a2f30..e02c4be 100644 --- a/package.json +++ b/package.json @@ -71,9 +71,7 @@ "@push.rocks/smartunique": "^3.0.9", "@serve.zone/interfaces": "^5.0.4", "@tsclass/tsclass": "^9.2.0", - "ip": "^2.0.1", "lru-cache": "^11.2.5", - "mailauth": "^4.13.0", "mailparser": "^3.9.3", "uuid": "^13.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ad0b7c..d58af31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,15 +89,9 @@ importers: '@tsclass/tsclass': specifier: ^9.2.0 version: 9.3.0 - ip: - specifier: ^2.0.1 - version: 2.0.1 lru-cache: specifier: ^11.2.5 version: 11.2.5 - mailauth: - specifier: ^4.13.0 - version: 4.13.0 mailparser: specifier: ^3.9.3 version: 3.9.3 @@ -575,26 +569,6 @@ packages: resolution: {integrity: sha512-nmiLGeOkKMkLDyIk5BUBLx5ExskFbKHKlPdrWCARPVFkU4cAAiuIyJWVfLwISoS0TO/zSInLqArPwIc76yvaNw==} hasBin: true - '@hapi/address@5.1.1': - resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} - engines: {node: '>=14.0.0'} - - '@hapi/formula@3.0.2': - resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==} - - '@hapi/hoek@11.0.7': - resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} - - '@hapi/pinpoint@2.0.1': - resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} - - '@hapi/tlds@1.1.4': - resolution: {integrity: sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==} - engines: {node: '>=14.0.0'} - - '@hapi/topo@6.0.2': - resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} - '@happy-dom/global-registrator@15.11.7': resolution: {integrity: sha512-mfOoUlIw8VBiJYPrl5RZfMzkXC/z7gbSpi2ecycrj/gRWLq2CMV+Q+0G+JPjeOmuNFgg0skEIzkVFzVYFP6URw==} engines: {node: '>=18.0.0'} @@ -820,9 +794,6 @@ packages: '@peculiar/asn1-x509-attr@2.6.0': resolution: {integrity: sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==} - '@peculiar/asn1-x509-logotype@2.6.0': - resolution: {integrity: sha512-9wWbG1JkOLV3yMwt93Q2z5HQM5VQbYO9J17Wr9NatMlObLAxKAwhYhG/FgYqEnPwBZCdOf6AOToW9c6SltZmPw==} - '@peculiar/asn1-x509@2.6.0': resolution: {integrity: sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==} @@ -846,9 +817,6 @@ packages: resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} engines: {node: '>=12'} - '@postalsys/vmc@1.1.3': - resolution: {integrity: sha512-oBmAYvc5Wqwf8T6Amlwx45C0Jq9hNKprKqO1y+2f2PKBQ7NrG6KkyrSdVDPT/OOk/s8qCVFt+H/Ry9ldV74Rvw==} - '@puppeteer/browsers@2.12.0': resolution: {integrity: sha512-Xuq42yxcQJ54ti8ZHNzF5snFvtpgXzNToJ1bXUGQRaiO8t+B6UM8sTUJfvV+AJnqtkJU/7hdy6nbKyA12aHtRw==} engines: {node: '>=18'} @@ -1576,9 +1544,6 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@svgdotjs/svg.draggable.js@3.0.6': resolution: {integrity: sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==} peerDependencies: @@ -2860,17 +2825,10 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} - ip@2.0.1: - resolution: {integrity: sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} - ipaddr.js@2.3.0: - resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} - engines: {node: '>= 10'} - is-arrayish@0.2.1: resolution: {integrity: sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=} @@ -2938,10 +2896,6 @@ packages: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} - joi@18.0.2: - resolution: {integrity: sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==} - engines: {node: '>= 20'} - js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} @@ -3097,11 +3051,6 @@ packages: lucide@0.563.0: resolution: {integrity: sha512-2zBzDJ5n2Plj3d0ksj6h9TWPOSiKu9gtxJxnBAye11X/8gfWied6IYJn6ADYBp1NPoJmgpyOYP3wMrVx69+2AA==} - mailauth@4.13.0: - resolution: {integrity: sha512-fLnDxb1m9hVmGjNsPE0FwuwV/UgxXYgJP9/Y78xSH5ara5zE4HmOJ2wWOpgmfp4JYpVFdPa0qutJ4bDHdm0aXw==} - engines: {node: '>=20.18.1'} - hasBin: true - mailparser@3.9.3: resolution: {integrity: sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==} @@ -4083,13 +4032,6 @@ packages: resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} hasBin: true - tldts-core@7.0.23: - resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} - - tldts@7.0.21: - resolution: {integrity: sha512-Plu6V8fF/XU6d2k8jPtlQf5F4Xx2hAin4r2C2ca7wR8NK5MbRTo9huLUWRe28f3Uk8bYZfg74tit/dSjc18xnw==} - hasBin: true - tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -4185,10 +4127,6 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@7.20.0: - resolution: {integrity: sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==} - engines: {node: '>=20.18.1'} - unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -5413,22 +5351,6 @@ snapshots: - utf-8-validate - vue - '@hapi/address@5.1.1': - dependencies: - '@hapi/hoek': 11.0.7 - - '@hapi/formula@3.0.2': {} - - '@hapi/hoek@11.0.7': {} - - '@hapi/pinpoint@2.0.1': {} - - '@hapi/tlds@1.1.4': {} - - '@hapi/topo@6.0.2': - dependencies: - '@hapi/hoek': 11.0.7 - '@happy-dom/global-registrator@15.11.7': dependencies: happy-dom: 15.11.7 @@ -5728,13 +5650,6 @@ snapshots: asn1js: 3.0.7 tslib: 2.8.1 - '@peculiar/asn1-x509-logotype@2.6.0': - dependencies: - '@peculiar/asn1-schema': 2.6.0 - '@peculiar/asn1-x509': 2.6.0 - asn1js: 3.0.7 - tslib: 2.8.1 - '@peculiar/asn1-x509@2.6.0': dependencies: '@peculiar/asn1-schema': 2.6.0 @@ -5771,12 +5686,6 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@postalsys/vmc@1.1.3': - dependencies: - '@peculiar/asn1-schema': 2.6.0 - '@peculiar/asn1-x509': 2.6.0 - '@peculiar/asn1-x509-logotype': 2.6.0 - '@puppeteer/browsers@2.12.0': dependencies: debug: 4.4.3 @@ -7273,8 +7182,6 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} - '@standard-schema/spec@1.1.0': {} - '@svgdotjs/svg.draggable.js@3.0.6(@svgdotjs/svg.js@3.2.5)': dependencies: '@svgdotjs/svg.js': 3.2.5 @@ -8720,12 +8627,8 @@ snapshots: ip-address@10.1.0: {} - ip@2.0.1: {} - ipaddr.js@1.9.1: {} - ipaddr.js@2.3.0: {} - is-arrayish@0.2.1: {} is-docker@2.2.1: {} @@ -8773,16 +8676,6 @@ snapshots: dependencies: '@isaacs/cliui': 9.0.0 - joi@18.0.2: - dependencies: - '@hapi/address': 5.1.1 - '@hapi/formula': 3.0.2 - '@hapi/hoek': 11.0.7 - '@hapi/pinpoint': 2.0.1 - '@hapi/tlds': 1.1.4 - '@hapi/topo': 6.0.2 - '@standard-schema/spec': 1.1.0 - js-base64@3.7.8: {} js-tokens@4.0.0: {} @@ -8944,19 +8837,6 @@ snapshots: lucide@0.563.0: {} - mailauth@4.13.0: - dependencies: - '@postalsys/vmc': 1.1.3 - fast-xml-parser: 5.3.4 - ipaddr.js: 2.3.0 - joi: 18.0.2 - libmime: 5.3.7 - nodemailer: 7.0.13 - punycode.js: 2.3.1 - tldts: 7.0.21 - undici: 7.20.0 - yargs: 17.7.2 - mailparser@3.9.3: dependencies: '@zone-eu/mailsplit': 5.4.8 @@ -10276,12 +10156,6 @@ snapshots: tlds@1.261.0: {} - tldts-core@7.0.23: {} - - tldts@7.0.21: - dependencies: - tldts-core: 7.0.23 - tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -10355,8 +10229,6 @@ snapshots: undici-types@7.16.0: {} - undici@7.20.0: {} - unified@11.0.5: dependencies: '@types/unist': 3.0.3 diff --git a/test/helpers/server.loader.ts b/test/helpers/server.loader.ts deleted file mode 100644 index c66fdb5..0000000 --- a/test/helpers/server.loader.ts +++ /dev/null @@ -1,148 +0,0 @@ -import * as plugins from '../../ts/plugins.js'; - -export interface ITestServerConfig { - port: number; - hostname?: string; - tlsEnabled?: boolean; - authRequired?: boolean; - timeout?: number; - testCertPath?: string; - testKeyPath?: string; - maxConnections?: number; - size?: number; - maxRecipients?: number; -} - -export interface ITestServer { - server: any; - smtpServer: any; - port: number; - hostname: string; - config: ITestServerConfig; - startTime: number; -} - -/** - * Starts a test SMTP server with the given configuration. - * - * NOTE: The TS SMTP server implementation was removed in Phase 7B - * (replaced by the Rust SMTP server). This stub preserves the interface - * for smtpclient tests that import it, but those tests require `node-forge` - * which is not installed (pre-existing issue). - */ -export async function startTestServer(_config: ITestServerConfig): Promise { - throw new Error( - 'startTestServer is no longer available — the TS SMTP server was removed in Phase 7B. ' + - 'Use the Rust SMTP server (via UnifiedEmailServer) for integration testing.' - ); -} - -/** - * Stops a test SMTP server - */ -export async function stopTestServer(testServer: ITestServer): Promise { - if (!testServer || !testServer.smtpServer) { - return; - } - - try { - if (testServer.smtpServer.close && typeof testServer.smtpServer.close === 'function') { - await testServer.smtpServer.close(); - } - } catch (error) { - console.error('Error stopping test server:', error); - throw error; - } -} - -/** - * Get an available port for testing - */ -export async function getAvailablePort(startPort: number = 25000): Promise { - for (let port = startPort; port < startPort + 1000; port++) { - if (await isPortFree(port)) { - return port; - } - } - throw new Error(`No available ports found starting from ${startPort}`); -} - -/** - * Check if a port is free - */ -async function isPortFree(port: number): Promise { - return new Promise((resolve) => { - const server = plugins.net.createServer(); - - server.listen(port, () => { - server.close(() => resolve(true)); - }); - - server.on('error', () => resolve(false)); - }); -} - -/** - * Create test email data - */ -export function createTestEmail(options: { - from?: string; - to?: string | string[]; - subject?: string; - text?: string; - html?: string; - attachments?: any[]; -} = {}): any { - return { - from: options.from || 'test@example.com', - to: options.to || 'recipient@example.com', - subject: options.subject || 'Test Email', - text: options.text || 'This is a test email', - html: options.html || '

This is a test email

', - attachments: options.attachments || [], - date: new Date(), - messageId: `<${Date.now()}@test.example.com>` - }; -} - -/** - * Simple test server for custom protocol testing - */ -export interface ISimpleTestServer { - server: any; - hostname: string; - port: number; -} - -export async function createTestServer(options: { - onConnection?: (socket: any) => void | Promise; - port?: number; - hostname?: string; -}): Promise { - const hostname = options.hostname || 'localhost'; - const port = options.port || await getAvailablePort(); - - const server = plugins.net.createServer((socket) => { - if (options.onConnection) { - const result = options.onConnection(socket); - if (result && typeof result.then === 'function') { - result.catch(error => { - console.error('Error in onConnection handler:', error); - socket.destroy(); - }); - } - } - }); - - return new Promise((resolve, reject) => { - server.listen(port, hostname, () => { - resolve({ - server, - hostname, - port - }); - }); - - server.on('error', reject); - }); -} diff --git a/test/test.rustsecuritybridge.resilience.node.ts b/test/test.rustsecuritybridge.resilience.node.ts new file mode 100644 index 0000000..3c9ccb3 --- /dev/null +++ b/test/test.rustsecuritybridge.resilience.node.ts @@ -0,0 +1,177 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js'; +import type { IBridgeResilienceConfig } from '../ts/security/classes.rustsecuritybridge.js'; + +// Use fast backoff settings for testing +const TEST_CONFIG: Partial = { + maxRestartAttempts: 3, + healthCheckIntervalMs: 60_000, // long interval so health checks don't interfere + restartBackoffBaseMs: 100, + restartBackoffMaxMs: 500, + healthCheckTimeoutMs: 2_000, +}; + +tap.test('Resilience - should start in Idle state', async () => { + RustSecurityBridge.resetInstance(); + RustSecurityBridge.configure(TEST_CONFIG); + const bridge = RustSecurityBridge.getInstance(); + expect(bridge.state).toEqual(BridgeState.Idle); +}); + +tap.test('Resilience - state transitions: Idle -> Starting -> Running', async () => { + RustSecurityBridge.resetInstance(); + RustSecurityBridge.configure(TEST_CONFIG); + const bridge = RustSecurityBridge.getInstance(); + + const transitions: Array<{ oldState: string; newState: string }> = []; + bridge.on('stateChange', (evt: { oldState: string; newState: string }) => { + transitions.push(evt); + }); + + const ok = await bridge.start(); + if (!ok) { + console.log('WARNING: Rust binary not available — skipping resilience start tests'); + return; + } + + // We should have seen Idle -> Starting -> Running + expect(transitions.length).toBeGreaterThanOrEqual(2); + expect(transitions[0].oldState).toEqual(BridgeState.Idle); + expect(transitions[0].newState).toEqual(BridgeState.Starting); + expect(transitions[1].oldState).toEqual(BridgeState.Starting); + expect(transitions[1].newState).toEqual(BridgeState.Running); + expect(bridge.state).toEqual(BridgeState.Running); +}); + +tap.test('Resilience - deliberate stop transitions to Stopped', async () => { + const bridge = RustSecurityBridge.getInstance(); + if (!bridge.running) { + console.log('SKIP: bridge not running'); + return; + } + + const transitions: Array<{ oldState: string; newState: string }> = []; + bridge.on('stateChange', (evt: { oldState: string; newState: string }) => { + transitions.push(evt); + }); + + await bridge.stop(); + expect(bridge.state).toEqual(BridgeState.Stopped); + + // Deliberate stop should NOT trigger restart + // Wait a bit to ensure no restart happens + await new Promise(resolve => setTimeout(resolve, 300)); + expect(bridge.state).toEqual(BridgeState.Stopped); + + bridge.removeAllListeners('stateChange'); +}); + +tap.test('Resilience - commands throw descriptive errors when not running', async () => { + RustSecurityBridge.resetInstance(); + RustSecurityBridge.configure(TEST_CONFIG); + const bridge = RustSecurityBridge.getInstance(); + + // Idle state + try { + await bridge.ping(); + expect(true).toBeFalse(); // Should not reach + } catch (err) { + expect((err as Error).message).toInclude('not been started'); + } + + // Stopped state + const ok = await bridge.start(); + if (ok) { + await bridge.stop(); + try { + await bridge.ping(); + expect(true).toBeFalse(); + } catch (err) { + expect((err as Error).message).toInclude('stopped'); + } + } +}); + +tap.test('Resilience - restart after stop and fresh start', async () => { + RustSecurityBridge.resetInstance(); + RustSecurityBridge.configure(TEST_CONFIG); + const bridge = RustSecurityBridge.getInstance(); + + const ok = await bridge.start(); + if (!ok) { + console.log('SKIP: Rust binary not available'); + return; + } + expect(bridge.state).toEqual(BridgeState.Running); + + // Stop + await bridge.stop(); + expect(bridge.state).toEqual(BridgeState.Stopped); + + // Start again + const ok2 = await bridge.start(); + expect(ok2).toBeTrue(); + expect(bridge.state).toEqual(BridgeState.Running); + + // Commands should work + const pong = await bridge.ping(); + expect(pong).toBeTrue(); + + await bridge.stop(); +}); + +tap.test('Resilience - stateChange events emitted correctly', async () => { + RustSecurityBridge.resetInstance(); + RustSecurityBridge.configure(TEST_CONFIG); + const bridge = RustSecurityBridge.getInstance(); + + const events: Array<{ oldState: string; newState: string }> = []; + bridge.on('stateChange', (evt: { oldState: string; newState: string }) => { + events.push(evt); + }); + + const ok = await bridge.start(); + if (!ok) { + console.log('SKIP: Rust binary not available'); + return; + } + + await bridge.stop(); + + // Verify the full lifecycle: Idle->Starting->Running->Stopped + const stateSequence = events.map(e => e.newState); + expect(stateSequence).toContain(BridgeState.Starting); + expect(stateSequence).toContain(BridgeState.Running); + expect(stateSequence).toContain(BridgeState.Stopped); + + bridge.removeAllListeners('stateChange'); +}); + +tap.test('Resilience - configure sets resilience parameters', async () => { + RustSecurityBridge.resetInstance(); + RustSecurityBridge.configure({ + maxRestartAttempts: 10, + healthCheckIntervalMs: 60_000, + }); + // Just verify no errors — config is private, but we can verify + // by the behavior in other tests + const bridge = RustSecurityBridge.getInstance(); + expect(bridge).toBeTruthy(); +}); + +tap.test('Resilience - resetInstance creates fresh singleton', async () => { + RustSecurityBridge.resetInstance(); + const bridge1 = RustSecurityBridge.getInstance(); + RustSecurityBridge.resetInstance(); + const bridge2 = RustSecurityBridge.getInstance(); + // They should be different instances (we can't compare directly since + // resetInstance nulls the static, and getInstance creates new) + expect(bridge2.state).toEqual(BridgeState.Idle); +}); + +tap.test('Resilience - cleanup', async () => { + RustSecurityBridge.resetInstance(); + RustSecurityBridge.configure(TEST_CONFIG); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index f922ca0..d75bf39 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: '2.4.0', + version: '3.0.0', description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.' } diff --git a/ts/mail/delivery/classes.emailsendjob.ts b/ts/mail/delivery/classes.emailsendjob.ts index 125c692..6bbe1f0 100644 --- a/ts/mail/delivery/classes.emailsendjob.ts +++ b/ts/mail/delivery/classes.emailsendjob.ts @@ -225,28 +225,6 @@ export class EmailSendJob { this.log(`Connecting to ${mxServer}:25`); try { - // Check if IP warmup is enabled and get an IP to use - let localAddress: string | undefined = undefined; - try { - const fromDomain = this.email.getFromDomain(); - const bestIP = this.emailServerRef.getBestIPForSending({ - from: this.email.from, - to: this.email.getAllRecipients(), - domain: fromDomain, - isTransactional: this.email.priority === 'high' - }); - - if (bestIP) { - this.log(`Using warmed-up IP ${bestIP} for sending`); - localAddress = bestIP; - - // Record the send for warm-up tracking - this.emailServerRef.recordIPSend(bestIP); - } - } catch (error) { - this.log(`Error selecting IP address: ${error.message}`); - } - // Get SMTP client from UnifiedEmailServer const smtpClient = this.emailServerRef.getSmtpClient(mxServer, 25); diff --git a/ts/mail/delivery/classes.smtp.client.legacy.ts b/ts/mail/delivery/classes.smtp.client.legacy.ts deleted file mode 100644 index c4a97f5..0000000 --- a/ts/mail/delivery/classes.smtp.client.legacy.ts +++ /dev/null @@ -1,1414 +0,0 @@ -import * as plugins from '../../plugins.js'; -import { logger } from '../../logger.js'; -import { - SecurityLogger, - SecurityLogLevel, - SecurityEventType -} from '../../security/index.js'; -import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js'; - -import { - MtaConnectionError, - MtaAuthenticationError, - MtaDeliveryError, - MtaConfigurationError, - MtaTimeoutError, - MtaProtocolError -} from '../../errors/index.js'; - -import { Email } from '../core/classes.email.js'; -import type { EmailProcessingMode } from './interfaces.js'; - -// Custom error type extension -interface NodeNetworkError extends Error { - code?: string; -} - -/** - * SMTP client connection options - */ -export type ISmtpClientOptions = { - /** - * Hostname of the SMTP server - */ - host: string; - - /** - * Port to connect to - */ - port: number; - - /** - * Whether to use TLS for the connection - */ - secure?: boolean; - - /** - * Connection timeout in milliseconds - */ - connectionTimeout?: number; - - /** - * Socket timeout in milliseconds - */ - socketTimeout?: number; - - /** - * Command timeout in milliseconds - */ - commandTimeout?: number; - - /** - * TLS options - */ - tls?: { - /** - * Whether to verify certificates - */ - rejectUnauthorized?: boolean; - - /** - * Minimum TLS version - */ - minVersion?: string; - - /** - * CA certificate path - */ - ca?: string; - }; - - /** - * Authentication options - */ - auth?: { - /** - * Authentication user - */ - user: string; - - /** - * Authentication password - */ - pass: string; - - /** - * Authentication method - */ - method?: 'PLAIN' | 'LOGIN' | 'OAUTH2'; - }; - - /** - * Domain name for EHLO - */ - domain?: string; - - /** - * DKIM options for signing outgoing emails - */ - dkim?: { - /** - * Whether to sign emails with DKIM - */ - enabled: boolean; - - /** - * Domain name for DKIM - */ - domain: string; - - /** - * Selector for DKIM - */ - selector: string; - - /** - * Private key for DKIM signing - */ - privateKey: string; - - /** - * Headers to sign - */ - headers?: string[]; - }; -}; - -/** - * SMTP delivery result - */ -export type ISmtpDeliveryResult = { - /** - * Whether the delivery was successful - */ - success: boolean; - - /** - * Message ID if successful - */ - messageId?: string; - - /** - * Error message if failed - */ - error?: string; - - /** - * SMTP response code - */ - responseCode?: string; - - /** - * Recipients successfully delivered to - */ - acceptedRecipients: string[]; - - /** - * Recipients rejected during delivery - */ - rejectedRecipients: string[]; - - /** - * Server response - */ - response?: string; - - /** - * Timestamp of the delivery attempt - */ - timestamp: number; - - /** - * Whether DKIM signing was applied - */ - dkimSigned?: boolean; - - /** - * Whether this was a TLS secured delivery - */ - secure?: boolean; - - /** - * Whether authentication was used - */ - authenticated?: boolean; -}; - -/** - * SMTP client for sending emails to remote mail servers - */ -export class SmtpClient { - private options: ISmtpClientOptions; - private connected: boolean = false; - private socket?: plugins.net.Socket | plugins.tls.TLSSocket; - private supportedExtensions: Set = new Set(); - - /** - * Create a new SMTP client instance - * @param options SMTP client connection options - */ - constructor(options: ISmtpClientOptions) { - // Set default options - this.options = { - ...options, - connectionTimeout: options.connectionTimeout || 30000, // 30 seconds - socketTimeout: options.socketTimeout || 60000, // 60 seconds - commandTimeout: options.commandTimeout || 30000, // 30 seconds - secure: options.secure || false, - domain: options.domain || 'localhost', - tls: { - rejectUnauthorized: options.tls?.rejectUnauthorized !== false, // Default to true - minVersion: options.tls?.minVersion || 'TLSv1.2' - } - }; - } - - /** - * Connect to the SMTP server - */ - public async connect(): Promise { - if (this.connected && this.socket) { - return; - } - - try { - logger.log('info', `Connecting to SMTP server ${this.options.host}:${this.options.port}`); - - // Create socket - const socket = new plugins.net.Socket(); - - // Set timeouts - socket.setTimeout(this.options.socketTimeout); - - // Connect to the server - await new Promise((resolve, reject) => { - // Handle connection events - socket.once('connect', () => { - logger.log('debug', `Connected to ${this.options.host}:${this.options.port}`); - resolve(); - }); - - socket.once('timeout', () => { - reject(MtaConnectionError.timeout( - this.options.host, - this.options.port, - this.options.connectionTimeout - )); - }); - - socket.once('error', (err: NodeNetworkError) => { - if (err.code === 'ECONNREFUSED') { - reject(MtaConnectionError.refused( - this.options.host, - this.options.port - )); - } else if (err.code === 'ENOTFOUND') { - reject(MtaConnectionError.dnsError( - this.options.host, - err - )); - } else { - reject(new MtaConnectionError( - `Connection error to ${this.options.host}:${this.options.port}: ${err.message}`, - { - data: { - host: this.options.host, - port: this.options.port, - error: err.message, - code: err.code - } - } - )); - } - }); - - // Connect to the server - const connectOptions = { - host: this.options.host, - port: this.options.port - }; - - // For direct TLS connections - if (this.options.secure) { - const tlsSocket = plugins.tls.connect({ - ...connectOptions, - rejectUnauthorized: this.options.tls.rejectUnauthorized, - minVersion: this.options.tls.minVersion as any, - ca: this.options.tls.ca ? [this.options.tls.ca] : undefined - } as plugins.tls.ConnectionOptions); - - tlsSocket.once('secureConnect', () => { - logger.log('debug', `Secure connection established to ${this.options.host}:${this.options.port}`); - this.socket = tlsSocket; - resolve(); - }); - - tlsSocket.once('error', (err: NodeNetworkError) => { - reject(new MtaConnectionError( - `TLS connection error to ${this.options.host}:${this.options.port}: ${err.message}`, - { - data: { - host: this.options.host, - port: this.options.port, - error: err.message, - code: err.code - } - } - )); - }); - - tlsSocket.setTimeout(this.options.socketTimeout); - - tlsSocket.once('timeout', () => { - reject(MtaConnectionError.timeout( - this.options.host, - this.options.port, - this.options.connectionTimeout - )); - }); - } else { - socket.connect(connectOptions); - this.socket = socket; - } - }); - - // Wait for server greeting - const greeting = await this.readResponse(); - - if (!greeting.startsWith('220')) { - throw new MtaConnectionError( - `Unexpected greeting from server: ${greeting}`, - { - data: { - host: this.options.host, - port: this.options.port, - greeting - } - } - ); - } - - // Send EHLO - await this.sendEhlo(); - - // Start TLS if not secure and supported - if (!this.options.secure && this.supportedExtensions.has('STARTTLS')) { - await this.startTls(); - - // Send EHLO again after STARTTLS - await this.sendEhlo(); - } - - // Authenticate if credentials provided - if (this.options.auth) { - await this.authenticate(); - } - - this.connected = true; - logger.log('info', `Successfully connected to SMTP server ${this.options.host}:${this.options.port}`); - - // Set up error handling for the socket - this.socket.on('error', (err) => { - logger.log('error', `Socket error: ${err.message}`); - this.connected = false; - this.socket = undefined; - }); - - this.socket.on('close', () => { - logger.log('debug', 'Socket closed'); - this.connected = false; - this.socket = undefined; - }); - - this.socket.on('timeout', () => { - logger.log('error', 'Socket timeout'); - this.connected = false; - if (this.socket) { - this.socket.destroy(); - this.socket = undefined; - } - }); - - } catch (error) { - // Clean up socket if connection failed - if (this.socket) { - this.socket.destroy(); - this.socket = undefined; - } - - logger.log('error', `Failed to connect to SMTP server: ${error.message}`); - throw error; - } - } - - /** - * Send EHLO command to the server - */ - private async sendEhlo(): Promise { - // Clear previous extensions - this.supportedExtensions.clear(); - - // Send EHLO - don't allow pipelining for this command - const response = await this.sendCommand(`EHLO ${this.options.domain}`, false); - - // Parse supported extensions - const lines = response.split('\r\n'); - for (let i = 1; i < lines.length; i++) { - const line = lines[i]; - if (line.startsWith('250-') || line.startsWith('250 ')) { - const extension = line.substring(4).split(' ')[0]; - this.supportedExtensions.add(extension); - } - } - - // Check if server supports pipelining - this.supportsPipelining = this.supportedExtensions.has('PIPELINING'); - - logger.log('debug', `Server supports extensions: ${Array.from(this.supportedExtensions).join(', ')}`); - if (this.supportsPipelining) { - logger.log('info', 'Server supports PIPELINING - will use for improved performance'); - } - } - - /** - * Start TLS negotiation - */ - private async startTls(): Promise { - logger.log('debug', 'Starting TLS negotiation'); - - // Send STARTTLS command - const response = await this.sendCommand('STARTTLS'); - - if (!response.startsWith('220')) { - throw new MtaConnectionError( - `Failed to start TLS: ${response}`, - { - data: { - host: this.options.host, - port: this.options.port, - response - } - } - ); - } - - if (!this.socket) { - throw new MtaConnectionError( - 'No socket available for TLS upgrade', - { - data: { - host: this.options.host, - port: this.options.port - } - } - ); - } - - // Upgrade socket to TLS - const currentSocket = this.socket; - this.socket = await this.upgradeTls(currentSocket); - } - - /** - * Upgrade socket to TLS - * @param socket Original socket - */ - private async upgradeTls(socket: plugins.net.Socket): Promise { - return new Promise((resolve, reject) => { - const tlsOptions: plugins.tls.ConnectionOptions = { - socket, - servername: this.options.host, - rejectUnauthorized: this.options.tls.rejectUnauthorized, - minVersion: this.options.tls.minVersion as any, - ca: this.options.tls.ca ? [this.options.tls.ca] : undefined - }; - - const tlsSocket = plugins.tls.connect(tlsOptions); - - tlsSocket.once('secureConnect', () => { - logger.log('debug', 'TLS negotiation successful'); - resolve(tlsSocket); - }); - - tlsSocket.once('error', (err: NodeNetworkError) => { - reject(new MtaConnectionError( - `TLS error: ${err.message}`, - { - data: { - host: this.options.host, - port: this.options.port, - error: err.message, - code: err.code - } - } - )); - }); - - tlsSocket.setTimeout(this.options.socketTimeout); - - tlsSocket.once('timeout', () => { - reject(MtaTimeoutError.commandTimeout( - 'STARTTLS', - this.options.host, - this.options.socketTimeout - )); - }); - }); - } - - /** - * Authenticate with the server - */ - private async authenticate(): Promise { - if (!this.options.auth) { - return; - } - - const { user, pass, method = 'LOGIN' } = this.options.auth; - - logger.log('debug', `Authenticating as ${user} using ${method}`); - - try { - switch (method) { - case 'PLAIN': - await this.authPlain(user, pass); - break; - - case 'LOGIN': - await this.authLogin(user, pass); - break; - - case 'OAUTH2': - await this.authOAuth2(user, pass); - break; - - default: - throw new MtaAuthenticationError( - `Authentication method ${method} not supported by client`, - { - data: { - method - } - } - ); - } - - logger.log('info', `Successfully authenticated as ${user}`); - } catch (error) { - logger.log('error', `Authentication failed: ${error.message}`); - throw error; - } - } - - /** - * Authenticate using PLAIN method - * @param user Username - * @param pass Password - */ - private async authPlain(user: string, pass: string): Promise { - // PLAIN authentication format: \0username\0password - const authString = Buffer.from(`\0${user}\0${pass}`).toString('base64'); - const response = await this.sendCommand(`AUTH PLAIN ${authString}`); - - if (!response.startsWith('235')) { - throw MtaAuthenticationError.invalidCredentials( - this.options.host, - user - ); - } - } - - /** - * Authenticate using LOGIN method - * @param user Username - * @param pass Password - */ - private async authLogin(user: string, pass: string): Promise { - // Start LOGIN authentication - const response = await this.sendCommand('AUTH LOGIN'); - - if (!response.startsWith('334')) { - throw new MtaAuthenticationError( - `Server did not accept AUTH LOGIN: ${response}`, - { - data: { - host: this.options.host, - response - } - } - ); - } - - // Send username (base64) - const userResponse = await this.sendCommand(Buffer.from(user).toString('base64')); - - if (!userResponse.startsWith('334')) { - throw MtaAuthenticationError.invalidCredentials( - this.options.host, - user - ); - } - - // Send password (base64) - const passResponse = await this.sendCommand(Buffer.from(pass).toString('base64')); - - if (!passResponse.startsWith('235')) { - throw MtaAuthenticationError.invalidCredentials( - this.options.host, - user - ); - } - } - - /** - * Authenticate using OAuth2 method - * @param user Username - * @param token OAuth2 token - */ - private async authOAuth2(user: string, token: string): Promise { - // XOAUTH2 format - const authString = `user=${user}\x01auth=Bearer ${token}\x01\x01`; - const response = await this.sendCommand(`AUTH XOAUTH2 ${Buffer.from(authString).toString('base64')}`); - - if (!response.startsWith('235')) { - throw MtaAuthenticationError.invalidCredentials( - this.options.host, - user - ); - } - } - - /** - * Send an email through the SMTP client - * @param email Email to send - * @param processingMode Optional processing mode - */ - public async sendMail(email: Email, processingMode?: EmailProcessingMode): Promise { - // Ensure we're connected - if (!this.connected || !this.socket) { - await this.connect(); - } - - const startTime = Date.now(); - const result: ISmtpDeliveryResult = { - success: false, - acceptedRecipients: [], - rejectedRecipients: [], - timestamp: startTime, - secure: this.options.secure || this.socket instanceof plugins.tls.TLSSocket, - authenticated: !!this.options.auth - }; - - try { - logger.log('info', `Sending email to ${email.getAllRecipients().join(', ')}`); - - // Apply DKIM signing if configured - if (this.options.dkim?.enabled) { - await this.applyDkimSignature(email); - result.dkimSigned = true; - } - - // Get envelope and recipients - const envelope_from = email.getEnvelopeFrom() || email.from; - const recipients = email.getAllRecipients(); - - // Check if we can use pipelining for MAIL FROM and RCPT TO commands - if (this.supportsPipelining && recipients.length > 0) { - logger.log('debug', 'Using SMTP pipelining for sending'); - - // Send MAIL FROM command first (always needed) - const mailFromCmd = `MAIL FROM:<${envelope_from}> SIZE=${this.getEmailSize(email)}`; - let mailFromResponse: string; - - try { - mailFromResponse = await this.sendCommand(mailFromCmd); - - if (!mailFromResponse.startsWith('250')) { - throw new MtaDeliveryError( - `MAIL FROM command failed: ${mailFromResponse}`, - { - data: { - command: mailFromCmd, - response: mailFromResponse - } - } - ); - } - } catch (error) { - logger.log('error', `MAIL FROM failed: ${error.message}`); - throw error; - } - - // Pipeline all RCPT TO commands - const rcptPromises = recipients.map(recipient => { - return this.sendCommand(`RCPT TO:<${recipient}>`) - .then(response => { - if (response.startsWith('250')) { - result.acceptedRecipients.push(recipient); - return { recipient, accepted: true, response }; - } else { - result.rejectedRecipients.push(recipient); - logger.log('warn', `Recipient ${recipient} rejected: ${response}`); - return { recipient, accepted: false, response }; - } - }) - .catch(error => { - result.rejectedRecipients.push(recipient); - logger.log('warn', `Recipient ${recipient} rejected with error: ${error.message}`); - return { recipient, accepted: false, error: error.message }; - }); - }); - - // Wait for all RCPT TO commands to complete - await Promise.all(rcptPromises); - } else { - // Fall back to sequential commands if pipelining not supported - logger.log('debug', 'Using sequential SMTP commands for sending'); - - // Send MAIL FROM - await this.sendCommand(`MAIL FROM:<${envelope_from}> SIZE=${this.getEmailSize(email)}`); - - // Send RCPT TO for each recipient - for (const recipient of recipients) { - try { - await this.sendCommand(`RCPT TO:<${recipient}>`); - result.acceptedRecipients.push(recipient); - } catch (error) { - logger.log('warn', `Recipient ${recipient} rejected: ${error.message}`); - result.rejectedRecipients.push(recipient); - } - } - } - - // Check if at least one recipient was accepted - if (result.acceptedRecipients.length === 0) { - throw new MtaDeliveryError( - 'All recipients were rejected', - { - data: { - recipients, - rejectedRecipients: result.rejectedRecipients - } - } - ); - } - - // Send DATA - const dataResponse = await this.sendCommand('DATA'); - - if (!dataResponse.startsWith('354')) { - throw new MtaProtocolError( - `Failed to start DATA phase: ${dataResponse}`, - { - data: { - response: dataResponse - } - } - ); - } - - // Format email content efficiently - const emailContent = await this.getFormattedEmail(email); - - // Send email content - const finalResponse = await this.sendCommand(emailContent + '\r\n.'); - - // Extract message ID if available - const messageIdMatch = finalResponse.match(/\[(.*?)\]/); - if (messageIdMatch) { - result.messageId = messageIdMatch[1]; - } - - result.success = true; - result.response = finalResponse; - - logger.log('info', `Email sent successfully to ${result.acceptedRecipients.join(', ')}`); - - // Log security event - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.INFO, - type: SecurityEventType.EMAIL_DELIVERY, - message: 'Email sent successfully', - details: { - recipients: result.acceptedRecipients, - rejectedRecipients: result.rejectedRecipients, - messageId: result.messageId, - secure: result.secure, - authenticated: result.authenticated, - server: `${this.options.host}:${this.options.port}`, - dkimSigned: result.dkimSigned - }, - success: true - }); - - return result; - } catch (error) { - logger.log('error', `Failed to send email: ${error.message}`); - - // Format error for result - result.error = error.message; - - // Extract SMTP code if available - if (error.context?.data?.statusCode) { - result.responseCode = error.context.data.statusCode; - } - - // Log security event - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.EMAIL_DELIVERY, - message: 'Email delivery failed', - details: { - error: error.message, - server: `${this.options.host}:${this.options.port}`, - recipients: email.getAllRecipients(), - acceptedRecipients: result.acceptedRecipients, - rejectedRecipients: result.rejectedRecipients, - secure: result.secure, - authenticated: result.authenticated - }, - success: false - }); - - return result; - } - } - - /** - * Apply DKIM signature to email - * @param email Email to sign - */ - private async applyDkimSignature(email: Email): Promise { - if (!this.options.dkim?.enabled || !this.options.dkim?.privateKey) { - return; - } - - try { - logger.log('debug', `Signing email with DKIM for domain ${this.options.dkim.domain}`); - - const emailContent = await this.getFormattedEmail(email); - - // Sign via Rust bridge - const bridge = RustSecurityBridge.getInstance(); - const signResult = await bridge.signDkim({ - rawMessage: emailContent, - domain: this.options.dkim.domain, - selector: this.options.dkim.selector, - privateKey: this.options.dkim.privateKey, - }); - - if (signResult.header) { - email.addHeader('DKIM-Signature', signResult.header); - } - - logger.log('debug', 'DKIM signature applied successfully'); - } catch (error) { - logger.log('error', `Failed to apply DKIM signature: ${error.message}`); - throw error; - } - } - - /** - * Format email for SMTP transmission - * @param email Email to format - */ - private async getFormattedEmail(email: Email): Promise { - // This is a simplified implementation - // In a full implementation, this would use proper MIME formatting - - let content = ''; - - // Add headers - content += `From: ${email.from}\r\n`; - content += `To: ${email.to.join(', ')}\r\n`; - content += `Subject: ${email.subject}\r\n`; - content += `Date: ${new Date().toUTCString()}\r\n`; - content += `Message-ID: <${plugins.uuid.v4()}@${this.options.domain}>\r\n`; - - // Add additional headers - for (const [name, value] of Object.entries(email.headers || {})) { - content += `${name}: ${value}\r\n`; - } - - // Add content type for multipart - if (email.attachments && email.attachments.length > 0) { - const boundary = `----_=_NextPart_${Math.random().toString(36).substr(2)}`; - content += `MIME-Version: 1.0\r\n`; - content += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`; - content += `\r\n`; - - // Add text part - content += `--${boundary}\r\n`; - content += `Content-Type: text/plain; charset="UTF-8"\r\n`; - content += `\r\n`; - content += `${email.text}\r\n`; - - // Add HTML part if present - if (email.html) { - content += `--${boundary}\r\n`; - content += `Content-Type: text/html; charset="UTF-8"\r\n`; - content += `\r\n`; - content += `${email.html}\r\n`; - } - - // Add attachments - for (const attachment of email.attachments) { - content += `--${boundary}\r\n`; - content += `Content-Type: ${attachment.contentType || 'application/octet-stream'}; name="${attachment.filename}"\r\n`; - content += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`; - content += `Content-Transfer-Encoding: base64\r\n`; - content += `\r\n`; - - // Add base64 encoded content - const base64Content = attachment.content.toString('base64'); - - // Split into lines of 76 characters - for (let i = 0; i < base64Content.length; i += 76) { - content += base64Content.substring(i, i + 76) + '\r\n'; - } - } - - // End boundary - content += `--${boundary}--\r\n`; - } else { - // Simple email with just text - content += `Content-Type: text/plain; charset="UTF-8"\r\n`; - content += `\r\n`; - content += `${email.text}\r\n`; - } - - return content; - } - - /** - * Get size of email in bytes - * @param email Email to measure - */ - private getEmailSize(email: Email): number { - // Simplified size estimation - let size = 0; - - // Headers - size += `From: ${email.from}\r\n`.length; - size += `To: ${email.to.join(', ')}\r\n`.length; - size += `Subject: ${email.subject}\r\n`.length; - - // Body - size += (email.text?.length || 0) + 2; // +2 for CRLF - - // HTML part if present - if (email.html) { - size += email.html.length + 2; - } - - // Attachments - for (const attachment of email.attachments || []) { - size += attachment.content.length; - } - - // Add overhead for MIME boundaries and headers - const overhead = email.attachments?.length ? 1000 + (email.attachments.length * 200) : 200; - - return size + overhead; - } - - /** - * Send SMTP command and wait for response - * @param command SMTP command to send - */ - // Queue for command pipelining - private commandQueue: Array<{ - command: string; - resolve: (response: string) => void; - reject: (error: any) => void; - timeout: NodeJS.Timeout; - }> = []; - - // Flag to indicate if we're currently processing commands - private processingCommands = false; - - // Flag to indicate if server supports pipelining - private supportsPipelining = false; - - /** - * Send an SMTP command and wait for response - * @param command SMTP command to send - * @param allowPipelining Whether this command can be pipelined - */ - private async sendCommand(command: string, allowPipelining = true): Promise { - if (!this.socket) { - throw new MtaConnectionError( - 'Not connected to server', - { - data: { - host: this.options.host, - port: this.options.port - } - } - ); - } - - // Log command if not sensitive - if (!command.startsWith('AUTH')) { - logger.log('debug', `> ${command}`); - } else { - logger.log('debug', '> AUTH ***'); - } - - return new Promise((resolve, reject) => { - // Set up timeout for command - const timeout = setTimeout(() => { - // Remove this command from the queue if it times out - const index = this.commandQueue.findIndex(item => item.command === command); - if (index !== -1) { - this.commandQueue.splice(index, 1); - } - - reject(MtaTimeoutError.commandTimeout( - command.split(' ')[0], - this.options.host, - this.options.commandTimeout - )); - }, this.options.commandTimeout); - - // Add command to the queue - this.commandQueue.push({ - command, - resolve, - reject, - timeout - }); - - // Process command queue if we can pipeline or if not currently processing commands - if ((this.supportsPipelining && allowPipelining) || !this.processingCommands) { - this.processCommandQueue(); - } - }); - } - - /** - * Process the command queue - either one by one or pipelined if supported - */ - private processCommandQueue(): void { - if (this.processingCommands || this.commandQueue.length === 0 || !this.socket) { - return; - } - - this.processingCommands = true; - - try { - // If pipelining is supported, send all commands at once - if (this.supportsPipelining) { - // Send all commands in queue at once - const commands = this.commandQueue.map(item => item.command).join('\r\n') + '\r\n'; - - this.socket.write(commands, (err) => { - if (err) { - // Handle write error for all commands - const error = new MtaConnectionError( - `Failed to send commands: ${err.message}`, - { - data: { - error: err.message - } - } - ); - - // Fail all pending commands - while (this.commandQueue.length > 0) { - const item = this.commandQueue.shift(); - clearTimeout(item.timeout); - item.reject(error); - } - - this.processingCommands = false; - } - }); - - // Process responses one by one in order - this.processResponses(); - } else { - // Process commands one by one if pipelining not supported - this.processNextCommand(); - } - } catch (error) { - logger.log('error', `Error processing command queue: ${error.message}`); - this.processingCommands = false; - } - } - - /** - * Process the next command in the queue (non-pipelined mode) - */ - private processNextCommand(): void { - if (this.commandQueue.length === 0 || !this.socket) { - this.processingCommands = false; - return; - } - - const currentCommand = this.commandQueue[0]; - - this.socket.write(currentCommand.command + '\r\n', (err) => { - if (err) { - // Handle write error - const error = new MtaConnectionError( - `Failed to send command: ${err.message}`, - { - data: { - command: currentCommand.command.split(' ')[0], - error: err.message - } - } - ); - - // Remove from queue - this.commandQueue.shift(); - clearTimeout(currentCommand.timeout); - currentCommand.reject(error); - - // Continue with next command - this.processNextCommand(); - return; - } - - // Read response - this.readResponse() - .then((response) => { - // Remove from queue and resolve - this.commandQueue.shift(); - clearTimeout(currentCommand.timeout); - currentCommand.resolve(response); - - // Process next command - this.processNextCommand(); - }) - .catch((err) => { - // Remove from queue and reject - this.commandQueue.shift(); - clearTimeout(currentCommand.timeout); - currentCommand.reject(err); - - // Process next command - this.processNextCommand(); - }); - }); - } - - /** - * Process responses for pipelined commands - */ - private async processResponses(): Promise { - try { - // Process responses for each command in order - while (this.commandQueue.length > 0) { - const currentCommand = this.commandQueue[0]; - - try { - // Wait for response - const response = await this.readResponse(); - - // Remove from queue and resolve - this.commandQueue.shift(); - clearTimeout(currentCommand.timeout); - currentCommand.resolve(response); - } catch (error) { - // Remove from queue and reject - this.commandQueue.shift(); - clearTimeout(currentCommand.timeout); - currentCommand.reject(error); - - // Stop processing if this is a critical error - if ( - error instanceof MtaConnectionError && - (error.message.includes('Connection closed') || error.message.includes('Not connected')) - ) { - break; - } - } - } - } catch (error) { - logger.log('error', `Error processing responses: ${error.message}`); - } finally { - this.processingCommands = false; - } - } - - /** - * Read response from the server - */ - private async readResponse(): Promise { - if (!this.socket) { - throw new MtaConnectionError( - 'Not connected to server', - { - data: { - host: this.options.host, - port: this.options.port - } - } - ); - } - - return new Promise((resolve, reject) => { - // Use an array to collect response chunks instead of string concatenation - const responseChunks: Buffer[] = []; - - // Single function to clean up all listeners - const cleanupListeners = () => { - if (!this.socket) return; - this.socket.removeListener('data', onData); - this.socket.removeListener('error', onError); - this.socket.removeListener('close', onClose); - this.socket.removeListener('end', onEnd); - }; - - const onData = (data: Buffer) => { - // Store buffer directly, avoiding unnecessary string conversion - responseChunks.push(data); - - // Convert to string only for response checking - const responseData = Buffer.concat(responseChunks).toString(); - - // Check if this is a complete response - if (this.isCompleteResponse(responseData)) { - // Clean up listeners - cleanupListeners(); - - const trimmedResponse = responseData.trim(); - logger.log('debug', `< ${trimmedResponse}`); - - // Check if this is an error response - if (this.isErrorResponse(responseData)) { - const code = responseData.substring(0, 3); - reject(this.createErrorFromResponse(trimmedResponse, code)); - } else { - resolve(trimmedResponse); - } - } - }; - - const onError = (err: Error) => { - cleanupListeners(); - - reject(new MtaConnectionError( - `Socket error while waiting for response: ${err.message}`, - { - data: { - error: err.message - } - } - )); - }; - - const onClose = () => { - cleanupListeners(); - - const responseData = Buffer.concat(responseChunks).toString(); - reject(new MtaConnectionError( - 'Connection closed while waiting for response', - { - data: { - partialResponse: responseData - } - } - )); - }; - - const onEnd = () => { - cleanupListeners(); - - const responseData = Buffer.concat(responseChunks).toString(); - reject(new MtaConnectionError( - 'Connection ended while waiting for response', - { - data: { - partialResponse: responseData - } - } - )); - }; - - // Set up listeners - this.socket.on('data', onData); - this.socket.once('error', onError); - this.socket.once('close', onClose); - this.socket.once('end', onEnd); - }); - } - - /** - * Check if the response is complete - * @param response Response to check - */ - private isCompleteResponse(response: string): boolean { - // Check if it's a multi-line response - const lines = response.split('\r\n'); - const lastLine = lines[lines.length - 2]; // Second to last because of the trailing CRLF - - // Check if the last line starts with a code followed by a space - // If it does, this is a complete response - if (lastLine && /^\d{3} /.test(lastLine)) { - return true; - } - - // For single line responses - if (lines.length === 2 && lines[0].length >= 3 && /^\d{3} /.test(lines[0])) { - return true; - } - - return false; - } - - /** - * Check if the response is an error - * @param response Response to check - */ - private isErrorResponse(response: string): boolean { - // Get the status code (first 3 characters) - const code = response.substring(0, 3); - - // 4xx and 5xx are error codes - return code.startsWith('4') || code.startsWith('5'); - } - - /** - * Create appropriate error from response - * @param response Error response - * @param code SMTP status code - */ - private createErrorFromResponse(response: string, code: string): Error { - // Extract message part - const message = response.substring(4).trim(); - - switch (code.charAt(0)) { - case '4': // Temporary errors - return MtaDeliveryError.temporary( - message, - 'recipient', - code, - response - ); - - case '5': // Permanent errors - return MtaDeliveryError.permanent( - message, - 'recipient', - code, - response - ); - - default: - return new MtaDeliveryError( - `Unexpected error response: ${response}`, - { - data: { - response, - code - } - } - ); - } - } - - /** - * Close the connection to the server - */ - public async close(): Promise { - if (!this.connected || !this.socket) { - return; - } - - try { - // Send QUIT - await this.sendCommand('QUIT'); - } catch (error) { - logger.log('warn', `Error sending QUIT command: ${error.message}`); - } finally { - // Close socket - this.socket.destroy(); - this.socket = undefined; - this.connected = false; - logger.log('info', 'SMTP connection closed'); - } - } - - /** - * Checks if the connection is active - */ - public isConnected(): boolean { - return this.connected && !!this.socket; - } - - /** - * Update SMTP client options - * @param options New options - */ - public updateOptions(options: Partial): void { - this.options = { - ...this.options, - ...options - }; - - logger.log('info', 'SMTP client options updated'); - } -} \ No newline at end of file diff --git a/ts/mail/delivery/interfaces.ts b/ts/mail/delivery/interfaces.ts index 8b00a6c..157d36d 100644 --- a/ts/mail/delivery/interfaces.ts +++ b/ts/mail/delivery/interfaces.ts @@ -2,8 +2,6 @@ * SMTP and email delivery interface definitions */ -import type { Email } from '../core/classes.email.js'; - /** * SMTP session state enumeration */ @@ -167,125 +165,3 @@ export interface ISmtpAuth { password: string; } -/** - * SMTP server options - */ -export interface ISmtpServerOptions { - /** - * Port to listen on - */ - port: number; - - /** - * TLS private key (PEM format) - */ - key: string; - - /** - * TLS certificate (PEM format) - */ - cert: string; - - /** - * Server hostname for SMTP banner - */ - hostname?: string; - - /** - * Host address to bind to (defaults to all interfaces) - */ - host?: string; - - /** - * Secure port for dedicated TLS connections - */ - securePort?: number; - - /** - * CA certificates for TLS (PEM format) - */ - ca?: string; - - /** - * Maximum size of messages in bytes - */ - maxSize?: number; - - /** - * Maximum number of concurrent connections - */ - maxConnections?: number; - - /** - * Authentication options - */ - auth?: { - /** - * Whether authentication is required - */ - required: boolean; - - /** - * Allowed authentication methods - */ - methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[]; - }; - - /** - * Socket timeout in milliseconds (default: 5 minutes / 300000ms) - */ - socketTimeout?: number; - - /** - * Initial connection timeout in milliseconds (default: 30 seconds / 30000ms) - */ - connectionTimeout?: number; - - /** - * Interval for checking idle sessions in milliseconds (default: 5 seconds / 5000ms) - * For testing, can be set lower (e.g. 1000ms) to detect timeouts more quickly - */ - cleanupInterval?: number; - - /** - * Maximum number of recipients allowed per message (default: 100) - */ - maxRecipients?: number; - - /** - * Maximum message size in bytes (default: 10MB / 10485760 bytes) - * This is advertised in the EHLO SIZE extension - */ - size?: number; - - /** - * Timeout for the DATA command in milliseconds (default: 60000ms / 1 minute) - * This controls how long to wait for the complete email data - */ - dataTimeout?: number; -} - -/** - * Result of SMTP transaction - */ -export interface ISmtpTransactionResult { - /** - * Whether the transaction was successful - */ - success: boolean; - - /** - * Error message if failed - */ - error?: string; - - /** - * Message ID if successful - */ - messageId?: string; - - /** - * Resulting email if successful - */ - email?: Email; -} \ No newline at end of file diff --git a/ts/mail/routing/classes.dnsmanager.ts b/ts/mail/routing/classes.dnsmanager.ts deleted file mode 100644 index 59b33da..0000000 --- a/ts/mail/routing/classes.dnsmanager.ts +++ /dev/null @@ -1,559 +0,0 @@ -import * as plugins from '../../plugins.js'; -import * as paths from '../../paths.js'; -import { DKIMCreator } from '../security/classes.dkimcreator.js'; - -/** - * Interface for DNS record information - */ -export interface IDnsRecord { - name: string; - type: string; - value: string; - ttl?: number; - dnsSecEnabled?: boolean; -} - -/** - * Interface for DNS lookup options - */ -export interface IDnsLookupOptions { - /** Cache time to live in milliseconds, 0 to disable caching */ - cacheTtl?: number; - /** Timeout for DNS queries in milliseconds */ - timeout?: number; -} - -/** - * Interface for DNS verification result - */ -export interface IDnsVerificationResult { - record: string; - found: boolean; - valid: boolean; - value?: string; - expectedValue?: string; - error?: string; -} - -/** - * Manager for DNS-related operations, including record lookups, verification, and generation - */ -export class DNSManager { - public dkimCreator: DKIMCreator; - private cache: Map = new Map(); - private defaultOptions: IDnsLookupOptions = { - cacheTtl: 300000, // 5 minutes - timeout: 5000 // 5 seconds - }; - - constructor(dkimCreatorArg: DKIMCreator, options?: IDnsLookupOptions) { - this.dkimCreator = dkimCreatorArg; - - if (options) { - this.defaultOptions = { - ...this.defaultOptions, - ...options - }; - } - - // Ensure the DNS records directory exists - plugins.fs.mkdirSync(paths.dnsRecordsDir, { recursive: true }); - } - - /** - * Lookup MX records for a domain - * @param domain Domain to look up - * @param options Lookup options - * @returns Array of MX records sorted by priority - */ - public async lookupMx(domain: string, options?: IDnsLookupOptions): Promise { - const lookupOptions = { ...this.defaultOptions, ...options }; - const cacheKey = `mx:${domain}`; - - // Check cache first - const cached = this.getFromCache(cacheKey); - if (cached) { - return cached; - } - - try { - const records = await this.dnsResolveMx(domain, lookupOptions.timeout); - - // Sort by priority - records.sort((a, b) => a.priority - b.priority); - - // Cache the result - this.setInCache(cacheKey, records, lookupOptions.cacheTtl); - - return records; - } catch (error) { - console.error(`Error looking up MX records for ${domain}:`, error); - throw new Error(`Failed to lookup MX records for ${domain}: ${error.message}`); - } - } - - /** - * Lookup TXT records for a domain - * @param domain Domain to look up - * @param options Lookup options - * @returns Array of TXT records - */ - public async lookupTxt(domain: string, options?: IDnsLookupOptions): Promise { - const lookupOptions = { ...this.defaultOptions, ...options }; - const cacheKey = `txt:${domain}`; - - // Check cache first - const cached = this.getFromCache(cacheKey); - if (cached) { - return cached; - } - - try { - const records = await this.dnsResolveTxt(domain, lookupOptions.timeout); - - // Cache the result - this.setInCache(cacheKey, records, lookupOptions.cacheTtl); - - return records; - } catch (error) { - console.error(`Error looking up TXT records for ${domain}:`, error); - throw new Error(`Failed to lookup TXT records for ${domain}: ${error.message}`); - } - } - - /** - * Find specific TXT record by subdomain and prefix - * @param domain Base domain - * @param subdomain Subdomain prefix (e.g., "dkim._domainkey") - * @param prefix Record prefix to match (e.g., "v=DKIM1") - * @param options Lookup options - * @returns Matching TXT record or null if not found - */ - public async findTxtRecord( - domain: string, - subdomain: string = '', - prefix: string = '', - options?: IDnsLookupOptions - ): Promise { - const fullDomain = subdomain ? `${subdomain}.${domain}` : domain; - - try { - const records = await this.lookupTxt(fullDomain, options); - - for (const recordArray of records) { - // TXT records can be split into chunks, join them - const record = recordArray.join(''); - - if (!prefix || record.startsWith(prefix)) { - return record; - } - } - - return null; - } catch (error) { - // Domain might not exist or no TXT records - console.log(`No matching TXT record found for ${fullDomain} with prefix ${prefix}`); - return null; - } - } - - /** - * Verify if a domain has a valid SPF record - * @param domain Domain to verify - * @returns Verification result - */ - public async verifySpfRecord(domain: string): Promise { - const result: IDnsVerificationResult = { - record: 'SPF', - found: false, - valid: false - }; - - try { - const spfRecord = await this.findTxtRecord(domain, '', 'v=spf1'); - - if (spfRecord) { - result.found = true; - result.value = spfRecord; - - // Basic validation - check if it contains all, include, ip4, ip6, or mx mechanisms - const isValid = /v=spf1\s+([-~?+]?(all|include:|ip4:|ip6:|mx|a|exists:))/.test(spfRecord); - result.valid = isValid; - - if (!isValid) { - result.error = 'SPF record format is invalid'; - } - } else { - result.error = 'No SPF record found'; - } - } catch (error) { - result.error = `Error verifying SPF: ${error.message}`; - } - - return result; - } - - /** - * Verify if a domain has a valid DKIM record - * @param domain Domain to verify - * @param selector DKIM selector (usually "mta" in our case) - * @returns Verification result - */ - public async verifyDkimRecord(domain: string, selector: string = 'mta'): Promise { - const result: IDnsVerificationResult = { - record: 'DKIM', - found: false, - valid: false - }; - - try { - const dkimSelector = `${selector}._domainkey`; - const dkimRecord = await this.findTxtRecord(domain, dkimSelector, 'v=DKIM1'); - - if (dkimRecord) { - result.found = true; - result.value = dkimRecord; - - // Basic validation - check for required fields - const hasP = dkimRecord.includes('p='); - result.valid = dkimRecord.includes('v=DKIM1') && hasP; - - if (!result.valid) { - result.error = 'DKIM record is missing required fields'; - } else if (dkimRecord.includes('p=') && !dkimRecord.match(/p=[a-zA-Z0-9+/]+/)) { - result.valid = false; - result.error = 'DKIM record has invalid public key format'; - } - } else { - result.error = `No DKIM record found for selector ${selector}`; - } - } catch (error) { - result.error = `Error verifying DKIM: ${error.message}`; - } - - return result; - } - - /** - * Verify if a domain has a valid DMARC record - * @param domain Domain to verify - * @returns Verification result - */ - public async verifyDmarcRecord(domain: string): Promise { - const result: IDnsVerificationResult = { - record: 'DMARC', - found: false, - valid: false - }; - - try { - const dmarcDomain = `_dmarc.${domain}`; - const dmarcRecord = await this.findTxtRecord(dmarcDomain, '', 'v=DMARC1'); - - if (dmarcRecord) { - result.found = true; - result.value = dmarcRecord; - - // Basic validation - check for required fields - const hasPolicy = dmarcRecord.includes('p='); - result.valid = dmarcRecord.includes('v=DMARC1') && hasPolicy; - - if (!result.valid) { - result.error = 'DMARC record is missing required fields'; - } - } else { - result.error = 'No DMARC record found'; - } - } catch (error) { - result.error = `Error verifying DMARC: ${error.message}`; - } - - return result; - } - - /** - * Check all email authentication records (SPF, DKIM, DMARC) for a domain - * @param domain Domain to check - * @param dkimSelector DKIM selector - * @returns Object with verification results for each record type - */ - public async verifyEmailAuthRecords(domain: string, dkimSelector: string = 'mta'): Promise<{ - spf: IDnsVerificationResult; - dkim: IDnsVerificationResult; - dmarc: IDnsVerificationResult; - }> { - const [spf, dkim, dmarc] = await Promise.all([ - this.verifySpfRecord(domain), - this.verifyDkimRecord(domain, dkimSelector), - this.verifyDmarcRecord(domain) - ]); - - return { spf, dkim, dmarc }; - } - - /** - * Generate a recommended SPF record for a domain - * @param domain Domain name - * @param options Configuration options for the SPF record - * @returns Generated SPF record - */ - public generateSpfRecord(domain: string, options: { - includeMx?: boolean; - includeA?: boolean; - includeIps?: string[]; - includeSpf?: string[]; - policy?: 'none' | 'neutral' | 'softfail' | 'fail' | 'reject'; - } = {}): IDnsRecord { - const { - includeMx = true, - includeA = true, - includeIps = [], - includeSpf = [], - policy = 'softfail' - } = options; - - let value = 'v=spf1'; - - if (includeMx) { - value += ' mx'; - } - - if (includeA) { - value += ' a'; - } - - // Add IP addresses - for (const ip of includeIps) { - if (ip.includes(':')) { - value += ` ip6:${ip}`; - } else { - value += ` ip4:${ip}`; - } - } - - // Add includes - for (const include of includeSpf) { - value += ` include:${include}`; - } - - // Add policy - const policyMap = { - 'none': '?all', - 'neutral': '~all', - 'softfail': '~all', - 'fail': '-all', - 'reject': '-all' - }; - - value += ` ${policyMap[policy]}`; - - return { - name: domain, - type: 'TXT', - value: value - }; - } - - /** - * Generate a recommended DMARC record for a domain - * @param domain Domain name - * @param options Configuration options for the DMARC record - * @returns Generated DMARC record - */ - public generateDmarcRecord(domain: string, options: { - policy?: 'none' | 'quarantine' | 'reject'; - subdomainPolicy?: 'none' | 'quarantine' | 'reject'; - pct?: number; - rua?: string; - ruf?: string; - daysInterval?: number; - } = {}): IDnsRecord { - const { - policy = 'none', - subdomainPolicy, - pct = 100, - rua, - ruf, - daysInterval = 1 - } = options; - - let value = 'v=DMARC1; p=' + policy; - - if (subdomainPolicy) { - value += `; sp=${subdomainPolicy}`; - } - - if (pct !== 100) { - value += `; pct=${pct}`; - } - - if (rua) { - value += `; rua=mailto:${rua}`; - } - - if (ruf) { - value += `; ruf=mailto:${ruf}`; - } - - if (daysInterval !== 1) { - value += `; ri=${daysInterval * 86400}`; - } - - // Add reporting format and ADKIM/ASPF alignment - value += '; fo=1; adkim=r; aspf=r'; - - return { - name: `_dmarc.${domain}`, - type: 'TXT', - value: value - }; - } - - /** - * Save DNS record recommendations to a file - * @param domain Domain name - * @param records DNS records to save - */ - public async saveDnsRecommendations(domain: string, records: IDnsRecord[]): Promise { - try { - const filePath = plugins.path.join(paths.dnsRecordsDir, `${domain}.recommendations.json`); - await plugins.smartfs.file(filePath).write(JSON.stringify(records, null, 2)); - console.log(`DNS recommendations for ${domain} saved to ${filePath}`); - } catch (error) { - console.error(`Error saving DNS recommendations for ${domain}:`, error); - } - } - - /** - * Get cache key value - * @param key Cache key - * @returns Cached value or undefined if not found or expired - */ - private getFromCache(key: string): T | undefined { - const cached = this.cache.get(key); - - if (cached && cached.expires > Date.now()) { - return cached.data as T; - } - - // Remove expired entry - if (cached) { - this.cache.delete(key); - } - - return undefined; - } - - /** - * Set cache key value - * @param key Cache key - * @param data Data to cache - * @param ttl TTL in milliseconds - */ - private setInCache(key: string, data: T, ttl: number = this.defaultOptions.cacheTtl): void { - if (ttl <= 0) return; // Don't cache if TTL is disabled - - this.cache.set(key, { - data, - expires: Date.now() + ttl - }); - } - - /** - * Clear the DNS cache - * @param key Optional specific key to clear, or all cache if not provided - */ - public clearCache(key?: string): void { - if (key) { - this.cache.delete(key); - } else { - this.cache.clear(); - } - } - - /** - * Promise-based wrapper for dns.resolveMx - * @param domain Domain to resolve - * @param timeout Timeout in milliseconds - * @returns Promise resolving to MX records - */ - private dnsResolveMx(domain: string, timeout: number = 5000): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error(`DNS MX lookup timeout for ${domain}`)); - }, timeout); - - plugins.dns.resolveMx(domain, (err, addresses) => { - clearTimeout(timeoutId); - - if (err) { - reject(err); - } else { - resolve(addresses); - } - }); - }); - } - - /** - * Promise-based wrapper for dns.resolveTxt - * @param domain Domain to resolve - * @param timeout Timeout in milliseconds - * @returns Promise resolving to TXT records - */ - private dnsResolveTxt(domain: string, timeout: number = 5000): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error(`DNS TXT lookup timeout for ${domain}`)); - }, timeout); - - plugins.dns.resolveTxt(domain, (err, records) => { - clearTimeout(timeoutId); - - if (err) { - reject(err); - } else { - resolve(records); - } - }); - }); - } - - /** - * Generate all recommended DNS records for proper email authentication - * @param domain Domain to generate records for - * @returns Array of recommended DNS records - */ - public async generateAllRecommendedRecords(domain: string): Promise { - const records: IDnsRecord[] = []; - - // Get DKIM record (already created by DKIMCreator) - try { - // Call the DKIM creator directly - const dkimRecord = await this.dkimCreator.getDNSRecordForDomain(domain); - records.push(dkimRecord); - } catch (error) { - console.error(`Error getting DKIM record for ${domain}:`, error); - } - - // Generate SPF record - const spfRecord = this.generateSpfRecord(domain, { - includeMx: true, - includeA: true, - policy: 'softfail' - }); - records.push(spfRecord); - - // Generate DMARC record - const dmarcRecord = this.generateDmarcRecord(domain, { - policy: 'none', // Start with monitoring mode - rua: `dmarc@${domain}` // Replace with appropriate report address - }); - records.push(dmarcRecord); - - // Save recommendations - await this.saveDnsRecommendations(domain, records); - - return records; - } -} \ No newline at end of file diff --git a/ts/mail/routing/classes.unified.email.server.ts b/ts/mail/routing/classes.unified.email.server.ts index 0df0dbf..c3a6410 100644 --- a/ts/mail/routing/classes.unified.email.server.ts +++ b/ts/mail/routing/classes.unified.email.server.ts @@ -11,35 +11,6 @@ import { DKIMCreator } from '../security/classes.dkimcreator.js'; import { IPReputationChecker } from '../../security/classes.ipreputationchecker.js'; import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js'; import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js'; -// Deliverability types (IPWarmupManager and SenderReputationMonitor are optional external modules) -interface IIPWarmupConfig { - enabled?: boolean; - ips?: string[]; - [key: string]: any; -} -interface IReputationMonitorConfig { - enabled?: boolean; - domains?: string[]; - [key: string]: any; -} -interface IPWarmupManager { - getWarmupStatus(ip: string): any; - addIPToWarmup(ip: string, config?: any): void; - removeIPFromWarmup(ip: string): void; - updateMetrics(ip: string, metrics: any): void; - canSendMoreToday(ip: string): boolean; - canSendMoreThisHour(ip: string): boolean; - getBestIPForSending(...args: any[]): string | null; - setActiveAllocationPolicy(policy: string): void; - recordSend(...args: any[]): void; -} -interface SenderReputationMonitor { - getReputationData(domain: string): any; - getReputationSummary(): any; - addDomain(domain: string): void; - removeDomain(domain: string): void; - recordSendEvent(domain: string, event: any): void; -} import { EmailRouter } from './classes.email.router.js'; import type { IEmailRoute, IEmailAction, IEmailContext, IEmailDomainConfig } from './interfaces.js'; import { Email } from '../core/classes.email.js'; @@ -128,10 +99,6 @@ export interface IUnifiedEmailServerOptions { // Rate limiting (global limits, can be overridden per domain) rateLimits?: IHierarchicalRateLimits; - - // Deliverability options - ipWarmupConfig?: IIPWarmupConfig; - reputationMonitorConfig?: IReputationMonitorConfig; } @@ -196,8 +163,6 @@ export class UnifiedEmailServer extends EventEmitter { private rustBridge: RustSecurityBridge; private ipReputationChecker: IPReputationChecker; private bounceManager: BounceManager; - private ipWarmupManager: IPWarmupManager | null; - private senderReputationMonitor: SenderReputationMonitor | null; public deliveryQueue: UnifiedDeliveryQueue; public deliverySystem: MultiModeDeliverySystem; private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers @@ -239,11 +204,6 @@ export class UnifiedEmailServer extends EventEmitter { storageManager: dcRouter.storageManager }); - // IP warmup manager and sender reputation monitor are optional - // They will be initialized when the deliverability module is available - this.ipWarmupManager = null; - this.senderReputationMonitor = null; - // Initialize domain registry this.domainRegistry = new DomainRegistry(options.domains, options.defaults); @@ -373,6 +333,13 @@ export class UnifiedEmailServer extends EventEmitter { } logger.log('info', 'Rust security bridge started — Rust is the primary security backend'); + // Listen for bridge state changes to propagate resilience events + this.rustBridge.on('stateChange', ({ oldState, newState }: { oldState: string; newState: string }) => { + if (newState === 'failed') this.emit('bridgeFailed'); + else if (newState === 'restarting') this.emit('bridgeRestarting'); + else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered'); + }); + // Set up DKIM for all domains await this.setupDkimForDomains(); logger.log('info', 'DKIM configuration completed for all domains'); @@ -414,13 +381,17 @@ export class UnifiedEmailServer extends EventEmitter { await this.handleRustEmailReceived(data); } catch (err) { logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`); - // Send rejection back to Rust - await this.rustBridge.sendEmailProcessingResult({ - correlationId: data.correlationId, - accepted: false, - smtpCode: 451, - smtpMessage: 'Internal processing error', - }); + // Send rejection back to Rust (may fail if bridge is restarting) + try { + await this.rustBridge.sendEmailProcessingResult({ + correlationId: data.correlationId, + accepted: false, + smtpCode: 451, + smtpMessage: 'Internal processing error', + }); + } catch (sendErr) { + logger.log('warn', `Could not send rejection back to Rust: ${(sendErr as Error).message}`); + } } }); @@ -429,11 +400,15 @@ export class UnifiedEmailServer extends EventEmitter { await this.handleRustAuthRequest(data); } catch (err) { logger.log('error', `Error handling auth from Rust SMTP: ${(err as Error).message}`); - await this.rustBridge.sendAuthResult({ - correlationId: data.correlationId, - success: false, - message: 'Internal auth error', - }); + try { + await this.rustBridge.sendAuthResult({ + correlationId: data.correlationId, + success: false, + message: 'Internal auth error', + }); + } catch (sendErr) { + logger.log('warn', `Could not send auth rejection back to Rust: ${(sendErr as Error).message}`); + } } }); @@ -495,7 +470,8 @@ export class UnifiedEmailServer extends EventEmitter { // Clear the servers array - servers will be garbage collected this.servers = []; - // Stop Rust security bridge + // Remove bridge state change listener and stop bridge + this.rustBridge.removeAllListeners('stateChange'); await this.rustBridge.stop(); // Stop the delivery system @@ -653,7 +629,7 @@ export class UnifiedEmailServer extends EventEmitter { logger.log('info', 'Using pre-computed security results from Rust in-process pipeline'); result = precomputed; } else { - // Fallback: IPC round-trip to Rust (for backward compat / handleSocket mode) + // Fallback: IPC round-trip to Rust (for backward compat) const rawMessage = session.emailData || email.toRFC822String(); result = await this.rustBridge.verifyEmail({ rawMessage, @@ -967,171 +943,6 @@ export class UnifiedEmailServer extends EventEmitter { throw error; } - /** - * Handle email in MTA mode (programmatic processing) - */ - private async _handleMtaMode(email: Email, session: IExtendedSmtpSession): Promise { - logger.log('info', `Handling email in MTA mode for session ${session.id}`); - - try { - // Apply MTA rule options if provided - if (session.matchedRoute?.action.options?.mtaOptions) { - const options = session.matchedRoute.action.options.mtaOptions; - - // Apply DKIM signing if enabled - if (options.dkimSign && options.dkimOptions) { - const dkimDomain = options.dkimOptions.domainName; - const dkimSelector = options.dkimOptions.keySelector || 'mta'; - logger.log('info', `Signing email with DKIM for domain ${dkimDomain}`); - await this.handleDkimSigning(email, dkimDomain, dkimSelector); - } - } - - // Get email content for logging/processing - const subject = email.subject; - const recipients = email.getAllRecipients().join(', '); - - logger.log('info', `Email processed by MTA: ${subject} to ${recipients}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.INFO, - type: SecurityEventType.EMAIL_PROCESSING, - message: 'Email processed by MTA', - ipAddress: session.remoteAddress, - details: { - sessionId: session.id, - ruleName: session.matchedRoute?.name || 'default', - subject, - recipients - }, - success: true - }); - } catch (error) { - logger.log('error', `Failed to process email in MTA mode: ${error.message}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.EMAIL_PROCESSING, - message: 'MTA processing failed', - ipAddress: session.remoteAddress, - details: { - sessionId: session.id, - ruleName: session.matchedRoute?.name || 'default', - error: error.message - }, - success: false - }); - - throw error; - } - } - - /** - * Handle email in process mode (store-and-forward with scanning) - */ - private async _handleProcessMode(email: Email, session: IExtendedSmtpSession): Promise { - logger.log('info', `Handling email in process mode for session ${session.id}`); - - try { - const route = session.matchedRoute; - - // Apply content scanning if enabled - if (route?.action.options?.contentScanning && route.action.options.scanners && route.action.options.scanners.length > 0) { - logger.log('info', 'Performing content scanning'); - - // Apply each scanner - for (const scanner of route.action.options.scanners) { - switch (scanner.type) { - case 'spam': - logger.log('info', 'Scanning for spam content'); - // Implement spam scanning - break; - - case 'virus': - logger.log('info', 'Scanning for virus content'); - // Implement virus scanning - break; - - case 'attachment': - logger.log('info', 'Scanning attachments'); - - // Check for blocked extensions - if (scanner.blockedExtensions && scanner.blockedExtensions.length > 0) { - for (const attachment of email.attachments) { - const ext = this.getFileExtension(attachment.filename); - if (scanner.blockedExtensions.includes(ext)) { - if (scanner.action === 'reject') { - throw new Error(`Blocked attachment type: ${ext}`); - } else { // tag - email.addHeader('X-Attachment-Warning', `Potentially unsafe attachment: ${attachment.filename}`); - } - } - } - } - break; - } - } - } - - // Apply transformations if defined - if (route?.action.options?.transformations && route.action.options.transformations.length > 0) { - logger.log('info', 'Applying email transformations'); - - for (const transform of route.action.options.transformations) { - switch (transform.type) { - case 'addHeader': - if (transform.header && transform.value) { - email.addHeader(transform.header, transform.value); - } - break; - } - } - } - - logger.log('info', `Email successfully processed in store-and-forward mode`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.INFO, - type: SecurityEventType.EMAIL_PROCESSING, - message: 'Email processed and queued', - ipAddress: session.remoteAddress, - details: { - sessionId: session.id, - ruleName: route?.name || 'default', - contentScanning: route?.action.options?.contentScanning || false, - subject: email.subject - }, - success: true - }); - } catch (error) { - logger.log('error', `Failed to process email: ${error.message}`); - - SecurityLogger.getInstance().logEvent({ - level: SecurityLogLevel.ERROR, - type: SecurityEventType.EMAIL_PROCESSING, - message: 'Email processing failed', - ipAddress: session.remoteAddress, - details: { - sessionId: session.id, - ruleName: session.matchedRoute?.name || 'default', - error: error.message - }, - success: false - }); - - throw error; - } - } - - /** - * Get file extension from filename - */ - private getFileExtension(filename: string): string { - return filename.substring(filename.lastIndexOf('.')).toLowerCase(); - } - - - /** * Set up DKIM configuration for all domains */ @@ -1474,44 +1285,6 @@ export class UnifiedEmailServer extends EventEmitter { } } - // IP warmup handling - let ipAddress = options?.ipAddress; - - // If no specific IP was provided, use IP warmup manager to find the best IP - if (!ipAddress) { - const domain = email.from.split('@')[1]; - - ipAddress = this.getBestIPForSending({ - from: email.from, - to: email.to, - domain, - isTransactional: options?.isTransactional - }); - - if (ipAddress) { - logger.log('info', `Selected IP ${ipAddress} for sending based on warmup status`); - } - } - - // If an IP is provided or selected by warmup manager, check its capacity - if (ipAddress) { - // Check if the IP can send more today - if (!this.canIPSendMoreToday(ipAddress)) { - logger.log('warn', `IP ${ipAddress} has reached its daily sending limit, email will be queued for later delivery`); - } - - // Check if the IP can send more this hour - if (!this.canIPSendMoreThisHour(ipAddress)) { - logger.log('warn', `IP ${ipAddress} has reached its hourly sending limit, email will be queued for later delivery`); - } - - // Record the send for IP warmup tracking - this.recordIPSend(ipAddress); - - // Add IP header to the email - email.addHeader('X-Sending-IP', ipAddress); - } - // Check if the sender domain has DKIM keys and sign the email if needed if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) { const domain = email.from.split('@')[1]; @@ -1794,125 +1567,8 @@ export class UnifiedEmailServer extends EventEmitter { } /** - * Get the status of IP warmup process - * @param ipAddress Optional specific IP to check - * @returns Status of IP warmup - */ - public getIPWarmupStatus(ipAddress?: string): any { - return this.ipWarmupManager.getWarmupStatus(ipAddress); - } - - /** - * Add a new IP address to the warmup process - * @param ipAddress IP address to add - */ - public addIPToWarmup(ipAddress: string): void { - this.ipWarmupManager.addIPToWarmup(ipAddress); - } - - /** - * Remove an IP address from the warmup process - * @param ipAddress IP address to remove - */ - public removeIPFromWarmup(ipAddress: string): void { - this.ipWarmupManager.removeIPFromWarmup(ipAddress); - } - - /** - * Update metrics for an IP in the warmup process - * @param ipAddress IP address - * @param metrics Metrics to update - */ - public updateIPWarmupMetrics( - ipAddress: string, - metrics: { openRate?: number; bounceRate?: number; complaintRate?: number } - ): void { - this.ipWarmupManager.updateMetrics(ipAddress, metrics); - } - - /** - * Check if an IP can send more emails today - * @param ipAddress IP address to check - * @returns Whether the IP can send more today - */ - public canIPSendMoreToday(ipAddress: string): boolean { - return this.ipWarmupManager.canSendMoreToday(ipAddress); - } - - /** - * Check if an IP can send more emails in the current hour - * @param ipAddress IP address to check - * @returns Whether the IP can send more this hour - */ - public canIPSendMoreThisHour(ipAddress: string): boolean { - return this.ipWarmupManager.canSendMoreThisHour(ipAddress); - } - - /** - * Get the best IP to use for sending an email based on warmup status - * @param emailInfo Information about the email being sent - * @returns Best IP to use or null - */ - public getBestIPForSending(emailInfo: { - from: string; - to: string[]; - domain: string; - isTransactional?: boolean; - }): string | null { - return this.ipWarmupManager.getBestIPForSending(emailInfo); - } - - /** - * Set the active IP allocation policy for warmup - * @param policyName Name of the policy to set - */ - public setIPAllocationPolicy(policyName: string): void { - this.ipWarmupManager.setActiveAllocationPolicy(policyName); - } - - /** - * Record that an email was sent using a specific IP - * @param ipAddress IP address used for sending - */ - public recordIPSend(ipAddress: string): void { - this.ipWarmupManager.recordSend(ipAddress); - } - - /** - * Get reputation data for a domain - * @param domain Domain to get reputation for - * @returns Domain reputation metrics - */ - public getDomainReputationData(domain: string): any { - return this.senderReputationMonitor.getReputationData(domain); - } - - /** - * Get summary reputation data for all monitored domains - * @returns Summary data for all domains - */ - public getReputationSummary(): any { - return this.senderReputationMonitor.getReputationSummary(); - } - - /** - * Add a domain to the reputation monitoring system - * @param domain Domain to add - */ - public addDomainToMonitoring(domain: string): void { - this.senderReputationMonitor.addDomain(domain); - } - - /** - * Remove a domain from the reputation monitoring system - * @param domain Domain to remove - */ - public removeDomainFromMonitoring(domain: string): void { - this.senderReputationMonitor.removeDomain(domain); - } - - /** - * Record an email event for domain reputation tracking + * Record an email event for domain reputation tracking. + * Currently a no-op — the sender reputation monitor is not yet implemented. * @param domain Domain sending the email * @param event Event details */ @@ -1922,7 +1578,7 @@ export class UnifiedEmailServer extends EventEmitter { hardBounce?: boolean; receivingDomain?: string; }): void { - this.senderReputationMonitor.recordSendEvent(domain, event); + logger.log('debug', `Reputation event for ${domain}: ${event.type}`); } /** diff --git a/ts/security/classes.rustsecuritybridge.ts b/ts/security/classes.rustsecuritybridge.ts index 8e5e18d..e393282 100644 --- a/ts/security/classes.rustsecuritybridge.ts +++ b/ts/security/classes.rustsecuritybridge.ts @@ -1,6 +1,7 @@ import * as plugins from '../plugins.js'; import * as paths from '../paths.js'; import { logger } from '../logger.js'; +import { EventEmitter } from 'events'; // --------------------------------------------------------------------------- // IPC command type map — mirrors the methods in mailer-bin's management mode @@ -213,6 +214,35 @@ type TMailerCommands = { }; }; +// --------------------------------------------------------------------------- +// Bridge state machine +// --------------------------------------------------------------------------- + +export enum BridgeState { + Idle = 'idle', + Starting = 'starting', + Running = 'running', + Restarting = 'restarting', + Failed = 'failed', + Stopped = 'stopped', +} + +export interface IBridgeResilienceConfig { + maxRestartAttempts: number; + healthCheckIntervalMs: number; + restartBackoffBaseMs: number; + restartBackoffMaxMs: number; + healthCheckTimeoutMs: number; +} + +const DEFAULT_RESILIENCE_CONFIG: IBridgeResilienceConfig = { + maxRestartAttempts: 5, + healthCheckIntervalMs: 30_000, + restartBackoffBaseMs: 1_000, + restartBackoffMaxMs: 30_000, + healthCheckTimeoutMs: 5_000, +}; + // --------------------------------------------------------------------------- // RustSecurityBridge — singleton wrapper around smartrust.RustBridge // --------------------------------------------------------------------------- @@ -222,14 +252,26 @@ type TMailerCommands = { * * Uses `@push.rocks/smartrust` for JSON-over-stdin/stdout IPC. * Singleton — access via `RustSecurityBridge.getInstance()`. + * + * Features resilience via auto-restart with exponential backoff, + * periodic health checks, and a state machine that tracks the + * bridge lifecycle. */ -export class RustSecurityBridge { +export class RustSecurityBridge extends EventEmitter { private static instance: RustSecurityBridge | null = null; + private static _resilienceConfig: IBridgeResilienceConfig = { ...DEFAULT_RESILIENCE_CONFIG }; private bridge: InstanceType>; private _running = false; + private _state: BridgeState = BridgeState.Idle; + private _restartAttempts = 0; + private _restartTimer: ReturnType | null = null; + private _healthCheckTimer: ReturnType | null = null; + private _deliberateStop = false; + private _smtpServerConfig: ISmtpServerConfig | null = null; private constructor() { + super(); this.bridge = new plugins.smartrust.RustBridge({ binaryName: 'mailer-bin', cliArgs: ['--management'], @@ -252,6 +294,13 @@ export class RustSecurityBridge { this.bridge.on('exit', (code: number | null, signal: string | null) => { this._running = false; logger.log('warn', `Rust security bridge exited (code=${code}, signal=${signal})`); + + if (this._deliberateStop) { + this.setState(BridgeState.Stopped); + } else if (this._state === BridgeState.Running) { + // Unexpected exit — attempt restart + this.attemptRestart(); + } }); this.bridge.on('stderr', (line: string) => { @@ -259,6 +308,10 @@ export class RustSecurityBridge { }); } + // ----------------------------------------------------------------------- + // Static configuration & singleton + // ----------------------------------------------------------------------- + /** Get or create the singleton instance. */ public static getInstance(): RustSecurityBridge { if (!RustSecurityBridge.instance) { @@ -267,11 +320,73 @@ export class RustSecurityBridge { return RustSecurityBridge.instance; } + /** Reset the singleton instance (for testing). */ + public static resetInstance(): void { + if (RustSecurityBridge.instance) { + RustSecurityBridge.instance.stopHealthCheck(); + if (RustSecurityBridge.instance._restartTimer) { + clearTimeout(RustSecurityBridge.instance._restartTimer); + RustSecurityBridge.instance._restartTimer = null; + } + RustSecurityBridge.instance.removeAllListeners(); + } + RustSecurityBridge.instance = null; + } + + /** Configure resilience parameters. Can be called before or after getInstance(). */ + public static configure(config: Partial): void { + RustSecurityBridge._resilienceConfig = { + ...RustSecurityBridge._resilienceConfig, + ...config, + }; + } + + // ----------------------------------------------------------------------- + // State management + // ----------------------------------------------------------------------- + + /** Current bridge state. */ + public get state(): BridgeState { + return this._state; + } + /** Whether the Rust process is currently running and accepting commands. */ public get running(): boolean { return this._running; } + private setState(newState: BridgeState): void { + const oldState = this._state; + if (oldState === newState) return; + this._state = newState; + logger.log('info', `Rust bridge state: ${oldState} -> ${newState}`); + this.emit('stateChange', { oldState, newState }); + } + + /** + * Throws a descriptive error if the bridge is not in Running state. + * Called at the top of every command method. + */ + private ensureRunning(): void { + if (this._state === BridgeState.Running && this._running) { + return; + } + switch (this._state) { + case BridgeState.Idle: + throw new Error('Rust bridge has not been started yet. Call start() first.'); + case BridgeState.Starting: + throw new Error('Rust bridge is still starting. Wait for start() to resolve.'); + case BridgeState.Restarting: + throw new Error('Rust bridge is restarting after a crash. Commands will resume once it recovers.'); + case BridgeState.Failed: + throw new Error('Rust bridge has failed after exhausting all restart attempts.'); + case BridgeState.Stopped: + throw new Error('Rust bridge has been stopped. Call start() to restart it.'); + default: + throw new Error(`Rust bridge is not running (state=${this._state}).`); + } + } + // ----------------------------------------------------------------------- // Lifecycle // ----------------------------------------------------------------------- @@ -281,55 +396,195 @@ export class RustSecurityBridge { * @returns `true` if the binary started successfully, `false` otherwise. */ public async start(): Promise { - if (this._running) { + if (this._running && this._state === BridgeState.Running) { return true; } + + this._deliberateStop = false; + this._restartAttempts = 0; + this.setState(BridgeState.Starting); + try { const ok = await this.bridge.spawn(); this._running = ok; if (ok) { + this.setState(BridgeState.Running); + this.startHealthCheck(); logger.log('info', 'Rust security bridge started'); } else { + this.setState(BridgeState.Failed); logger.log('warn', 'Rust security bridge failed to start (binary not found or timeout)'); } return ok; } catch (err) { + this.setState(BridgeState.Failed); logger.log('error', `Failed to start Rust security bridge: ${(err as Error).message}`); return false; } } - /** Kill the Rust process. */ + /** Kill the Rust process deliberately. */ public async stop(): Promise { + this._deliberateStop = true; + + // Cancel any pending restart + if (this._restartTimer) { + clearTimeout(this._restartTimer); + this._restartTimer = null; + } + + this.stopHealthCheck(); + this._smtpServerConfig = null; + if (!this._running) { + this.setState(BridgeState.Stopped); return; } try { this.bridge.kill(); this._running = false; + this.setState(BridgeState.Stopped); logger.log('info', 'Rust security bridge stopped'); } catch (err) { logger.log('error', `Error stopping Rust security bridge: ${(err as Error).message}`); } } + // ----------------------------------------------------------------------- + // Auto-restart with exponential backoff + // ----------------------------------------------------------------------- + + private attemptRestart(): void { + const config = RustSecurityBridge._resilienceConfig; + this._restartAttempts++; + + if (this._restartAttempts > config.maxRestartAttempts) { + logger.log('error', `Rust bridge exceeded max restart attempts (${config.maxRestartAttempts}). Giving up.`); + this.setState(BridgeState.Failed); + return; + } + + this.setState(BridgeState.Restarting); + this.stopHealthCheck(); + + const delay = Math.min( + config.restartBackoffBaseMs * Math.pow(2, this._restartAttempts - 1), + config.restartBackoffMaxMs, + ); + + logger.log('info', `Rust bridge restart attempt ${this._restartAttempts}/${config.maxRestartAttempts} in ${delay}ms`); + + this._restartTimer = setTimeout(async () => { + this._restartTimer = null; + + // Guard: if stop() was called while we were waiting, don't restart + if (this._deliberateStop) { + this.setState(BridgeState.Stopped); + return; + } + + try { + const ok = await this.bridge.spawn(); + this._running = ok; + + if (ok) { + logger.log('info', 'Rust bridge restarted successfully'); + this._restartAttempts = 0; + this.setState(BridgeState.Running); + this.startHealthCheck(); + await this.restoreAfterRestart(); + } else { + logger.log('warn', 'Rust bridge restart failed (spawn returned false)'); + this.attemptRestart(); + } + } catch (err) { + logger.log('error', `Rust bridge restart failed: ${(err as Error).message}`); + this.attemptRestart(); + } + }, delay); + } + + /** + * Restore state after a successful restart: + * - Re-send startSmtpServer command if the SMTP server was running + */ + private async restoreAfterRestart(): Promise { + if (this._smtpServerConfig) { + try { + logger.log('info', 'Restoring SMTP server after bridge restart'); + const result = await this.bridge.sendCommand('startSmtpServer', this._smtpServerConfig); + if (result?.started) { + logger.log('info', 'SMTP server restored after bridge restart'); + } else { + logger.log('warn', 'SMTP server failed to restore after bridge restart'); + } + } catch (err) { + logger.log('error', `Failed to restore SMTP server after restart: ${(err as Error).message}`); + } + } + } + + // ----------------------------------------------------------------------- + // Health check + // ----------------------------------------------------------------------- + + private startHealthCheck(): void { + this.stopHealthCheck(); + const config = RustSecurityBridge._resilienceConfig; + + this._healthCheckTimer = setInterval(async () => { + if (this._state !== BridgeState.Running || !this._running) { + return; + } + + try { + const pongPromise = this.bridge.sendCommand('ping', {} as any); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Health check timeout')), config.healthCheckTimeoutMs), + ); + const res = await Promise.race([pongPromise, timeoutPromise]); + if (!(res as any)?.pong) { + throw new Error('Health check: unexpected ping response'); + } + } catch (err) { + logger.log('warn', `Rust bridge health check failed: ${(err as Error).message}. Killing process to trigger restart.`); + try { + this.bridge.kill(); + } catch { + // Already dead + } + // The exit handler will trigger attemptRestart() + } + }, config.healthCheckIntervalMs); + } + + private stopHealthCheck(): void { + if (this._healthCheckTimer) { + clearInterval(this._healthCheckTimer); + this._healthCheckTimer = null; + } + } + // ----------------------------------------------------------------------- // Commands — thin typed wrappers over sendCommand // ----------------------------------------------------------------------- /** Ping the Rust process. */ public async ping(): Promise { + this.ensureRunning(); const res = await this.bridge.sendCommand('ping', {} as any); return res?.pong === true; } /** Get version information for all Rust crates. */ public async getVersion(): Promise { + this.ensureRunning(); return this.bridge.sendCommand('version', {} as any); } /** Validate an email address. */ public async validateEmail(email: string): Promise { + this.ensureRunning(); return this.bridge.sendCommand('validateEmail', { email }); } @@ -339,6 +594,7 @@ export class RustSecurityBridge { diagnosticCode?: string; statusCode?: string; }): Promise { + this.ensureRunning(); return this.bridge.sendCommand('detectBounce', opts); } @@ -349,16 +605,19 @@ export class RustSecurityBridge { htmlBody?: string; attachmentNames?: string[]; }): Promise { + this.ensureRunning(); return this.bridge.sendCommand('scanContent', opts); } /** Check IP reputation via DNSBL. */ public async checkIpReputation(ip: string): Promise { + this.ensureRunning(); return this.bridge.sendCommand('checkIpReputation', { ip }); } /** Verify DKIM signatures on a raw email message. */ public async verifyDkim(rawMessage: string): Promise { + this.ensureRunning(); return this.bridge.sendCommand('verifyDkim', { rawMessage }); } @@ -369,6 +628,7 @@ export class RustSecurityBridge { selector?: string; privateKey: string; }): Promise<{ header: string; signedMessage: string }> { + this.ensureRunning(); return this.bridge.sendCommand('signDkim', opts); } @@ -379,6 +639,7 @@ export class RustSecurityBridge { hostname?: string; mailFrom: string; }): Promise { + this.ensureRunning(); return this.bridge.sendCommand('checkSpf', opts); } @@ -395,6 +656,7 @@ export class RustSecurityBridge { hostname?: string; mailFrom: string; }): Promise { + this.ensureRunning(); return this.bridge.sendCommand('verifyEmail', opts); } @@ -408,12 +670,16 @@ export class RustSecurityBridge { * emailReceived and authRequest that must be handled by the caller. */ public async startSmtpServer(config: ISmtpServerConfig): Promise { + this.ensureRunning(); + this._smtpServerConfig = config; const result = await this.bridge.sendCommand('startSmtpServer', config); return result?.started === true; } /** Stop the Rust SMTP server. */ public async stopSmtpServer(): Promise { + this.ensureRunning(); + this._smtpServerConfig = null; await this.bridge.sendCommand('stopSmtpServer', {} as any); } @@ -428,6 +694,7 @@ export class RustSecurityBridge { smtpCode?: number; smtpMessage?: string; }): Promise { + this.ensureRunning(); await this.bridge.sendCommand('emailProcessingResult', opts); } @@ -439,11 +706,13 @@ export class RustSecurityBridge { success: boolean; message?: string; }): Promise { + this.ensureRunning(); await this.bridge.sendCommand('authResult', opts); } /** Update rate limit configuration at runtime. */ public async configureRateLimits(config: IRateLimitConfig): Promise { + this.ensureRunning(); await this.bridge.sendCommand('configureRateLimits', config); } diff --git a/ts/security/index.ts b/ts/security/index.ts index befbe7e..5c0de62 100644 --- a/ts/security/index.ts +++ b/ts/security/index.ts @@ -22,6 +22,8 @@ export { export { RustSecurityBridge, + BridgeState, + type IBridgeResilienceConfig, type IDkimVerificationResult, type ISpfResult, type IDmarcResult,