Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc4877e06b | |||
| 36006191fc |
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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)
|
## 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
|
document Rust-side in-process security pipeline and update README to reflect SMTP server behavior and crate/test counts
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartmta",
|
"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.",
|
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mta",
|
"mta",
|
||||||
@@ -71,9 +71,7 @@
|
|||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@serve.zone/interfaces": "^5.0.4",
|
"@serve.zone/interfaces": "^5.0.4",
|
||||||
"@tsclass/tsclass": "^9.2.0",
|
"@tsclass/tsclass": "^9.2.0",
|
||||||
"ip": "^2.0.1",
|
|
||||||
"lru-cache": "^11.2.5",
|
"lru-cache": "^11.2.5",
|
||||||
"mailauth": "^4.13.0",
|
|
||||||
"mailparser": "^3.9.3",
|
"mailparser": "^3.9.3",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
128
pnpm-lock.yaml
generated
128
pnpm-lock.yaml
generated
@@ -89,15 +89,9 @@ importers:
|
|||||||
'@tsclass/tsclass':
|
'@tsclass/tsclass':
|
||||||
specifier: ^9.2.0
|
specifier: ^9.2.0
|
||||||
version: 9.3.0
|
version: 9.3.0
|
||||||
ip:
|
|
||||||
specifier: ^2.0.1
|
|
||||||
version: 2.0.1
|
|
||||||
lru-cache:
|
lru-cache:
|
||||||
specifier: ^11.2.5
|
specifier: ^11.2.5
|
||||||
version: 11.2.5
|
version: 11.2.5
|
||||||
mailauth:
|
|
||||||
specifier: ^4.13.0
|
|
||||||
version: 4.13.0
|
|
||||||
mailparser:
|
mailparser:
|
||||||
specifier: ^3.9.3
|
specifier: ^3.9.3
|
||||||
version: 3.9.3
|
version: 3.9.3
|
||||||
@@ -575,26 +569,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-nmiLGeOkKMkLDyIk5BUBLx5ExskFbKHKlPdrWCARPVFkU4cAAiuIyJWVfLwISoS0TO/zSInLqArPwIc76yvaNw==}
|
resolution: {integrity: sha512-nmiLGeOkKMkLDyIk5BUBLx5ExskFbKHKlPdrWCARPVFkU4cAAiuIyJWVfLwISoS0TO/zSInLqArPwIc76yvaNw==}
|
||||||
hasBin: true
|
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':
|
'@happy-dom/global-registrator@15.11.7':
|
||||||
resolution: {integrity: sha512-mfOoUlIw8VBiJYPrl5RZfMzkXC/z7gbSpi2ecycrj/gRWLq2CMV+Q+0G+JPjeOmuNFgg0skEIzkVFzVYFP6URw==}
|
resolution: {integrity: sha512-mfOoUlIw8VBiJYPrl5RZfMzkXC/z7gbSpi2ecycrj/gRWLq2CMV+Q+0G+JPjeOmuNFgg0skEIzkVFzVYFP6URw==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -820,9 +794,6 @@ packages:
|
|||||||
'@peculiar/asn1-x509-attr@2.6.0':
|
'@peculiar/asn1-x509-attr@2.6.0':
|
||||||
resolution: {integrity: sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==}
|
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':
|
'@peculiar/asn1-x509@2.6.0':
|
||||||
resolution: {integrity: sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==}
|
resolution: {integrity: sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==}
|
||||||
|
|
||||||
@@ -846,9 +817,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==}
|
resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
'@postalsys/vmc@1.1.3':
|
|
||||||
resolution: {integrity: sha512-oBmAYvc5Wqwf8T6Amlwx45C0Jq9hNKprKqO1y+2f2PKBQ7NrG6KkyrSdVDPT/OOk/s8qCVFt+H/Ry9ldV74Rvw==}
|
|
||||||
|
|
||||||
'@puppeteer/browsers@2.12.0':
|
'@puppeteer/browsers@2.12.0':
|
||||||
resolution: {integrity: sha512-Xuq42yxcQJ54ti8ZHNzF5snFvtpgXzNToJ1bXUGQRaiO8t+B6UM8sTUJfvV+AJnqtkJU/7hdy6nbKyA12aHtRw==}
|
resolution: {integrity: sha512-Xuq42yxcQJ54ti8ZHNzF5snFvtpgXzNToJ1bXUGQRaiO8t+B6UM8sTUJfvV+AJnqtkJU/7hdy6nbKyA12aHtRw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1576,9 +1544,6 @@ packages:
|
|||||||
'@socket.io/component-emitter@3.1.2':
|
'@socket.io/component-emitter@3.1.2':
|
||||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
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':
|
'@svgdotjs/svg.draggable.js@3.0.6':
|
||||||
resolution: {integrity: sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==}
|
resolution: {integrity: sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2860,17 +2825,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
ip@2.0.1:
|
|
||||||
resolution: {integrity: sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==}
|
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
ipaddr.js@2.3.0:
|
|
||||||
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
|
|
||||||
is-arrayish@0.2.1:
|
is-arrayish@0.2.1:
|
||||||
resolution: {integrity: sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=}
|
resolution: {integrity: sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=}
|
||||||
|
|
||||||
@@ -2938,10 +2896,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==}
|
resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
joi@18.0.2:
|
|
||||||
resolution: {integrity: sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==}
|
|
||||||
engines: {node: '>= 20'}
|
|
||||||
|
|
||||||
js-base64@3.7.8:
|
js-base64@3.7.8:
|
||||||
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
|
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
|
||||||
|
|
||||||
@@ -3097,11 +3051,6 @@ packages:
|
|||||||
lucide@0.563.0:
|
lucide@0.563.0:
|
||||||
resolution: {integrity: sha512-2zBzDJ5n2Plj3d0ksj6h9TWPOSiKu9gtxJxnBAye11X/8gfWied6IYJn6ADYBp1NPoJmgpyOYP3wMrVx69+2AA==}
|
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:
|
mailparser@3.9.3:
|
||||||
resolution: {integrity: sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==}
|
resolution: {integrity: sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==}
|
||||||
|
|
||||||
@@ -4083,13 +4032,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==}
|
resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==}
|
||||||
hasBin: true
|
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:
|
tmp@0.0.33:
|
||||||
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
|
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
|
||||||
engines: {node: '>=0.6.0'}
|
engines: {node: '>=0.6.0'}
|
||||||
@@ -4185,10 +4127,6 @@ packages:
|
|||||||
undici-types@7.16.0:
|
undici-types@7.16.0:
|
||||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
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:
|
unified@11.0.5:
|
||||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||||
|
|
||||||
@@ -5413,22 +5351,6 @@ snapshots:
|
|||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
- vue
|
- 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':
|
'@happy-dom/global-registrator@15.11.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
happy-dom: 15.11.7
|
happy-dom: 15.11.7
|
||||||
@@ -5728,13 +5650,6 @@ snapshots:
|
|||||||
asn1js: 3.0.7
|
asn1js: 3.0.7
|
||||||
tslib: 2.8.1
|
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':
|
'@peculiar/asn1-x509@2.6.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@peculiar/asn1-schema': 2.6.0
|
'@peculiar/asn1-schema': 2.6.0
|
||||||
@@ -5771,12 +5686,6 @@ snapshots:
|
|||||||
'@pnpm/network.ca-file': 1.0.2
|
'@pnpm/network.ca-file': 1.0.2
|
||||||
config-chain: 1.1.13
|
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':
|
'@puppeteer/browsers@2.12.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -7273,8 +7182,6 @@ snapshots:
|
|||||||
|
|
||||||
'@socket.io/component-emitter@3.1.2': {}
|
'@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)':
|
'@svgdotjs/svg.draggable.js@3.0.6(@svgdotjs/svg.js@3.2.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@svgdotjs/svg.js': 3.2.5
|
'@svgdotjs/svg.js': 3.2.5
|
||||||
@@ -8720,12 +8627,8 @@ snapshots:
|
|||||||
|
|
||||||
ip-address@10.1.0: {}
|
ip-address@10.1.0: {}
|
||||||
|
|
||||||
ip@2.0.1: {}
|
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
ipaddr.js@2.3.0: {}
|
|
||||||
|
|
||||||
is-arrayish@0.2.1: {}
|
is-arrayish@0.2.1: {}
|
||||||
|
|
||||||
is-docker@2.2.1: {}
|
is-docker@2.2.1: {}
|
||||||
@@ -8773,16 +8676,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@isaacs/cliui': 9.0.0
|
'@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-base64@3.7.8: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
@@ -8944,19 +8837,6 @@ snapshots:
|
|||||||
|
|
||||||
lucide@0.563.0: {}
|
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:
|
mailparser@3.9.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@zone-eu/mailsplit': 5.4.8
|
'@zone-eu/mailsplit': 5.4.8
|
||||||
@@ -10276,12 +10156,6 @@ snapshots:
|
|||||||
|
|
||||||
tlds@1.261.0: {}
|
tlds@1.261.0: {}
|
||||||
|
|
||||||
tldts-core@7.0.23: {}
|
|
||||||
|
|
||||||
tldts@7.0.21:
|
|
||||||
dependencies:
|
|
||||||
tldts-core: 7.0.23
|
|
||||||
|
|
||||||
tmp@0.0.33:
|
tmp@0.0.33:
|
||||||
dependencies:
|
dependencies:
|
||||||
os-tmpdir: 1.0.2
|
os-tmpdir: 1.0.2
|
||||||
@@ -10355,8 +10229,6 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.16.0: {}
|
undici-types@7.16.0: {}
|
||||||
|
|
||||||
undici@7.20.0: {}
|
|
||||||
|
|
||||||
unified@11.0.5:
|
unified@11.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
|
|||||||
@@ -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<ITestServer> {
|
|
||||||
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<void> {
|
|
||||||
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<number> {
|
|
||||||
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<boolean> {
|
|
||||||
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 || '<p>This is a test email</p>',
|
|
||||||
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<void>;
|
|
||||||
port?: number;
|
|
||||||
hostname?: string;
|
|
||||||
}): Promise<ISimpleTestServer> {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
177
test/test.rustsecuritybridge.resilience.node.ts
Normal file
177
test/test.rustsecuritybridge.resilience.node.ts
Normal file
@@ -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<IBridgeResilienceConfig> = {
|
||||||
|
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();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartmta',
|
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.'
|
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,28 +225,6 @@ export class EmailSendJob {
|
|||||||
this.log(`Connecting to ${mxServer}:25`);
|
this.log(`Connecting to ${mxServer}:25`);
|
||||||
|
|
||||||
try {
|
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
|
// Get SMTP client from UnifiedEmailServer
|
||||||
const smtpClient = this.emailServerRef.getSmtpClient(mxServer, 25);
|
const smtpClient = this.emailServerRef.getSmtpClient(mxServer, 25);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,6 @@
|
|||||||
* SMTP and email delivery interface definitions
|
* SMTP and email delivery interface definitions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Email } from '../core/classes.email.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SMTP session state enumeration
|
* SMTP session state enumeration
|
||||||
*/
|
*/
|
||||||
@@ -167,125 +165,3 @@ export interface ISmtpAuth {
|
|||||||
password: string;
|
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;
|
|
||||||
}
|
|
||||||
@@ -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<string, { data: any; expires: number }> = 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<plugins.dns.MxRecord[]> {
|
|
||||||
const lookupOptions = { ...this.defaultOptions, ...options };
|
|
||||||
const cacheKey = `mx:${domain}`;
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = this.getFromCache<plugins.dns.MxRecord[]>(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<string[][]> {
|
|
||||||
const lookupOptions = { ...this.defaultOptions, ...options };
|
|
||||||
const cacheKey = `txt:${domain}`;
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = this.getFromCache<string[][]>(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<string | null> {
|
|
||||||
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<IDnsVerificationResult> {
|
|
||||||
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<IDnsVerificationResult> {
|
|
||||||
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<IDnsVerificationResult> {
|
|
||||||
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<void> {
|
|
||||||
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<T>(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<T>(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<plugins.dns.MxRecord[]> {
|
|
||||||
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<string[][]> {
|
|
||||||
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<IDnsRecord[]> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,35 +11,6 @@ import { DKIMCreator } from '../security/classes.dkimcreator.js';
|
|||||||
import { IPReputationChecker } from '../../security/classes.ipreputationchecker.js';
|
import { IPReputationChecker } from '../../security/classes.ipreputationchecker.js';
|
||||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } 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 { EmailRouter } from './classes.email.router.js';
|
||||||
import type { IEmailRoute, IEmailAction, IEmailContext, IEmailDomainConfig } from './interfaces.js';
|
import type { IEmailRoute, IEmailAction, IEmailContext, IEmailDomainConfig } from './interfaces.js';
|
||||||
import { Email } from '../core/classes.email.js';
|
import { Email } from '../core/classes.email.js';
|
||||||
@@ -128,10 +99,6 @@ export interface IUnifiedEmailServerOptions {
|
|||||||
|
|
||||||
// Rate limiting (global limits, can be overridden per domain)
|
// Rate limiting (global limits, can be overridden per domain)
|
||||||
rateLimits?: IHierarchicalRateLimits;
|
rateLimits?: IHierarchicalRateLimits;
|
||||||
|
|
||||||
// Deliverability options
|
|
||||||
ipWarmupConfig?: IIPWarmupConfig;
|
|
||||||
reputationMonitorConfig?: IReputationMonitorConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -196,8 +163,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
private rustBridge: RustSecurityBridge;
|
private rustBridge: RustSecurityBridge;
|
||||||
private ipReputationChecker: IPReputationChecker;
|
private ipReputationChecker: IPReputationChecker;
|
||||||
private bounceManager: BounceManager;
|
private bounceManager: BounceManager;
|
||||||
private ipWarmupManager: IPWarmupManager | null;
|
|
||||||
private senderReputationMonitor: SenderReputationMonitor | null;
|
|
||||||
public deliveryQueue: UnifiedDeliveryQueue;
|
public deliveryQueue: UnifiedDeliveryQueue;
|
||||||
public deliverySystem: MultiModeDeliverySystem;
|
public deliverySystem: MultiModeDeliverySystem;
|
||||||
private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers
|
private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers
|
||||||
@@ -239,11 +204,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
storageManager: dcRouter.storageManager
|
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
|
// Initialize domain registry
|
||||||
this.domainRegistry = new DomainRegistry(options.domains, options.defaults);
|
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');
|
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
|
// Set up DKIM for all domains
|
||||||
await this.setupDkimForDomains();
|
await this.setupDkimForDomains();
|
||||||
logger.log('info', 'DKIM configuration completed for all domains');
|
logger.log('info', 'DKIM configuration completed for all domains');
|
||||||
@@ -414,13 +381,17 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
await this.handleRustEmailReceived(data);
|
await this.handleRustEmailReceived(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`);
|
logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`);
|
||||||
// Send rejection back to Rust
|
// Send rejection back to Rust (may fail if bridge is restarting)
|
||||||
|
try {
|
||||||
await this.rustBridge.sendEmailProcessingResult({
|
await this.rustBridge.sendEmailProcessingResult({
|
||||||
correlationId: data.correlationId,
|
correlationId: data.correlationId,
|
||||||
accepted: false,
|
accepted: false,
|
||||||
smtpCode: 451,
|
smtpCode: 451,
|
||||||
smtpMessage: 'Internal processing error',
|
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);
|
await this.handleRustAuthRequest(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log('error', `Error handling auth from Rust SMTP: ${(err as Error).message}`);
|
logger.log('error', `Error handling auth from Rust SMTP: ${(err as Error).message}`);
|
||||||
|
try {
|
||||||
await this.rustBridge.sendAuthResult({
|
await this.rustBridge.sendAuthResult({
|
||||||
correlationId: data.correlationId,
|
correlationId: data.correlationId,
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Internal auth error',
|
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
|
// Clear the servers array - servers will be garbage collected
|
||||||
this.servers = [];
|
this.servers = [];
|
||||||
|
|
||||||
// Stop Rust security bridge
|
// Remove bridge state change listener and stop bridge
|
||||||
|
this.rustBridge.removeAllListeners('stateChange');
|
||||||
await this.rustBridge.stop();
|
await this.rustBridge.stop();
|
||||||
|
|
||||||
// Stop the delivery system
|
// 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');
|
logger.log('info', 'Using pre-computed security results from Rust in-process pipeline');
|
||||||
result = precomputed;
|
result = precomputed;
|
||||||
} else {
|
} 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();
|
const rawMessage = session.emailData || email.toRFC822String();
|
||||||
result = await this.rustBridge.verifyEmail({
|
result = await this.rustBridge.verifyEmail({
|
||||||
rawMessage,
|
rawMessage,
|
||||||
@@ -967,171 +943,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle email in MTA mode (programmatic processing)
|
|
||||||
*/
|
|
||||||
private async _handleMtaMode(email: Email, session: IExtendedSmtpSession): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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
|
* 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
|
// Check if the sender domain has DKIM keys and sign the email if needed
|
||||||
if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) {
|
if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) {
|
||||||
const domain = email.from.split('@')[1];
|
const domain = email.from.split('@')[1];
|
||||||
@@ -1794,125 +1567,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the status of IP warmup process
|
* Record an email event for domain reputation tracking.
|
||||||
* @param ipAddress Optional specific IP to check
|
* Currently a no-op — the sender reputation monitor is not yet implemented.
|
||||||
* @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
|
|
||||||
* @param domain Domain sending the email
|
* @param domain Domain sending the email
|
||||||
* @param event Event details
|
* @param event Event details
|
||||||
*/
|
*/
|
||||||
@@ -1922,7 +1578,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
hardBounce?: boolean;
|
hardBounce?: boolean;
|
||||||
receivingDomain?: string;
|
receivingDomain?: string;
|
||||||
}): void {
|
}): void {
|
||||||
this.senderReputationMonitor.recordSendEvent(domain, event);
|
logger.log('debug', `Reputation event for ${domain}: ${event.type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as paths from '../paths.js';
|
import * as paths from '../paths.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// IPC command type map — mirrors the methods in mailer-bin's management mode
|
// 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
|
// RustSecurityBridge — singleton wrapper around smartrust.RustBridge
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -222,14 +252,26 @@ type TMailerCommands = {
|
|||||||
*
|
*
|
||||||
* Uses `@push.rocks/smartrust` for JSON-over-stdin/stdout IPC.
|
* Uses `@push.rocks/smartrust` for JSON-over-stdin/stdout IPC.
|
||||||
* Singleton — access via `RustSecurityBridge.getInstance()`.
|
* 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 instance: RustSecurityBridge | null = null;
|
||||||
|
private static _resilienceConfig: IBridgeResilienceConfig = { ...DEFAULT_RESILIENCE_CONFIG };
|
||||||
|
|
||||||
private bridge: InstanceType<typeof plugins.smartrust.RustBridge<TMailerCommands>>;
|
private bridge: InstanceType<typeof plugins.smartrust.RustBridge<TMailerCommands>>;
|
||||||
private _running = false;
|
private _running = false;
|
||||||
|
private _state: BridgeState = BridgeState.Idle;
|
||||||
|
private _restartAttempts = 0;
|
||||||
|
private _restartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private _healthCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private _deliberateStop = false;
|
||||||
|
private _smtpServerConfig: ISmtpServerConfig | null = null;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
|
super();
|
||||||
this.bridge = new plugins.smartrust.RustBridge<TMailerCommands>({
|
this.bridge = new plugins.smartrust.RustBridge<TMailerCommands>({
|
||||||
binaryName: 'mailer-bin',
|
binaryName: 'mailer-bin',
|
||||||
cliArgs: ['--management'],
|
cliArgs: ['--management'],
|
||||||
@@ -252,6 +294,13 @@ export class RustSecurityBridge {
|
|||||||
this.bridge.on('exit', (code: number | null, signal: string | null) => {
|
this.bridge.on('exit', (code: number | null, signal: string | null) => {
|
||||||
this._running = false;
|
this._running = false;
|
||||||
logger.log('warn', `Rust security bridge exited (code=${code}, signal=${signal})`);
|
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) => {
|
this.bridge.on('stderr', (line: string) => {
|
||||||
@@ -259,6 +308,10 @@ export class RustSecurityBridge {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Static configuration & singleton
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/** Get or create the singleton instance. */
|
/** Get or create the singleton instance. */
|
||||||
public static getInstance(): RustSecurityBridge {
|
public static getInstance(): RustSecurityBridge {
|
||||||
if (!RustSecurityBridge.instance) {
|
if (!RustSecurityBridge.instance) {
|
||||||
@@ -267,11 +320,73 @@ export class RustSecurityBridge {
|
|||||||
return RustSecurityBridge.instance;
|
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<IBridgeResilienceConfig>): 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. */
|
/** Whether the Rust process is currently running and accepting commands. */
|
||||||
public get running(): boolean {
|
public get running(): boolean {
|
||||||
return this._running;
|
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
|
// Lifecycle
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -281,55 +396,195 @@ export class RustSecurityBridge {
|
|||||||
* @returns `true` if the binary started successfully, `false` otherwise.
|
* @returns `true` if the binary started successfully, `false` otherwise.
|
||||||
*/
|
*/
|
||||||
public async start(): Promise<boolean> {
|
public async start(): Promise<boolean> {
|
||||||
if (this._running) {
|
if (this._running && this._state === BridgeState.Running) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._deliberateStop = false;
|
||||||
|
this._restartAttempts = 0;
|
||||||
|
this.setState(BridgeState.Starting);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ok = await this.bridge.spawn();
|
const ok = await this.bridge.spawn();
|
||||||
this._running = ok;
|
this._running = ok;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
this.setState(BridgeState.Running);
|
||||||
|
this.startHealthCheck();
|
||||||
logger.log('info', 'Rust security bridge started');
|
logger.log('info', 'Rust security bridge started');
|
||||||
} else {
|
} else {
|
||||||
|
this.setState(BridgeState.Failed);
|
||||||
logger.log('warn', 'Rust security bridge failed to start (binary not found or timeout)');
|
logger.log('warn', 'Rust security bridge failed to start (binary not found or timeout)');
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this.setState(BridgeState.Failed);
|
||||||
logger.log('error', `Failed to start Rust security bridge: ${(err as Error).message}`);
|
logger.log('error', `Failed to start Rust security bridge: ${(err as Error).message}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kill the Rust process. */
|
/** Kill the Rust process deliberately. */
|
||||||
public async stop(): Promise<void> {
|
public async stop(): Promise<void> {
|
||||||
|
this._deliberateStop = true;
|
||||||
|
|
||||||
|
// Cancel any pending restart
|
||||||
|
if (this._restartTimer) {
|
||||||
|
clearTimeout(this._restartTimer);
|
||||||
|
this._restartTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopHealthCheck();
|
||||||
|
this._smtpServerConfig = null;
|
||||||
|
|
||||||
if (!this._running) {
|
if (!this._running) {
|
||||||
|
this.setState(BridgeState.Stopped);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.bridge.kill();
|
this.bridge.kill();
|
||||||
this._running = false;
|
this._running = false;
|
||||||
|
this.setState(BridgeState.Stopped);
|
||||||
logger.log('info', 'Rust security bridge stopped');
|
logger.log('info', 'Rust security bridge stopped');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log('error', `Error stopping Rust security bridge: ${(err as Error).message}`);
|
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<void> {
|
||||||
|
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<never>((_, 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
|
// Commands — thin typed wrappers over sendCommand
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/** Ping the Rust process. */
|
/** Ping the Rust process. */
|
||||||
public async ping(): Promise<boolean> {
|
public async ping(): Promise<boolean> {
|
||||||
|
this.ensureRunning();
|
||||||
const res = await this.bridge.sendCommand('ping', {} as any);
|
const res = await this.bridge.sendCommand('ping', {} as any);
|
||||||
return res?.pong === true;
|
return res?.pong === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get version information for all Rust crates. */
|
/** Get version information for all Rust crates. */
|
||||||
public async getVersion(): Promise<IVersionInfo> {
|
public async getVersion(): Promise<IVersionInfo> {
|
||||||
|
this.ensureRunning();
|
||||||
return this.bridge.sendCommand('version', {} as any);
|
return this.bridge.sendCommand('version', {} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate an email address. */
|
/** Validate an email address. */
|
||||||
public async validateEmail(email: string): Promise<IValidationResult> {
|
public async validateEmail(email: string): Promise<IValidationResult> {
|
||||||
|
this.ensureRunning();
|
||||||
return this.bridge.sendCommand('validateEmail', { email });
|
return this.bridge.sendCommand('validateEmail', { email });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +594,7 @@ export class RustSecurityBridge {
|
|||||||
diagnosticCode?: string;
|
diagnosticCode?: string;
|
||||||
statusCode?: string;
|
statusCode?: string;
|
||||||
}): Promise<IBounceDetection> {
|
}): Promise<IBounceDetection> {
|
||||||
|
this.ensureRunning();
|
||||||
return this.bridge.sendCommand('detectBounce', opts);
|
return this.bridge.sendCommand('detectBounce', opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,16 +605,19 @@ export class RustSecurityBridge {
|
|||||||
htmlBody?: string;
|
htmlBody?: string;
|
||||||
attachmentNames?: string[];
|
attachmentNames?: string[];
|
||||||
}): Promise<IContentScanResult> {
|
}): Promise<IContentScanResult> {
|
||||||
|
this.ensureRunning();
|
||||||
return this.bridge.sendCommand('scanContent', opts);
|
return this.bridge.sendCommand('scanContent', opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check IP reputation via DNSBL. */
|
/** Check IP reputation via DNSBL. */
|
||||||
public async checkIpReputation(ip: string): Promise<IReputationResult> {
|
public async checkIpReputation(ip: string): Promise<IReputationResult> {
|
||||||
|
this.ensureRunning();
|
||||||
return this.bridge.sendCommand('checkIpReputation', { ip });
|
return this.bridge.sendCommand('checkIpReputation', { ip });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verify DKIM signatures on a raw email message. */
|
/** Verify DKIM signatures on a raw email message. */
|
||||||
public async verifyDkim(rawMessage: string): Promise<IDkimVerificationResult[]> {
|
public async verifyDkim(rawMessage: string): Promise<IDkimVerificationResult[]> {
|
||||||
|
this.ensureRunning();
|
||||||
return this.bridge.sendCommand('verifyDkim', { rawMessage });
|
return this.bridge.sendCommand('verifyDkim', { rawMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +628,7 @@ export class RustSecurityBridge {
|
|||||||
selector?: string;
|
selector?: string;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
}): Promise<{ header: string; signedMessage: string }> {
|
}): Promise<{ header: string; signedMessage: string }> {
|
||||||
|
this.ensureRunning();
|
||||||
return this.bridge.sendCommand('signDkim', opts);
|
return this.bridge.sendCommand('signDkim', opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +639,7 @@ export class RustSecurityBridge {
|
|||||||
hostname?: string;
|
hostname?: string;
|
||||||
mailFrom: string;
|
mailFrom: string;
|
||||||
}): Promise<ISpfResult> {
|
}): Promise<ISpfResult> {
|
||||||
|
this.ensureRunning();
|
||||||
return this.bridge.sendCommand('checkSpf', opts);
|
return this.bridge.sendCommand('checkSpf', opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +656,7 @@ export class RustSecurityBridge {
|
|||||||
hostname?: string;
|
hostname?: string;
|
||||||
mailFrom: string;
|
mailFrom: string;
|
||||||
}): Promise<IEmailSecurityResult> {
|
}): Promise<IEmailSecurityResult> {
|
||||||
|
this.ensureRunning();
|
||||||
return this.bridge.sendCommand('verifyEmail', opts);
|
return this.bridge.sendCommand('verifyEmail', opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,12 +670,16 @@ export class RustSecurityBridge {
|
|||||||
* emailReceived and authRequest that must be handled by the caller.
|
* emailReceived and authRequest that must be handled by the caller.
|
||||||
*/
|
*/
|
||||||
public async startSmtpServer(config: ISmtpServerConfig): Promise<boolean> {
|
public async startSmtpServer(config: ISmtpServerConfig): Promise<boolean> {
|
||||||
|
this.ensureRunning();
|
||||||
|
this._smtpServerConfig = config;
|
||||||
const result = await this.bridge.sendCommand('startSmtpServer', config);
|
const result = await this.bridge.sendCommand('startSmtpServer', config);
|
||||||
return result?.started === true;
|
return result?.started === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop the Rust SMTP server. */
|
/** Stop the Rust SMTP server. */
|
||||||
public async stopSmtpServer(): Promise<void> {
|
public async stopSmtpServer(): Promise<void> {
|
||||||
|
this.ensureRunning();
|
||||||
|
this._smtpServerConfig = null;
|
||||||
await this.bridge.sendCommand('stopSmtpServer', {} as any);
|
await this.bridge.sendCommand('stopSmtpServer', {} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +694,7 @@ export class RustSecurityBridge {
|
|||||||
smtpCode?: number;
|
smtpCode?: number;
|
||||||
smtpMessage?: string;
|
smtpMessage?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
this.ensureRunning();
|
||||||
await this.bridge.sendCommand('emailProcessingResult', opts);
|
await this.bridge.sendCommand('emailProcessingResult', opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,11 +706,13 @@ export class RustSecurityBridge {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
this.ensureRunning();
|
||||||
await this.bridge.sendCommand('authResult', opts);
|
await this.bridge.sendCommand('authResult', opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update rate limit configuration at runtime. */
|
/** Update rate limit configuration at runtime. */
|
||||||
public async configureRateLimits(config: IRateLimitConfig): Promise<void> {
|
public async configureRateLimits(config: IRateLimitConfig): Promise<void> {
|
||||||
|
this.ensureRunning();
|
||||||
await this.bridge.sendCommand('configureRateLimits', config);
|
await this.bridge.sendCommand('configureRateLimits', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
RustSecurityBridge,
|
RustSecurityBridge,
|
||||||
|
BridgeState,
|
||||||
|
type IBridgeResilienceConfig,
|
||||||
type IDkimVerificationResult,
|
type IDkimVerificationResult,
|
||||||
type ISpfResult,
|
type ISpfResult,
|
||||||
type IDmarcResult,
|
type IDmarcResult,
|
||||||
|
|||||||
Reference in New Issue
Block a user