Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7853ef67b6 | |||
| f7af8c4534 | |||
| a7ea1d86cb | |||
| 27bab5f345 | |||
| fc4877e06b | |||
| 36006191fc |
27
changelog.md
27
changelog.md
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-11 - 4.1.0 - feat(e2e-tests)
|
||||||
|
add Node.js end-to-end tests covering server lifecycle, inbound SMTP handling, outbound delivery and routing actions
|
||||||
|
|
||||||
|
- Adds four end-to-end test files: test.e2e.server-lifecycle.node.ts, test.e2e.inbound-smtp.node.ts, test.e2e.outbound-delivery.node.ts, test.e2e.routing-actions.node.ts
|
||||||
|
- Tests exercise UnifiedEmailServer start/stop, SMTP handshake and transactions, outbound delivery via a mock SMTP server, routing actions (process, deliver, reject, forward), concurrency, and RSET handling mid-session
|
||||||
|
- Introduces a minimal mock SMTP server to avoid IPC deadlock with the Rust SMTP client during outbound delivery tests
|
||||||
|
- Tests will skip when the Rust bridge or server cannot start (binary build required)
|
||||||
|
|
||||||
|
## 2026-02-11 - 4.0.0 - BREAKING CHANGE(smtp-client)
|
||||||
|
Replace the legacy TypeScript SMTP client with a new Rust-based SMTP client and IPC bridge for outbound delivery
|
||||||
|
|
||||||
|
- Introduce a Rust SMTP client crate with connection handling, TLS, protocol engine, and connection pooling (new modules: connection, pool, protocol, error, config).
|
||||||
|
- Add IPC handlers and management commands in the Rust binary: sendEmail, sendRawEmail, verifySmtpConnection, closeSmtpPool, getSmtpPoolStatus and integrate a SmtpClientManager into the runtime.
|
||||||
|
- Update TypeScript bridge (RustSecurityBridge) with new types and methods (ISmtpSendOptions, ISmtpSendResult, verifySmtpConnection, sendOutboundEmail, sendRawEmail, getSmtpPoolStatus, closeSmtpPool) and rework UnifiedEmailServer to use the Rust bridge for outbound delivery and DKIM signing.
|
||||||
|
- Remove the previous TypeScript SMTP client implementation and associated tests/utilities (many ts/mail/delivery/smtpclient modules and tests deleted) in favor of the Rust implementation.
|
||||||
|
- Bump dependencies and cargo config: @push.rocks/smartrust to ^1.2.0 in package.json and add/require crates (uuid, base64, webpki-roots) in Rust Cargo files.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
|||||||
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartmta",
|
"name": "@push.rocks/smartmta",
|
||||||
"version": "2.4.0",
|
"version": "4.1.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",
|
||||||
@@ -44,36 +44,14 @@
|
|||||||
"tsx": "^4.21.0"
|
"tsx": "^4.21.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.2.5",
|
|
||||||
"@api.global/typedserver": "^8.3.0",
|
|
||||||
"@api.global/typedsocket": "^4.1.0",
|
|
||||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
|
||||||
"@push.rocks/projectinfo": "^5.0.1",
|
|
||||||
"@push.rocks/qenv": "^6.1.0",
|
|
||||||
"@push.rocks/smartacme": "^8.0.0",
|
|
||||||
"@push.rocks/smartdata": "^7.0.15",
|
|
||||||
"@push.rocks/smartdns": "^7.5.0",
|
|
||||||
"@push.rocks/smartfile": "^13.1.2",
|
"@push.rocks/smartfile": "^13.1.2",
|
||||||
"@push.rocks/smartfs": "^1.3.1",
|
"@push.rocks/smartfs": "^1.3.1",
|
||||||
"@push.rocks/smartguard": "^3.1.0",
|
|
||||||
"@push.rocks/smartjwt": "^2.2.1",
|
|
||||||
"@push.rocks/smartlog": "^3.1.8",
|
"@push.rocks/smartlog": "^3.1.8",
|
||||||
"@push.rocks/smartmail": "^2.2.0",
|
"@push.rocks/smartmail": "^2.2.0",
|
||||||
"@push.rocks/smartmetrics": "^2.0.10",
|
|
||||||
"@push.rocks/smartnetwork": "^4.0.2",
|
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.0.3",
|
"@push.rocks/smartrust": "^1.2.0",
|
||||||
"@push.rocks/smartproxy": "^23.1.0",
|
|
||||||
"@push.rocks/smartrequest": "^5.0.1",
|
|
||||||
"@push.rocks/smartrule": "^2.0.1",
|
|
||||||
"@push.rocks/smartrust": "^1.1.1",
|
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
|
||||||
"@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"
|
||||||
},
|
},
|
||||||
|
|||||||
1887
pnpm-lock.yaml
generated
1887
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
143
rust/Cargo.lock
generated
143
rust/Cargo.lock
generated
@@ -274,15 +274,6 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "convert_case"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
|
||||||
dependencies = [
|
|
||||||
"unicode-segmentation",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -356,16 +347,6 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ctor"
|
|
||||||
version = "0.2.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
|
|
||||||
dependencies = [
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dashmap"
|
name = "dashmap"
|
||||||
version = "6.1.0"
|
version = "6.1.0"
|
||||||
@@ -913,16 +894,6 @@ version = "0.2.181"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
|
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libloading"
|
|
||||||
version = "0.8.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"windows-link",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -1004,6 +975,7 @@ dependencies = [
|
|||||||
name = "mailer-bin"
|
name = "mailer-bin"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"clap",
|
"clap",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"hickory-resolver 0.25.2",
|
"hickory-resolver 0.25.2",
|
||||||
@@ -1014,6 +986,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1021,48 +994,28 @@ name = "mailer-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
|
||||||
"mailparse",
|
"mailparse",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tracing",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mailer-napi"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"mailer-core",
|
|
||||||
"mailer-security",
|
|
||||||
"mailer-smtp",
|
|
||||||
"napi",
|
|
||||||
"napi-build",
|
|
||||||
"napi-derive",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mailer-security"
|
name = "mailer-security"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hickory-resolver 0.25.2",
|
"hickory-resolver 0.25.2",
|
||||||
"ipnet",
|
|
||||||
"mail-auth",
|
"mail-auth",
|
||||||
"mailer-core",
|
"mailer-core",
|
||||||
"psl",
|
"psl",
|
||||||
"regex",
|
"regex",
|
||||||
"ring",
|
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1070,7 +1023,6 @@ name = "mailer-smtp"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"hickory-resolver 0.25.2",
|
"hickory-resolver 0.25.2",
|
||||||
"mailer-core",
|
"mailer-core",
|
||||||
@@ -1087,6 +1039,7 @@ dependencies = [
|
|||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1144,66 +1097,6 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "napi"
|
|
||||||
version = "2.16.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"ctor",
|
|
||||||
"napi-derive",
|
|
||||||
"napi-sys",
|
|
||||||
"once_cell",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "napi-build"
|
|
||||||
version = "2.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "napi-derive"
|
|
||||||
version = "2.16.13"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"convert_case",
|
|
||||||
"napi-derive-backend",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "napi-derive-backend"
|
|
||||||
version = "1.0.75"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
|
|
||||||
dependencies = [
|
|
||||||
"convert_case",
|
|
||||||
"once_cell",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"regex",
|
|
||||||
"semver",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "napi-sys"
|
|
||||||
version = "2.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
|
|
||||||
dependencies = [
|
|
||||||
"libloading",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1543,12 +1436,6 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "semver"
|
|
||||||
version = "1.0.27"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
@@ -1859,12 +1746,6 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
|
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-segmentation"
|
|
||||||
version = "1.12.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -1972,6 +1853,24 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "0.26.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||||
|
dependencies = [
|
||||||
|
"webpki-roots 1.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "widestring"
|
name = "widestring"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ members = [
|
|||||||
"crates/mailer-core",
|
"crates/mailer-core",
|
||||||
"crates/mailer-smtp",
|
"crates/mailer-smtp",
|
||||||
"crates/mailer-security",
|
"crates/mailer-security",
|
||||||
"crates/mailer-napi",
|
|
||||||
"crates/mailer-bin",
|
"crates/mailer-bin",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -19,19 +18,14 @@ tokio-rustls = "0.26"
|
|||||||
hickory-resolver = "0.25"
|
hickory-resolver = "0.25"
|
||||||
mail-auth = "0.7"
|
mail-auth = "0.7"
|
||||||
mailparse = "0.16"
|
mailparse = "0.16"
|
||||||
napi = { version = "2", features = ["napi9", "async", "serde-json"] }
|
|
||||||
napi-derive = "2"
|
|
||||||
ring = "0.17"
|
|
||||||
dashmap = "6"
|
dashmap = "6"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
bytes = "1"
|
|
||||||
regex = "1"
|
regex = "1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
ipnet = "2"
|
|
||||||
rustls-pki-types = "1"
|
rustls-pki-types = "1"
|
||||||
psl = "2"
|
psl = "2"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|||||||
@@ -19,3 +19,5 @@ serde_json.workspace = true
|
|||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
hickory-resolver.workspace = true
|
hickory-resolver.workspace = true
|
||||||
dashmap.workspace = true
|
dashmap.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ struct ManagementState {
|
|||||||
callbacks: Arc<PendingCallbacks>,
|
callbacks: Arc<PendingCallbacks>,
|
||||||
smtp_handle: Option<mailer_smtp::server::SmtpServerHandle>,
|
smtp_handle: Option<mailer_smtp::server::SmtpServerHandle>,
|
||||||
smtp_event_rx: Option<tokio::sync::mpsc::Receiver<ConnectionEvent>>,
|
smtp_event_rx: Option<tokio::sync::mpsc::Receiver<ConnectionEvent>>,
|
||||||
|
smtp_client_manager: Arc<mailer_smtp::client::SmtpClientManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run in management/IPC mode for smartrust bridge.
|
/// Run in management/IPC mode for smartrust bridge.
|
||||||
@@ -349,10 +350,12 @@ fn run_management_mode() {
|
|||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
|
||||||
let callbacks = Arc::new(PendingCallbacks::new());
|
let callbacks = Arc::new(PendingCallbacks::new());
|
||||||
|
let smtp_client_manager = Arc::new(mailer_smtp::client::SmtpClientManager::new());
|
||||||
let mut state = ManagementState {
|
let mut state = ManagementState {
|
||||||
callbacks: callbacks.clone(),
|
callbacks: callbacks.clone(),
|
||||||
smtp_handle: None,
|
smtp_handle: None,
|
||||||
smtp_event_rx: None,
|
smtp_event_rx: None,
|
||||||
|
smtp_client_manager: smtp_client_manager.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// We need to read stdin in a separate thread (blocking I/O)
|
// We need to read stdin in a separate thread (blocking I/O)
|
||||||
@@ -833,6 +836,28 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SMTP Client commands ---
|
||||||
|
|
||||||
|
"sendEmail" => {
|
||||||
|
handle_send_email(req, state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
"sendRawEmail" => {
|
||||||
|
handle_send_raw_email(req, state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
"verifySmtpConnection" => {
|
||||||
|
handle_verify_smtp_connection(req, state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
"closeSmtpPool" => {
|
||||||
|
handle_close_smtp_pool(req, state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
"getSmtpPoolStatus" => {
|
||||||
|
handle_get_smtp_pool_status(req, state)
|
||||||
|
}
|
||||||
|
|
||||||
_ => IpcResponse {
|
_ => IpcResponse {
|
||||||
id: req.id.clone(),
|
id: req.id.clone(),
|
||||||
success: false,
|
success: false,
|
||||||
@@ -1052,3 +1077,297 @@ fn parse_smtp_config(
|
|||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SMTP Client IPC handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Structured email to build a MIME message from.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct OutboundEmail {
|
||||||
|
from: String,
|
||||||
|
to: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
cc: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
bcc: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
subject: String,
|
||||||
|
#[serde(default)]
|
||||||
|
text: String,
|
||||||
|
#[serde(default)]
|
||||||
|
html: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
headers: std::collections::HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutboundEmail {
|
||||||
|
/// Convert to `mailer_core::Email` for proper RFC 5322 MIME building.
|
||||||
|
fn to_core_email(&self) -> mailer_core::Email {
|
||||||
|
let mut email = mailer_core::Email::new(&self.from, &self.subject, &self.text);
|
||||||
|
for addr in &self.to {
|
||||||
|
email.add_to(addr);
|
||||||
|
}
|
||||||
|
for addr in &self.cc {
|
||||||
|
email.add_cc(addr);
|
||||||
|
}
|
||||||
|
for addr in &self.bcc {
|
||||||
|
email.add_bcc(addr);
|
||||||
|
}
|
||||||
|
if let Some(html) = &self.html {
|
||||||
|
email.set_html(html);
|
||||||
|
}
|
||||||
|
for (key, value) in &self.headers {
|
||||||
|
email.add_header(key, value);
|
||||||
|
}
|
||||||
|
email
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an RFC 5322 compliant message using `mailer_core::build_rfc822`.
|
||||||
|
fn to_rfc822(&self) -> Vec<u8> {
|
||||||
|
let email = self.to_core_email();
|
||||||
|
match mailer_core::build_rfc822(&email) {
|
||||||
|
Ok(msg) => msg.into_bytes(),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to build RFC 822 message: {e}");
|
||||||
|
// Fallback: minimal message
|
||||||
|
format!(
|
||||||
|
"From: {}\r\nTo: {}\r\nSubject: {}\r\n\r\n{}",
|
||||||
|
self.from,
|
||||||
|
self.to.join(", "),
|
||||||
|
self.subject,
|
||||||
|
self.text
|
||||||
|
)
|
||||||
|
.into_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect all recipients (to + cc + bcc).
|
||||||
|
fn all_recipients(&self) -> Vec<String> {
|
||||||
|
let mut all = self.to.clone();
|
||||||
|
all.extend(self.cc.clone());
|
||||||
|
all.extend(self.bcc.clone());
|
||||||
|
all
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle sendEmail IPC command — build MIME, optional DKIM sign, send via pool.
|
||||||
|
async fn handle_send_email(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||||
|
// Parse client config from params
|
||||||
|
let config: mailer_smtp::client::SmtpClientConfig = match serde_json::from_value(req.params.clone()) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(format!("Invalid config: {}", e)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse the email
|
||||||
|
let email: OutboundEmail = match req.params.get("email").and_then(|v| serde_json::from_value(v.clone()).ok()) {
|
||||||
|
Some(e) => e,
|
||||||
|
None => {
|
||||||
|
return IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some("Missing or invalid 'email' field".into()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build raw message
|
||||||
|
let mut raw_message = email.to_rfc822();
|
||||||
|
|
||||||
|
// Optional DKIM signing
|
||||||
|
if let Some(dkim_val) = req.params.get("dkim") {
|
||||||
|
if let Ok(dkim_config) = serde_json::from_value::<mailer_smtp::client::DkimSignConfig>(dkim_val.clone()) {
|
||||||
|
match mailer_security::sign_dkim(
|
||||||
|
&raw_message,
|
||||||
|
&dkim_config.domain,
|
||||||
|
&dkim_config.selector,
|
||||||
|
&dkim_config.private_key,
|
||||||
|
) {
|
||||||
|
Ok(header) => {
|
||||||
|
// Prepend DKIM header to the message
|
||||||
|
let mut signed = header.into_bytes();
|
||||||
|
signed.extend_from_slice(&raw_message);
|
||||||
|
raw_message = signed;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Log but don't fail — send unsigned
|
||||||
|
eprintln!("DKIM signing failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let all_recipients = email.all_recipients();
|
||||||
|
let sender = &email.from;
|
||||||
|
|
||||||
|
match state
|
||||||
|
.smtp_client_manager
|
||||||
|
.send_message(&config, sender, &all_recipients, &raw_message)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: true,
|
||||||
|
result: Some(serde_json::to_value(&result).unwrap()),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(e) => IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(serde_json::to_string(&serde_json::json!({
|
||||||
|
"message": e.to_string(),
|
||||||
|
"errorType": e.error_type(),
|
||||||
|
"retryable": e.is_retryable(),
|
||||||
|
"smtpCode": e.smtp_code(),
|
||||||
|
}))
|
||||||
|
.unwrap()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle sendRawEmail IPC command — send a pre-formatted message.
|
||||||
|
async fn handle_send_raw_email(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||||
|
// Parse client config from params
|
||||||
|
let config: mailer_smtp::client::SmtpClientConfig = match serde_json::from_value(req.params.clone()) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(format!("Invalid config: {}", e)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let envelope_from = req
|
||||||
|
.params
|
||||||
|
.get("envelopeFrom")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let envelope_to: Vec<String> = req
|
||||||
|
.params
|
||||||
|
.get("envelopeTo")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let raw_b64 = req
|
||||||
|
.params
|
||||||
|
.get("rawMessageBase64")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// Decode base64 message
|
||||||
|
use base64::Engine;
|
||||||
|
let raw_message = match base64::engine::general_purpose::STANDARD.decode(raw_b64) {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(e) => {
|
||||||
|
return IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(format!("Invalid base64 message: {}", e)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match state
|
||||||
|
.smtp_client_manager
|
||||||
|
.send_message(&config, envelope_from, &envelope_to, &raw_message)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: true,
|
||||||
|
result: Some(serde_json::to_value(&result).unwrap()),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(e) => IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(serde_json::to_string(&serde_json::json!({
|
||||||
|
"message": e.to_string(),
|
||||||
|
"errorType": e.error_type(),
|
||||||
|
"retryable": e.is_retryable(),
|
||||||
|
"smtpCode": e.smtp_code(),
|
||||||
|
}))
|
||||||
|
.unwrap()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle verifySmtpConnection IPC command.
|
||||||
|
async fn handle_verify_smtp_connection(
|
||||||
|
req: &IpcRequest,
|
||||||
|
state: &ManagementState,
|
||||||
|
) -> IpcResponse {
|
||||||
|
let config: mailer_smtp::client::SmtpClientConfig = match serde_json::from_value(req.params.clone()) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(format!("Invalid config: {}", e)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match state.smtp_client_manager.verify_connection(&config).await {
|
||||||
|
Ok(result) => IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: true,
|
||||||
|
result: Some(serde_json::to_value(&result).unwrap()),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(e) => IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: false,
|
||||||
|
result: None,
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle closeSmtpPool IPC command.
|
||||||
|
async fn handle_close_smtp_pool(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||||
|
if let Some(pool_key) = req.params.get("poolKey").and_then(|v| v.as_str()) {
|
||||||
|
state.smtp_client_manager.close_pool(pool_key).await;
|
||||||
|
} else {
|
||||||
|
state.smtp_client_manager.close_all_pools().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: true,
|
||||||
|
result: Some(serde_json::json!({"closed": true})),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle getSmtpPoolStatus IPC command.
|
||||||
|
fn handle_get_smtp_pool_status(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
|
||||||
|
let pools = state.smtp_client_manager.pool_status();
|
||||||
|
IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: true,
|
||||||
|
result: Some(serde_json::json!({"pools": pools})),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ license.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
|
||||||
bytes.workspace = true
|
|
||||||
mailparse.workspace = true
|
mailparse.workspace = true
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "mailer-napi"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
mailer-core = { path = "../mailer-core" }
|
|
||||||
mailer-smtp = { path = "../mailer-smtp" }
|
|
||||||
mailer-security = { path = "../mailer-security" }
|
|
||||||
napi.workspace = true
|
|
||||||
napi-derive.workspace = true
|
|
||||||
tokio.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
napi-build = "2"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
extern crate napi_build;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
napi_build::setup();
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
//! mailer-napi: N-API bindings exposing Rust mailer to Node.js/TypeScript.
|
|
||||||
|
|
||||||
use napi_derive::napi;
|
|
||||||
|
|
||||||
/// Returns the version of the native mailer module.
|
|
||||||
#[napi]
|
|
||||||
pub fn get_version() -> String {
|
|
||||||
format!(
|
|
||||||
"mailer-napi v{} (core: {}, smtp: {}, security: {})",
|
|
||||||
env!("CARGO_PKG_VERSION"),
|
|
||||||
mailer_core::version(),
|
|
||||||
mailer_smtp::version(),
|
|
||||||
mailer_security::version(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -7,14 +7,11 @@ license.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
mailer-core = { path = "../mailer-core" }
|
mailer-core = { path = "../mailer-core" }
|
||||||
mail-auth.workspace = true
|
mail-auth.workspace = true
|
||||||
ring.workspace = true
|
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
hickory-resolver.workspace = true
|
hickory-resolver.workspace = true
|
||||||
ipnet.workspace = true
|
|
||||||
rustls-pki-types.workspace = true
|
rustls-pki-types.workspace = true
|
||||||
psl.workspace = true
|
psl.workspace = true
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
|
|||||||
@@ -111,16 +111,18 @@ static MACRO_DOCUMENT_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(||
|
|||||||
// HTML helpers
|
// HTML helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Regexes for HTML text extraction (compiled once via LazyLock).
|
||||||
|
static HTML_STYLE_RE: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap());
|
||||||
|
static HTML_SCRIPT_RE: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap());
|
||||||
|
static HTML_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]+>").unwrap());
|
||||||
|
|
||||||
/// Strip HTML tags and decode common entities to produce plain text.
|
/// Strip HTML tags and decode common entities to produce plain text.
|
||||||
fn extract_text_from_html(html: &str) -> String {
|
fn extract_text_from_html(html: &str) -> String {
|
||||||
// Remove style and script blocks first
|
let text = HTML_STYLE_RE.replace_all(html, " ");
|
||||||
let no_style = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
|
let text = HTML_SCRIPT_RE.replace_all(&text, " ");
|
||||||
let no_script = Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap();
|
let text = HTML_TAG_RE.replace_all(&text, " ");
|
||||||
let no_tags = Regex::new(r"<[^>]+>").unwrap();
|
|
||||||
|
|
||||||
let text = no_style.replace_all(html, " ");
|
|
||||||
let text = no_script.replace_all(&text, " ");
|
|
||||||
let text = no_tags.replace_all(&text, " ");
|
|
||||||
|
|
||||||
text.replace(" ", " ")
|
text.replace(" ", " ")
|
||||||
.replace("<", "<")
|
.replace("<", "<")
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ hickory-resolver.workspace = true
|
|||||||
dashmap.workspace = true
|
dashmap.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
bytes.workspace = true
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json = "1"
|
serde_json.workspace = true
|
||||||
regex = "1"
|
regex.workspace = true
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
rustls-pki-types.workspace = true
|
rustls-pki-types.workspace = true
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
rustls-pemfile = "2"
|
rustls-pemfile = "2"
|
||||||
mailparse.workspace = true
|
mailparse.workspace = true
|
||||||
|
webpki-roots = "0.26"
|
||||||
|
|||||||
157
rust/crates/mailer-smtp/src/client/config.rs
Normal file
157
rust/crates/mailer-smtp/src/client/config.rs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
//! SMTP client configuration types.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Configuration for connecting to an SMTP server.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SmtpClientConfig {
|
||||||
|
/// Target SMTP server hostname.
|
||||||
|
pub host: String,
|
||||||
|
|
||||||
|
/// Target port (25 = SMTP, 465 = implicit TLS, 587 = submission).
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
/// Use implicit TLS (port 465). If false, STARTTLS is attempted.
|
||||||
|
#[serde(default)]
|
||||||
|
pub secure: bool,
|
||||||
|
|
||||||
|
/// Domain to use in EHLO command. Defaults to "localhost".
|
||||||
|
#[serde(default = "default_domain")]
|
||||||
|
pub domain: String,
|
||||||
|
|
||||||
|
/// Authentication credentials (optional).
|
||||||
|
pub auth: Option<SmtpAuthConfig>,
|
||||||
|
|
||||||
|
/// Connection timeout in seconds. Default: 30.
|
||||||
|
#[serde(default = "default_connection_timeout")]
|
||||||
|
pub connection_timeout_secs: u64,
|
||||||
|
|
||||||
|
/// Socket read/write timeout in seconds. Default: 120.
|
||||||
|
#[serde(default = "default_socket_timeout")]
|
||||||
|
pub socket_timeout_secs: u64,
|
||||||
|
|
||||||
|
/// Pool key override. Defaults to "host:port".
|
||||||
|
pub pool_key: Option<String>,
|
||||||
|
|
||||||
|
/// Maximum connections per pool. Default: 10.
|
||||||
|
#[serde(default = "default_max_pool_connections")]
|
||||||
|
pub max_pool_connections: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication configuration.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SmtpAuthConfig {
|
||||||
|
/// Username.
|
||||||
|
pub user: String,
|
||||||
|
/// Password.
|
||||||
|
pub pass: String,
|
||||||
|
/// Method: "PLAIN" or "LOGIN". Default: "PLAIN".
|
||||||
|
#[serde(default = "default_auth_method")]
|
||||||
|
pub method: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DKIM signing configuration (applied before sending).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DkimSignConfig {
|
||||||
|
/// Signing domain (e.g. "example.com").
|
||||||
|
pub domain: String,
|
||||||
|
/// DKIM selector (e.g. "default" or "mta").
|
||||||
|
pub selector: String,
|
||||||
|
/// PEM-encoded RSA private key.
|
||||||
|
pub private_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpClientConfig {
|
||||||
|
/// Get the effective pool key for this config.
|
||||||
|
pub fn effective_pool_key(&self) -> String {
|
||||||
|
self.pool_key
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{}:{}", self.host, self.port))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_domain() -> String {
|
||||||
|
"localhost".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_connection_timeout() -> u64 {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_socket_timeout() -> u64 {
|
||||||
|
120
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_pool_connections() -> usize {
|
||||||
|
10
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_auth_method() -> String {
|
||||||
|
"PLAIN".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_minimal_config() {
|
||||||
|
let json = r#"{"host":"mail.example.com","port":25}"#;
|
||||||
|
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.host, "mail.example.com");
|
||||||
|
assert_eq!(config.port, 25);
|
||||||
|
assert!(!config.secure);
|
||||||
|
assert_eq!(config.domain, "localhost");
|
||||||
|
assert!(config.auth.is_none());
|
||||||
|
assert_eq!(config.connection_timeout_secs, 30);
|
||||||
|
assert_eq!(config.socket_timeout_secs, 120);
|
||||||
|
assert_eq!(config.max_pool_connections, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_full_config() {
|
||||||
|
let json = r#"{
|
||||||
|
"host": "smtp.gmail.com",
|
||||||
|
"port": 465,
|
||||||
|
"secure": true,
|
||||||
|
"domain": "myserver.com",
|
||||||
|
"auth": { "user": "u", "pass": "p", "method": "LOGIN" },
|
||||||
|
"connectionTimeoutSecs": 60,
|
||||||
|
"socketTimeoutSecs": 300,
|
||||||
|
"poolKey": "gmail",
|
||||||
|
"maxPoolConnections": 5
|
||||||
|
}"#;
|
||||||
|
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.host, "smtp.gmail.com");
|
||||||
|
assert_eq!(config.port, 465);
|
||||||
|
assert!(config.secure);
|
||||||
|
assert_eq!(config.domain, "myserver.com");
|
||||||
|
assert_eq!(config.connection_timeout_secs, 60);
|
||||||
|
assert_eq!(config.socket_timeout_secs, 300);
|
||||||
|
assert_eq!(config.effective_pool_key(), "gmail");
|
||||||
|
assert_eq!(config.max_pool_connections, 5);
|
||||||
|
let auth = config.auth.unwrap();
|
||||||
|
assert_eq!(auth.user, "u");
|
||||||
|
assert_eq!(auth.pass, "p");
|
||||||
|
assert_eq!(auth.method, "LOGIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effective_pool_key_default() {
|
||||||
|
let json = r#"{"host":"mx.example.com","port":587}"#;
|
||||||
|
let config: SmtpClientConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(config.effective_pool_key(), "mx.example.com:587");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dkim_config_deserialize() {
|
||||||
|
let json = r#"{"domain":"example.com","selector":"mta","privateKey":"-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----"}"#;
|
||||||
|
let dkim: DkimSignConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(dkim.domain, "example.com");
|
||||||
|
assert_eq!(dkim.selector, "mta");
|
||||||
|
assert!(dkim.private_key.contains("RSA PRIVATE KEY"));
|
||||||
|
}
|
||||||
|
}
|
||||||
206
rust/crates/mailer-smtp/src/client/connection.rs
Normal file
206
rust/crates/mailer-smtp/src/client/connection.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
//! TCP/TLS connection management for the SMTP client.
|
||||||
|
|
||||||
|
use super::error::SmtpClientError;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::time::{timeout, Duration};
|
||||||
|
use tokio_rustls::client::TlsStream;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// A client-side SMTP stream that may be plain or TLS.
|
||||||
|
pub enum ClientSmtpStream {
|
||||||
|
Plain(BufReader<TcpStream>),
|
||||||
|
Tls(BufReader<TlsStream<TcpStream>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ClientSmtpStream {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ClientSmtpStream::Plain(_) => write!(f, "ClientSmtpStream::Plain"),
|
||||||
|
ClientSmtpStream::Tls(_) => write!(f, "ClientSmtpStream::Tls"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientSmtpStream {
|
||||||
|
/// Read a line from the stream (CRLF-terminated).
|
||||||
|
pub async fn read_line(&mut self, buf: &mut String) -> Result<usize, SmtpClientError> {
|
||||||
|
match self {
|
||||||
|
ClientSmtpStream::Plain(reader) => reader.read_line(buf).await.map_err(|e| {
|
||||||
|
SmtpClientError::ConnectionError {
|
||||||
|
message: format!("Read error: {e}"),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
ClientSmtpStream::Tls(reader) => reader.read_line(buf).await.map_err(|e| {
|
||||||
|
SmtpClientError::ConnectionError {
|
||||||
|
message: format!("TLS read error: {e}"),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write bytes to the stream.
|
||||||
|
pub async fn write_all(&mut self, data: &[u8]) -> Result<(), SmtpClientError> {
|
||||||
|
match self {
|
||||||
|
ClientSmtpStream::Plain(reader) => {
|
||||||
|
reader.get_mut().write_all(data).await.map_err(|e| {
|
||||||
|
SmtpClientError::ConnectionError {
|
||||||
|
message: format!("Write error: {e}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ClientSmtpStream::Tls(reader) => {
|
||||||
|
reader.get_mut().write_all(data).await.map_err(|e| {
|
||||||
|
SmtpClientError::ConnectionError {
|
||||||
|
message: format!("TLS write error: {e}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush the stream.
|
||||||
|
pub async fn flush(&mut self) -> Result<(), SmtpClientError> {
|
||||||
|
match self {
|
||||||
|
ClientSmtpStream::Plain(reader) => {
|
||||||
|
reader.get_mut().flush().await.map_err(|e| {
|
||||||
|
SmtpClientError::ConnectionError {
|
||||||
|
message: format!("Flush error: {e}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ClientSmtpStream::Tls(reader) => {
|
||||||
|
reader.get_mut().flush().await.map_err(|e| {
|
||||||
|
SmtpClientError::ConnectionError {
|
||||||
|
message: format!("TLS flush error: {e}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume this stream and return the inner TcpStream (for STARTTLS upgrade).
|
||||||
|
/// Only works on Plain streams; returns an error on TLS streams.
|
||||||
|
pub fn into_tcp_stream(self) -> Result<TcpStream, SmtpClientError> {
|
||||||
|
match self {
|
||||||
|
ClientSmtpStream::Plain(reader) => Ok(reader.into_inner()),
|
||||||
|
ClientSmtpStream::Tls(_) => Err(SmtpClientError::TlsError {
|
||||||
|
message: "Cannot extract TcpStream from an already-TLS stream".into(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to an SMTP server via plain TCP.
|
||||||
|
pub async fn connect_plain(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<ClientSmtpStream, SmtpClientError> {
|
||||||
|
debug!("Connecting to {}:{} (plain)", host, port);
|
||||||
|
let addr = format!("{host}:{port}");
|
||||||
|
let stream = timeout(Duration::from_secs(timeout_secs), TcpStream::connect(&addr))
|
||||||
|
.await
|
||||||
|
.map_err(|_| SmtpClientError::TimeoutError {
|
||||||
|
message: format!("Connection to {addr} timed out after {timeout_secs}s"),
|
||||||
|
})?
|
||||||
|
.map_err(|e| SmtpClientError::ConnectionError {
|
||||||
|
message: format!("Failed to connect to {addr}: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(ClientSmtpStream::Plain(BufReader::new(stream)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to an SMTP server via implicit TLS (port 465).
|
||||||
|
pub async fn connect_tls(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<ClientSmtpStream, SmtpClientError> {
|
||||||
|
debug!("Connecting to {}:{} (implicit TLS)", host, port);
|
||||||
|
let addr = format!("{host}:{port}");
|
||||||
|
|
||||||
|
let tcp_stream = timeout(Duration::from_secs(timeout_secs), TcpStream::connect(&addr))
|
||||||
|
.await
|
||||||
|
.map_err(|_| SmtpClientError::TimeoutError {
|
||||||
|
message: format!("Connection to {addr} timed out after {timeout_secs}s"),
|
||||||
|
})?
|
||||||
|
.map_err(|e| SmtpClientError::ConnectionError {
|
||||||
|
message: format!("Failed to connect to {addr}: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let tls_stream = perform_tls_handshake(tcp_stream, host).await?;
|
||||||
|
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upgrade a plain TCP connection to TLS (STARTTLS).
|
||||||
|
pub async fn upgrade_to_tls(
|
||||||
|
stream: ClientSmtpStream,
|
||||||
|
hostname: &str,
|
||||||
|
) -> Result<ClientSmtpStream, SmtpClientError> {
|
||||||
|
debug!("Upgrading connection to TLS (STARTTLS) for {}", hostname);
|
||||||
|
let tcp_stream = stream.into_tcp_stream()?;
|
||||||
|
let tls_stream = perform_tls_handshake(tcp_stream, hostname).await?;
|
||||||
|
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform the TLS handshake on a TCP stream using webpki-roots.
|
||||||
|
async fn perform_tls_handshake(
|
||||||
|
tcp_stream: TcpStream,
|
||||||
|
hostname: &str,
|
||||||
|
) -> Result<TlsStream<TcpStream>, SmtpClientError> {
|
||||||
|
let mut root_store = rustls::RootCertStore::empty();
|
||||||
|
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||||
|
|
||||||
|
let tls_config = rustls::ClientConfig::builder()
|
||||||
|
.with_root_certificates(root_store)
|
||||||
|
.with_no_client_auth();
|
||||||
|
|
||||||
|
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
|
||||||
|
let server_name = rustls_pki_types::ServerName::try_from(hostname.to_string()).map_err(|e| {
|
||||||
|
SmtpClientError::TlsError {
|
||||||
|
message: format!("Invalid server name '{hostname}': {e}"),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let tls_stream = connector
|
||||||
|
.connect(server_name, tcp_stream)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SmtpClientError::TlsError {
|
||||||
|
message: format!("TLS handshake with {hostname} failed: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(tls_stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_connect_plain_refused() {
|
||||||
|
// Connecting to a port that's not listening should fail
|
||||||
|
let result = connect_plain("127.0.0.1", 19999, 2).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(matches!(err, SmtpClientError::ConnectionError { .. }));
|
||||||
|
assert!(err.is_retryable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_connect_tls_refused() {
|
||||||
|
let result = connect_tls("127.0.0.1", 19998, 2).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_connect_timeout() {
|
||||||
|
// 192.0.2.1 is TEST-NET, should time out
|
||||||
|
let result = connect_plain("192.0.2.1", 25, 1).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
// May be timeout or connection error depending on network
|
||||||
|
assert!(err.is_retryable());
|
||||||
|
}
|
||||||
|
}
|
||||||
160
rust/crates/mailer-smtp/src/client/error.rs
Normal file
160
rust/crates/mailer-smtp/src/client/error.rs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
//! SMTP client error types.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Errors that can occur during SMTP client operations.
|
||||||
|
#[derive(Debug, thiserror::Error, Serialize)]
|
||||||
|
pub enum SmtpClientError {
|
||||||
|
#[error("Connection error: {message}")]
|
||||||
|
ConnectionError { message: String },
|
||||||
|
|
||||||
|
#[error("Timeout: {message}")]
|
||||||
|
TimeoutError { message: String },
|
||||||
|
|
||||||
|
#[error("TLS error: {message}")]
|
||||||
|
TlsError { message: String },
|
||||||
|
|
||||||
|
#[error("Authentication failed: {message}")]
|
||||||
|
AuthenticationError { message: String },
|
||||||
|
|
||||||
|
#[error("Protocol error ({code}): {message}")]
|
||||||
|
ProtocolError { code: u16, message: String },
|
||||||
|
|
||||||
|
#[error("Pool exhausted: {message}")]
|
||||||
|
PoolExhausted { message: String },
|
||||||
|
|
||||||
|
#[error("Invalid configuration: {message}")]
|
||||||
|
ConfigError { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpClientError {
|
||||||
|
/// Whether this error is retryable (temporary failure).
|
||||||
|
/// Permanent failures (5xx, auth failures) are not retryable.
|
||||||
|
pub fn is_retryable(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
SmtpClientError::ConnectionError { .. } => true,
|
||||||
|
SmtpClientError::TimeoutError { .. } => true,
|
||||||
|
SmtpClientError::TlsError { .. } => false,
|
||||||
|
SmtpClientError::AuthenticationError { .. } => false,
|
||||||
|
SmtpClientError::ProtocolError { code, .. } => *code >= 400 && *code < 500,
|
||||||
|
SmtpClientError::PoolExhausted { .. } => true,
|
||||||
|
SmtpClientError::ConfigError { .. } => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The error type as a string for IPC serialization.
|
||||||
|
pub fn error_type(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SmtpClientError::ConnectionError { .. } => "connection",
|
||||||
|
SmtpClientError::TimeoutError { .. } => "timeout",
|
||||||
|
SmtpClientError::TlsError { .. } => "tls",
|
||||||
|
SmtpClientError::AuthenticationError { .. } => "authentication",
|
||||||
|
SmtpClientError::ProtocolError { .. } => "protocol",
|
||||||
|
SmtpClientError::PoolExhausted { .. } => "pool_exhausted",
|
||||||
|
SmtpClientError::ConfigError { .. } => "config",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SMTP code if this is a protocol error.
|
||||||
|
pub fn smtp_code(&self) -> Option<u16> {
|
||||||
|
match self {
|
||||||
|
SmtpClientError::ProtocolError { code, .. } => Some(*code),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retryable_errors() {
|
||||||
|
assert!(SmtpClientError::ConnectionError {
|
||||||
|
message: "refused".into()
|
||||||
|
}
|
||||||
|
.is_retryable());
|
||||||
|
assert!(SmtpClientError::TimeoutError {
|
||||||
|
message: "timed out".into()
|
||||||
|
}
|
||||||
|
.is_retryable());
|
||||||
|
assert!(SmtpClientError::PoolExhausted {
|
||||||
|
message: "full".into()
|
||||||
|
}
|
||||||
|
.is_retryable());
|
||||||
|
assert!(SmtpClientError::ProtocolError {
|
||||||
|
code: 421,
|
||||||
|
message: "try later".into()
|
||||||
|
}
|
||||||
|
.is_retryable());
|
||||||
|
assert!(SmtpClientError::ProtocolError {
|
||||||
|
code: 450,
|
||||||
|
message: "mailbox busy".into()
|
||||||
|
}
|
||||||
|
.is_retryable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_non_retryable_errors() {
|
||||||
|
assert!(!SmtpClientError::AuthenticationError {
|
||||||
|
message: "bad creds".into()
|
||||||
|
}
|
||||||
|
.is_retryable());
|
||||||
|
assert!(!SmtpClientError::TlsError {
|
||||||
|
message: "cert invalid".into()
|
||||||
|
}
|
||||||
|
.is_retryable());
|
||||||
|
assert!(!SmtpClientError::ProtocolError {
|
||||||
|
code: 550,
|
||||||
|
message: "no such user".into()
|
||||||
|
}
|
||||||
|
.is_retryable());
|
||||||
|
assert!(!SmtpClientError::ProtocolError {
|
||||||
|
code: 554,
|
||||||
|
message: "rejected".into()
|
||||||
|
}
|
||||||
|
.is_retryable());
|
||||||
|
assert!(!SmtpClientError::ConfigError {
|
||||||
|
message: "bad config".into()
|
||||||
|
}
|
||||||
|
.is_retryable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_type_strings() {
|
||||||
|
assert_eq!(
|
||||||
|
SmtpClientError::ConnectionError {
|
||||||
|
message: "x".into()
|
||||||
|
}
|
||||||
|
.error_type(),
|
||||||
|
"connection"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SmtpClientError::ProtocolError {
|
||||||
|
code: 550,
|
||||||
|
message: "x".into()
|
||||||
|
}
|
||||||
|
.error_type(),
|
||||||
|
"protocol"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_smtp_code() {
|
||||||
|
assert_eq!(
|
||||||
|
SmtpClientError::ProtocolError {
|
||||||
|
code: 550,
|
||||||
|
message: "x".into()
|
||||||
|
}
|
||||||
|
.smtp_code(),
|
||||||
|
Some(550)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SmtpClientError::ConnectionError {
|
||||||
|
message: "x".into()
|
||||||
|
}
|
||||||
|
.smtp_code(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
rust/crates/mailer-smtp/src/client/mod.rs
Normal file
16
rust/crates/mailer-smtp/src/client/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//! SMTP client module for outbound email delivery.
|
||||||
|
//!
|
||||||
|
//! Provides connection pooling, SMTP protocol, TLS, and authentication
|
||||||
|
//! for sending outbound emails through remote SMTP servers.
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod connection;
|
||||||
|
pub mod error;
|
||||||
|
pub mod pool;
|
||||||
|
pub mod protocol;
|
||||||
|
|
||||||
|
// Re-export key types for convenience.
|
||||||
|
pub use config::{DkimSignConfig, SmtpAuthConfig, SmtpClientConfig};
|
||||||
|
pub use error::SmtpClientError;
|
||||||
|
pub use pool::{SmtpClientManager, SmtpSendResult, SmtpVerifyResult};
|
||||||
|
pub use protocol::{dot_stuff, EhloCapabilities, SmtpClientResponse};
|
||||||
503
rust/crates/mailer-smtp/src/client/pool.rs
Normal file
503
rust/crates/mailer-smtp/src/client/pool.rs
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
//! Connection pooling for the SMTP client.
|
||||||
|
//!
|
||||||
|
//! Manages reusable connections per destination `host:port`.
|
||||||
|
|
||||||
|
use super::config::SmtpClientConfig;
|
||||||
|
use super::connection::{connect_plain, connect_tls, ClientSmtpStream};
|
||||||
|
use super::error::SmtpClientError;
|
||||||
|
use super::protocol::{self, EhloCapabilities};
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
/// Maximum age of a pooled connection (5 minutes).
|
||||||
|
const MAX_CONNECTION_AGE_SECS: u64 = 300;
|
||||||
|
|
||||||
|
/// Maximum idle time before a connection is reaped (30 seconds).
|
||||||
|
const MAX_IDLE_SECS: u64 = 30;
|
||||||
|
|
||||||
|
/// Maximum messages per pooled connection before it's recycled.
|
||||||
|
const MAX_MESSAGES_PER_CONNECTION: u32 = 100;
|
||||||
|
|
||||||
|
/// A pooled SMTP connection.
|
||||||
|
pub struct PooledConnection {
|
||||||
|
pub stream: ClientSmtpStream,
|
||||||
|
pub capabilities: EhloCapabilities,
|
||||||
|
pub created_at: Instant,
|
||||||
|
pub last_used: Instant,
|
||||||
|
pub message_count: u32,
|
||||||
|
pub idle: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a pooled connection is stale (too old, too many messages, or idle too long).
|
||||||
|
fn is_connection_stale(conn: &PooledConnection) -> bool {
|
||||||
|
conn.created_at.elapsed().as_secs() > MAX_CONNECTION_AGE_SECS
|
||||||
|
|| conn.message_count >= MAX_MESSAGES_PER_CONNECTION
|
||||||
|
|| (conn.idle && conn.last_used.elapsed().as_secs() > MAX_IDLE_SECS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-destination connection pool.
|
||||||
|
pub struct ConnectionPool {
|
||||||
|
connections: Vec<PooledConnection>,
|
||||||
|
max_connections: usize,
|
||||||
|
config: SmtpClientConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectionPool {
|
||||||
|
fn new(config: SmtpClientConfig) -> Self {
|
||||||
|
let max_connections = config.max_pool_connections;
|
||||||
|
Self {
|
||||||
|
connections: Vec::new(),
|
||||||
|
max_connections,
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get an idle connection or create a new one.
|
||||||
|
async fn acquire(&mut self) -> Result<PooledConnection, SmtpClientError> {
|
||||||
|
// Remove stale connections first
|
||||||
|
self.cleanup_stale();
|
||||||
|
|
||||||
|
// Find an idle connection
|
||||||
|
if let Some(idx) = self
|
||||||
|
.connections
|
||||||
|
.iter()
|
||||||
|
.position(|c| c.idle && !is_connection_stale(c))
|
||||||
|
{
|
||||||
|
let mut conn = self.connections.remove(idx);
|
||||||
|
conn.idle = false;
|
||||||
|
conn.last_used = Instant::now();
|
||||||
|
debug!(
|
||||||
|
"Reusing pooled connection (age={}s, msgs={})",
|
||||||
|
conn.created_at.elapsed().as_secs(),
|
||||||
|
conn.message_count
|
||||||
|
);
|
||||||
|
return Ok(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we can create a new connection
|
||||||
|
if self.connections.len() >= self.max_connections {
|
||||||
|
return Err(SmtpClientError::PoolExhausted {
|
||||||
|
message: format!(
|
||||||
|
"Pool for {} is at max capacity ({})",
|
||||||
|
self.config.effective_pool_key(),
|
||||||
|
self.max_connections
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new connection
|
||||||
|
self.create_connection().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a connection to the pool (or close it if it's expired).
|
||||||
|
fn release(&mut self, mut conn: PooledConnection) {
|
||||||
|
conn.message_count += 1;
|
||||||
|
conn.last_used = Instant::now();
|
||||||
|
conn.idle = true;
|
||||||
|
|
||||||
|
// Don't return if it's stale
|
||||||
|
if is_connection_stale(&conn) || self.connections.len() >= self.max_connections {
|
||||||
|
debug!("Discarding stale/excess pooled connection");
|
||||||
|
// Drop the connection (stream will be closed)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.connections.push(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a fresh SMTP connection and complete the handshake.
|
||||||
|
async fn create_connection(&self) -> Result<PooledConnection, SmtpClientError> {
|
||||||
|
let mut stream = if self.config.secure {
|
||||||
|
connect_tls(
|
||||||
|
&self.config.host,
|
||||||
|
self.config.port,
|
||||||
|
self.config.connection_timeout_secs,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
connect_plain(
|
||||||
|
&self.config.host,
|
||||||
|
self.config.port,
|
||||||
|
self.config.connection_timeout_secs,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read greeting
|
||||||
|
protocol::read_greeting(&mut stream, self.config.socket_timeout_secs).await?;
|
||||||
|
|
||||||
|
// Send EHLO
|
||||||
|
let mut capabilities =
|
||||||
|
protocol::send_ehlo(&mut stream, &self.config.domain, self.config.socket_timeout_secs)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// STARTTLS if available and not already secure
|
||||||
|
if !self.config.secure && capabilities.starttls {
|
||||||
|
protocol::send_starttls(&mut stream, self.config.socket_timeout_secs).await?;
|
||||||
|
stream =
|
||||||
|
super::connection::upgrade_to_tls(stream, &self.config.host).await?;
|
||||||
|
|
||||||
|
// Re-EHLO after STARTTLS — use updated capabilities for auth
|
||||||
|
capabilities = protocol::send_ehlo(
|
||||||
|
&mut stream,
|
||||||
|
&self.config.domain,
|
||||||
|
self.config.socket_timeout_secs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate if credentials provided
|
||||||
|
if let Some(auth) = &self.config.auth {
|
||||||
|
protocol::authenticate(
|
||||||
|
&mut stream,
|
||||||
|
auth,
|
||||||
|
&capabilities,
|
||||||
|
self.config.socket_timeout_secs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"New SMTP connection to {} established",
|
||||||
|
self.config.effective_pool_key()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(PooledConnection {
|
||||||
|
stream,
|
||||||
|
capabilities,
|
||||||
|
created_at: Instant::now(),
|
||||||
|
last_used: Instant::now(),
|
||||||
|
message_count: 0,
|
||||||
|
idle: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_stale(&mut self) {
|
||||||
|
self.connections.retain(|c| !is_connection_stale(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of connections in the pool.
|
||||||
|
fn total(&self) -> usize {
|
||||||
|
self.connections.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of idle connections.
|
||||||
|
fn idle_count(&self) -> usize {
|
||||||
|
self.connections.iter().filter(|c| c.idle).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close all connections.
|
||||||
|
fn close_all(&mut self) {
|
||||||
|
self.connections.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status report for a single pool.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PoolStatus {
|
||||||
|
pub total: usize,
|
||||||
|
pub active: usize,
|
||||||
|
pub idle: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages connection pools for multiple SMTP destinations.
|
||||||
|
pub struct SmtpClientManager {
|
||||||
|
pools: DashMap<String, Arc<Mutex<ConnectionPool>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpClientManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
pools: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get or create a pool for the given config.
|
||||||
|
fn get_pool(&self, config: &SmtpClientConfig) -> Arc<Mutex<ConnectionPool>> {
|
||||||
|
let key = config.effective_pool_key();
|
||||||
|
self.pools
|
||||||
|
.entry(key)
|
||||||
|
.or_insert_with(|| Arc::new(Mutex::new(ConnectionPool::new(config.clone()))))
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire a connection from the pool, send a message, and release it.
|
||||||
|
pub async fn send_message(
|
||||||
|
&self,
|
||||||
|
config: &SmtpClientConfig,
|
||||||
|
sender: &str,
|
||||||
|
recipients: &[String],
|
||||||
|
message: &[u8],
|
||||||
|
) -> Result<SmtpSendResult, SmtpClientError> {
|
||||||
|
let pool_arc = self.get_pool(config);
|
||||||
|
let mut pool = pool_arc.lock().await;
|
||||||
|
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
drop(pool); // Release the pool lock while we do network I/O
|
||||||
|
|
||||||
|
// Reset server state if reusing a connection that has already sent messages
|
||||||
|
if conn.message_count > 0 {
|
||||||
|
protocol::send_rset(&mut conn.stream, config.socket_timeout_secs).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the SMTP transaction
|
||||||
|
let result =
|
||||||
|
Self::perform_send(&mut conn.stream, sender, recipients, message, config).await;
|
||||||
|
|
||||||
|
// Re-acquire the pool lock and release the connection
|
||||||
|
let mut pool = pool_arc.lock().await;
|
||||||
|
match &result {
|
||||||
|
Ok(_) => pool.release(conn),
|
||||||
|
Err(_) => {
|
||||||
|
// Don't return failed connections to the pool
|
||||||
|
debug!("Discarding connection after send failure");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform the SMTP send transaction on a connected stream.
|
||||||
|
async fn perform_send(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
sender: &str,
|
||||||
|
recipients: &[String],
|
||||||
|
message: &[u8],
|
||||||
|
config: &SmtpClientConfig,
|
||||||
|
) -> Result<SmtpSendResult, SmtpClientError> {
|
||||||
|
let timeout_secs = config.socket_timeout_secs;
|
||||||
|
|
||||||
|
// MAIL FROM
|
||||||
|
protocol::send_mail_from(stream, sender, timeout_secs).await?;
|
||||||
|
|
||||||
|
// RCPT TO for each recipient
|
||||||
|
let mut accepted = Vec::new();
|
||||||
|
let mut rejected = Vec::new();
|
||||||
|
|
||||||
|
for rcpt in recipients {
|
||||||
|
match protocol::send_rcpt_to(stream, rcpt, timeout_secs).await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if resp.is_success() {
|
||||||
|
accepted.push(rcpt.clone());
|
||||||
|
} else {
|
||||||
|
rejected.push(rcpt.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
rejected.push(rcpt.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no recipients were accepted, fail
|
||||||
|
if accepted.is_empty() {
|
||||||
|
return Err(SmtpClientError::ProtocolError {
|
||||||
|
code: 550,
|
||||||
|
message: "All recipients were rejected".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DATA
|
||||||
|
let data_resp = protocol::send_data(stream, message, timeout_secs).await?;
|
||||||
|
|
||||||
|
// Extract message ID from the response if present
|
||||||
|
let message_id = data_resp
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.find_map(|line| {
|
||||||
|
// Look for a pattern like "queued as XXXX" or message-id
|
||||||
|
if line.contains("queued") || line.contains("id=") {
|
||||||
|
Some(line.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(SmtpSendResult {
|
||||||
|
accepted,
|
||||||
|
rejected,
|
||||||
|
message_id,
|
||||||
|
response: data_resp.full_message(),
|
||||||
|
envelope: SmtpEnvelope {
|
||||||
|
from: sender.to_string(),
|
||||||
|
to: recipients.to_vec(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify connectivity to an SMTP server (connect, EHLO, QUIT).
|
||||||
|
pub async fn verify_connection(
|
||||||
|
&self,
|
||||||
|
config: &SmtpClientConfig,
|
||||||
|
) -> Result<SmtpVerifyResult, SmtpClientError> {
|
||||||
|
let mut stream = if config.secure {
|
||||||
|
connect_tls(
|
||||||
|
&config.host,
|
||||||
|
config.port,
|
||||||
|
config.connection_timeout_secs,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
connect_plain(
|
||||||
|
&config.host,
|
||||||
|
config.port,
|
||||||
|
config.connection_timeout_secs,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let greeting = protocol::read_greeting(&mut stream, config.socket_timeout_secs).await?;
|
||||||
|
let caps =
|
||||||
|
protocol::send_ehlo(&mut stream, &config.domain, config.socket_timeout_secs).await?;
|
||||||
|
let _ = protocol::send_quit(&mut stream, config.socket_timeout_secs).await;
|
||||||
|
|
||||||
|
Ok(SmtpVerifyResult {
|
||||||
|
reachable: true,
|
||||||
|
greeting: Some(greeting.full_message()),
|
||||||
|
capabilities: Some(caps.extensions),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get status of all pools.
|
||||||
|
pub fn pool_status(&self) -> std::collections::HashMap<String, PoolStatus> {
|
||||||
|
let mut result = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for entry in self.pools.iter() {
|
||||||
|
let key = entry.key().clone();
|
||||||
|
// Try to get the lock without blocking — if locked, report as active
|
||||||
|
match entry.value().try_lock() {
|
||||||
|
Ok(pool) => {
|
||||||
|
let total = pool.total();
|
||||||
|
let idle = pool.idle_count();
|
||||||
|
result.insert(
|
||||||
|
key,
|
||||||
|
PoolStatus {
|
||||||
|
total,
|
||||||
|
active: total - idle,
|
||||||
|
idle,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Pool is in use; report as busy
|
||||||
|
result.insert(
|
||||||
|
key,
|
||||||
|
PoolStatus {
|
||||||
|
total: 0,
|
||||||
|
active: 1,
|
||||||
|
idle: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close a specific pool.
|
||||||
|
pub async fn close_pool(&self, key: &str) {
|
||||||
|
if let Some(pool_ref) = self.pools.get(key) {
|
||||||
|
let mut pool = pool_ref.lock().await;
|
||||||
|
pool.close_all();
|
||||||
|
}
|
||||||
|
self.pools.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close all pools.
|
||||||
|
pub async fn close_all_pools(&self) {
|
||||||
|
let keys: Vec<String> = self.pools.iter().map(|e| e.key().clone()).collect();
|
||||||
|
for key in keys {
|
||||||
|
self.close_pool(&key).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of sending an email via SMTP.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SmtpSendResult {
|
||||||
|
pub accepted: Vec<String>,
|
||||||
|
pub rejected: Vec<String>,
|
||||||
|
#[serde(rename = "messageId")]
|
||||||
|
pub message_id: Option<String>,
|
||||||
|
pub response: String,
|
||||||
|
pub envelope: SmtpEnvelope,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SMTP envelope (sender + recipients).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SmtpEnvelope {
|
||||||
|
pub from: String,
|
||||||
|
pub to: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of verifying an SMTP connection.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SmtpVerifyResult {
|
||||||
|
pub reachable: bool,
|
||||||
|
pub greeting: Option<String>,
|
||||||
|
pub capabilities: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pool_status_serialization() {
|
||||||
|
let status = PoolStatus {
|
||||||
|
total: 5,
|
||||||
|
active: 2,
|
||||||
|
idle: 3,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&status).unwrap();
|
||||||
|
assert!(json.contains("\"total\":5"));
|
||||||
|
assert!(json.contains("\"active\":2"));
|
||||||
|
assert!(json.contains("\"idle\":3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_send_result_serialization() {
|
||||||
|
let result = SmtpSendResult {
|
||||||
|
accepted: vec!["a@b.com".into()],
|
||||||
|
rejected: vec![],
|
||||||
|
message_id: Some("abc123".into()),
|
||||||
|
response: "250 OK".into(),
|
||||||
|
envelope: SmtpEnvelope {
|
||||||
|
from: "from@test.com".into(),
|
||||||
|
to: vec!["a@b.com".into()],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
|
assert!(json.contains("\"messageId\":\"abc123\""));
|
||||||
|
assert!(json.contains("\"accepted\":[\"a@b.com\"]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify_result_serialization() {
|
||||||
|
let result = SmtpVerifyResult {
|
||||||
|
reachable: true,
|
||||||
|
greeting: Some("220 mail.example.com".into()),
|
||||||
|
capabilities: Some(vec!["SIZE 10485760".into(), "STARTTLS".into()]),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&result).unwrap();
|
||||||
|
assert!(json.contains("\"reachable\":true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_smtp_client_manager_new() {
|
||||||
|
let mgr = SmtpClientManager::new();
|
||||||
|
assert!(mgr.pool_status().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_close_all_empty() {
|
||||||
|
let mgr = SmtpClientManager::new();
|
||||||
|
mgr.close_all_pools().await;
|
||||||
|
assert!(mgr.pool_status().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
520
rust/crates/mailer-smtp/src/client/protocol.rs
Normal file
520
rust/crates/mailer-smtp/src/client/protocol.rs
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
//! SMTP client protocol engine.
|
||||||
|
//!
|
||||||
|
//! Implements the SMTP command/response flow for sending outbound email.
|
||||||
|
|
||||||
|
use super::config::SmtpAuthConfig;
|
||||||
|
use super::connection::ClientSmtpStream;
|
||||||
|
use super::error::SmtpClientError;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use base64::Engine;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::time::{timeout, Duration};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Parsed SMTP response (from the remote server).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SmtpClientResponse {
|
||||||
|
pub code: u16,
|
||||||
|
pub lines: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmtpClientResponse {
|
||||||
|
pub fn is_success(&self) -> bool {
|
||||||
|
self.code >= 200 && self.code < 300
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_positive_intermediate(&self) -> bool {
|
||||||
|
self.code >= 300 && self.code < 400
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_temp_error(&self) -> bool {
|
||||||
|
self.code >= 400 && self.code < 500
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_perm_error(&self) -> bool {
|
||||||
|
self.code >= 500
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full response text (all lines joined).
|
||||||
|
pub fn full_message(&self) -> String {
|
||||||
|
self.lines.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to a protocol error if this is an error response.
|
||||||
|
pub fn to_error(&self) -> SmtpClientError {
|
||||||
|
SmtpClientError::ProtocolError {
|
||||||
|
code: self.code,
|
||||||
|
message: self.full_message(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server capabilities parsed from EHLO response.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct EhloCapabilities {
|
||||||
|
pub extensions: Vec<String>,
|
||||||
|
pub max_size: Option<u64>,
|
||||||
|
pub starttls: bool,
|
||||||
|
pub auth_methods: Vec<String>,
|
||||||
|
pub pipelining: bool,
|
||||||
|
pub eight_bit_mime: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a multi-line SMTP response from the server.
|
||||||
|
pub async fn read_response(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut code: u16;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
let n = timeout(
|
||||||
|
Duration::from_secs(timeout_secs),
|
||||||
|
stream.read_line(&mut line),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| SmtpClientError::TimeoutError {
|
||||||
|
message: format!("Timeout reading SMTP response after {timeout_secs}s"),
|
||||||
|
})??;
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
return Err(SmtpClientError::ConnectionError {
|
||||||
|
message: "Connection closed while reading response".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against unbounded lines from malicious servers (RFC 5321 §4.5.3.1.4 says 512 max)
|
||||||
|
if line.len() > 4096 {
|
||||||
|
return Err(SmtpClientError::ProtocolError {
|
||||||
|
code: 0,
|
||||||
|
message: format!("Response line too long ({} bytes, max 4096)", line.len()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = line.trim_end_matches('\n').trim_end_matches('\r');
|
||||||
|
|
||||||
|
if line.len() < 3 {
|
||||||
|
return Err(SmtpClientError::ProtocolError {
|
||||||
|
code: 0,
|
||||||
|
message: format!("Invalid response line: {line}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the 3-digit code
|
||||||
|
let parsed_code: u16 = line[..3].parse().map_err(|_| SmtpClientError::ProtocolError {
|
||||||
|
code: 0,
|
||||||
|
message: format!("Invalid response code in: {line}"),
|
||||||
|
})?;
|
||||||
|
code = parsed_code;
|
||||||
|
|
||||||
|
// Text after the code (skip the separator character)
|
||||||
|
let text = if line.len() > 4 { &line[4..] } else { "" };
|
||||||
|
lines.push(text.to_string());
|
||||||
|
|
||||||
|
// Check for continuation: "250-" means more lines, "250 " means last line
|
||||||
|
if line.len() >= 4 && line.as_bytes()[3] == b'-' {
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("SMTP response: {} {}", code, lines.join(" | "));
|
||||||
|
Ok(SmtpClientResponse { code, lines })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the server greeting (first response after connect).
|
||||||
|
pub async fn read_greeting(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||||
|
let resp = read_response(stream, timeout_secs).await?;
|
||||||
|
if resp.code == 220 {
|
||||||
|
Ok(resp)
|
||||||
|
} else {
|
||||||
|
Err(SmtpClientError::ProtocolError {
|
||||||
|
code: resp.code,
|
||||||
|
message: format!("Unexpected greeting: {}", resp.full_message()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a raw command and read the response.
|
||||||
|
async fn send_command(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
command: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||||
|
debug!("SMTP C: {}", command);
|
||||||
|
stream
|
||||||
|
.write_all(format!("{command}\r\n").as_bytes())
|
||||||
|
.await?;
|
||||||
|
stream.flush().await?;
|
||||||
|
read_response(stream, timeout_secs).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send EHLO and parse capabilities.
|
||||||
|
pub async fn send_ehlo(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
domain: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<EhloCapabilities, SmtpClientError> {
|
||||||
|
let resp = send_command(stream, &format!("EHLO {domain}"), timeout_secs).await?;
|
||||||
|
|
||||||
|
if !resp.is_success() {
|
||||||
|
// Fall back to HELO
|
||||||
|
let helo_resp = send_command(stream, &format!("HELO {domain}"), timeout_secs).await?;
|
||||||
|
if !helo_resp.is_success() {
|
||||||
|
return Err(helo_resp.to_error());
|
||||||
|
}
|
||||||
|
return Ok(EhloCapabilities::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut caps = EhloCapabilities::default();
|
||||||
|
|
||||||
|
// First line is the greeting, remaining lines are capabilities
|
||||||
|
for line in resp.lines.iter().skip(1) {
|
||||||
|
let upper = line.to_uppercase();
|
||||||
|
if upper.starts_with("SIZE ") {
|
||||||
|
caps.max_size = upper[5..].trim().parse().ok();
|
||||||
|
} else if upper == "STARTTLS" {
|
||||||
|
caps.starttls = true;
|
||||||
|
} else if upper.starts_with("AUTH ") {
|
||||||
|
caps.auth_methods = upper[5..]
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
} else if upper == "PIPELINING" {
|
||||||
|
caps.pipelining = true;
|
||||||
|
} else if upper == "8BITMIME" {
|
||||||
|
caps.eight_bit_mime = true;
|
||||||
|
}
|
||||||
|
caps.extensions.push(line.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(caps)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send STARTTLS command (does not perform the TLS handshake itself).
|
||||||
|
pub async fn send_starttls(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<(), SmtpClientError> {
|
||||||
|
let resp = send_command(stream, "STARTTLS", timeout_secs).await?;
|
||||||
|
if resp.code != 220 {
|
||||||
|
return Err(SmtpClientError::ProtocolError {
|
||||||
|
code: resp.code,
|
||||||
|
message: format!("STARTTLS rejected: {}", resp.full_message()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate using AUTH PLAIN.
|
||||||
|
pub async fn send_auth_plain(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
user: &str,
|
||||||
|
pass: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<(), SmtpClientError> {
|
||||||
|
// AUTH PLAIN sends \0user\0pass in base64
|
||||||
|
let credentials = format!("\x00{user}\x00{pass}");
|
||||||
|
let encoded = BASE64.encode(credentials.as_bytes());
|
||||||
|
let resp = send_command(stream, &format!("AUTH PLAIN {encoded}"), timeout_secs).await?;
|
||||||
|
|
||||||
|
if resp.code != 235 {
|
||||||
|
return Err(SmtpClientError::AuthenticationError {
|
||||||
|
message: format!("AUTH PLAIN failed ({}): {}", resp.code, resp.full_message()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate using AUTH LOGIN.
|
||||||
|
pub async fn send_auth_login(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
user: &str,
|
||||||
|
pass: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<(), SmtpClientError> {
|
||||||
|
// Step 1: Send AUTH LOGIN
|
||||||
|
let resp = send_command(stream, "AUTH LOGIN", timeout_secs).await?;
|
||||||
|
if resp.code != 334 {
|
||||||
|
return Err(SmtpClientError::AuthenticationError {
|
||||||
|
message: format!(
|
||||||
|
"AUTH LOGIN challenge failed ({}): {}",
|
||||||
|
resp.code,
|
||||||
|
resp.full_message()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Send base64 username
|
||||||
|
let user_b64 = BASE64.encode(user.as_bytes());
|
||||||
|
let resp = send_command(stream, &user_b64, timeout_secs).await?;
|
||||||
|
if resp.code != 334 {
|
||||||
|
return Err(SmtpClientError::AuthenticationError {
|
||||||
|
message: format!(
|
||||||
|
"AUTH LOGIN username rejected ({}): {}",
|
||||||
|
resp.code,
|
||||||
|
resp.full_message()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Send base64 password
|
||||||
|
let pass_b64 = BASE64.encode(pass.as_bytes());
|
||||||
|
let resp = send_command(stream, &pass_b64, timeout_secs).await?;
|
||||||
|
if resp.code != 235 {
|
||||||
|
return Err(SmtpClientError::AuthenticationError {
|
||||||
|
message: format!(
|
||||||
|
"AUTH LOGIN password rejected ({}): {}",
|
||||||
|
resp.code,
|
||||||
|
resp.full_message()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate using the configured method.
|
||||||
|
pub async fn authenticate(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
auth: &SmtpAuthConfig,
|
||||||
|
_caps: &EhloCapabilities,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<(), SmtpClientError> {
|
||||||
|
match auth.method.to_uppercase().as_str() {
|
||||||
|
"LOGIN" => send_auth_login(stream, &auth.user, &auth.pass, timeout_secs).await,
|
||||||
|
_ => send_auth_plain(stream, &auth.user, &auth.pass, timeout_secs).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send MAIL FROM.
|
||||||
|
pub async fn send_mail_from(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
sender: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||||
|
let resp = send_command(stream, &format!("MAIL FROM:<{sender}>"), timeout_secs).await?;
|
||||||
|
if !resp.is_success() {
|
||||||
|
return Err(resp.to_error());
|
||||||
|
}
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send RCPT TO. Returns per-recipient success/failure.
|
||||||
|
pub async fn send_rcpt_to(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
recipient: &str,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||||
|
let resp = send_command(stream, &format!("RCPT TO:<{recipient}>"), timeout_secs).await?;
|
||||||
|
// We don't fail the entire send on per-recipient errors;
|
||||||
|
// the caller decides based on the response code.
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send DATA command, followed by the message body with dot-stuffing.
|
||||||
|
pub async fn send_data(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
message: &[u8],
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<SmtpClientResponse, SmtpClientError> {
|
||||||
|
// Send DATA command
|
||||||
|
let resp = send_command(stream, "DATA", timeout_secs).await?;
|
||||||
|
if !resp.is_positive_intermediate() {
|
||||||
|
return Err(resp.to_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the message body with dot-stuffing
|
||||||
|
let stuffed = dot_stuff(message);
|
||||||
|
stream.write_all(&stuffed).await?;
|
||||||
|
|
||||||
|
// Send terminator: CRLF.CRLF
|
||||||
|
// If the message doesn't end with CRLF, add one
|
||||||
|
if !stuffed.ends_with(b"\r\n") {
|
||||||
|
stream.write_all(b"\r\n").await?;
|
||||||
|
}
|
||||||
|
stream.write_all(b".\r\n").await?;
|
||||||
|
stream.flush().await?;
|
||||||
|
|
||||||
|
// Read final response
|
||||||
|
let final_resp = read_response(stream, timeout_secs).await?;
|
||||||
|
if !final_resp.is_success() {
|
||||||
|
return Err(final_resp.to_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(final_resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send RSET command to reset the server state between messages on a reused connection.
|
||||||
|
pub async fn send_rset(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<(), SmtpClientError> {
|
||||||
|
let resp = send_command(stream, "RSET", timeout_secs).await?;
|
||||||
|
if !resp.is_success() {
|
||||||
|
return Err(resp.to_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send QUIT command.
|
||||||
|
pub async fn send_quit(
|
||||||
|
stream: &mut ClientSmtpStream,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<(), SmtpClientError> {
|
||||||
|
// Best-effort QUIT — ignore errors since we're closing anyway
|
||||||
|
let _ = send_command(stream, "QUIT", timeout_secs).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply SMTP dot-stuffing to a message body.
|
||||||
|
///
|
||||||
|
/// Any line starting with a period gets an extra period prepended.
|
||||||
|
/// Also normalizes bare LF to CRLF.
|
||||||
|
pub fn dot_stuff(data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut result = Vec::with_capacity(data.len() + data.len() / 40);
|
||||||
|
let mut at_line_start = true;
|
||||||
|
|
||||||
|
for i in 0..data.len() {
|
||||||
|
let byte = data[i];
|
||||||
|
|
||||||
|
// Normalize bare LF to CRLF
|
||||||
|
if byte == b'\n' && (i == 0 || data[i - 1] != b'\r') {
|
||||||
|
result.push(b'\r');
|
||||||
|
result.push(b'\n');
|
||||||
|
at_line_start = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dot-stuff: add extra dot at start of line
|
||||||
|
if at_line_start && byte == b'.' {
|
||||||
|
result.push(b'.');
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(byte);
|
||||||
|
at_line_start = byte == b'\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dot_stuffing_basic() {
|
||||||
|
assert_eq!(
|
||||||
|
dot_stuff(b"Hello\r\n.World\r\n"),
|
||||||
|
b"Hello\r\n..World\r\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dot_stuffing_leading_dot() {
|
||||||
|
assert_eq!(dot_stuff(b".starts with dot\r\n"), b"..starts with dot\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dot_stuffing_multiple_dots() {
|
||||||
|
assert_eq!(
|
||||||
|
dot_stuff(b"ok\r\n.line1\r\n..line2\r\n"),
|
||||||
|
b"ok\r\n..line1\r\n...line2\r\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dot_stuffing_bare_lf() {
|
||||||
|
assert_eq!(
|
||||||
|
dot_stuff(b"line1\nline2\n"),
|
||||||
|
b"line1\r\nline2\r\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dot_stuffing_bare_lf_with_dot() {
|
||||||
|
assert_eq!(
|
||||||
|
dot_stuff(b"ok\n.dotline\n"),
|
||||||
|
b"ok\r\n..dotline\r\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dot_stuffing_no_change() {
|
||||||
|
assert_eq!(
|
||||||
|
dot_stuff(b"Hello World\r\nNo dots here\r\n"),
|
||||||
|
b"Hello World\r\nNo dots here\r\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_dot_stuffing_empty() {
|
||||||
|
assert_eq!(dot_stuff(b""), b"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_response_is_success() {
|
||||||
|
let resp = SmtpClientResponse {
|
||||||
|
code: 250,
|
||||||
|
lines: vec!["OK".into()],
|
||||||
|
};
|
||||||
|
assert!(resp.is_success());
|
||||||
|
assert!(!resp.is_temp_error());
|
||||||
|
assert!(!resp.is_perm_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_response_temp_error() {
|
||||||
|
let resp = SmtpClientResponse {
|
||||||
|
code: 450,
|
||||||
|
lines: vec!["Mailbox busy".into()],
|
||||||
|
};
|
||||||
|
assert!(!resp.is_success());
|
||||||
|
assert!(resp.is_temp_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_response_perm_error() {
|
||||||
|
let resp = SmtpClientResponse {
|
||||||
|
code: 550,
|
||||||
|
lines: vec!["No such user".into()],
|
||||||
|
};
|
||||||
|
assert!(!resp.is_success());
|
||||||
|
assert!(resp.is_perm_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_response_positive_intermediate() {
|
||||||
|
let resp = SmtpClientResponse {
|
||||||
|
code: 354,
|
||||||
|
lines: vec!["Start mail input".into()],
|
||||||
|
};
|
||||||
|
assert!(resp.is_positive_intermediate());
|
||||||
|
assert!(!resp.is_success());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_response_full_message() {
|
||||||
|
let resp = SmtpClientResponse {
|
||||||
|
code: 250,
|
||||||
|
lines: vec!["OK".into(), "SIZE 10485760".into()],
|
||||||
|
};
|
||||||
|
assert_eq!(resp.full_message(), "OK SIZE 10485760");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ehlo_capabilities_default() {
|
||||||
|
let caps = EhloCapabilities::default();
|
||||||
|
assert!(!caps.starttls);
|
||||||
|
assert!(!caps.pipelining);
|
||||||
|
assert!(!caps.eight_bit_mime);
|
||||||
|
assert!(caps.auth_methods.is_empty());
|
||||||
|
assert!(caps.max_size.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
//! - TCP/TLS server (`server`)
|
//! - TCP/TLS server (`server`)
|
||||||
//! - Connection handling (`connection`)
|
//! - Connection handling (`connection`)
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod connection;
|
pub mod connection;
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Get version from deno.json
|
|
||||||
VERSION=$(cat deno.json | grep -o '"version": *"[^"]*"' | cut -d'"' -f4)
|
|
||||||
BINARY_DIR="dist/binaries"
|
|
||||||
|
|
||||||
echo "================================================"
|
|
||||||
echo " MAILER Compilation Script"
|
|
||||||
echo " Version: ${VERSION}"
|
|
||||||
echo "================================================"
|
|
||||||
echo ""
|
|
||||||
echo "Compiling for all supported platforms..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Clean up old binaries and create fresh directory
|
|
||||||
rm -rf "$BINARY_DIR"
|
|
||||||
mkdir -p "$BINARY_DIR"
|
|
||||||
echo "→ Cleaned old binaries from $BINARY_DIR"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Linux x86_64
|
|
||||||
echo "→ Compiling for Linux x86_64..."
|
|
||||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-linux-x64" \
|
|
||||||
--target x86_64-unknown-linux-gnu mod.ts
|
|
||||||
echo " ✓ Linux x86_64 complete"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Linux ARM64
|
|
||||||
echo "→ Compiling for Linux ARM64..."
|
|
||||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-linux-arm64" \
|
|
||||||
--target aarch64-unknown-linux-gnu mod.ts
|
|
||||||
echo " ✓ Linux ARM64 complete"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# macOS x86_64
|
|
||||||
echo "→ Compiling for macOS x86_64..."
|
|
||||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-macos-x64" \
|
|
||||||
--target x86_64-apple-darwin mod.ts
|
|
||||||
echo " ✓ macOS x86_64 complete"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# macOS ARM64
|
|
||||||
echo "→ Compiling for macOS ARM64..."
|
|
||||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-macos-arm64" \
|
|
||||||
--target aarch64-apple-darwin mod.ts
|
|
||||||
echo " ✓ macOS ARM64 complete"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Windows x86_64
|
|
||||||
echo "→ Compiling for Windows x86_64..."
|
|
||||||
deno compile --allow-all --no-check --output "$BINARY_DIR/mailer-windows-x64.exe" \
|
|
||||||
--target x86_64-pc-windows-msvc mod.ts
|
|
||||||
echo " ✓ Windows x86_64 complete"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "================================================"
|
|
||||||
echo " Compilation Summary"
|
|
||||||
echo "================================================"
|
|
||||||
echo ""
|
|
||||||
ls -lh "$BINARY_DIR/" | tail -n +2
|
|
||||||
echo ""
|
|
||||||
echo "✓ All binaries compiled successfully!"
|
|
||||||
echo ""
|
|
||||||
echo "Binary location: $BINARY_DIR/"
|
|
||||||
echo ""
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { smtpClientMod } from '../../ts/mail/delivery/index.js';
|
|
||||||
import type { ISmtpClientOptions, SmtpClient } from '../../ts/mail/delivery/smtpclient/index.js';
|
|
||||||
import { Email } from '../../ts/mail/core/classes.email.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a test SMTP client
|
|
||||||
*/
|
|
||||||
export function createTestSmtpClient(options: Partial<ISmtpClientOptions> = {}): SmtpClient {
|
|
||||||
const defaultOptions: ISmtpClientOptions = {
|
|
||||||
host: options.host || 'localhost',
|
|
||||||
port: options.port || 2525,
|
|
||||||
secure: options.secure || false,
|
|
||||||
auth: options.auth,
|
|
||||||
connectionTimeout: options.connectionTimeout || 5000,
|
|
||||||
socketTimeout: options.socketTimeout || 5000,
|
|
||||||
maxConnections: options.maxConnections || 5,
|
|
||||||
maxMessages: options.maxMessages || 100,
|
|
||||||
debug: options.debug || false,
|
|
||||||
tls: options.tls || {
|
|
||||||
rejectUnauthorized: false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return smtpClientMod.createSmtpClient(defaultOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send test email using SMTP client
|
|
||||||
*/
|
|
||||||
export async function sendTestEmail(
|
|
||||||
client: SmtpClient,
|
|
||||||
options: {
|
|
||||||
from?: string;
|
|
||||||
to?: string | string[];
|
|
||||||
subject?: string;
|
|
||||||
text?: string;
|
|
||||||
html?: string;
|
|
||||||
} = {}
|
|
||||||
): Promise<any> {
|
|
||||||
const mailOptions = {
|
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
const email = new Email({
|
|
||||||
from: mailOptions.from,
|
|
||||||
to: mailOptions.to,
|
|
||||||
subject: mailOptions.subject,
|
|
||||||
text: mailOptions.text,
|
|
||||||
html: mailOptions.html
|
|
||||||
});
|
|
||||||
return client.sendMail(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test SMTP client connection
|
|
||||||
*/
|
|
||||||
export async function testClientConnection(
|
|
||||||
host: string,
|
|
||||||
port: number,
|
|
||||||
timeout: number = 5000
|
|
||||||
): Promise<boolean> {
|
|
||||||
const client = createTestSmtpClient({
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
connectionTimeout: timeout
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await client.verify();
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
if (client.close) {
|
|
||||||
await client.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create authenticated SMTP client
|
|
||||||
*/
|
|
||||||
export function createAuthenticatedClient(
|
|
||||||
host: string,
|
|
||||||
port: number,
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
authMethod: 'PLAIN' | 'LOGIN' = 'PLAIN'
|
|
||||||
): SmtpClient {
|
|
||||||
return createTestSmtpClient({
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
auth: {
|
|
||||||
user: username,
|
|
||||||
pass: password,
|
|
||||||
method: authMethod
|
|
||||||
},
|
|
||||||
secure: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create TLS-enabled SMTP client
|
|
||||||
*/
|
|
||||||
export function createTlsClient(
|
|
||||||
host: string,
|
|
||||||
port: number,
|
|
||||||
options: {
|
|
||||||
secure?: boolean;
|
|
||||||
rejectUnauthorized?: boolean;
|
|
||||||
} = {}
|
|
||||||
): SmtpClient {
|
|
||||||
return createTestSmtpClient({
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
secure: options.secure || false,
|
|
||||||
tls: {
|
|
||||||
rejectUnauthorized: options.rejectUnauthorized || false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test client pool status
|
|
||||||
*/
|
|
||||||
export async function testClientPoolStatus(client: SmtpClient): Promise<any> {
|
|
||||||
if (typeof client.getPoolStatus === 'function') {
|
|
||||||
return client.getPoolStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for clients without pool status
|
|
||||||
return {
|
|
||||||
size: 1,
|
|
||||||
available: 1,
|
|
||||||
pending: 0,
|
|
||||||
connecting: 0,
|
|
||||||
active: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send multiple emails concurrently
|
|
||||||
*/
|
|
||||||
export async function sendConcurrentEmails(
|
|
||||||
client: SmtpClient,
|
|
||||||
count: number,
|
|
||||||
emailOptions: {
|
|
||||||
from?: string;
|
|
||||||
to?: string;
|
|
||||||
subject?: string;
|
|
||||||
text?: string;
|
|
||||||
} = {}
|
|
||||||
): Promise<any[]> {
|
|
||||||
const promises = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
promises.push(
|
|
||||||
sendTestEmail(client, {
|
|
||||||
...emailOptions,
|
|
||||||
subject: `${emailOptions.subject || 'Test Email'} ${i + 1}`
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.all(promises);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Measure client throughput
|
|
||||||
*/
|
|
||||||
export async function measureClientThroughput(
|
|
||||||
client: SmtpClient,
|
|
||||||
duration: number = 10000,
|
|
||||||
emailOptions: {
|
|
||||||
from?: string;
|
|
||||||
to?: string;
|
|
||||||
subject?: string;
|
|
||||||
text?: string;
|
|
||||||
} = {}
|
|
||||||
): Promise<{ totalSent: number; successCount: number; errorCount: number; throughput: number }> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
let totalSent = 0;
|
|
||||||
let successCount = 0;
|
|
||||||
let errorCount = 0;
|
|
||||||
|
|
||||||
while (Date.now() - startTime < duration) {
|
|
||||||
try {
|
|
||||||
await sendTestEmail(client, emailOptions);
|
|
||||||
successCount++;
|
|
||||||
} catch (error) {
|
|
||||||
errorCount++;
|
|
||||||
}
|
|
||||||
totalSent++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actualDuration = (Date.now() - startTime) / 1000; // in seconds
|
|
||||||
const throughput = totalSent / actualDuration;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalSent,
|
|
||||||
successCount,
|
|
||||||
errorCount,
|
|
||||||
throughput
|
|
||||||
};
|
|
||||||
}
|
|
||||||
239
test/test.e2e.inbound-smtp.node.ts
Normal file
239
test/test.e2e.inbound-smtp.node.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||||
|
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
import {
|
||||||
|
connectToSmtp,
|
||||||
|
waitForGreeting,
|
||||||
|
sendSmtpCommand,
|
||||||
|
performSmtpHandshake,
|
||||||
|
createConcurrentConnections,
|
||||||
|
createMimeMessage,
|
||||||
|
} from './helpers/utils.js';
|
||||||
|
|
||||||
|
const storageMap = new Map<string, string>();
|
||||||
|
const mockDcRouter = {
|
||||||
|
storageManager: {
|
||||||
|
get: async (key: string) => storageMap.get(key) || null,
|
||||||
|
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: UnifiedEmailServer;
|
||||||
|
let bridge: RustSecurityBridge;
|
||||||
|
let bridgeAvailable = false;
|
||||||
|
|
||||||
|
tap.test('setup - start server on port 10125', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
bridge = RustSecurityBridge.getInstance();
|
||||||
|
|
||||||
|
server = new UnifiedEmailServer(mockDcRouter, {
|
||||||
|
ports: [10125],
|
||||||
|
hostname: 'test.inbound.local',
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
domain: 'testdomain.com',
|
||||||
|
dnsMode: 'forward',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'catch-all',
|
||||||
|
priority: 0,
|
||||||
|
match: {
|
||||||
|
recipients: '*@testdomain.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
bridgeAvailable = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||||
|
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EHLO and capability discovery', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||||
|
const capabilities = await performSmtpHandshake(socket, 'test-client.local');
|
||||||
|
|
||||||
|
// Verify we received capabilities from the EHLO response
|
||||||
|
expect(capabilities.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The server hostname should be in the first capability line
|
||||||
|
const firstLine = capabilities[0];
|
||||||
|
expect(firstLine).toBeTruthy();
|
||||||
|
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('send valid email - full SMTP transaction', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||||
|
await waitForGreeting(socket, 10000);
|
||||||
|
|
||||||
|
// EHLO
|
||||||
|
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
|
||||||
|
|
||||||
|
// MAIL FROM
|
||||||
|
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
|
||||||
|
|
||||||
|
// RCPT TO
|
||||||
|
await sendSmtpCommand(socket, 'RCPT TO:<user@testdomain.com>', '250', 10000);
|
||||||
|
|
||||||
|
// DATA
|
||||||
|
await sendSmtpCommand(socket, 'DATA', '354', 10000);
|
||||||
|
|
||||||
|
// Send MIME message
|
||||||
|
const mimeMessage = createMimeMessage({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'user@testdomain.com',
|
||||||
|
subject: 'E2E Test Email',
|
||||||
|
text: 'This is an end-to-end test email.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send the message data followed by the terminator
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let buffer = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
socket.removeAllListeners('data');
|
||||||
|
reject(new Error('DATA response timeout'));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const onData = (data: Buffer) => {
|
||||||
|
buffer += data.toString();
|
||||||
|
if (buffer.includes('250')) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.removeListener('data', onData);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('data', onData);
|
||||||
|
socket.write(mimeMessage + '\r\n.\r\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
// QUIT
|
||||||
|
try {
|
||||||
|
await sendSmtpCommand(socket, 'QUIT', '221', 5000);
|
||||||
|
} catch {
|
||||||
|
// Ignore QUIT errors
|
||||||
|
}
|
||||||
|
socket.destroy();
|
||||||
|
|
||||||
|
// Verify the email was queued for processing
|
||||||
|
const stats = server.deliveryQueue.getStats();
|
||||||
|
expect(stats.queueSize).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('multiple recipients', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||||
|
await waitForGreeting(socket, 10000);
|
||||||
|
|
||||||
|
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
|
||||||
|
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
|
||||||
|
await sendSmtpCommand(socket, 'RCPT TO:<user1@testdomain.com>', '250', 10000);
|
||||||
|
await sendSmtpCommand(socket, 'RCPT TO:<user2@testdomain.com>', '250', 10000);
|
||||||
|
|
||||||
|
await sendSmtpCommand(socket, 'DATA', '354', 10000);
|
||||||
|
|
||||||
|
const mimeMessage = createMimeMessage({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'user1@testdomain.com',
|
||||||
|
subject: 'Multi-recipient Test',
|
||||||
|
text: 'Testing multiple recipients.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let buffer = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
socket.removeAllListeners('data');
|
||||||
|
reject(new Error('DATA response timeout'));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const onData = (data: Buffer) => {
|
||||||
|
buffer += data.toString();
|
||||||
|
if (buffer.includes('250')) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.removeListener('data', onData);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('data', onData);
|
||||||
|
socket.write(mimeMessage + '\r\n.\r\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('concurrent connections', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sockets = await createConcurrentConnections('127.0.0.1', 10125, 3, 10000);
|
||||||
|
expect(sockets.length).toEqual(3);
|
||||||
|
|
||||||
|
// Perform EHLO on each connection
|
||||||
|
for (const socket of sockets) {
|
||||||
|
await waitForGreeting(socket, 10000);
|
||||||
|
await sendSmtpCommand(socket, 'EHLO concurrent-client.local', '250', 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close all connections
|
||||||
|
for (const socket of sockets) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RSET mid-session', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
|
||||||
|
await waitForGreeting(socket, 10000);
|
||||||
|
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
|
||||||
|
|
||||||
|
// Start a transaction
|
||||||
|
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
|
||||||
|
|
||||||
|
// Reset the transaction
|
||||||
|
await sendSmtpCommand(socket, 'RSET', '250', 10000);
|
||||||
|
|
||||||
|
// Start a new transaction after RSET
|
||||||
|
await sendSmtpCommand(socket, 'MAIL FROM:<other@example.com>', '250', 10000);
|
||||||
|
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup - stop server', async () => {
|
||||||
|
if (bridgeAvailable) {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
196
test/test.e2e.outbound-delivery.node.ts
Normal file
196
test/test.e2e.outbound-delivery.node.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||||
|
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
import type { ISmtpPoolStatus } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
import * as net from 'net';
|
||||||
|
|
||||||
|
const storageMap = new Map<string, string>();
|
||||||
|
const mockDcRouter = {
|
||||||
|
storageManager: {
|
||||||
|
get: async (key: string) => storageMap.get(key) || null,
|
||||||
|
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: UnifiedEmailServer;
|
||||||
|
let bridge: RustSecurityBridge;
|
||||||
|
let bridgeAvailable = false;
|
||||||
|
let mockSmtpServer: net.Server;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a minimal mock SMTP server that accepts any email.
|
||||||
|
* This avoids the IPC deadlock that occurs when the Rust SMTP client
|
||||||
|
* sends to the same Rust process's SMTP server (the IPC stdin reader
|
||||||
|
* blocks on the sendEmail command and can't process emailProcessingResult).
|
||||||
|
*/
|
||||||
|
function createMockSmtpServer(port: number): Promise<net.Server> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const srv = net.createServer((socket) => {
|
||||||
|
socket.write('220 mock-smtp.local ESMTP MockServer\r\n');
|
||||||
|
|
||||||
|
let inData = false;
|
||||||
|
let dataBuffer = '';
|
||||||
|
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
const input = chunk.toString();
|
||||||
|
|
||||||
|
if (inData) {
|
||||||
|
dataBuffer += input;
|
||||||
|
if (dataBuffer.includes('\r\n.\r\n')) {
|
||||||
|
inData = false;
|
||||||
|
dataBuffer = '';
|
||||||
|
socket.write('250 2.0.0 Ok: queued\r\n');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process SMTP commands line by line
|
||||||
|
const lines = input.split('\r\n').filter((l: string) => l.length > 0);
|
||||||
|
for (const line of lines) {
|
||||||
|
const cmd = line.toUpperCase();
|
||||||
|
if (cmd.startsWith('EHLO') || cmd.startsWith('HELO')) {
|
||||||
|
socket.write(`250-mock-smtp.local\r\n250-SIZE 10485760\r\n250 OK\r\n`);
|
||||||
|
} else if (cmd.startsWith('MAIL FROM')) {
|
||||||
|
socket.write('250 2.1.0 Ok\r\n');
|
||||||
|
} else if (cmd.startsWith('RCPT TO')) {
|
||||||
|
socket.write('250 2.1.5 Ok\r\n');
|
||||||
|
} else if (cmd === 'DATA') {
|
||||||
|
inData = true;
|
||||||
|
dataBuffer = '';
|
||||||
|
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
||||||
|
} else if (cmd === 'QUIT') {
|
||||||
|
socket.write('221 2.0.0 Bye\r\n');
|
||||||
|
socket.end();
|
||||||
|
} else if (cmd === 'RSET') {
|
||||||
|
socket.write('250 2.0.0 Ok\r\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
srv.listen(port, '127.0.0.1', () => {
|
||||||
|
resolve(srv);
|
||||||
|
});
|
||||||
|
|
||||||
|
srv.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('setup - start bridge and mock SMTP server', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
bridge = RustSecurityBridge.getInstance();
|
||||||
|
|
||||||
|
server = new UnifiedEmailServer(mockDcRouter, {
|
||||||
|
ports: [10325],
|
||||||
|
hostname: 'test.outbound.local',
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
domain: 'outbound-test.com',
|
||||||
|
dnsMode: 'forward',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'catch-all',
|
||||||
|
priority: 0,
|
||||||
|
match: {
|
||||||
|
recipients: '*',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
bridgeAvailable = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||||
|
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a mock SMTP server on a separate port for outbound delivery tests
|
||||||
|
mockSmtpServer = await createMockSmtpServer(10326);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('send email to mock SMTP receiver', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@outbound-test.com',
|
||||||
|
to: 'recipient@outbound-test.com',
|
||||||
|
subject: 'Outbound E2E Test',
|
||||||
|
text: 'Testing outbound delivery to the mock SMTP server.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to the mock SMTP server (port 10326), not the Rust SMTP server (port 10325)
|
||||||
|
const result = await server.sendOutboundEmail('127.0.0.1', 10326, email);
|
||||||
|
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(result.accepted).toBeTruthy();
|
||||||
|
expect(result.accepted.length).toBeGreaterThan(0);
|
||||||
|
expect(result.response).toBeTruthy();
|
||||||
|
// Rust SMTP client returns enhanced status code without the 250 prefix
|
||||||
|
expect(result.response).toInclude('2.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('send email - connection refused', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@outbound-test.com',
|
||||||
|
to: 'recipient@outbound-test.com',
|
||||||
|
subject: 'Connection Refused Test',
|
||||||
|
text: 'This should fail — no server at port 59888.',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.sendOutboundEmail('127.0.0.1', 59888, email);
|
||||||
|
throw new Error('Expected sendOutboundEmail to fail on connection refused');
|
||||||
|
} catch (err: any) {
|
||||||
|
expect(err).toBeTruthy();
|
||||||
|
expect(err.message.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('SMTP pool status and cleanup', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status: ISmtpPoolStatus = await bridge.getSmtpPoolStatus();
|
||||||
|
expect(status).toBeTruthy();
|
||||||
|
expect(status.pools).toBeTruthy();
|
||||||
|
expect(typeof status.pools).toEqual('object');
|
||||||
|
|
||||||
|
// Close all pools
|
||||||
|
await bridge.closeSmtpPool();
|
||||||
|
|
||||||
|
// Verify pools are empty
|
||||||
|
const statusAfter = await bridge.getSmtpPoolStatus();
|
||||||
|
const poolKeys = Object.keys(statusAfter.pools);
|
||||||
|
expect(poolKeys.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup - stop server and mock', async () => {
|
||||||
|
if (mockSmtpServer) {
|
||||||
|
await new Promise<void>((resolve) => mockSmtpServer.close(() => resolve()));
|
||||||
|
}
|
||||||
|
if (bridgeAvailable) {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
239
test/test.e2e.routing-actions.node.ts
Normal file
239
test/test.e2e.routing-actions.node.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||||
|
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
import { SmtpState } from '../ts/mail/delivery/interfaces.js';
|
||||||
|
|
||||||
|
const storageMap = new Map<string, string>();
|
||||||
|
const mockDcRouter = {
|
||||||
|
storageManager: {
|
||||||
|
get: async (key: string) => storageMap.get(key) || null,
|
||||||
|
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: UnifiedEmailServer;
|
||||||
|
let bridge: RustSecurityBridge;
|
||||||
|
let bridgeAvailable = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal SMTP session object for processEmailByMode().
|
||||||
|
*/
|
||||||
|
function buildSession(email: Email): any {
|
||||||
|
return {
|
||||||
|
id: `test-${Date.now()}-${Math.random().toString(36).substring(2)}`,
|
||||||
|
state: SmtpState.FINISHED,
|
||||||
|
mailFrom: email.from,
|
||||||
|
rcptTo: email.to,
|
||||||
|
emailData: '',
|
||||||
|
useTLS: false,
|
||||||
|
connectionEnded: false,
|
||||||
|
remoteAddress: '127.0.0.1',
|
||||||
|
clientHostname: 'test-client.local',
|
||||||
|
secure: false,
|
||||||
|
authenticated: false,
|
||||||
|
envelope: {
|
||||||
|
mailFrom: { address: email.from, args: {} },
|
||||||
|
rcptTo: email.to.map((addr: string) => ({ address: addr, args: {} })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('setup - start server with routing rules on port 10225', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
bridge = RustSecurityBridge.getInstance();
|
||||||
|
|
||||||
|
server = new UnifiedEmailServer(mockDcRouter, {
|
||||||
|
ports: [10225],
|
||||||
|
hostname: 'test.routing.local',
|
||||||
|
domains: [
|
||||||
|
{ domain: 'process.com', dnsMode: 'forward' },
|
||||||
|
{ domain: 'local.com', dnsMode: 'forward' },
|
||||||
|
{ domain: 'external.com', dnsMode: 'forward' },
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'reject-route',
|
||||||
|
priority: 40,
|
||||||
|
match: {
|
||||||
|
senders: '*@spammer.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'reject',
|
||||||
|
reject: {
|
||||||
|
code: 550,
|
||||||
|
message: 'Spam rejected',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'process-route',
|
||||||
|
priority: 30,
|
||||||
|
match: {
|
||||||
|
recipients: '*@process.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
process: {
|
||||||
|
scan: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deliver-route',
|
||||||
|
priority: 20,
|
||||||
|
match: {
|
||||||
|
recipients: '*@local.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'deliver',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'forward-route',
|
||||||
|
priority: 10,
|
||||||
|
match: {
|
||||||
|
recipients: '*@external.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
forward: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 59999, // No server listening — expected failure
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
bridgeAvailable = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||||
|
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('process action - queues email for processing', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'user@process.com',
|
||||||
|
subject: 'Process test',
|
||||||
|
text: 'This email should be queued for processing.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = buildSession(email);
|
||||||
|
const result = await server.processEmailByMode(email, session);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
|
||||||
|
const stats = server.deliveryQueue.getStats();
|
||||||
|
expect(stats.modes.process).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('deliver action - queues email for MTA delivery', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'user@local.com',
|
||||||
|
subject: 'Deliver test',
|
||||||
|
text: 'This email should be queued for local delivery.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = buildSession(email);
|
||||||
|
const result = await server.processEmailByMode(email, session);
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
|
||||||
|
const stats = server.deliveryQueue.getStats();
|
||||||
|
expect(stats.modes.mta).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reject action - throws with correct code', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'bad@spammer.com',
|
||||||
|
to: 'user@process.com',
|
||||||
|
subject: 'Spam attempt',
|
||||||
|
text: 'This should be rejected.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = buildSession(email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.processEmailByMode(email, session);
|
||||||
|
throw new Error('Expected processEmailByMode to throw for rejected email');
|
||||||
|
} catch (err: any) {
|
||||||
|
expect(err.responseCode).toEqual(550);
|
||||||
|
expect(err.message).toInclude('Spam rejected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('forward action - fails to unreachable host', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'user@external.com',
|
||||||
|
subject: 'Forward test',
|
||||||
|
text: 'This forward should fail — no server at port 59999.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = buildSession(email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.processEmailByMode(email, session);
|
||||||
|
throw new Error('Expected processEmailByMode to throw for unreachable forward host');
|
||||||
|
} catch (err: any) {
|
||||||
|
// We expect an error from the failed SMTP connection
|
||||||
|
expect(err).toBeTruthy();
|
||||||
|
expect(err.message).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('no matching route - throws error', async () => {
|
||||||
|
if (!bridgeAvailable) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = new Email({
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: 'nobody@unmatched.com',
|
||||||
|
subject: 'Unmatched route test',
|
||||||
|
text: 'No route matches this recipient.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = buildSession(email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.processEmailByMode(email, session);
|
||||||
|
throw new Error('Expected processEmailByMode to throw for no matching route');
|
||||||
|
} catch (err: any) {
|
||||||
|
expect(err.message).toInclude('No matching route');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup - stop server', async () => {
|
||||||
|
if (bridgeAvailable) {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
118
test/test.e2e.server-lifecycle.node.ts
Normal file
118
test/test.e2e.server-lifecycle.node.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
|
||||||
|
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
import { connectToSmtp, waitForGreeting } from './helpers/utils.js';
|
||||||
|
import * as net from 'net';
|
||||||
|
|
||||||
|
// Common mock pattern for dcRouter dependency
|
||||||
|
const storageMap = new Map<string, string>();
|
||||||
|
const mockDcRouter = {
|
||||||
|
storageManager: {
|
||||||
|
get: async (key: string) => storageMap.get(key) || null,
|
||||||
|
set: async (key: string, value: string) => { storageMap.set(key, value); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: UnifiedEmailServer;
|
||||||
|
let bridge: RustSecurityBridge;
|
||||||
|
|
||||||
|
tap.test('setup - reset bridge singleton', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
bridge = RustSecurityBridge.getInstance();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('construct server - should create UnifiedEmailServer', async () => {
|
||||||
|
server = new UnifiedEmailServer(mockDcRouter, {
|
||||||
|
ports: [10025, 10587],
|
||||||
|
hostname: 'test.e2e.local',
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
domain: 'e2e-test.com',
|
||||||
|
dnsMode: 'forward',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'catch-all',
|
||||||
|
priority: 0,
|
||||||
|
match: {
|
||||||
|
recipients: '*@e2e-test.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'process',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(server).toBeTruthy();
|
||||||
|
expect(server).toBeInstanceOf(UnifiedEmailServer);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('start server - should start and accept SMTP connections', async () => {
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
|
||||||
|
console.log('Build the Rust binary with: cd rust && cargo build --release');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(bridge.running).toBeTrue();
|
||||||
|
|
||||||
|
// Connect to port 10025 and verify we get a 220 greeting
|
||||||
|
const socket = await connectToSmtp('127.0.0.1', 10025, 10000);
|
||||||
|
const greeting = await waitForGreeting(socket, 10000);
|
||||||
|
expect(greeting).toInclude('220');
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('get stats - should return server statistics', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = server.getStats();
|
||||||
|
expect(stats).toBeTruthy();
|
||||||
|
expect(stats.startTime).toBeInstanceOf(Date);
|
||||||
|
expect(stats.connections).toBeTruthy();
|
||||||
|
expect(typeof stats.connections.current).toEqual('number');
|
||||||
|
expect(typeof stats.connections.total).toEqual('number');
|
||||||
|
expect(stats.messages).toBeTruthy();
|
||||||
|
expect(typeof stats.messages.processed).toEqual('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop server - should stop and refuse connections', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await server.stop();
|
||||||
|
|
||||||
|
// Verify connection is refused after stop
|
||||||
|
try {
|
||||||
|
const socket = await connectToSmtp('127.0.0.1', 10025, 3000);
|
||||||
|
socket.destroy();
|
||||||
|
// If we get here, the connection was accepted — that's unexpected
|
||||||
|
throw new Error('Expected connection to be refused after server stop');
|
||||||
|
} catch (err) {
|
||||||
|
// Connection refused or timeout is expected
|
||||||
|
const msg = (err as Error).message;
|
||||||
|
expect(
|
||||||
|
msg.includes('ECONNREFUSED') || msg.includes('timeout') || msg.includes('refused')
|
||||||
|
).toBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
// Clean up if not already stopped
|
||||||
|
if (bridge.running) {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
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();
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import { smtpClientMod } from '../ts/mail/delivery/index.js';
|
|
||||||
import type { ISmtpClientOptions, SmtpClient } from '../ts/mail/delivery/smtpclient/index.js';
|
|
||||||
import { Email } from '../ts/mail/core/classes.email.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compatibility tests for the legacy SMTP client facade
|
|
||||||
*/
|
|
||||||
|
|
||||||
tap.test('verify backward compatibility - client creation', async () => {
|
|
||||||
// Create test configuration
|
|
||||||
const options: ISmtpClientOptions = {
|
|
||||||
host: 'smtp.example.com',
|
|
||||||
port: 587,
|
|
||||||
secure: false,
|
|
||||||
connectionTimeout: 10000,
|
|
||||||
domain: 'test.example.com'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create SMTP client instance using legacy constructor
|
|
||||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
|
||||||
|
|
||||||
// Verify instance was created correctly
|
|
||||||
expect(smtpClient).toBeTruthy();
|
|
||||||
expect(smtpClient.isConnected()).toBeFalsy(); // Should start disconnected
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('verify backward compatibility - methods exist', async () => {
|
|
||||||
const options: ISmtpClientOptions = {
|
|
||||||
host: 'smtp.example.com',
|
|
||||||
port: 587,
|
|
||||||
secure: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
|
||||||
|
|
||||||
// Verify all expected methods exist
|
|
||||||
expect(typeof smtpClient.sendMail === 'function').toBeTruthy();
|
|
||||||
expect(typeof smtpClient.verify === 'function').toBeTruthy();
|
|
||||||
expect(typeof smtpClient.isConnected === 'function').toBeTruthy();
|
|
||||||
expect(typeof smtpClient.getPoolStatus === 'function').toBeTruthy();
|
|
||||||
expect(typeof smtpClient.updateOptions === 'function').toBeTruthy();
|
|
||||||
expect(typeof smtpClient.close === 'function').toBeTruthy();
|
|
||||||
expect(typeof smtpClient.on === 'function').toBeTruthy();
|
|
||||||
expect(typeof smtpClient.off === 'function').toBeTruthy();
|
|
||||||
expect(typeof smtpClient.emit === 'function').toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('verify backward compatibility - options update', async () => {
|
|
||||||
const options: ISmtpClientOptions = {
|
|
||||||
host: 'smtp.example.com',
|
|
||||||
port: 587,
|
|
||||||
secure: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
|
||||||
|
|
||||||
// Test option updates don't throw
|
|
||||||
expect(() => smtpClient.updateOptions({
|
|
||||||
host: 'new-smtp.example.com',
|
|
||||||
port: 465,
|
|
||||||
secure: true
|
|
||||||
})).not.toThrow();
|
|
||||||
|
|
||||||
expect(() => smtpClient.updateOptions({
|
|
||||||
debug: true,
|
|
||||||
connectionTimeout: 5000
|
|
||||||
})).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('verify backward compatibility - connection failure handling', async () => {
|
|
||||||
const options: ISmtpClientOptions = {
|
|
||||||
host: 'nonexistent.invalid.domain',
|
|
||||||
port: 587,
|
|
||||||
secure: false,
|
|
||||||
connectionTimeout: 1000 // Short timeout for faster test
|
|
||||||
};
|
|
||||||
|
|
||||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
|
||||||
|
|
||||||
// verify() should return false for invalid hosts
|
|
||||||
const isValid = await smtpClient.verify();
|
|
||||||
expect(isValid).toBeFalsy();
|
|
||||||
|
|
||||||
// sendMail should fail gracefully for invalid hosts
|
|
||||||
const email = new Email({
|
|
||||||
from: 'test@example.com',
|
|
||||||
to: 'recipient@example.com',
|
|
||||||
subject: 'Test Email',
|
|
||||||
text: 'This is a test email'
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await smtpClient.sendMail(email);
|
|
||||||
expect(result.success).toBeFalsy();
|
|
||||||
expect(result.error).toBeTruthy();
|
|
||||||
} catch (error) {
|
|
||||||
// Connection errors are expected for invalid domains
|
|
||||||
expect(error).toBeTruthy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('verify backward compatibility - pool status', async () => {
|
|
||||||
const options: ISmtpClientOptions = {
|
|
||||||
host: 'smtp.example.com',
|
|
||||||
port: 587,
|
|
||||||
secure: false,
|
|
||||||
pool: true,
|
|
||||||
maxConnections: 5
|
|
||||||
};
|
|
||||||
|
|
||||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
|
||||||
|
|
||||||
// Get pool status
|
|
||||||
const status = smtpClient.getPoolStatus();
|
|
||||||
expect(status).toBeTruthy();
|
|
||||||
expect(typeof status.total === 'number').toBeTruthy();
|
|
||||||
expect(typeof status.active === 'number').toBeTruthy();
|
|
||||||
expect(typeof status.idle === 'number').toBeTruthy();
|
|
||||||
expect(typeof status.pending === 'number').toBeTruthy();
|
|
||||||
|
|
||||||
// Initially should have no connections
|
|
||||||
expect(status.total).toEqual(0);
|
|
||||||
expect(status.active).toEqual(0);
|
|
||||||
expect(status.idle).toEqual(0);
|
|
||||||
expect(status.pending).toEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('verify backward compatibility - event handling', async () => {
|
|
||||||
const options: ISmtpClientOptions = {
|
|
||||||
host: 'smtp.example.com',
|
|
||||||
port: 587,
|
|
||||||
secure: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
|
||||||
|
|
||||||
// Test event listener methods don't throw
|
|
||||||
const testListener = () => {};
|
|
||||||
|
|
||||||
expect(() => smtpClient.on('test', testListener)).not.toThrow();
|
|
||||||
expect(() => smtpClient.off('test', testListener)).not.toThrow();
|
|
||||||
expect(() => smtpClient.emit('test')).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('clean up after compatibility tests', async () => {
|
|
||||||
// No-op - just to make sure everything is cleaned up properly
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
|
||||||
await tap.stopForcefully();
|
|
||||||
});
|
|
||||||
|
|
||||||
export default tap.start();
|
|
||||||
107
test/test.smtp.client.rust.node.ts
Normal file
107
test/test.smtp.client.rust.node.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
import type { ISmtpSendOptions, ISmtpPoolStatus } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
|
let bridge: RustSecurityBridge;
|
||||||
|
|
||||||
|
tap.test('Rust SMTP Client - should start bridge', async () => {
|
||||||
|
RustSecurityBridge.resetInstance();
|
||||||
|
bridge = RustSecurityBridge.getInstance();
|
||||||
|
const ok = await bridge.start();
|
||||||
|
if (!ok) {
|
||||||
|
console.log('WARNING: Rust binary not available — skipping Rust SMTP client tests');
|
||||||
|
console.log('Build it with: cd rust && cargo build --release');
|
||||||
|
}
|
||||||
|
expect(typeof ok).toEqual('boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Rust SMTP Client - getSmtpPoolStatus returns valid structure', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const status: ISmtpPoolStatus = await bridge.getSmtpPoolStatus();
|
||||||
|
expect(status).toBeTruthy();
|
||||||
|
expect(status.pools).toBeTruthy();
|
||||||
|
expect(typeof status.pools).toEqual('object');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Rust SMTP Client - verifySmtpConnection with unreachable host', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Use a non-routable IP to test connection failure handling
|
||||||
|
const result = await bridge.verifySmtpConnection({
|
||||||
|
host: '192.0.2.1', // TEST-NET-1 (RFC 5737) - guaranteed unreachable
|
||||||
|
port: 25,
|
||||||
|
secure: false,
|
||||||
|
domain: 'test.example.com',
|
||||||
|
});
|
||||||
|
// If it returns rather than throwing, reachable should be false
|
||||||
|
expect(result.reachable).toBeFalse();
|
||||||
|
} catch (err) {
|
||||||
|
// Connection errors are expected for unreachable hosts
|
||||||
|
expect(err).toBeTruthy();
|
||||||
|
expect(err.message || String(err)).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Rust SMTP Client - sendEmail with connection refused error', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opts: ISmtpSendOptions = {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 52599, // random high port - should be refused
|
||||||
|
secure: false,
|
||||||
|
domain: 'test.example.com',
|
||||||
|
email: {
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: ['recipient@example.com'],
|
||||||
|
subject: 'Test email',
|
||||||
|
text: 'This is a test.',
|
||||||
|
},
|
||||||
|
connectionTimeoutSecs: 5,
|
||||||
|
socketTimeoutSecs: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bridge.sendOutboundEmail(opts);
|
||||||
|
// Should not succeed — no server is listening
|
||||||
|
throw new Error('Expected sendEmail to fail on connection refused');
|
||||||
|
} catch (err) {
|
||||||
|
// We expect a connection error
|
||||||
|
expect(err).toBeTruthy();
|
||||||
|
const msg = err.message || String(err);
|
||||||
|
expect(msg.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Rust SMTP Client - closeSmtpPool cleans up', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Should not throw, even when no pools exist
|
||||||
|
await bridge.closeSmtpPool();
|
||||||
|
|
||||||
|
// Verify pool status is empty after close
|
||||||
|
const status = await bridge.getSmtpPoolStatus();
|
||||||
|
expect(status.pools).toBeTruthy();
|
||||||
|
const poolKeys = Object.keys(status.pools);
|
||||||
|
expect(poolKeys.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Rust SMTP Client - stop bridge', async () => {
|
||||||
|
if (!bridge.running) {
|
||||||
|
console.log('SKIP: bridge not running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await bridge.stop();
|
||||||
|
expect(bridge.state).toEqual(BridgeState.Stopped);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
||||||
import * as plugins from '../ts/plugins.js';
|
|
||||||
import * as paths from '../ts/paths.js';
|
|
||||||
import { smtpClientMod } from '../ts/mail/delivery/index.js';
|
|
||||||
import type { ISmtpClientOptions, SmtpClient } from '../ts/mail/delivery/smtpclient/index.js';
|
|
||||||
import { Email } from '../ts/mail/core/classes.email.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for the SMTP client class
|
|
||||||
*/
|
|
||||||
tap.test('verify SMTP client initialization', async () => {
|
|
||||||
// Create test configuration
|
|
||||||
const options: ISmtpClientOptions = {
|
|
||||||
host: 'smtp.example.com',
|
|
||||||
port: 587,
|
|
||||||
secure: false,
|
|
||||||
connectionTimeout: 10000,
|
|
||||||
domain: 'test.example.com'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create SMTP client instance
|
|
||||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
|
||||||
|
|
||||||
// Verify instance was created correctly
|
|
||||||
expect(smtpClient).toBeTruthy();
|
|
||||||
expect(smtpClient.isConnected()).toBeFalsy(); // Should start disconnected
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('test SMTP client configuration update', async () => {
|
|
||||||
// Create test configuration
|
|
||||||
const options: ISmtpClientOptions = {
|
|
||||||
host: 'smtp.example.com',
|
|
||||||
port: 587,
|
|
||||||
secure: false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create SMTP client instance
|
|
||||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
|
||||||
|
|
||||||
// Update configuration
|
|
||||||
smtpClient.updateOptions({
|
|
||||||
host: 'new-smtp.example.com',
|
|
||||||
port: 465,
|
|
||||||
secure: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Can't directly test private fields, but we can verify it doesn't throw
|
|
||||||
expect(() => smtpClient.updateOptions({
|
|
||||||
tls: {
|
|
||||||
rejectUnauthorized: false
|
|
||||||
}
|
|
||||||
})).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mocked SMTP server for testing
|
|
||||||
class MockSmtpServer {
|
|
||||||
private responses: Map<string, string>;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.responses = new Map();
|
|
||||||
|
|
||||||
// Default responses
|
|
||||||
this.responses.set('connect', '220 smtp.example.com ESMTP ready');
|
|
||||||
this.responses.set('EHLO', '250-smtp.example.com\r\n250-PIPELINING\r\n250-SIZE 10240000\r\n250-STARTTLS\r\n250-AUTH PLAIN LOGIN\r\n250 HELP');
|
|
||||||
this.responses.set('MAIL FROM', '250 OK');
|
|
||||||
this.responses.set('RCPT TO', '250 OK');
|
|
||||||
this.responses.set('DATA', '354 Start mail input; end with <CRLF>.<CRLF>');
|
|
||||||
this.responses.set('data content', '250 OK: message accepted');
|
|
||||||
this.responses.set('QUIT', '221 Bye');
|
|
||||||
}
|
|
||||||
|
|
||||||
public setResponse(command: string, response: string): void {
|
|
||||||
this.responses.set(command, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getResponse(command: string): string {
|
|
||||||
if (command.startsWith('MAIL FROM')) {
|
|
||||||
return this.responses.get('MAIL FROM') || '250 OK';
|
|
||||||
} else if (command.startsWith('RCPT TO')) {
|
|
||||||
return this.responses.get('RCPT TO') || '250 OK';
|
|
||||||
} else if (command.startsWith('EHLO') || command.startsWith('HELO')) {
|
|
||||||
return this.responses.get('EHLO') || '250 OK';
|
|
||||||
} else if (command === 'DATA') {
|
|
||||||
return this.responses.get('DATA') || '354 Start mail input; end with <CRLF>.<CRLF>';
|
|
||||||
} else if (command.includes('Content-Type')) {
|
|
||||||
return this.responses.get('data content') || '250 OK: message accepted';
|
|
||||||
} else if (command === 'QUIT') {
|
|
||||||
return this.responses.get('QUIT') || '221 Bye';
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.responses.get(command) || '250 OK';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This test validates the SMTP client public interface
|
|
||||||
*/
|
|
||||||
tap.test('verify SMTP client email delivery functionality with mock', async () => {
|
|
||||||
// Create a test email
|
|
||||||
const testEmail = new Email({
|
|
||||||
from: 'sender@example.com',
|
|
||||||
to: ['recipient@example.com'],
|
|
||||||
subject: 'Test Email',
|
|
||||||
text: 'This is a test email'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create SMTP client options
|
|
||||||
const options: ISmtpClientOptions = {
|
|
||||||
host: 'smtp.example.com',
|
|
||||||
port: 587,
|
|
||||||
secure: false,
|
|
||||||
domain: 'test.example.com',
|
|
||||||
auth: {
|
|
||||||
user: 'testuser',
|
|
||||||
pass: 'testpass'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create SMTP client instance
|
|
||||||
const smtpClient = smtpClientMod.createSmtpClient(options);
|
|
||||||
|
|
||||||
// Test public methods exist and have correct signatures
|
|
||||||
expect(typeof smtpClient.sendMail).toEqual('function');
|
|
||||||
expect(typeof smtpClient.verify).toEqual('function');
|
|
||||||
expect(typeof smtpClient.isConnected).toEqual('function');
|
|
||||||
expect(typeof smtpClient.getPoolStatus).toEqual('function');
|
|
||||||
expect(typeof smtpClient.updateOptions).toEqual('function');
|
|
||||||
expect(typeof smtpClient.close).toEqual('function');
|
|
||||||
|
|
||||||
// Test connection status before any operation
|
|
||||||
expect(smtpClient.isConnected()).toBeFalsy();
|
|
||||||
|
|
||||||
// Test pool status
|
|
||||||
const poolStatus = smtpClient.getPoolStatus();
|
|
||||||
expect(poolStatus).toBeTruthy();
|
|
||||||
expect(typeof poolStatus.active).toEqual('number');
|
|
||||||
expect(typeof poolStatus.idle).toEqual('number');
|
|
||||||
expect(typeof poolStatus.total).toEqual('number');
|
|
||||||
|
|
||||||
// Since we can't connect to a real server, we'll skip the actual send test
|
|
||||||
// and just verify the client was created correctly
|
|
||||||
expect(smtpClient).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('test SMTP client error handling with mock', async () => {
|
|
||||||
// Create SMTP client instance
|
|
||||||
const smtpClient = smtpClientMod.createSmtpClient({
|
|
||||||
host: 'smtp.example.com',
|
|
||||||
port: 587,
|
|
||||||
secure: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test with valid email (Email class might allow any string)
|
|
||||||
const testEmail = new Email({
|
|
||||||
from: 'sender@example.com',
|
|
||||||
to: ['recipient@example.com'],
|
|
||||||
subject: 'Test Email',
|
|
||||||
text: 'This is a test email'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test event listener methods
|
|
||||||
const mockListener = () => {};
|
|
||||||
smtpClient.on('test-event', mockListener);
|
|
||||||
smtpClient.off('test-event', mockListener);
|
|
||||||
|
|
||||||
// Test update options
|
|
||||||
smtpClient.updateOptions({
|
|
||||||
auth: {
|
|
||||||
user: 'newuser',
|
|
||||||
pass: 'newpass'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify client is still functional
|
|
||||||
expect(smtpClient.isConnected()).toBeFalsy();
|
|
||||||
|
|
||||||
// Test close on a non-connected client
|
|
||||||
await smtpClient.close();
|
|
||||||
expect(smtpClient.isConnected()).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Final clean-up test
|
|
||||||
tap.test('clean up after tests', async () => {
|
|
||||||
// No-op - just to make sure everything is cleaned up properly
|
|
||||||
});
|
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
|
||||||
await tap.stopForcefully();
|
|
||||||
});
|
|
||||||
|
|
||||||
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: '4.1.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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import * as net from 'node:net';
|
|
||||||
import * as tls from 'node:tls';
|
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import {
|
import {
|
||||||
SecurityLogger,
|
SecurityLogger,
|
||||||
SecurityLogLevel,
|
SecurityLogLevel,
|
||||||
SecurityEventType
|
SecurityEventType
|
||||||
} from '../../security/index.js';
|
} from '../../security/index.js';
|
||||||
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js';
|
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js';
|
||||||
import type { Email } from '../core/classes.email.js';
|
import type { Email } from '../core/classes.email.js';
|
||||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||||
import type { SmtpClient } from './smtpclient/smtp-client.js';
|
|
||||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,7 +114,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|||||||
* Create a new multi-mode delivery system
|
* Create a new multi-mode delivery system
|
||||||
* @param queue Unified delivery queue
|
* @param queue Unified delivery queue
|
||||||
* @param options Delivery options
|
* @param options Delivery options
|
||||||
* @param emailServer Optional reference to unified email server for SmtpClient access
|
* @param emailServer Optional reference to unified email server for outbound delivery
|
||||||
*/
|
*/
|
||||||
constructor(queue: UnifiedDeliveryQueue, options: IMultiModeDeliveryOptions, emailServer?: UnifiedEmailServer) {
|
constructor(queue: UnifiedDeliveryQueue, options: IMultiModeDeliveryOptions, emailServer?: UnifiedEmailServer) {
|
||||||
super();
|
super();
|
||||||
@@ -433,191 +430,52 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private async handleForwardDelivery(item: IQueueItem): Promise<any> {
|
private async handleForwardDelivery(item: IQueueItem): Promise<any> {
|
||||||
logger.log('info', `Forward delivery for item ${item.id}`);
|
logger.log('info', `Forward delivery for item ${item.id}`);
|
||||||
|
|
||||||
const email = item.processingResult as Email;
|
const email = item.processingResult as Email;
|
||||||
const route = item.route;
|
const route = item.route;
|
||||||
|
|
||||||
// Get target server information
|
// Get target server information
|
||||||
const targetServer = route?.action.forward?.host;
|
const targetServer = route?.action.forward?.host;
|
||||||
const targetPort = route?.action.forward?.port || 25;
|
const targetPort = route?.action.forward?.port || 25;
|
||||||
const useTls = false; // TLS configuration can be enhanced later
|
|
||||||
|
|
||||||
if (!targetServer) {
|
if (!targetServer) {
|
||||||
throw new Error('No target server configured for forward mode');
|
throw new Error('No target server configured for forward mode');
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', `Forwarding email to ${targetServer}:${targetPort}, TLS: ${useTls}`);
|
logger.log('info', `Forwarding email to ${targetServer}:${targetPort}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get SMTP client from email server if available
|
|
||||||
if (!this.emailServer) {
|
if (!this.emailServer) {
|
||||||
// Fall back to raw socket implementation if no email server
|
throw new Error('No email server available for forward delivery');
|
||||||
logger.log('warn', 'No email server available, falling back to raw socket implementation');
|
|
||||||
return this.handleForwardDeliveryLegacy(item);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get SMTP client from UnifiedEmailServer
|
// Build DKIM options from route config
|
||||||
const smtpClient = this.emailServer.getSmtpClient(targetServer, targetPort);
|
const dkimDomain = item.route?.action.options?.mtaOptions?.dkimSign
|
||||||
|
? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.from.split('@')[1])
|
||||||
// Apply DKIM signing if configured in the route
|
: undefined;
|
||||||
if (item.route?.action.options?.mtaOptions?.dkimSign) {
|
const dkimSelector = item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector || 'default';
|
||||||
await this.applyDkimSigning(email, item.route.action.options.mtaOptions);
|
|
||||||
}
|
// Build auth options from route forward config
|
||||||
|
const auth = route?.action.forward?.auth as { user: string; pass: string } | undefined;
|
||||||
// Send the email using SmtpClient
|
|
||||||
const result = await smtpClient.sendMail(email);
|
// Send via Rust SMTP client
|
||||||
|
const result = await this.emailServer.sendOutboundEmail(targetServer, targetPort, email, {
|
||||||
if (result.success) {
|
auth,
|
||||||
logger.log('info', `Email forwarded successfully to ${targetServer}:${targetPort}`);
|
dkimDomain,
|
||||||
|
dkimSelector,
|
||||||
return {
|
|
||||||
targetServer: targetServer,
|
|
||||||
targetPort: targetPort,
|
|
||||||
recipients: result.acceptedRecipients.length,
|
|
||||||
messageId: result.messageId,
|
|
||||||
rejectedRecipients: result.rejectedRecipients
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error?.message || 'Failed to forward email');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.log('error', `Failed to forward email: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy forward delivery using raw sockets (fallback)
|
|
||||||
* @param item Queue item
|
|
||||||
*/
|
|
||||||
private async handleForwardDeliveryLegacy(item: IQueueItem): Promise<any> {
|
|
||||||
const email = item.processingResult as Email;
|
|
||||||
const route = item.route;
|
|
||||||
|
|
||||||
// Get target server information
|
|
||||||
const targetServer = route?.action.forward?.host;
|
|
||||||
const targetPort = route?.action.forward?.port || 25;
|
|
||||||
const useTls = false; // TLS configuration can be enhanced later
|
|
||||||
|
|
||||||
if (!targetServer) {
|
|
||||||
throw new Error('No target server configured for forward mode');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a socket connection to the target server
|
|
||||||
const socket = new net.Socket();
|
|
||||||
|
|
||||||
// Set timeout
|
|
||||||
socket.setTimeout(this.options.socketTimeout);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Connect to the target server
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
// Handle connection events
|
|
||||||
socket.on('connect', () => {
|
|
||||||
logger.log('debug', `Connected to ${targetServer}:${targetPort}`);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
reject(new Error(`Connection timeout to ${targetServer}:${targetPort}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
reject(new Error(`Connection error to ${targetServer}:${targetPort}: ${err.message}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connect to the server
|
|
||||||
socket.connect({
|
|
||||||
host: targetServer,
|
|
||||||
port: targetPort
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send EHLO
|
logger.log('info', `Email forwarded successfully to ${targetServer}:${targetPort}`);
|
||||||
await this.smtpCommand(socket, `EHLO ${route?.action.options?.mtaOptions?.domain || 'localhost'}`);
|
|
||||||
|
|
||||||
// Start TLS if required
|
|
||||||
if (useTls) {
|
|
||||||
await this.smtpCommand(socket, 'STARTTLS');
|
|
||||||
|
|
||||||
// Upgrade to TLS
|
|
||||||
const tlsSocket = await this.upgradeTls(socket, targetServer);
|
|
||||||
|
|
||||||
// Send EHLO again after STARTTLS
|
|
||||||
await this.smtpCommand(tlsSocket, `EHLO ${route?.action.options?.mtaOptions?.domain || 'localhost'}`);
|
|
||||||
|
|
||||||
// Use tlsSocket for remaining commands
|
|
||||||
return this.completeSMTPExchange(tlsSocket, email, route);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete the SMTP exchange
|
|
||||||
return this.completeSMTPExchange(socket, email, route);
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.log('error', `Failed to forward email: ${error.message}`);
|
|
||||||
|
|
||||||
// Close the connection
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Complete the SMTP exchange after connection and initial setup
|
|
||||||
* @param socket Network socket
|
|
||||||
* @param email Email to send
|
|
||||||
* @param rule Domain rule
|
|
||||||
*/
|
|
||||||
private async completeSMTPExchange(socket: net.Socket | tls.TLSSocket, email: Email, route: any): Promise<any> {
|
|
||||||
try {
|
|
||||||
// Authenticate if credentials provided
|
|
||||||
if (route?.action?.forward?.auth?.user && route?.action?.forward?.auth?.pass) {
|
|
||||||
// Send AUTH LOGIN
|
|
||||||
await this.smtpCommand(socket, 'AUTH LOGIN');
|
|
||||||
|
|
||||||
// Send username (base64)
|
|
||||||
const username = Buffer.from(route.action.forward.auth.user).toString('base64');
|
|
||||||
await this.smtpCommand(socket, username);
|
|
||||||
|
|
||||||
// Send password (base64)
|
|
||||||
const password = Buffer.from(route.action.forward.auth.pass).toString('base64');
|
|
||||||
await this.smtpCommand(socket, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
await this.smtpCommand(socket, `MAIL FROM:<${email.from}>`);
|
|
||||||
|
|
||||||
// Send RCPT TO for each recipient
|
|
||||||
for (const recipient of email.getAllRecipients()) {
|
|
||||||
await this.smtpCommand(socket, `RCPT TO:<${recipient}>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send DATA
|
|
||||||
await this.smtpCommand(socket, 'DATA');
|
|
||||||
|
|
||||||
// Send email content (simplified)
|
|
||||||
const emailContent = await this.getFormattedEmail(email);
|
|
||||||
await this.smtpData(socket, emailContent);
|
|
||||||
|
|
||||||
// Send QUIT
|
|
||||||
await this.smtpCommand(socket, 'QUIT');
|
|
||||||
|
|
||||||
// Close the connection
|
|
||||||
socket.end();
|
|
||||||
|
|
||||||
logger.log('info', `Email forwarded successfully to ${route?.action?.forward?.host}:${route?.action?.forward?.port || 25}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
targetServer: route?.action?.forward?.host,
|
targetServer,
|
||||||
targetPort: route?.action?.forward?.port || 25,
|
targetPort,
|
||||||
recipients: email.getAllRecipients().length
|
recipients: result.accepted.length,
|
||||||
|
messageId: result.messageId,
|
||||||
|
rejectedRecipients: result.rejected,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.log('error', `Failed to forward email: ${error.message}`);
|
logger.log('error', `Failed to forward email: ${error.message}`);
|
||||||
|
|
||||||
// Close the connection
|
|
||||||
socket.destroy();
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -790,210 +648,6 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format email for SMTP transmission
|
|
||||||
* @param email Email to format
|
|
||||||
*/
|
|
||||||
private async getFormattedEmail(email: Email): Promise<string> {
|
|
||||||
// This is a simplified implementation
|
|
||||||
// In a full implementation, this would use proper MIME formatting
|
|
||||||
|
|
||||||
let content = '';
|
|
||||||
|
|
||||||
// Add headers
|
|
||||||
content += `From: ${email.from}\r\n`;
|
|
||||||
content += `To: ${email.to.join(', ')}\r\n`;
|
|
||||||
content += `Subject: ${email.subject}\r\n`;
|
|
||||||
|
|
||||||
// Add additional headers
|
|
||||||
for (const [name, value] of Object.entries(email.headers || {})) {
|
|
||||||
content += `${name}: ${value}\r\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add content type for multipart
|
|
||||||
if (email.attachments && email.attachments.length > 0) {
|
|
||||||
const boundary = `----_=_NextPart_${Math.random().toString(36).substr(2)}`;
|
|
||||||
content += `MIME-Version: 1.0\r\n`;
|
|
||||||
content += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
|
|
||||||
content += `\r\n`;
|
|
||||||
|
|
||||||
// Add text part
|
|
||||||
content += `--${boundary}\r\n`;
|
|
||||||
content += `Content-Type: text/plain; charset="UTF-8"\r\n`;
|
|
||||||
content += `\r\n`;
|
|
||||||
content += `${email.text}\r\n`;
|
|
||||||
|
|
||||||
// Add HTML part if present
|
|
||||||
if (email.html) {
|
|
||||||
content += `--${boundary}\r\n`;
|
|
||||||
content += `Content-Type: text/html; charset="UTF-8"\r\n`;
|
|
||||||
content += `\r\n`;
|
|
||||||
content += `${email.html}\r\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add attachments
|
|
||||||
for (const attachment of email.attachments) {
|
|
||||||
content += `--${boundary}\r\n`;
|
|
||||||
content += `Content-Type: ${attachment.contentType || 'application/octet-stream'}; name="${attachment.filename}"\r\n`;
|
|
||||||
content += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
|
|
||||||
content += `Content-Transfer-Encoding: base64\r\n`;
|
|
||||||
content += `\r\n`;
|
|
||||||
|
|
||||||
// Add base64 encoded content
|
|
||||||
const base64Content = attachment.content.toString('base64');
|
|
||||||
|
|
||||||
// Split into lines of 76 characters
|
|
||||||
for (let i = 0; i < base64Content.length; i += 76) {
|
|
||||||
content += base64Content.substring(i, i + 76) + '\r\n';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// End boundary
|
|
||||||
content += `--${boundary}--\r\n`;
|
|
||||||
} else {
|
|
||||||
// Simple email with just text
|
|
||||||
content += `Content-Type: text/plain; charset="UTF-8"\r\n`;
|
|
||||||
content += `\r\n`;
|
|
||||||
content += `${email.text}\r\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send SMTP command and wait for response
|
|
||||||
* @param socket Socket connection
|
|
||||||
* @param command SMTP command to send
|
|
||||||
*/
|
|
||||||
private async smtpCommand(socket: net.Socket, command: string): Promise<string> {
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
|
||||||
const onData = (data: Buffer) => {
|
|
||||||
const response = data.toString().trim();
|
|
||||||
|
|
||||||
// Clean up listeners
|
|
||||||
socket.removeListener('data', onData);
|
|
||||||
socket.removeListener('error', onError);
|
|
||||||
socket.removeListener('timeout', onTimeout);
|
|
||||||
|
|
||||||
// Check response code
|
|
||||||
if (response.charAt(0) === '2' || response.charAt(0) === '3') {
|
|
||||||
resolve(response);
|
|
||||||
} else {
|
|
||||||
reject(new Error(`SMTP error: ${response}`));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onError = (err: Error) => {
|
|
||||||
// Clean up listeners
|
|
||||||
socket.removeListener('data', onData);
|
|
||||||
socket.removeListener('error', onError);
|
|
||||||
socket.removeListener('timeout', onTimeout);
|
|
||||||
|
|
||||||
reject(err);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onTimeout = () => {
|
|
||||||
// Clean up listeners
|
|
||||||
socket.removeListener('data', onData);
|
|
||||||
socket.removeListener('error', onError);
|
|
||||||
socket.removeListener('timeout', onTimeout);
|
|
||||||
|
|
||||||
reject(new Error('SMTP command timeout'));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up listeners
|
|
||||||
socket.once('data', onData);
|
|
||||||
socket.once('error', onError);
|
|
||||||
socket.once('timeout', onTimeout);
|
|
||||||
|
|
||||||
// Send command
|
|
||||||
socket.write(command + '\r\n');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send SMTP DATA command with content
|
|
||||||
* @param socket Socket connection
|
|
||||||
* @param data Email content to send
|
|
||||||
*/
|
|
||||||
private async smtpData(socket: net.Socket, data: string): Promise<string> {
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
|
||||||
const onData = (responseData: Buffer) => {
|
|
||||||
const response = responseData.toString().trim();
|
|
||||||
|
|
||||||
// Clean up listeners
|
|
||||||
socket.removeListener('data', onData);
|
|
||||||
socket.removeListener('error', onError);
|
|
||||||
socket.removeListener('timeout', onTimeout);
|
|
||||||
|
|
||||||
// Check response code
|
|
||||||
if (response.charAt(0) === '2') {
|
|
||||||
resolve(response);
|
|
||||||
} else {
|
|
||||||
reject(new Error(`SMTP error: ${response}`));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onError = (err: Error) => {
|
|
||||||
// Clean up listeners
|
|
||||||
socket.removeListener('data', onData);
|
|
||||||
socket.removeListener('error', onError);
|
|
||||||
socket.removeListener('timeout', onTimeout);
|
|
||||||
|
|
||||||
reject(err);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onTimeout = () => {
|
|
||||||
// Clean up listeners
|
|
||||||
socket.removeListener('data', onData);
|
|
||||||
socket.removeListener('error', onError);
|
|
||||||
socket.removeListener('timeout', onTimeout);
|
|
||||||
|
|
||||||
reject(new Error('SMTP data timeout'));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up listeners
|
|
||||||
socket.once('data', onData);
|
|
||||||
socket.once('error', onError);
|
|
||||||
socket.once('timeout', onTimeout);
|
|
||||||
|
|
||||||
// Send data and end with CRLF.CRLF
|
|
||||||
socket.write(data + '\r\n.\r\n');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upgrade socket to TLS
|
|
||||||
* @param socket Socket connection
|
|
||||||
* @param hostname Target hostname for TLS
|
|
||||||
*/
|
|
||||||
private async upgradeTls(socket: net.Socket, hostname: string): Promise<tls.TLSSocket> {
|
|
||||||
return new Promise<tls.TLSSocket>((resolve, reject) => {
|
|
||||||
const tlsOptions: tls.ConnectionOptions = {
|
|
||||||
socket,
|
|
||||||
servername: hostname,
|
|
||||||
rejectUnauthorized: this.options.verifyCertificates,
|
|
||||||
minVersion: this.options.tlsMinVersion as tls.SecureVersion
|
|
||||||
};
|
|
||||||
|
|
||||||
const tlsSocket = tls.connect(tlsOptions);
|
|
||||||
|
|
||||||
tlsSocket.once('secureConnect', () => {
|
|
||||||
resolve(tlsSocket);
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.once('error', (err) => {
|
|
||||||
reject(new Error(`TLS error: ${err.message}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.setTimeout(this.options.socketTimeout);
|
|
||||||
|
|
||||||
tlsSocket.once('timeout', () => {
|
|
||||||
reject(new Error('TLS connection timeout'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update delivery time statistics
|
* Update delivery time statistics
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,447 +0,0 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import * as paths from '../../paths.js';
|
|
||||||
import { Email } from '../core/classes.email.js';
|
|
||||||
import { EmailSignJob } from './classes.emailsignjob.js';
|
|
||||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
|
||||||
import type { SmtpClient } from './smtpclient/smtp-client.js';
|
|
||||||
import type { ISmtpSendResult } from './smtpclient/interfaces.js';
|
|
||||||
|
|
||||||
// Configuration options for email sending
|
|
||||||
export interface IEmailSendOptions {
|
|
||||||
maxRetries?: number;
|
|
||||||
retryDelay?: number; // in milliseconds
|
|
||||||
connectionTimeout?: number; // in milliseconds
|
|
||||||
tlsOptions?: plugins.tls.ConnectionOptions;
|
|
||||||
debugMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Email delivery status
|
|
||||||
export enum DeliveryStatus {
|
|
||||||
PENDING = 'pending',
|
|
||||||
SENDING = 'sending',
|
|
||||||
DELIVERED = 'delivered',
|
|
||||||
FAILED = 'failed',
|
|
||||||
DEFERRED = 'deferred' // Temporary failure, will retry
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detailed information about delivery attempts
|
|
||||||
export interface DeliveryInfo {
|
|
||||||
status: DeliveryStatus;
|
|
||||||
attempts: number;
|
|
||||||
error?: Error;
|
|
||||||
lastAttempt?: Date;
|
|
||||||
nextAttempt?: Date;
|
|
||||||
mxServer?: string;
|
|
||||||
deliveryTime?: Date;
|
|
||||||
logs: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EmailSendJob {
|
|
||||||
emailServerRef: UnifiedEmailServer;
|
|
||||||
private email: Email;
|
|
||||||
private mxServers: string[] = [];
|
|
||||||
private currentMxIndex = 0;
|
|
||||||
private options: IEmailSendOptions;
|
|
||||||
public deliveryInfo: DeliveryInfo;
|
|
||||||
|
|
||||||
constructor(emailServerRef: UnifiedEmailServer, emailArg: Email, options: IEmailSendOptions = {}) {
|
|
||||||
this.email = emailArg;
|
|
||||||
this.emailServerRef = emailServerRef;
|
|
||||||
|
|
||||||
// Set default options
|
|
||||||
this.options = {
|
|
||||||
maxRetries: options.maxRetries || 3,
|
|
||||||
retryDelay: options.retryDelay || 30000, // 30 seconds
|
|
||||||
connectionTimeout: options.connectionTimeout || 60000, // 60 seconds
|
|
||||||
tlsOptions: options.tlsOptions || {},
|
|
||||||
debugMode: options.debugMode || false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize delivery info
|
|
||||||
this.deliveryInfo = {
|
|
||||||
status: DeliveryStatus.PENDING,
|
|
||||||
attempts: 0,
|
|
||||||
logs: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send the email to its recipients
|
|
||||||
*/
|
|
||||||
async send(): Promise<DeliveryStatus> {
|
|
||||||
try {
|
|
||||||
// Check if the email is valid before attempting to send
|
|
||||||
this.validateEmail();
|
|
||||||
|
|
||||||
// Resolve MX records for the recipient domain
|
|
||||||
await this.resolveMxRecords();
|
|
||||||
|
|
||||||
// Try to send the email
|
|
||||||
return await this.attemptDelivery();
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Critical error in send process: ${error.message}`);
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
|
||||||
this.deliveryInfo.error = error;
|
|
||||||
|
|
||||||
// Save failed email for potential future retry or analysis
|
|
||||||
await this.saveFailed();
|
|
||||||
return DeliveryStatus.FAILED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the email before sending
|
|
||||||
*/
|
|
||||||
private validateEmail(): void {
|
|
||||||
if (!this.email.to || this.email.to.length === 0) {
|
|
||||||
throw new Error('No recipients specified');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.email.from) {
|
|
||||||
throw new Error('No sender specified');
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromDomain = this.email.getFromDomain();
|
|
||||||
if (!fromDomain) {
|
|
||||||
throw new Error('Invalid sender domain');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve MX records for the recipient domain
|
|
||||||
*/
|
|
||||||
private async resolveMxRecords(): Promise<void> {
|
|
||||||
const domain = this.email.getPrimaryRecipient()?.split('@')[1];
|
|
||||||
if (!domain) {
|
|
||||||
throw new Error('Invalid recipient domain');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log(`Resolving MX records for domain: ${domain}`);
|
|
||||||
try {
|
|
||||||
const addresses = await this.resolveMx(domain);
|
|
||||||
|
|
||||||
// Sort by priority (lowest number = highest priority)
|
|
||||||
addresses.sort((a, b) => a.priority - b.priority);
|
|
||||||
|
|
||||||
this.mxServers = addresses.map(mx => mx.exchange);
|
|
||||||
this.log(`Found ${this.mxServers.length} MX servers: ${this.mxServers.join(', ')}`);
|
|
||||||
|
|
||||||
if (this.mxServers.length === 0) {
|
|
||||||
throw new Error(`No MX records found for domain: ${domain}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Failed to resolve MX records: ${error.message}`);
|
|
||||||
throw new Error(`MX lookup failed for ${domain}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to deliver the email with retries
|
|
||||||
*/
|
|
||||||
private async attemptDelivery(): Promise<DeliveryStatus> {
|
|
||||||
while (this.deliveryInfo.attempts < this.options.maxRetries) {
|
|
||||||
this.deliveryInfo.attempts++;
|
|
||||||
this.deliveryInfo.lastAttempt = new Date();
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.SENDING;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.log(`Delivery attempt ${this.deliveryInfo.attempts} of ${this.options.maxRetries}`);
|
|
||||||
|
|
||||||
// Try each MX server in order of priority
|
|
||||||
while (this.currentMxIndex < this.mxServers.length) {
|
|
||||||
const currentMx = this.mxServers[this.currentMxIndex];
|
|
||||||
this.deliveryInfo.mxServer = currentMx;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.log(`Attempting delivery to MX server: ${currentMx}`);
|
|
||||||
await this.connectAndSend(currentMx);
|
|
||||||
|
|
||||||
// If we get here, email was sent successfully
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.DELIVERED;
|
|
||||||
this.deliveryInfo.deliveryTime = new Date();
|
|
||||||
this.log(`Email delivered successfully to ${currentMx}`);
|
|
||||||
|
|
||||||
// Record delivery for sender reputation monitoring
|
|
||||||
this.recordDeliveryEvent('delivered');
|
|
||||||
|
|
||||||
// Save successful email record
|
|
||||||
await this.saveSuccess();
|
|
||||||
return DeliveryStatus.DELIVERED;
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Failed to deliver to ${currentMx}: ${error.message}`);
|
|
||||||
this.currentMxIndex++;
|
|
||||||
|
|
||||||
// If this MX server failed, try the next one
|
|
||||||
if (this.currentMxIndex >= this.mxServers.length) {
|
|
||||||
throw error; // No more MX servers to try
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('All MX servers failed');
|
|
||||||
} catch (error) {
|
|
||||||
this.deliveryInfo.error = error;
|
|
||||||
|
|
||||||
// Check if this is a permanent failure
|
|
||||||
if (this.isPermanentFailure(error)) {
|
|
||||||
this.log('Permanent failure detected, not retrying');
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
|
||||||
|
|
||||||
// Record permanent failure for bounce management
|
|
||||||
this.recordDeliveryEvent('bounced', true);
|
|
||||||
|
|
||||||
await this.saveFailed();
|
|
||||||
return DeliveryStatus.FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a temporary failure
|
|
||||||
if (this.deliveryInfo.attempts < this.options.maxRetries) {
|
|
||||||
this.log(`Temporary failure, will retry in ${this.options.retryDelay}ms`);
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.DEFERRED;
|
|
||||||
this.deliveryInfo.nextAttempt = new Date(Date.now() + this.options.retryDelay);
|
|
||||||
|
|
||||||
// Record temporary failure for monitoring
|
|
||||||
this.recordDeliveryEvent('deferred');
|
|
||||||
|
|
||||||
// Reset MX server index for next retry
|
|
||||||
this.currentMxIndex = 0;
|
|
||||||
|
|
||||||
// Wait before retrying
|
|
||||||
await this.delay(this.options.retryDelay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we get here, all retries failed
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
|
||||||
await this.saveFailed();
|
|
||||||
return DeliveryStatus.FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to a specific MX server and send the email using SmtpClient
|
|
||||||
*/
|
|
||||||
private async connectAndSend(mxServer: string): Promise<void> {
|
|
||||||
this.log(`Connecting to ${mxServer}:25`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if IP warmup is enabled and get an IP to use
|
|
||||||
let localAddress: string | undefined = undefined;
|
|
||||||
try {
|
|
||||||
const fromDomain = this.email.getFromDomain();
|
|
||||||
const bestIP = this.emailServerRef.getBestIPForSending({
|
|
||||||
from: this.email.from,
|
|
||||||
to: this.email.getAllRecipients(),
|
|
||||||
domain: fromDomain,
|
|
||||||
isTransactional: this.email.priority === 'high'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (bestIP) {
|
|
||||||
this.log(`Using warmed-up IP ${bestIP} for sending`);
|
|
||||||
localAddress = bestIP;
|
|
||||||
|
|
||||||
// Record the send for warm-up tracking
|
|
||||||
this.emailServerRef.recordIPSend(bestIP);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Error selecting IP address: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get SMTP client from UnifiedEmailServer
|
|
||||||
const smtpClient = this.emailServerRef.getSmtpClient(mxServer, 25);
|
|
||||||
|
|
||||||
// Sign the email with DKIM if available
|
|
||||||
let signedEmail = this.email;
|
|
||||||
try {
|
|
||||||
const fromDomain = this.email.getFromDomain();
|
|
||||||
if (fromDomain && this.emailServerRef.hasDkimKey(fromDomain)) {
|
|
||||||
// Convert email to RFC822 format for signing
|
|
||||||
const emailMessage = this.email.toRFC822String();
|
|
||||||
|
|
||||||
// Create sign job with proper options
|
|
||||||
const emailSignJob = new EmailSignJob(this.emailServerRef, {
|
|
||||||
domain: fromDomain,
|
|
||||||
selector: 'default', // Using default selector
|
|
||||||
headers: {}, // Headers will be extracted from emailMessage
|
|
||||||
body: emailMessage
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the DKIM signature header
|
|
||||||
const signatureHeader = await emailSignJob.getSignatureHeader(emailMessage);
|
|
||||||
|
|
||||||
// Add the signature to the email
|
|
||||||
if (signatureHeader) {
|
|
||||||
// For now, we'll use the email as-is since SmtpClient will handle DKIM
|
|
||||||
this.log(`Email ready for DKIM signing for domain: ${fromDomain}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Failed to prepare DKIM: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the email using SmtpClient
|
|
||||||
const result: ISmtpSendResult = await smtpClient.sendMail(signedEmail);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
this.log(`Email sent successfully: ${result.response}`);
|
|
||||||
|
|
||||||
// Record the send for reputation monitoring
|
|
||||||
this.recordDeliveryEvent('delivered');
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error?.message || 'Failed to send email');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Failed to send email via ${mxServer}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record delivery event for monitoring
|
|
||||||
*/
|
|
||||||
private recordDeliveryEvent(
|
|
||||||
eventType: 'delivered' | 'bounced' | 'deferred',
|
|
||||||
isHardBounce: boolean = false
|
|
||||||
): void {
|
|
||||||
try {
|
|
||||||
const domain = this.email.getFromDomain();
|
|
||||||
if (domain) {
|
|
||||||
if (eventType === 'delivered') {
|
|
||||||
this.emailServerRef.recordDelivery(domain);
|
|
||||||
} else if (eventType === 'bounced') {
|
|
||||||
// Get the receiving domain for bounce recording
|
|
||||||
let receivingDomain = null;
|
|
||||||
const primaryRecipient = this.email.getPrimaryRecipient();
|
|
||||||
if (primaryRecipient) {
|
|
||||||
receivingDomain = primaryRecipient.split('@')[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (receivingDomain) {
|
|
||||||
this.emailServerRef.recordBounce(
|
|
||||||
domain,
|
|
||||||
receivingDomain,
|
|
||||||
isHardBounce ? 'hard' : 'soft',
|
|
||||||
this.deliveryInfo.error?.message || 'Unknown error'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Failed to record delivery event: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an error represents a permanent failure
|
|
||||||
*/
|
|
||||||
private isPermanentFailure(error: Error): boolean {
|
|
||||||
const permanentFailurePatterns = [
|
|
||||||
'User unknown',
|
|
||||||
'No such user',
|
|
||||||
'Mailbox not found',
|
|
||||||
'Invalid recipient',
|
|
||||||
'Account disabled',
|
|
||||||
'Account suspended',
|
|
||||||
'Domain not found',
|
|
||||||
'No such domain',
|
|
||||||
'Invalid domain',
|
|
||||||
'Relay access denied',
|
|
||||||
'Access denied',
|
|
||||||
'Blacklisted',
|
|
||||||
'Blocked',
|
|
||||||
'550', // Permanent failure SMTP code
|
|
||||||
'551',
|
|
||||||
'552',
|
|
||||||
'553',
|
|
||||||
'554'
|
|
||||||
];
|
|
||||||
|
|
||||||
const errorMessage = error.message.toLowerCase();
|
|
||||||
return permanentFailurePatterns.some(pattern =>
|
|
||||||
errorMessage.includes(pattern.toLowerCase())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve MX records for a domain
|
|
||||||
*/
|
|
||||||
private resolveMx(domain: string): Promise<plugins.dns.MxRecord[]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
resolve(addresses || []);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log a message with timestamp
|
|
||||||
*/
|
|
||||||
private log(message: string): void {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const logEntry = `[${timestamp}] ${message}`;
|
|
||||||
this.deliveryInfo.logs.push(logEntry);
|
|
||||||
|
|
||||||
if (this.options.debugMode) {
|
|
||||||
console.log(`[EmailSendJob] ${logEntry}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save successful email to storage
|
|
||||||
*/
|
|
||||||
private async saveSuccess(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Use the existing email storage path
|
|
||||||
const emailContent = this.email.toRFC822String();
|
|
||||||
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_success.eml`;
|
|
||||||
const filePath = plugins.path.join(paths.sentEmailsDir, fileName);
|
|
||||||
|
|
||||||
await plugins.smartfs.directory(paths.sentEmailsDir).recursive().create();
|
|
||||||
await plugins.smartfs.file(filePath).write(emailContent);
|
|
||||||
|
|
||||||
// Also save delivery info
|
|
||||||
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_info.json`;
|
|
||||||
const infoPath = plugins.path.join(paths.sentEmailsDir, infoFileName);
|
|
||||||
await plugins.smartfs.file(infoPath).write(JSON.stringify(this.deliveryInfo, null, 2));
|
|
||||||
|
|
||||||
this.log(`Email saved to ${fileName}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Failed to save email: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save failed email to storage
|
|
||||||
*/
|
|
||||||
private async saveFailed(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Use the existing email storage path
|
|
||||||
const emailContent = this.email.toRFC822String();
|
|
||||||
const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_failed.eml`;
|
|
||||||
const filePath = plugins.path.join(paths.failedEmailsDir, fileName);
|
|
||||||
|
|
||||||
await plugins.smartfs.directory(paths.failedEmailsDir).recursive().create();
|
|
||||||
await plugins.smartfs.file(filePath).write(emailContent);
|
|
||||||
|
|
||||||
// Also save delivery info with error details
|
|
||||||
const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_error.json`;
|
|
||||||
const infoPath = plugins.path.join(paths.failedEmailsDir, infoFileName);
|
|
||||||
await plugins.smartfs.file(infoPath).write(JSON.stringify(this.deliveryInfo, null, 2));
|
|
||||||
|
|
||||||
this.log(`Failed email saved to ${fileName}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Failed to save failed email: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delay for specified milliseconds
|
|
||||||
*/
|
|
||||||
private delay(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
|
||||||
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
|
||||||
|
|
||||||
interface Headers {
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IEmailSignJobOptions {
|
|
||||||
domain: string;
|
|
||||||
selector: string;
|
|
||||||
headers: Headers;
|
|
||||||
body: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EmailSignJob {
|
|
||||||
emailServerRef: UnifiedEmailServer;
|
|
||||||
jobOptions: IEmailSignJobOptions;
|
|
||||||
|
|
||||||
constructor(emailServerRef: UnifiedEmailServer, options: IEmailSignJobOptions) {
|
|
||||||
this.emailServerRef = emailServerRef;
|
|
||||||
this.jobOptions = options;
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadPrivateKey(): Promise<string> {
|
|
||||||
const keyInfo = await this.emailServerRef.dkimCreator.readDKIMKeys(this.jobOptions.domain);
|
|
||||||
return keyInfo.privateKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getSignatureHeader(emailMessage: string): Promise<string> {
|
|
||||||
const privateKey = await this.loadPrivateKey();
|
|
||||||
const bridge = RustSecurityBridge.getInstance();
|
|
||||||
const signResult = await bridge.signDkim({
|
|
||||||
rawMessage: emailMessage,
|
|
||||||
domain: this.jobOptions.domain,
|
|
||||||
selector: this.jobOptions.selector,
|
|
||||||
privateKey,
|
|
||||||
});
|
|
||||||
return signResult.header;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import * as paths from '../../paths.js';
|
|
||||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configures email server storage settings
|
|
||||||
* @param emailServer Reference to the unified email server
|
|
||||||
* @param options Configuration options containing storage paths
|
|
||||||
*/
|
|
||||||
export async function configureEmailStorage(emailServer: UnifiedEmailServer, options: any): Promise<void> {
|
|
||||||
// Extract the receivedEmailsPath if available
|
|
||||||
if (options?.emailPortConfig?.receivedEmailsPath) {
|
|
||||||
const receivedEmailsPath = options.emailPortConfig.receivedEmailsPath;
|
|
||||||
|
|
||||||
// Ensure the directory exists
|
|
||||||
await plugins.smartfs.directory(receivedEmailsPath).recursive().create();
|
|
||||||
|
|
||||||
// Set path for received emails
|
|
||||||
if (emailServer) {
|
|
||||||
// Storage paths are now handled by the unified email server system
|
|
||||||
await plugins.smartfs.directory(paths.receivedEmailsDir).recursive().create();
|
|
||||||
|
|
||||||
console.log(`Configured email server to store received emails to: ${receivedEmailsPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure email server with port and storage settings
|
|
||||||
* @param emailServer Reference to the unified email server
|
|
||||||
* @param config Configuration settings for email server
|
|
||||||
*/
|
|
||||||
export async function configureEmailServer(
|
|
||||||
emailServer: UnifiedEmailServer,
|
|
||||||
config: {
|
|
||||||
ports?: number[];
|
|
||||||
hostname?: string;
|
|
||||||
tls?: {
|
|
||||||
certPath?: string;
|
|
||||||
keyPath?: string;
|
|
||||||
caPath?: string;
|
|
||||||
};
|
|
||||||
storagePath?: string;
|
|
||||||
}
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!emailServer) {
|
|
||||||
console.error('Email server not available');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure the email server with updated options
|
|
||||||
const serverOptions = {
|
|
||||||
ports: config.ports || [25, 587, 465],
|
|
||||||
hostname: config.hostname || 'localhost',
|
|
||||||
tls: config.tls
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update the email server options
|
|
||||||
emailServer.updateOptions(serverOptions);
|
|
||||||
|
|
||||||
console.log(`Configured email server on ports ${serverOptions.ports.join(', ')}`);
|
|
||||||
|
|
||||||
// Set up storage path if provided
|
|
||||||
if (config.storagePath) {
|
|
||||||
await configureEmailStorage(emailServer, {
|
|
||||||
emailPortConfig: {
|
|
||||||
receivedEmailsPath: config.storagePath
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
import { logger } from '../../logger.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration options for rate limiter
|
|
||||||
*/
|
|
||||||
export interface IRateLimitConfig {
|
|
||||||
/** Maximum tokens per period */
|
|
||||||
maxPerPeriod: number;
|
|
||||||
|
|
||||||
/** Time period in milliseconds */
|
|
||||||
periodMs: number;
|
|
||||||
|
|
||||||
/** Whether to apply per domain/key (vs globally) */
|
|
||||||
perKey: boolean;
|
|
||||||
|
|
||||||
/** Initial token count (defaults to max) */
|
|
||||||
initialTokens?: number;
|
|
||||||
|
|
||||||
/** Grace tokens to allow occasional bursts */
|
|
||||||
burstTokens?: number;
|
|
||||||
|
|
||||||
/** Apply global limit in addition to per-key limits */
|
|
||||||
useGlobalLimit?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Token bucket for an individual key
|
|
||||||
*/
|
|
||||||
interface TokenBucket {
|
|
||||||
/** Current number of tokens */
|
|
||||||
tokens: number;
|
|
||||||
|
|
||||||
/** Last time tokens were refilled */
|
|
||||||
lastRefill: number;
|
|
||||||
|
|
||||||
/** Total allowed requests */
|
|
||||||
allowed: number;
|
|
||||||
|
|
||||||
/** Total denied requests */
|
|
||||||
denied: number;
|
|
||||||
|
|
||||||
/** Error count for blocking decisions */
|
|
||||||
errors: number;
|
|
||||||
|
|
||||||
/** Timestamp of first error in current window */
|
|
||||||
firstErrorTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiter using token bucket algorithm
|
|
||||||
* Provides more sophisticated rate limiting with burst handling
|
|
||||||
*/
|
|
||||||
export class RateLimiter {
|
|
||||||
/** Rate limit configuration */
|
|
||||||
private config: IRateLimitConfig;
|
|
||||||
|
|
||||||
/** Token buckets per key */
|
|
||||||
private buckets: Map<string, TokenBucket> = new Map();
|
|
||||||
|
|
||||||
/** Global bucket for non-keyed rate limiting */
|
|
||||||
private globalBucket: TokenBucket;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new rate limiter
|
|
||||||
* @param config Rate limiter configuration
|
|
||||||
*/
|
|
||||||
constructor(config: IRateLimitConfig) {
|
|
||||||
// Set defaults
|
|
||||||
this.config = {
|
|
||||||
maxPerPeriod: config.maxPerPeriod,
|
|
||||||
periodMs: config.periodMs,
|
|
||||||
perKey: config.perKey ?? true,
|
|
||||||
initialTokens: config.initialTokens ?? config.maxPerPeriod,
|
|
||||||
burstTokens: config.burstTokens ?? 0,
|
|
||||||
useGlobalLimit: config.useGlobalLimit ?? false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize global bucket
|
|
||||||
this.globalBucket = {
|
|
||||||
tokens: this.config.initialTokens,
|
|
||||||
lastRefill: Date.now(),
|
|
||||||
allowed: 0,
|
|
||||||
denied: 0,
|
|
||||||
errors: 0,
|
|
||||||
firstErrorTime: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log initialization
|
|
||||||
logger.log('info', `Rate limiter initialized: ${this.config.maxPerPeriod} per ${this.config.periodMs}ms${this.config.perKey ? ' per key' : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a request is allowed under rate limits
|
|
||||||
* @param key Key to check rate limit for (e.g. domain, user, IP)
|
|
||||||
* @param cost Token cost (defaults to 1)
|
|
||||||
* @returns Whether the request is allowed
|
|
||||||
*/
|
|
||||||
public isAllowed(key: string = 'global', cost: number = 1): boolean {
|
|
||||||
// If using global bucket directly, just check that
|
|
||||||
if (key === 'global' || !this.config.perKey) {
|
|
||||||
return this.checkBucket(this.globalBucket, cost);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the key-specific bucket
|
|
||||||
const bucket = this.getBucket(key);
|
|
||||||
|
|
||||||
// If we also need to check global limit
|
|
||||||
if (this.config.useGlobalLimit) {
|
|
||||||
// Both key bucket and global bucket must have tokens
|
|
||||||
return this.checkBucket(bucket, cost) && this.checkBucket(this.globalBucket, cost);
|
|
||||||
} else {
|
|
||||||
// Only need to check the key-specific bucket
|
|
||||||
return this.checkBucket(bucket, cost);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a bucket has enough tokens and consume them
|
|
||||||
* @param bucket The token bucket to check
|
|
||||||
* @param cost Token cost
|
|
||||||
* @returns Whether tokens were consumed
|
|
||||||
*/
|
|
||||||
private checkBucket(bucket: TokenBucket, cost: number): boolean {
|
|
||||||
// Refill tokens based on elapsed time
|
|
||||||
this.refillBucket(bucket);
|
|
||||||
|
|
||||||
// Check if we have enough tokens
|
|
||||||
if (bucket.tokens >= cost) {
|
|
||||||
// Use tokens
|
|
||||||
bucket.tokens -= cost;
|
|
||||||
bucket.allowed++;
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
// Rate limit exceeded
|
|
||||||
bucket.denied++;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consume tokens for a request (if available)
|
|
||||||
* @param key Key to consume tokens for
|
|
||||||
* @param cost Token cost (defaults to 1)
|
|
||||||
* @returns Whether tokens were consumed
|
|
||||||
*/
|
|
||||||
public consume(key: string = 'global', cost: number = 1): boolean {
|
|
||||||
const isAllowed = this.isAllowed(key, cost);
|
|
||||||
return isAllowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the remaining tokens for a key
|
|
||||||
* @param key Key to check
|
|
||||||
* @returns Number of remaining tokens
|
|
||||||
*/
|
|
||||||
public getRemainingTokens(key: string = 'global'): number {
|
|
||||||
const bucket = this.getBucket(key);
|
|
||||||
this.refillBucket(bucket);
|
|
||||||
return bucket.tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get stats for a specific key
|
|
||||||
* @param key Key to get stats for
|
|
||||||
* @returns Rate limit statistics
|
|
||||||
*/
|
|
||||||
public getStats(key: string = 'global'): {
|
|
||||||
remaining: number;
|
|
||||||
limit: number;
|
|
||||||
resetIn: number;
|
|
||||||
allowed: number;
|
|
||||||
denied: number;
|
|
||||||
} {
|
|
||||||
const bucket = this.getBucket(key);
|
|
||||||
this.refillBucket(bucket);
|
|
||||||
|
|
||||||
// Calculate time until next token
|
|
||||||
const resetIn = bucket.tokens < this.config.maxPerPeriod ?
|
|
||||||
Math.ceil(this.config.periodMs / this.config.maxPerPeriod) :
|
|
||||||
0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
remaining: bucket.tokens,
|
|
||||||
limit: this.config.maxPerPeriod,
|
|
||||||
resetIn,
|
|
||||||
allowed: bucket.allowed,
|
|
||||||
denied: bucket.denied
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create a token bucket for a key
|
|
||||||
* @param key The rate limit key
|
|
||||||
* @returns Token bucket
|
|
||||||
*/
|
|
||||||
private getBucket(key: string): TokenBucket {
|
|
||||||
if (!this.config.perKey || key === 'global') {
|
|
||||||
return this.globalBucket;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.buckets.has(key)) {
|
|
||||||
// Create new bucket
|
|
||||||
this.buckets.set(key, {
|
|
||||||
tokens: this.config.initialTokens,
|
|
||||||
lastRefill: Date.now(),
|
|
||||||
allowed: 0,
|
|
||||||
denied: 0,
|
|
||||||
errors: 0,
|
|
||||||
firstErrorTime: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.buckets.get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refill tokens in a bucket based on elapsed time
|
|
||||||
* @param bucket Token bucket to refill
|
|
||||||
*/
|
|
||||||
private refillBucket(bucket: TokenBucket): void {
|
|
||||||
const now = Date.now();
|
|
||||||
const elapsedMs = now - bucket.lastRefill;
|
|
||||||
|
|
||||||
// Calculate how many tokens to add
|
|
||||||
const rate = this.config.maxPerPeriod / this.config.periodMs;
|
|
||||||
const tokensToAdd = elapsedMs * rate;
|
|
||||||
|
|
||||||
if (tokensToAdd >= 0.1) { // Allow for partial token refills
|
|
||||||
// Add tokens, but don't exceed the normal maximum (without burst)
|
|
||||||
// This ensures burst tokens are only used for bursts and don't refill
|
|
||||||
const normalMax = this.config.maxPerPeriod;
|
|
||||||
bucket.tokens = Math.min(
|
|
||||||
// Don't exceed max + burst
|
|
||||||
this.config.maxPerPeriod + (this.config.burstTokens || 0),
|
|
||||||
// Don't exceed normal max when refilling
|
|
||||||
Math.min(normalMax, bucket.tokens + tokensToAdd)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update last refill time
|
|
||||||
bucket.lastRefill = now;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset rate limits for a specific key
|
|
||||||
* @param key Key to reset
|
|
||||||
*/
|
|
||||||
public reset(key: string = 'global'): void {
|
|
||||||
if (key === 'global' || !this.config.perKey) {
|
|
||||||
this.globalBucket.tokens = this.config.initialTokens;
|
|
||||||
this.globalBucket.lastRefill = Date.now();
|
|
||||||
} else if (this.buckets.has(key)) {
|
|
||||||
const bucket = this.buckets.get(key);
|
|
||||||
bucket.tokens = this.config.initialTokens;
|
|
||||||
bucket.lastRefill = Date.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset all rate limiters
|
|
||||||
*/
|
|
||||||
public resetAll(): void {
|
|
||||||
this.globalBucket.tokens = this.config.initialTokens;
|
|
||||||
this.globalBucket.lastRefill = Date.now();
|
|
||||||
|
|
||||||
for (const bucket of this.buckets.values()) {
|
|
||||||
bucket.tokens = this.config.initialTokens;
|
|
||||||
bucket.lastRefill = Date.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup old buckets to prevent memory leaks
|
|
||||||
* @param maxAge Maximum age in milliseconds
|
|
||||||
*/
|
|
||||||
public cleanup(maxAge: number = 24 * 60 * 60 * 1000): void {
|
|
||||||
const now = Date.now();
|
|
||||||
let removed = 0;
|
|
||||||
|
|
||||||
for (const [key, bucket] of this.buckets.entries()) {
|
|
||||||
if (now - bucket.lastRefill > maxAge) {
|
|
||||||
this.buckets.delete(key);
|
|
||||||
removed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removed > 0) {
|
|
||||||
logger.log('debug', `Cleaned up ${removed} stale rate limit buckets`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record an error for a key (e.g., IP address) and determine if blocking is needed
|
|
||||||
* RFC 5321 Section 4.5.4.1 suggests limiting errors to prevent abuse
|
|
||||||
*
|
|
||||||
* @param key Key to record error for (typically an IP address)
|
|
||||||
* @param errorWindow Time window for error tracking in ms (default: 5 minutes)
|
|
||||||
* @param errorThreshold Maximum errors before blocking (default: 10)
|
|
||||||
* @returns true if the key should be blocked due to excessive errors
|
|
||||||
*/
|
|
||||||
public recordError(key: string, errorWindow: number = 5 * 60 * 1000, errorThreshold: number = 10): boolean {
|
|
||||||
const bucket = this.getBucket(key);
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// Reset error count if the time window has expired
|
|
||||||
if (bucket.firstErrorTime === 0 || now - bucket.firstErrorTime > errorWindow) {
|
|
||||||
bucket.errors = 0;
|
|
||||||
bucket.firstErrorTime = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment error count
|
|
||||||
bucket.errors++;
|
|
||||||
|
|
||||||
// Log error tracking
|
|
||||||
logger.log('debug', `Error recorded for ${key}: ${bucket.errors}/${errorThreshold} in window`);
|
|
||||||
|
|
||||||
// Check if threshold exceeded
|
|
||||||
if (bucket.errors >= errorThreshold) {
|
|
||||||
logger.log('warn', `Error threshold exceeded for ${key}: ${bucket.errors} errors`);
|
|
||||||
return true; // Should block
|
|
||||||
}
|
|
||||||
|
|
||||||
return false; // Continue allowing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,5 @@
|
|||||||
// Email delivery components
|
// Email delivery components
|
||||||
export * from './classes.emailsignjob.js';
|
|
||||||
export * from './classes.delivery.queue.js';
|
export * from './classes.delivery.queue.js';
|
||||||
export * from './classes.delivery.system.js';
|
export * from './classes.delivery.system.js';
|
||||||
|
|
||||||
// Handle exports with naming conflicts
|
|
||||||
export { EmailSendJob } from './classes.emailsendjob.js';
|
|
||||||
export { DeliveryStatus } from './classes.delivery.system.js';
|
export { DeliveryStatus } from './classes.delivery.system.js';
|
||||||
|
|
||||||
// Rate limiter exports - fix naming conflict
|
|
||||||
export { RateLimiter } from './classes.ratelimiter.js';
|
|
||||||
export type { IRateLimitConfig } from './classes.ratelimiter.js';
|
|
||||||
|
|
||||||
// Unified rate limiter
|
|
||||||
export * from './classes.unified.rate.limiter.js';
|
export * from './classes.unified.rate.limiter.js';
|
||||||
|
|
||||||
// SMTP client and configuration
|
|
||||||
export * from './classes.mta.config.js';
|
|
||||||
|
|
||||||
// Import and export SMTP modules as namespaces to avoid conflicts
|
|
||||||
import * as smtpClientMod from './smtpclient/index.js';
|
|
||||||
|
|
||||||
export { smtpClientMod };
|
|
||||||
@@ -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,232 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Client Authentication Handler
|
|
||||||
* Authentication mechanisms implementation
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AUTH_METHODS } from './constants.js';
|
|
||||||
import type {
|
|
||||||
ISmtpConnection,
|
|
||||||
ISmtpAuthOptions,
|
|
||||||
ISmtpClientOptions,
|
|
||||||
ISmtpResponse,
|
|
||||||
IOAuth2Options
|
|
||||||
} from './interfaces.js';
|
|
||||||
import {
|
|
||||||
encodeAuthPlain,
|
|
||||||
encodeAuthLogin,
|
|
||||||
generateOAuth2String,
|
|
||||||
isSuccessCode
|
|
||||||
} from './utils/helpers.js';
|
|
||||||
import { logAuthentication, logDebug } from './utils/logging.js';
|
|
||||||
import type { CommandHandler } from './command-handler.js';
|
|
||||||
|
|
||||||
export class AuthHandler {
|
|
||||||
private options: ISmtpClientOptions;
|
|
||||||
private commandHandler: CommandHandler;
|
|
||||||
|
|
||||||
constructor(options: ISmtpClientOptions, commandHandler: CommandHandler) {
|
|
||||||
this.options = options;
|
|
||||||
this.commandHandler = commandHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate using the configured method
|
|
||||||
*/
|
|
||||||
public async authenticate(connection: ISmtpConnection): Promise<void> {
|
|
||||||
if (!this.options.auth) {
|
|
||||||
logDebug('No authentication configured', this.options);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authOptions = this.options.auth;
|
|
||||||
const capabilities = connection.capabilities;
|
|
||||||
|
|
||||||
if (!capabilities || capabilities.authMethods.size === 0) {
|
|
||||||
throw new Error('Server does not support authentication');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine authentication method
|
|
||||||
const method = this.selectAuthMethod(authOptions, capabilities.authMethods);
|
|
||||||
|
|
||||||
logAuthentication('start', method, this.options);
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (method) {
|
|
||||||
case AUTH_METHODS.PLAIN:
|
|
||||||
await this.authenticatePlain(connection, authOptions);
|
|
||||||
break;
|
|
||||||
case AUTH_METHODS.LOGIN:
|
|
||||||
await this.authenticateLogin(connection, authOptions);
|
|
||||||
break;
|
|
||||||
case AUTH_METHODS.OAUTH2:
|
|
||||||
await this.authenticateOAuth2(connection, authOptions);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported authentication method: ${method}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logAuthentication('success', method, this.options);
|
|
||||||
} catch (error) {
|
|
||||||
logAuthentication('failure', method, this.options, { error });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate using AUTH PLAIN
|
|
||||||
*/
|
|
||||||
private async authenticatePlain(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise<void> {
|
|
||||||
if (!auth.user || !auth.pass) {
|
|
||||||
throw new Error('Username and password required for PLAIN authentication');
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials = encodeAuthPlain(auth.user, auth.pass);
|
|
||||||
const response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.PLAIN, credentials);
|
|
||||||
|
|
||||||
if (!isSuccessCode(response.code)) {
|
|
||||||
throw new Error(`PLAIN authentication failed: ${response.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate using AUTH LOGIN
|
|
||||||
*/
|
|
||||||
private async authenticateLogin(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise<void> {
|
|
||||||
if (!auth.user || !auth.pass) {
|
|
||||||
throw new Error('Username and password required for LOGIN authentication');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Send AUTH LOGIN
|
|
||||||
let response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.LOGIN);
|
|
||||||
|
|
||||||
if (response.code !== 334) {
|
|
||||||
throw new Error(`LOGIN authentication initiation failed: ${response.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Send username
|
|
||||||
const encodedUser = encodeAuthLogin(auth.user);
|
|
||||||
response = await this.commandHandler.sendCommand(connection, encodedUser);
|
|
||||||
|
|
||||||
if (response.code !== 334) {
|
|
||||||
throw new Error(`LOGIN username failed: ${response.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Send password
|
|
||||||
const encodedPass = encodeAuthLogin(auth.pass);
|
|
||||||
response = await this.commandHandler.sendCommand(connection, encodedPass);
|
|
||||||
|
|
||||||
if (!isSuccessCode(response.code)) {
|
|
||||||
throw new Error(`LOGIN password failed: ${response.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate using OAuth2
|
|
||||||
*/
|
|
||||||
private async authenticateOAuth2(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise<void> {
|
|
||||||
if (!auth.oauth2) {
|
|
||||||
throw new Error('OAuth2 configuration required for OAUTH2 authentication');
|
|
||||||
}
|
|
||||||
|
|
||||||
let accessToken = auth.oauth2.accessToken;
|
|
||||||
|
|
||||||
// Refresh token if needed
|
|
||||||
if (!accessToken || this.isTokenExpired(auth.oauth2)) {
|
|
||||||
accessToken = await this.refreshOAuth2Token(auth.oauth2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authString = generateOAuth2String(auth.oauth2.user, accessToken);
|
|
||||||
const response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.OAUTH2, authString);
|
|
||||||
|
|
||||||
if (!isSuccessCode(response.code)) {
|
|
||||||
throw new Error(`OAUTH2 authentication failed: ${response.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select appropriate authentication method
|
|
||||||
*/
|
|
||||||
private selectAuthMethod(auth: ISmtpAuthOptions, serverMethods: Set<string>): string {
|
|
||||||
// If method is explicitly specified, use it
|
|
||||||
if (auth.method && auth.method !== 'AUTO') {
|
|
||||||
const method = auth.method === 'OAUTH2' ? AUTH_METHODS.OAUTH2 : auth.method;
|
|
||||||
if (serverMethods.has(method)) {
|
|
||||||
return method;
|
|
||||||
}
|
|
||||||
throw new Error(`Requested authentication method ${auth.method} not supported by server`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-select based on available credentials and server support
|
|
||||||
if (auth.oauth2 && serverMethods.has(AUTH_METHODS.OAUTH2)) {
|
|
||||||
return AUTH_METHODS.OAUTH2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.user && auth.pass) {
|
|
||||||
// Prefer PLAIN over LOGIN for simplicity
|
|
||||||
if (serverMethods.has(AUTH_METHODS.PLAIN)) {
|
|
||||||
return AUTH_METHODS.PLAIN;
|
|
||||||
}
|
|
||||||
if (serverMethods.has(AUTH_METHODS.LOGIN)) {
|
|
||||||
return AUTH_METHODS.LOGIN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('No compatible authentication method found');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if OAuth2 token is expired
|
|
||||||
*/
|
|
||||||
private isTokenExpired(oauth2: IOAuth2Options): boolean {
|
|
||||||
if (!oauth2.expires) {
|
|
||||||
return false; // No expiry information, assume valid
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const buffer = 300000; // 5 minutes buffer
|
|
||||||
|
|
||||||
return oauth2.expires < (now + buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh OAuth2 access token
|
|
||||||
*/
|
|
||||||
private async refreshOAuth2Token(oauth2: IOAuth2Options): Promise<string> {
|
|
||||||
// This is a simplified implementation
|
|
||||||
// In a real implementation, you would make an HTTP request to the OAuth2 provider
|
|
||||||
logDebug('OAuth2 token refresh required', this.options);
|
|
||||||
|
|
||||||
if (!oauth2.refreshToken) {
|
|
||||||
throw new Error('Refresh token required for OAuth2 token refresh');
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Implement actual OAuth2 token refresh
|
|
||||||
// For now, throw an error to indicate this needs to be implemented
|
|
||||||
throw new Error('OAuth2 token refresh not implemented. Please provide a valid access token.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate authentication configuration
|
|
||||||
*/
|
|
||||||
public validateAuthConfig(auth: ISmtpAuthOptions): string[] {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
if (auth.method === 'OAUTH2' || auth.oauth2) {
|
|
||||||
if (!auth.oauth2) {
|
|
||||||
errors.push('OAuth2 configuration required when using OAUTH2 method');
|
|
||||||
} else {
|
|
||||||
if (!auth.oauth2.user) errors.push('OAuth2 user required');
|
|
||||||
if (!auth.oauth2.clientId) errors.push('OAuth2 clientId required');
|
|
||||||
if (!auth.oauth2.clientSecret) errors.push('OAuth2 clientSecret required');
|
|
||||||
if (!auth.oauth2.refreshToken && !auth.oauth2.accessToken) {
|
|
||||||
errors.push('OAuth2 refreshToken or accessToken required');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (auth.method === 'PLAIN' || auth.method === 'LOGIN' || (!auth.method && (auth.user || auth.pass))) {
|
|
||||||
if (!auth.user) errors.push('Username required for basic authentication');
|
|
||||||
if (!auth.pass) errors.push('Password required for basic authentication');
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Client Command Handler
|
|
||||||
* SMTP command sending and response parsing
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events';
|
|
||||||
import { SMTP_COMMANDS, SMTP_CODES, LINE_ENDINGS } from './constants.js';
|
|
||||||
import type {
|
|
||||||
ISmtpConnection,
|
|
||||||
ISmtpResponse,
|
|
||||||
ISmtpClientOptions,
|
|
||||||
ISmtpCapabilities
|
|
||||||
} from './interfaces.js';
|
|
||||||
import {
|
|
||||||
parseSmtpResponse,
|
|
||||||
parseEhloResponse,
|
|
||||||
formatCommand,
|
|
||||||
isSuccessCode
|
|
||||||
} from './utils/helpers.js';
|
|
||||||
import { logCommand, logDebug } from './utils/logging.js';
|
|
||||||
|
|
||||||
export class CommandHandler extends EventEmitter {
|
|
||||||
private options: ISmtpClientOptions;
|
|
||||||
private responseBuffer: string = '';
|
|
||||||
private pendingCommand: { resolve: Function; reject: Function; command: string } | null = null;
|
|
||||||
private commandTimeout: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
constructor(options: ISmtpClientOptions) {
|
|
||||||
super();
|
|
||||||
this.options = options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send EHLO command and parse capabilities
|
|
||||||
*/
|
|
||||||
public async sendEhlo(connection: ISmtpConnection, domain?: string): Promise<ISmtpCapabilities> {
|
|
||||||
const hostname = domain || this.options.domain || 'localhost';
|
|
||||||
const command = `${SMTP_COMMANDS.EHLO} ${hostname}`;
|
|
||||||
|
|
||||||
const response = await this.sendCommand(connection, command);
|
|
||||||
|
|
||||||
if (!isSuccessCode(response.code)) {
|
|
||||||
throw new Error(`EHLO failed: ${response.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const capabilities = parseEhloResponse(response.raw);
|
|
||||||
connection.capabilities = capabilities;
|
|
||||||
|
|
||||||
logDebug('EHLO capabilities parsed', this.options, { capabilities });
|
|
||||||
return capabilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send MAIL FROM command
|
|
||||||
*/
|
|
||||||
public async sendMailFrom(connection: ISmtpConnection, fromAddress: string): Promise<ISmtpResponse> {
|
|
||||||
// Handle empty return path for bounce messages
|
|
||||||
const command = fromAddress === ''
|
|
||||||
? `${SMTP_COMMANDS.MAIL_FROM}:<>`
|
|
||||||
: `${SMTP_COMMANDS.MAIL_FROM}:<${fromAddress}>`;
|
|
||||||
return this.sendCommand(connection, command);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send RCPT TO command
|
|
||||||
*/
|
|
||||||
public async sendRcptTo(connection: ISmtpConnection, toAddress: string): Promise<ISmtpResponse> {
|
|
||||||
const command = `${SMTP_COMMANDS.RCPT_TO}:<${toAddress}>`;
|
|
||||||
return this.sendCommand(connection, command);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send DATA command
|
|
||||||
*/
|
|
||||||
public async sendData(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
|
||||||
return this.sendCommand(connection, SMTP_COMMANDS.DATA);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send email data content
|
|
||||||
*/
|
|
||||||
public async sendDataContent(connection: ISmtpConnection, emailData: string): Promise<ISmtpResponse> {
|
|
||||||
// Normalize line endings to CRLF
|
|
||||||
let data = emailData.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n/g, '\r\n');
|
|
||||||
|
|
||||||
// Ensure email data ends with CRLF
|
|
||||||
if (!data.endsWith(LINE_ENDINGS.CRLF)) {
|
|
||||||
data += LINE_ENDINGS.CRLF;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform dot stuffing (escape lines starting with a dot)
|
|
||||||
data = data.replace(/\r\n\./g, '\r\n..');
|
|
||||||
|
|
||||||
// Add termination sequence
|
|
||||||
data += '.' + LINE_ENDINGS.CRLF;
|
|
||||||
|
|
||||||
return this.sendRawData(connection, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send RSET command
|
|
||||||
*/
|
|
||||||
public async sendRset(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
|
||||||
return this.sendCommand(connection, SMTP_COMMANDS.RSET);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send NOOP command
|
|
||||||
*/
|
|
||||||
public async sendNoop(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
|
||||||
return this.sendCommand(connection, SMTP_COMMANDS.NOOP);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send QUIT command
|
|
||||||
*/
|
|
||||||
public async sendQuit(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
|
||||||
return this.sendCommand(connection, SMTP_COMMANDS.QUIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send STARTTLS command
|
|
||||||
*/
|
|
||||||
public async sendStartTls(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
|
||||||
return this.sendCommand(connection, SMTP_COMMANDS.STARTTLS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send AUTH command
|
|
||||||
*/
|
|
||||||
public async sendAuth(connection: ISmtpConnection, method: string, credentials?: string): Promise<ISmtpResponse> {
|
|
||||||
const command = credentials ?
|
|
||||||
`${SMTP_COMMANDS.AUTH} ${method} ${credentials}` :
|
|
||||||
`${SMTP_COMMANDS.AUTH} ${method}`;
|
|
||||||
return this.sendCommand(connection, command);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a generic SMTP command
|
|
||||||
*/
|
|
||||||
public async sendCommand(connection: ISmtpConnection, command: string): Promise<ISmtpResponse> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (this.pendingCommand) {
|
|
||||||
reject(new Error('Another command is already pending'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pendingCommand = { resolve, reject, command };
|
|
||||||
|
|
||||||
// Set command timeout
|
|
||||||
const timeout = 30000; // 30 seconds
|
|
||||||
this.commandTimeout = setTimeout(() => {
|
|
||||||
this.pendingCommand = null;
|
|
||||||
this.commandTimeout = null;
|
|
||||||
reject(new Error(`Command timeout: ${command}`));
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
// Set up data handler
|
|
||||||
const dataHandler = (data: Buffer) => {
|
|
||||||
this.handleIncomingData(data.toString());
|
|
||||||
};
|
|
||||||
|
|
||||||
connection.socket.on('data', dataHandler);
|
|
||||||
|
|
||||||
// Clean up function
|
|
||||||
const cleanup = () => {
|
|
||||||
connection.socket.removeListener('data', dataHandler);
|
|
||||||
if (this.commandTimeout) {
|
|
||||||
clearTimeout(this.commandTimeout);
|
|
||||||
this.commandTimeout = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send command
|
|
||||||
const formattedCommand = command.endsWith(LINE_ENDINGS.CRLF) ? command : formatCommand(command);
|
|
||||||
|
|
||||||
logCommand(command, undefined, this.options);
|
|
||||||
logDebug(`Sending command: ${command}`, this.options);
|
|
||||||
|
|
||||||
connection.socket.write(formattedCommand, (error) => {
|
|
||||||
if (error) {
|
|
||||||
cleanup();
|
|
||||||
this.pendingCommand = null;
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Override resolve/reject to include cleanup
|
|
||||||
const originalResolve = resolve;
|
|
||||||
const originalReject = reject;
|
|
||||||
|
|
||||||
this.pendingCommand.resolve = (response: ISmtpResponse) => {
|
|
||||||
cleanup();
|
|
||||||
this.pendingCommand = null;
|
|
||||||
logCommand(command, response, this.options);
|
|
||||||
originalResolve(response);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.pendingCommand.reject = (error: Error) => {
|
|
||||||
cleanup();
|
|
||||||
this.pendingCommand = null;
|
|
||||||
originalReject(error);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send raw data without command formatting
|
|
||||||
*/
|
|
||||||
public async sendRawData(connection: ISmtpConnection, data: string): Promise<ISmtpResponse> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (this.pendingCommand) {
|
|
||||||
reject(new Error('Another command is already pending'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pendingCommand = { resolve, reject, command: 'DATA_CONTENT' };
|
|
||||||
|
|
||||||
// Set data timeout
|
|
||||||
const timeout = 60000; // 60 seconds for data
|
|
||||||
this.commandTimeout = setTimeout(() => {
|
|
||||||
this.pendingCommand = null;
|
|
||||||
this.commandTimeout = null;
|
|
||||||
reject(new Error('Data transmission timeout'));
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
// Set up data handler
|
|
||||||
const dataHandler = (chunk: Buffer) => {
|
|
||||||
this.handleIncomingData(chunk.toString());
|
|
||||||
};
|
|
||||||
|
|
||||||
connection.socket.on('data', dataHandler);
|
|
||||||
|
|
||||||
// Clean up function
|
|
||||||
const cleanup = () => {
|
|
||||||
connection.socket.removeListener('data', dataHandler);
|
|
||||||
if (this.commandTimeout) {
|
|
||||||
clearTimeout(this.commandTimeout);
|
|
||||||
this.commandTimeout = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Override resolve/reject to include cleanup
|
|
||||||
const originalResolve = resolve;
|
|
||||||
const originalReject = reject;
|
|
||||||
|
|
||||||
this.pendingCommand.resolve = (response: ISmtpResponse) => {
|
|
||||||
cleanup();
|
|
||||||
this.pendingCommand = null;
|
|
||||||
originalResolve(response);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.pendingCommand.reject = (error: Error) => {
|
|
||||||
cleanup();
|
|
||||||
this.pendingCommand = null;
|
|
||||||
originalReject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send data
|
|
||||||
connection.socket.write(data, (error) => {
|
|
||||||
if (error) {
|
|
||||||
cleanup();
|
|
||||||
this.pendingCommand = null;
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for server greeting
|
|
||||||
*/
|
|
||||||
public async waitForGreeting(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timeout = 30000; // 30 seconds
|
|
||||||
let timeoutHandler: NodeJS.Timeout;
|
|
||||||
|
|
||||||
const dataHandler = (data: Buffer) => {
|
|
||||||
this.responseBuffer += data.toString();
|
|
||||||
|
|
||||||
if (this.isCompleteResponse(this.responseBuffer)) {
|
|
||||||
clearTimeout(timeoutHandler);
|
|
||||||
connection.socket.removeListener('data', dataHandler);
|
|
||||||
|
|
||||||
const response = parseSmtpResponse(this.responseBuffer);
|
|
||||||
this.responseBuffer = '';
|
|
||||||
|
|
||||||
if (isSuccessCode(response.code)) {
|
|
||||||
resolve(response);
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Server greeting failed: ${response.message}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
timeoutHandler = setTimeout(() => {
|
|
||||||
connection.socket.removeListener('data', dataHandler);
|
|
||||||
reject(new Error('Greeting timeout'));
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
connection.socket.on('data', dataHandler);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleIncomingData(data: string): void {
|
|
||||||
if (!this.pendingCommand) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.responseBuffer += data;
|
|
||||||
|
|
||||||
if (this.isCompleteResponse(this.responseBuffer)) {
|
|
||||||
const response = parseSmtpResponse(this.responseBuffer);
|
|
||||||
this.responseBuffer = '';
|
|
||||||
|
|
||||||
if (isSuccessCode(response.code) || (response.code >= 300 && response.code < 400) || response.code >= 400) {
|
|
||||||
this.pendingCommand.resolve(response);
|
|
||||||
} else {
|
|
||||||
this.pendingCommand.reject(new Error(`Command failed: ${response.message}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private isCompleteResponse(buffer: string): boolean {
|
|
||||||
// Check if we have a complete response
|
|
||||||
const lines = buffer.split(/\r?\n/);
|
|
||||||
|
|
||||||
if (lines.length < 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check the last non-empty line
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
const line = lines[i].trim();
|
|
||||||
if (line.length > 0) {
|
|
||||||
// Response is complete if line starts with "XXX " (space after code)
|
|
||||||
return /^\d{3} /.test(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Client Connection Manager
|
|
||||||
* Connection pooling and lifecycle management
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as net from 'node:net';
|
|
||||||
import * as tls from 'node:tls';
|
|
||||||
import { EventEmitter } from 'node:events';
|
|
||||||
import { DEFAULTS, CONNECTION_STATES } from './constants.js';
|
|
||||||
import type {
|
|
||||||
ISmtpClientOptions,
|
|
||||||
ISmtpConnection,
|
|
||||||
IConnectionPoolStatus,
|
|
||||||
ConnectionState
|
|
||||||
} from './interfaces.js';
|
|
||||||
import { logConnection, logDebug } from './utils/logging.js';
|
|
||||||
import { generateConnectionId } from './utils/helpers.js';
|
|
||||||
|
|
||||||
export class ConnectionManager extends EventEmitter {
|
|
||||||
private options: ISmtpClientOptions;
|
|
||||||
private connections: Map<string, ISmtpConnection> = new Map();
|
|
||||||
private pendingConnections: Set<string> = new Set();
|
|
||||||
private idleTimeout: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
constructor(options: ISmtpClientOptions) {
|
|
||||||
super();
|
|
||||||
this.options = options;
|
|
||||||
this.setupIdleCleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create a connection
|
|
||||||
*/
|
|
||||||
public async getConnection(): Promise<ISmtpConnection> {
|
|
||||||
// Try to reuse an idle connection if pooling is enabled
|
|
||||||
if (this.options.pool) {
|
|
||||||
const idleConnection = this.findIdleConnection();
|
|
||||||
if (idleConnection) {
|
|
||||||
const connectionId = this.getConnectionId(idleConnection) || 'unknown';
|
|
||||||
logDebug('Reusing idle connection', this.options, { connectionId });
|
|
||||||
return idleConnection;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we can create a new connection
|
|
||||||
if (this.getActiveConnectionCount() >= (this.options.maxConnections || DEFAULTS.MAX_CONNECTIONS)) {
|
|
||||||
throw new Error('Maximum number of connections reached');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.createConnection();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new connection
|
|
||||||
*/
|
|
||||||
public async createConnection(): Promise<ISmtpConnection> {
|
|
||||||
const connectionId = generateConnectionId();
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.pendingConnections.add(connectionId);
|
|
||||||
logConnection('connecting', this.options, { connectionId });
|
|
||||||
|
|
||||||
const socket = await this.establishSocket();
|
|
||||||
const connection: ISmtpConnection = {
|
|
||||||
socket,
|
|
||||||
state: CONNECTION_STATES.CONNECTED as ConnectionState,
|
|
||||||
options: this.options,
|
|
||||||
secure: this.options.secure || false,
|
|
||||||
createdAt: new Date(),
|
|
||||||
lastActivity: new Date(),
|
|
||||||
messageCount: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
this.setupSocketHandlers(socket, connectionId);
|
|
||||||
this.connections.set(connectionId, connection);
|
|
||||||
this.pendingConnections.delete(connectionId);
|
|
||||||
|
|
||||||
logConnection('connected', this.options, { connectionId });
|
|
||||||
this.emit('connection', connection);
|
|
||||||
|
|
||||||
return connection;
|
|
||||||
} catch (error) {
|
|
||||||
this.pendingConnections.delete(connectionId);
|
|
||||||
logConnection('error', this.options, { connectionId, error });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Release a connection back to the pool or close it
|
|
||||||
*/
|
|
||||||
public releaseConnection(connection: ISmtpConnection): void {
|
|
||||||
const connectionId = this.getConnectionId(connection);
|
|
||||||
|
|
||||||
if (!connectionId || !this.connections.has(connectionId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.options.pool && this.shouldReuseConnection(connection)) {
|
|
||||||
// Return to pool
|
|
||||||
connection.state = CONNECTION_STATES.READY as ConnectionState;
|
|
||||||
connection.lastActivity = new Date();
|
|
||||||
logDebug('Connection returned to pool', this.options, { connectionId });
|
|
||||||
} else {
|
|
||||||
// Close connection
|
|
||||||
this.closeConnection(connection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close a specific connection
|
|
||||||
*/
|
|
||||||
public closeConnection(connection: ISmtpConnection): void {
|
|
||||||
const connectionId = this.getConnectionId(connection);
|
|
||||||
|
|
||||||
if (connectionId) {
|
|
||||||
this.connections.delete(connectionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.state = CONNECTION_STATES.CLOSING as ConnectionState;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!connection.socket.destroyed) {
|
|
||||||
connection.socket.destroy();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logDebug('Error closing connection', this.options, { error });
|
|
||||||
}
|
|
||||||
|
|
||||||
logConnection('disconnected', this.options, { connectionId });
|
|
||||||
this.emit('disconnect', connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close all connections
|
|
||||||
*/
|
|
||||||
public closeAllConnections(): void {
|
|
||||||
logDebug('Closing all connections', this.options);
|
|
||||||
|
|
||||||
for (const connection of this.connections.values()) {
|
|
||||||
this.closeConnection(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.connections.clear();
|
|
||||||
this.pendingConnections.clear();
|
|
||||||
|
|
||||||
if (this.idleTimeout) {
|
|
||||||
clearInterval(this.idleTimeout);
|
|
||||||
this.idleTimeout = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get connection pool status
|
|
||||||
*/
|
|
||||||
public getPoolStatus(): IConnectionPoolStatus {
|
|
||||||
const total = this.connections.size;
|
|
||||||
const active = Array.from(this.connections.values())
|
|
||||||
.filter(conn => conn.state === CONNECTION_STATES.BUSY).length;
|
|
||||||
const idle = total - active;
|
|
||||||
const pending = this.pendingConnections.size;
|
|
||||||
|
|
||||||
return { total, active, idle, pending };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update connection activity timestamp
|
|
||||||
*/
|
|
||||||
public updateActivity(connection: ISmtpConnection): void {
|
|
||||||
connection.lastActivity = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async establishSocket(): Promise<net.Socket | tls.TLSSocket> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT;
|
|
||||||
let socket: net.Socket | tls.TLSSocket;
|
|
||||||
|
|
||||||
if (this.options.secure) {
|
|
||||||
// Direct TLS connection
|
|
||||||
socket = tls.connect({
|
|
||||||
host: this.options.host,
|
|
||||||
port: this.options.port,
|
|
||||||
...this.options.tls
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Plain connection
|
|
||||||
socket = new net.Socket();
|
|
||||||
socket.connect(this.options.port, this.options.host);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutHandler = setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
reject(new Error(`Connection timeout after ${timeout}ms`));
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
// For TLS connections, we need to wait for 'secureConnect' instead of 'connect'
|
|
||||||
const successEvent = this.options.secure ? 'secureConnect' : 'connect';
|
|
||||||
|
|
||||||
socket.once(successEvent, () => {
|
|
||||||
clearTimeout(timeoutHandler);
|
|
||||||
resolve(socket);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.once('error', (error) => {
|
|
||||||
clearTimeout(timeoutHandler);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupSocketHandlers(socket: net.Socket | tls.TLSSocket, connectionId: string): void {
|
|
||||||
const socketTimeout = this.options.socketTimeout || DEFAULTS.SOCKET_TIMEOUT;
|
|
||||||
|
|
||||||
socket.setTimeout(socketTimeout);
|
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
logDebug('Socket timeout', this.options, { connectionId });
|
|
||||||
socket.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
logConnection('error', this.options, { connectionId, error });
|
|
||||||
this.connections.delete(connectionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
this.connections.delete(connectionId);
|
|
||||||
logDebug('Socket closed', this.options, { connectionId });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private findIdleConnection(): ISmtpConnection | null {
|
|
||||||
for (const connection of this.connections.values()) {
|
|
||||||
if (connection.state === CONNECTION_STATES.READY) {
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private shouldReuseConnection(connection: ISmtpConnection): boolean {
|
|
||||||
const maxMessages = this.options.maxMessages || DEFAULTS.MAX_MESSAGES;
|
|
||||||
const maxAge = 300000; // 5 minutes
|
|
||||||
const age = Date.now() - connection.createdAt.getTime();
|
|
||||||
|
|
||||||
return connection.messageCount < maxMessages &&
|
|
||||||
age < maxAge &&
|
|
||||||
!connection.socket.destroyed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getActiveConnectionCount(): number {
|
|
||||||
return this.connections.size + this.pendingConnections.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getConnectionId(connection: ISmtpConnection): string | null {
|
|
||||||
for (const [id, conn] of this.connections.entries()) {
|
|
||||||
if (conn === connection) {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupIdleCleanup(): void {
|
|
||||||
if (!this.options.pool) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanupInterval = DEFAULTS.POOL_IDLE_TIMEOUT;
|
|
||||||
|
|
||||||
this.idleTimeout = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
const connectionsToClose: ISmtpConnection[] = [];
|
|
||||||
|
|
||||||
for (const connection of this.connections.values()) {
|
|
||||||
const idleTime = now - connection.lastActivity.getTime();
|
|
||||||
|
|
||||||
if (connection.state === CONNECTION_STATES.READY && idleTime > cleanupInterval) {
|
|
||||||
connectionsToClose.push(connection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const connection of connectionsToClose) {
|
|
||||||
logDebug('Closing idle connection', this.options);
|
|
||||||
this.closeConnection(connection);
|
|
||||||
}
|
|
||||||
}, cleanupInterval);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Client Constants and Error Codes
|
|
||||||
* All constants, error codes, and enums for SMTP client operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SMTP response codes
|
|
||||||
*/
|
|
||||||
export const SMTP_CODES = {
|
|
||||||
// Positive completion replies
|
|
||||||
SERVICE_READY: 220,
|
|
||||||
SERVICE_CLOSING: 221,
|
|
||||||
AUTHENTICATION_SUCCESSFUL: 235,
|
|
||||||
REQUESTED_ACTION_OK: 250,
|
|
||||||
USER_NOT_LOCAL: 251,
|
|
||||||
CANNOT_VERIFY_USER: 252,
|
|
||||||
|
|
||||||
// Positive intermediate replies
|
|
||||||
START_MAIL_INPUT: 354,
|
|
||||||
|
|
||||||
// Transient negative completion replies
|
|
||||||
SERVICE_NOT_AVAILABLE: 421,
|
|
||||||
MAILBOX_BUSY: 450,
|
|
||||||
LOCAL_ERROR: 451,
|
|
||||||
INSUFFICIENT_STORAGE: 452,
|
|
||||||
UNABLE_TO_ACCOMMODATE: 455,
|
|
||||||
|
|
||||||
// Permanent negative completion replies
|
|
||||||
SYNTAX_ERROR: 500,
|
|
||||||
SYNTAX_ERROR_PARAMETERS: 501,
|
|
||||||
COMMAND_NOT_IMPLEMENTED: 502,
|
|
||||||
BAD_SEQUENCE: 503,
|
|
||||||
PARAMETER_NOT_IMPLEMENTED: 504,
|
|
||||||
MAILBOX_UNAVAILABLE: 550,
|
|
||||||
USER_NOT_LOCAL_TRY_FORWARD: 551,
|
|
||||||
EXCEEDED_STORAGE: 552,
|
|
||||||
MAILBOX_NAME_NOT_ALLOWED: 553,
|
|
||||||
TRANSACTION_FAILED: 554
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SMTP command names
|
|
||||||
*/
|
|
||||||
export const SMTP_COMMANDS = {
|
|
||||||
HELO: 'HELO',
|
|
||||||
EHLO: 'EHLO',
|
|
||||||
MAIL_FROM: 'MAIL FROM',
|
|
||||||
RCPT_TO: 'RCPT TO',
|
|
||||||
DATA: 'DATA',
|
|
||||||
RSET: 'RSET',
|
|
||||||
NOOP: 'NOOP',
|
|
||||||
QUIT: 'QUIT',
|
|
||||||
STARTTLS: 'STARTTLS',
|
|
||||||
AUTH: 'AUTH'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication methods
|
|
||||||
*/
|
|
||||||
export const AUTH_METHODS = {
|
|
||||||
PLAIN: 'PLAIN',
|
|
||||||
LOGIN: 'LOGIN',
|
|
||||||
OAUTH2: 'XOAUTH2',
|
|
||||||
CRAM_MD5: 'CRAM-MD5'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Common SMTP extensions
|
|
||||||
*/
|
|
||||||
export const SMTP_EXTENSIONS = {
|
|
||||||
PIPELINING: 'PIPELINING',
|
|
||||||
SIZE: 'SIZE',
|
|
||||||
STARTTLS: 'STARTTLS',
|
|
||||||
AUTH: 'AUTH',
|
|
||||||
EIGHT_BIT_MIME: '8BITMIME',
|
|
||||||
CHUNKING: 'CHUNKING',
|
|
||||||
ENHANCED_STATUS_CODES: 'ENHANCEDSTATUSCODES',
|
|
||||||
DSN: 'DSN'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default configuration values
|
|
||||||
*/
|
|
||||||
export const DEFAULTS = {
|
|
||||||
CONNECTION_TIMEOUT: 60000, // 60 seconds
|
|
||||||
SOCKET_TIMEOUT: 300000, // 5 minutes
|
|
||||||
COMMAND_TIMEOUT: 30000, // 30 seconds
|
|
||||||
MAX_CONNECTIONS: 5,
|
|
||||||
MAX_MESSAGES: 100,
|
|
||||||
PORT_SMTP: 25,
|
|
||||||
PORT_SUBMISSION: 587,
|
|
||||||
PORT_SMTPS: 465,
|
|
||||||
RETRY_ATTEMPTS: 3,
|
|
||||||
RETRY_DELAY: 1000,
|
|
||||||
POOL_IDLE_TIMEOUT: 30000 // 30 seconds
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error types for classification
|
|
||||||
*/
|
|
||||||
export enum SmtpErrorType {
|
|
||||||
CONNECTION_ERROR = 'CONNECTION_ERROR',
|
|
||||||
AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR',
|
|
||||||
PROTOCOL_ERROR = 'PROTOCOL_ERROR',
|
|
||||||
TIMEOUT_ERROR = 'TIMEOUT_ERROR',
|
|
||||||
TLS_ERROR = 'TLS_ERROR',
|
|
||||||
SYNTAX_ERROR = 'SYNTAX_ERROR',
|
|
||||||
MAILBOX_ERROR = 'MAILBOX_ERROR',
|
|
||||||
QUOTA_ERROR = 'QUOTA_ERROR',
|
|
||||||
UNKNOWN_ERROR = 'UNKNOWN_ERROR'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regular expressions for parsing
|
|
||||||
*/
|
|
||||||
export const REGEX_PATTERNS = {
|
|
||||||
EMAIL_ADDRESS: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
||||||
RESPONSE_CODE: /^(\d{3})([ -])(.*)/,
|
|
||||||
ENHANCED_STATUS: /^(\d\.\d\.\d)\s/,
|
|
||||||
AUTH_CAPABILITIES: /AUTH\s+(.+)/i,
|
|
||||||
SIZE_EXTENSION: /SIZE\s+(\d+)/i
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Line endings and separators
|
|
||||||
*/
|
|
||||||
export const LINE_ENDINGS = {
|
|
||||||
CRLF: '\r\n',
|
|
||||||
LF: '\n',
|
|
||||||
CR: '\r'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connection states for internal use
|
|
||||||
*/
|
|
||||||
export const CONNECTION_STATES = {
|
|
||||||
DISCONNECTED: 'disconnected',
|
|
||||||
CONNECTING: 'connecting',
|
|
||||||
CONNECTED: 'connected',
|
|
||||||
AUTHENTICATED: 'authenticated',
|
|
||||||
READY: 'ready',
|
|
||||||
BUSY: 'busy',
|
|
||||||
CLOSING: 'closing',
|
|
||||||
ERROR: 'error'
|
|
||||||
} as const;
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Client Factory
|
|
||||||
* Factory function for client creation and dependency injection
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { SmtpClient } from './smtp-client.js';
|
|
||||||
import { ConnectionManager } from './connection-manager.js';
|
|
||||||
import { CommandHandler } from './command-handler.js';
|
|
||||||
import { AuthHandler } from './auth-handler.js';
|
|
||||||
import { TlsHandler } from './tls-handler.js';
|
|
||||||
import { SmtpErrorHandler } from './error-handler.js';
|
|
||||||
import type { ISmtpClientOptions } from './interfaces.js';
|
|
||||||
import { validateClientOptions } from './utils/validation.js';
|
|
||||||
import { DEFAULTS } from './constants.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a complete SMTP client with all components
|
|
||||||
*/
|
|
||||||
export function createSmtpClient(options: ISmtpClientOptions): SmtpClient {
|
|
||||||
// Validate options
|
|
||||||
const errors = validateClientOptions(options);
|
|
||||||
if (errors.length > 0) {
|
|
||||||
throw new Error(`Invalid client options: ${errors.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply defaults
|
|
||||||
const clientOptions: ISmtpClientOptions = {
|
|
||||||
connectionTimeout: DEFAULTS.CONNECTION_TIMEOUT,
|
|
||||||
socketTimeout: DEFAULTS.SOCKET_TIMEOUT,
|
|
||||||
maxConnections: DEFAULTS.MAX_CONNECTIONS,
|
|
||||||
maxMessages: DEFAULTS.MAX_MESSAGES,
|
|
||||||
pool: false,
|
|
||||||
secure: false,
|
|
||||||
debug: false,
|
|
||||||
...options
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create handlers
|
|
||||||
const errorHandler = new SmtpErrorHandler(clientOptions);
|
|
||||||
const connectionManager = new ConnectionManager(clientOptions);
|
|
||||||
const commandHandler = new CommandHandler(clientOptions);
|
|
||||||
const authHandler = new AuthHandler(clientOptions, commandHandler);
|
|
||||||
const tlsHandler = new TlsHandler(clientOptions, commandHandler);
|
|
||||||
|
|
||||||
// Create and return SMTP client
|
|
||||||
return new SmtpClient({
|
|
||||||
options: clientOptions,
|
|
||||||
connectionManager,
|
|
||||||
commandHandler,
|
|
||||||
authHandler,
|
|
||||||
tlsHandler,
|
|
||||||
errorHandler
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create SMTP client with connection pooling enabled
|
|
||||||
*/
|
|
||||||
export function createPooledSmtpClient(options: ISmtpClientOptions): SmtpClient {
|
|
||||||
return createSmtpClient({
|
|
||||||
...options,
|
|
||||||
pool: true,
|
|
||||||
maxConnections: options.maxConnections || DEFAULTS.MAX_CONNECTIONS,
|
|
||||||
maxMessages: options.maxMessages || DEFAULTS.MAX_MESSAGES
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create SMTP client for high-volume sending
|
|
||||||
*/
|
|
||||||
export function createBulkSmtpClient(options: ISmtpClientOptions): SmtpClient {
|
|
||||||
return createSmtpClient({
|
|
||||||
...options,
|
|
||||||
pool: true,
|
|
||||||
maxConnections: Math.max(options.maxConnections || 10, 10),
|
|
||||||
maxMessages: Math.max(options.maxMessages || 1000, 1000),
|
|
||||||
connectionTimeout: options.connectionTimeout || 30000,
|
|
||||||
socketTimeout: options.socketTimeout || 120000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create SMTP client for transactional emails
|
|
||||||
*/
|
|
||||||
export function createTransactionalSmtpClient(options: ISmtpClientOptions): SmtpClient {
|
|
||||||
return createSmtpClient({
|
|
||||||
...options,
|
|
||||||
pool: false, // Use fresh connections for transactional emails
|
|
||||||
maxConnections: 1,
|
|
||||||
maxMessages: 1,
|
|
||||||
connectionTimeout: options.connectionTimeout || 10000,
|
|
||||||
socketTimeout: options.socketTimeout || 30000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Client Error Handler
|
|
||||||
* Error classification and recovery strategies
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { SmtpErrorType } from './constants.js';
|
|
||||||
import type { ISmtpResponse, ISmtpErrorContext, ISmtpClientOptions } from './interfaces.js';
|
|
||||||
import { logDebug } from './utils/logging.js';
|
|
||||||
|
|
||||||
export class SmtpErrorHandler {
|
|
||||||
private options: ISmtpClientOptions;
|
|
||||||
|
|
||||||
constructor(options: ISmtpClientOptions) {
|
|
||||||
this.options = options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify error type based on response or error
|
|
||||||
*/
|
|
||||||
public classifyError(error: Error | ISmtpResponse, context?: ISmtpErrorContext): SmtpErrorType {
|
|
||||||
logDebug('Classifying error', this.options, { errorMessage: error instanceof Error ? error.message : String(error), context });
|
|
||||||
|
|
||||||
// Handle Error objects
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return this.classifyErrorByMessage(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle SMTP response codes
|
|
||||||
if (typeof error === 'object' && 'code' in error) {
|
|
||||||
return this.classifyErrorByCode(error.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SmtpErrorType.UNKNOWN_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if error is retryable
|
|
||||||
*/
|
|
||||||
public isRetryable(errorType: SmtpErrorType, response?: ISmtpResponse): boolean {
|
|
||||||
switch (errorType) {
|
|
||||||
case SmtpErrorType.CONNECTION_ERROR:
|
|
||||||
case SmtpErrorType.TIMEOUT_ERROR:
|
|
||||||
return true;
|
|
||||||
|
|
||||||
case SmtpErrorType.PROTOCOL_ERROR:
|
|
||||||
// Only retry on temporary failures (4xx codes)
|
|
||||||
return response ? response.code >= 400 && response.code < 500 : false;
|
|
||||||
|
|
||||||
case SmtpErrorType.AUTHENTICATION_ERROR:
|
|
||||||
case SmtpErrorType.TLS_ERROR:
|
|
||||||
case SmtpErrorType.SYNTAX_ERROR:
|
|
||||||
case SmtpErrorType.MAILBOX_ERROR:
|
|
||||||
case SmtpErrorType.QUOTA_ERROR:
|
|
||||||
return false;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get retry delay for error type
|
|
||||||
*/
|
|
||||||
public getRetryDelay(attempt: number, errorType: SmtpErrorType): number {
|
|
||||||
const baseDelay = 1000; // 1 second
|
|
||||||
const maxDelay = 30000; // 30 seconds
|
|
||||||
|
|
||||||
// Exponential backoff with jitter
|
|
||||||
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
|
|
||||||
const jitter = Math.random() * 0.1 * delay; // 10% jitter
|
|
||||||
|
|
||||||
return Math.floor(delay + jitter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create enhanced error with context
|
|
||||||
*/
|
|
||||||
public createError(
|
|
||||||
message: string,
|
|
||||||
errorType: SmtpErrorType,
|
|
||||||
context?: ISmtpErrorContext,
|
|
||||||
originalError?: Error
|
|
||||||
): Error {
|
|
||||||
const error = new Error(message);
|
|
||||||
(error as any).type = errorType;
|
|
||||||
(error as any).context = context;
|
|
||||||
(error as any).originalError = originalError;
|
|
||||||
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
private classifyErrorByMessage(error: Error): SmtpErrorType {
|
|
||||||
const message = error.message.toLowerCase();
|
|
||||||
|
|
||||||
if (message.includes('timeout') || message.includes('etimedout')) {
|
|
||||||
return SmtpErrorType.TIMEOUT_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes('connect') || message.includes('econnrefused') ||
|
|
||||||
message.includes('enotfound') || message.includes('enetunreach')) {
|
|
||||||
return SmtpErrorType.CONNECTION_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes('tls') || message.includes('ssl') ||
|
|
||||||
message.includes('certificate') || message.includes('handshake')) {
|
|
||||||
return SmtpErrorType.TLS_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes('auth')) {
|
|
||||||
return SmtpErrorType.AUTHENTICATION_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
return SmtpErrorType.UNKNOWN_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
private classifyErrorByCode(code: number): SmtpErrorType {
|
|
||||||
if (code >= 500) {
|
|
||||||
// Permanent failures
|
|
||||||
if (code === 550 || code === 551 || code === 553) {
|
|
||||||
return SmtpErrorType.MAILBOX_ERROR;
|
|
||||||
}
|
|
||||||
if (code === 552) {
|
|
||||||
return SmtpErrorType.QUOTA_ERROR;
|
|
||||||
}
|
|
||||||
if (code === 500 || code === 501 || code === 502 || code === 504) {
|
|
||||||
return SmtpErrorType.SYNTAX_ERROR;
|
|
||||||
}
|
|
||||||
return SmtpErrorType.PROTOCOL_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code >= 400) {
|
|
||||||
// Temporary failures
|
|
||||||
if (code === 450 || code === 451 || code === 452) {
|
|
||||||
return SmtpErrorType.QUOTA_ERROR;
|
|
||||||
}
|
|
||||||
return SmtpErrorType.PROTOCOL_ERROR;
|
|
||||||
}
|
|
||||||
|
|
||||||
return SmtpErrorType.UNKNOWN_ERROR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Client Module Exports
|
|
||||||
* Modular SMTP client implementation for robust email delivery
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Main client class and factory
|
|
||||||
export * from './smtp-client.js';
|
|
||||||
export * from './create-client.js';
|
|
||||||
|
|
||||||
// Core handlers
|
|
||||||
export * from './connection-manager.js';
|
|
||||||
export * from './command-handler.js';
|
|
||||||
export * from './auth-handler.js';
|
|
||||||
export * from './tls-handler.js';
|
|
||||||
export * from './error-handler.js';
|
|
||||||
|
|
||||||
// Interfaces and types
|
|
||||||
export * from './interfaces.js';
|
|
||||||
export * from './constants.js';
|
|
||||||
|
|
||||||
// Utilities
|
|
||||||
export * from './utils/validation.js';
|
|
||||||
export * from './utils/logging.js';
|
|
||||||
export * from './utils/helpers.js';
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Client Interfaces and Types
|
|
||||||
* All interface definitions for the modular SMTP client
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type * as tls from 'node:tls';
|
|
||||||
import type * as net from 'node:net';
|
|
||||||
import type { Email } from '../../core/classes.email.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SMTP client connection options
|
|
||||||
*/
|
|
||||||
export interface ISmtpClientOptions {
|
|
||||||
/** Hostname of the SMTP server */
|
|
||||||
host: string;
|
|
||||||
|
|
||||||
/** Port to connect to */
|
|
||||||
port: number;
|
|
||||||
|
|
||||||
/** Whether to use TLS for the connection */
|
|
||||||
secure?: boolean;
|
|
||||||
|
|
||||||
/** Connection timeout in milliseconds */
|
|
||||||
connectionTimeout?: number;
|
|
||||||
|
|
||||||
/** Socket timeout in milliseconds */
|
|
||||||
socketTimeout?: number;
|
|
||||||
|
|
||||||
/** Domain name for EHLO command */
|
|
||||||
domain?: string;
|
|
||||||
|
|
||||||
/** Authentication options */
|
|
||||||
auth?: ISmtpAuthOptions;
|
|
||||||
|
|
||||||
/** TLS options */
|
|
||||||
tls?: tls.ConnectionOptions;
|
|
||||||
|
|
||||||
/** Maximum number of connections in pool */
|
|
||||||
pool?: boolean;
|
|
||||||
maxConnections?: number;
|
|
||||||
maxMessages?: number;
|
|
||||||
|
|
||||||
/** Enable debug logging */
|
|
||||||
debug?: boolean;
|
|
||||||
|
|
||||||
/** Proxy settings */
|
|
||||||
proxy?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication options for SMTP
|
|
||||||
*/
|
|
||||||
export interface ISmtpAuthOptions {
|
|
||||||
/** Username */
|
|
||||||
user?: string;
|
|
||||||
|
|
||||||
/** Password */
|
|
||||||
pass?: string;
|
|
||||||
|
|
||||||
/** OAuth2 settings */
|
|
||||||
oauth2?: IOAuth2Options;
|
|
||||||
|
|
||||||
/** Authentication method preference */
|
|
||||||
method?: 'PLAIN' | 'LOGIN' | 'OAUTH2' | 'AUTO';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OAuth2 authentication options
|
|
||||||
*/
|
|
||||||
export interface IOAuth2Options {
|
|
||||||
/** OAuth2 user identifier */
|
|
||||||
user: string;
|
|
||||||
|
|
||||||
/** OAuth2 client ID */
|
|
||||||
clientId: string;
|
|
||||||
|
|
||||||
/** OAuth2 client secret */
|
|
||||||
clientSecret: string;
|
|
||||||
|
|
||||||
/** OAuth2 refresh token */
|
|
||||||
refreshToken: string;
|
|
||||||
|
|
||||||
/** OAuth2 access token */
|
|
||||||
accessToken?: string;
|
|
||||||
|
|
||||||
/** Token expiry time */
|
|
||||||
expires?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of an email send operation
|
|
||||||
*/
|
|
||||||
export interface ISmtpSendResult {
|
|
||||||
/** Whether the send was successful */
|
|
||||||
success: boolean;
|
|
||||||
|
|
||||||
/** Message ID from server */
|
|
||||||
messageId?: string;
|
|
||||||
|
|
||||||
/** List of accepted recipients */
|
|
||||||
acceptedRecipients: string[];
|
|
||||||
|
|
||||||
/** List of rejected recipients */
|
|
||||||
rejectedRecipients: string[];
|
|
||||||
|
|
||||||
/** Error information if failed */
|
|
||||||
error?: Error;
|
|
||||||
|
|
||||||
/** Server response */
|
|
||||||
response?: string;
|
|
||||||
|
|
||||||
/** Envelope information */
|
|
||||||
envelope?: ISmtpEnvelope;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SMTP envelope information
|
|
||||||
*/
|
|
||||||
export interface ISmtpEnvelope {
|
|
||||||
/** Sender address */
|
|
||||||
from: string;
|
|
||||||
|
|
||||||
/** Recipient addresses */
|
|
||||||
to: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connection pool status
|
|
||||||
*/
|
|
||||||
export interface IConnectionPoolStatus {
|
|
||||||
/** Total connections in pool */
|
|
||||||
total: number;
|
|
||||||
|
|
||||||
/** Active connections */
|
|
||||||
active: number;
|
|
||||||
|
|
||||||
/** Idle connections */
|
|
||||||
idle: number;
|
|
||||||
|
|
||||||
/** Pending connection requests */
|
|
||||||
pending: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SMTP command response
|
|
||||||
*/
|
|
||||||
export interface ISmtpResponse {
|
|
||||||
/** Response code */
|
|
||||||
code: number;
|
|
||||||
|
|
||||||
/** Response message */
|
|
||||||
message: string;
|
|
||||||
|
|
||||||
/** Enhanced status code */
|
|
||||||
enhancedCode?: string;
|
|
||||||
|
|
||||||
/** Raw response */
|
|
||||||
raw: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connection state
|
|
||||||
*/
|
|
||||||
export enum ConnectionState {
|
|
||||||
DISCONNECTED = 'disconnected',
|
|
||||||
CONNECTING = 'connecting',
|
|
||||||
CONNECTED = 'connected',
|
|
||||||
AUTHENTICATED = 'authenticated',
|
|
||||||
READY = 'ready',
|
|
||||||
BUSY = 'busy',
|
|
||||||
CLOSING = 'closing',
|
|
||||||
ERROR = 'error'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SMTP capabilities
|
|
||||||
*/
|
|
||||||
export interface ISmtpCapabilities {
|
|
||||||
/** Supported extensions */
|
|
||||||
extensions: Set<string>;
|
|
||||||
|
|
||||||
/** Maximum message size */
|
|
||||||
maxSize?: number;
|
|
||||||
|
|
||||||
/** Supported authentication methods */
|
|
||||||
authMethods: Set<string>;
|
|
||||||
|
|
||||||
/** Support for pipelining */
|
|
||||||
pipelining: boolean;
|
|
||||||
|
|
||||||
/** Support for STARTTLS */
|
|
||||||
starttls: boolean;
|
|
||||||
|
|
||||||
/** Support for 8BITMIME */
|
|
||||||
eightBitMime: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal connection interface
|
|
||||||
*/
|
|
||||||
export interface ISmtpConnection {
|
|
||||||
/** Socket connection */
|
|
||||||
socket: net.Socket | tls.TLSSocket;
|
|
||||||
|
|
||||||
/** Connection state */
|
|
||||||
state: ConnectionState;
|
|
||||||
|
|
||||||
/** Server capabilities */
|
|
||||||
capabilities?: ISmtpCapabilities;
|
|
||||||
|
|
||||||
/** Connection options */
|
|
||||||
options: ISmtpClientOptions;
|
|
||||||
|
|
||||||
/** Whether connection is secure */
|
|
||||||
secure: boolean;
|
|
||||||
|
|
||||||
/** Connection creation time */
|
|
||||||
createdAt: Date;
|
|
||||||
|
|
||||||
/** Last activity time */
|
|
||||||
lastActivity: Date;
|
|
||||||
|
|
||||||
/** Number of messages sent */
|
|
||||||
messageCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error context for detailed error reporting
|
|
||||||
*/
|
|
||||||
export interface ISmtpErrorContext {
|
|
||||||
/** Command that caused the error */
|
|
||||||
command?: string;
|
|
||||||
|
|
||||||
/** Server response */
|
|
||||||
response?: ISmtpResponse;
|
|
||||||
|
|
||||||
/** Connection state */
|
|
||||||
connectionState?: ConnectionState;
|
|
||||||
|
|
||||||
/** Additional context data */
|
|
||||||
data?: Record<string, any>;
|
|
||||||
}
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Client Core Implementation
|
|
||||||
* Main client class with delegation to handlers
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events';
|
|
||||||
import type { Email } from '../../core/classes.email.js';
|
|
||||||
import type {
|
|
||||||
ISmtpClientOptions,
|
|
||||||
ISmtpSendResult,
|
|
||||||
ISmtpConnection,
|
|
||||||
IConnectionPoolStatus,
|
|
||||||
ConnectionState
|
|
||||||
} from './interfaces.js';
|
|
||||||
import { CONNECTION_STATES, SmtpErrorType } from './constants.js';
|
|
||||||
import type { ConnectionManager } from './connection-manager.js';
|
|
||||||
import type { CommandHandler } from './command-handler.js';
|
|
||||||
import type { AuthHandler } from './auth-handler.js';
|
|
||||||
import type { TlsHandler } from './tls-handler.js';
|
|
||||||
import type { SmtpErrorHandler } from './error-handler.js';
|
|
||||||
import { validateSender, validateRecipients } from './utils/validation.js';
|
|
||||||
import { logEmailSend, logPerformance, logDebug } from './utils/logging.js';
|
|
||||||
|
|
||||||
interface ISmtpClientDependencies {
|
|
||||||
options: ISmtpClientOptions;
|
|
||||||
connectionManager: ConnectionManager;
|
|
||||||
commandHandler: CommandHandler;
|
|
||||||
authHandler: AuthHandler;
|
|
||||||
tlsHandler: TlsHandler;
|
|
||||||
errorHandler: SmtpErrorHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SmtpClient extends EventEmitter {
|
|
||||||
private options: ISmtpClientOptions;
|
|
||||||
private connectionManager: ConnectionManager;
|
|
||||||
private commandHandler: CommandHandler;
|
|
||||||
private authHandler: AuthHandler;
|
|
||||||
private tlsHandler: TlsHandler;
|
|
||||||
private errorHandler: SmtpErrorHandler;
|
|
||||||
private isShuttingDown: boolean = false;
|
|
||||||
|
|
||||||
constructor(dependencies: ISmtpClientDependencies) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.options = dependencies.options;
|
|
||||||
this.connectionManager = dependencies.connectionManager;
|
|
||||||
this.commandHandler = dependencies.commandHandler;
|
|
||||||
this.authHandler = dependencies.authHandler;
|
|
||||||
this.tlsHandler = dependencies.tlsHandler;
|
|
||||||
this.errorHandler = dependencies.errorHandler;
|
|
||||||
|
|
||||||
this.setupEventForwarding();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email
|
|
||||||
*/
|
|
||||||
public async sendMail(email: Email): Promise<ISmtpSendResult> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// Extract clean email addresses without display names for SMTP operations
|
|
||||||
const fromAddress = email.getFromAddress();
|
|
||||||
const recipients = email.getToAddresses();
|
|
||||||
const ccRecipients = email.getCcAddresses();
|
|
||||||
const bccRecipients = email.getBccAddresses();
|
|
||||||
|
|
||||||
// Combine all recipients for SMTP operations
|
|
||||||
const allRecipients = [...recipients, ...ccRecipients, ...bccRecipients];
|
|
||||||
|
|
||||||
// Validate email addresses
|
|
||||||
if (!validateSender(fromAddress)) {
|
|
||||||
throw new Error(`Invalid sender address: ${fromAddress}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipientErrors = validateRecipients(allRecipients);
|
|
||||||
if (recipientErrors.length > 0) {
|
|
||||||
throw new Error(`Invalid recipients: ${recipientErrors.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logEmailSend('start', allRecipients, this.options);
|
|
||||||
|
|
||||||
let connection: ISmtpConnection | null = null;
|
|
||||||
const result: ISmtpSendResult = {
|
|
||||||
success: false,
|
|
||||||
acceptedRecipients: [],
|
|
||||||
rejectedRecipients: [],
|
|
||||||
envelope: {
|
|
||||||
from: fromAddress,
|
|
||||||
to: allRecipients
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get connection
|
|
||||||
connection = await this.connectionManager.getConnection();
|
|
||||||
connection.state = CONNECTION_STATES.BUSY as ConnectionState;
|
|
||||||
|
|
||||||
// Wait for greeting if new connection
|
|
||||||
if (!connection.capabilities) {
|
|
||||||
await this.commandHandler.waitForGreeting(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform EHLO
|
|
||||||
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
|
||||||
|
|
||||||
// Upgrade to TLS if needed
|
|
||||||
if (this.tlsHandler.shouldUseTLS(connection)) {
|
|
||||||
await this.tlsHandler.upgradeToTLS(connection);
|
|
||||||
// Re-send EHLO after TLS upgrade
|
|
||||||
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticate if needed
|
|
||||||
if (this.options.auth) {
|
|
||||||
await this.authHandler.authenticate(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send MAIL FROM
|
|
||||||
const mailFromResponse = await this.commandHandler.sendMailFrom(connection, fromAddress);
|
|
||||||
if (mailFromResponse.code >= 400) {
|
|
||||||
throw new Error(`MAIL FROM failed: ${mailFromResponse.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send RCPT TO for each recipient (includes TO, CC, and BCC)
|
|
||||||
for (const recipient of allRecipients) {
|
|
||||||
try {
|
|
||||||
const rcptResponse = await this.commandHandler.sendRcptTo(connection, recipient);
|
|
||||||
if (rcptResponse.code >= 400) {
|
|
||||||
result.rejectedRecipients.push(recipient);
|
|
||||||
logDebug(`Recipient rejected: ${recipient}`, this.options, { response: rcptResponse });
|
|
||||||
} else {
|
|
||||||
result.acceptedRecipients.push(recipient);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
result.rejectedRecipients.push(recipient);
|
|
||||||
logDebug(`Recipient error: ${recipient}`, this.options, { error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have any accepted recipients
|
|
||||||
if (result.acceptedRecipients.length === 0) {
|
|
||||||
throw new Error('All recipients were rejected');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send DATA command
|
|
||||||
const dataResponse = await this.commandHandler.sendData(connection);
|
|
||||||
if (dataResponse.code !== 354) {
|
|
||||||
throw new Error(`DATA command failed: ${dataResponse.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send email content
|
|
||||||
const emailData = await this.formatEmailData(email);
|
|
||||||
const sendResponse = await this.commandHandler.sendDataContent(connection, emailData);
|
|
||||||
|
|
||||||
if (sendResponse.code >= 400) {
|
|
||||||
throw new Error(`Email data rejected: ${sendResponse.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success
|
|
||||||
result.success = true;
|
|
||||||
result.messageId = this.extractMessageId(sendResponse.message);
|
|
||||||
result.response = sendResponse.message;
|
|
||||||
|
|
||||||
connection.messageCount++;
|
|
||||||
logEmailSend('success', recipients, this.options, {
|
|
||||||
messageId: result.messageId,
|
|
||||||
duration: Date.now() - startTime
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
result.success = false;
|
|
||||||
result.error = error instanceof Error ? error : new Error(String(error));
|
|
||||||
|
|
||||||
// Classify error and determine if we should retry
|
|
||||||
const errorType = this.errorHandler.classifyError(result.error);
|
|
||||||
result.error = this.errorHandler.createError(
|
|
||||||
result.error.message,
|
|
||||||
errorType,
|
|
||||||
{ command: 'SEND_MAIL' },
|
|
||||||
result.error
|
|
||||||
);
|
|
||||||
|
|
||||||
logEmailSend('failure', recipients, this.options, {
|
|
||||||
error: result.error,
|
|
||||||
duration: Date.now() - startTime
|
|
||||||
});
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
// Release connection
|
|
||||||
if (connection) {
|
|
||||||
connection.state = CONNECTION_STATES.READY as ConnectionState;
|
|
||||||
this.connectionManager.updateActivity(connection);
|
|
||||||
this.connectionManager.releaseConnection(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
logPerformance('sendMail', Date.now() - startTime, this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test connection to SMTP server
|
|
||||||
*/
|
|
||||||
public async verify(): Promise<boolean> {
|
|
||||||
let connection: ISmtpConnection | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
connection = await this.connectionManager.createConnection();
|
|
||||||
await this.commandHandler.waitForGreeting(connection);
|
|
||||||
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
|
||||||
|
|
||||||
if (this.tlsHandler.shouldUseTLS(connection)) {
|
|
||||||
await this.tlsHandler.upgradeToTLS(connection);
|
|
||||||
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.options.auth) {
|
|
||||||
await this.authHandler.authenticate(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.commandHandler.sendQuit(connection);
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logDebug('Connection verification failed', this.options, { error });
|
|
||||||
return false;
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
if (connection) {
|
|
||||||
this.connectionManager.closeConnection(connection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if client is connected
|
|
||||||
*/
|
|
||||||
public isConnected(): boolean {
|
|
||||||
const status = this.connectionManager.getPoolStatus();
|
|
||||||
return status.total > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get connection pool status
|
|
||||||
*/
|
|
||||||
public getPoolStatus(): IConnectionPoolStatus {
|
|
||||||
return this.connectionManager.getPoolStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update client options
|
|
||||||
*/
|
|
||||||
public updateOptions(newOptions: Partial<ISmtpClientOptions>): void {
|
|
||||||
this.options = { ...this.options, ...newOptions };
|
|
||||||
logDebug('Client options updated', this.options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close all connections and shutdown client
|
|
||||||
*/
|
|
||||||
public async close(): Promise<void> {
|
|
||||||
if (this.isShuttingDown) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isShuttingDown = true;
|
|
||||||
logDebug('Shutting down SMTP client', this.options);
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.connectionManager.closeAllConnections();
|
|
||||||
this.emit('close');
|
|
||||||
} catch (error) {
|
|
||||||
logDebug('Error during client shutdown', this.options, { error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async formatEmailData(email: Email): Promise<string> {
|
|
||||||
// Convert Email object to raw SMTP data
|
|
||||||
const headers: string[] = [];
|
|
||||||
|
|
||||||
// Required headers
|
|
||||||
headers.push(`From: ${email.from}`);
|
|
||||||
headers.push(`To: ${Array.isArray(email.to) ? email.to.join(', ') : email.to}`);
|
|
||||||
headers.push(`Subject: ${email.subject || ''}`);
|
|
||||||
headers.push(`Date: ${new Date().toUTCString()}`);
|
|
||||||
headers.push(`Message-ID: <${Date.now()}.${Math.random().toString(36)}@${this.options.host}>`);
|
|
||||||
|
|
||||||
// Optional headers
|
|
||||||
if (email.cc) {
|
|
||||||
const cc = Array.isArray(email.cc) ? email.cc.join(', ') : email.cc;
|
|
||||||
headers.push(`Cc: ${cc}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (email.bcc) {
|
|
||||||
const bcc = Array.isArray(email.bcc) ? email.bcc.join(', ') : email.bcc;
|
|
||||||
headers.push(`Bcc: ${bcc}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content headers
|
|
||||||
if (email.html && email.text) {
|
|
||||||
// Multipart message
|
|
||||||
const boundary = `boundary_${Date.now()}_${Math.random().toString(36)}`;
|
|
||||||
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
|
||||||
headers.push('MIME-Version: 1.0');
|
|
||||||
|
|
||||||
const body = [
|
|
||||||
`--${boundary}`,
|
|
||||||
'Content-Type: text/plain; charset=utf-8',
|
|
||||||
'Content-Transfer-Encoding: quoted-printable',
|
|
||||||
'',
|
|
||||||
email.text,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
'Content-Type: text/html; charset=utf-8',
|
|
||||||
'Content-Transfer-Encoding: quoted-printable',
|
|
||||||
'',
|
|
||||||
email.html,
|
|
||||||
'',
|
|
||||||
`--${boundary}--`
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
return headers.join('\r\n') + '\r\n\r\n' + body;
|
|
||||||
} else if (email.html) {
|
|
||||||
headers.push('Content-Type: text/html; charset=utf-8');
|
|
||||||
headers.push('MIME-Version: 1.0');
|
|
||||||
return headers.join('\r\n') + '\r\n\r\n' + email.html;
|
|
||||||
} else {
|
|
||||||
headers.push('Content-Type: text/plain; charset=utf-8');
|
|
||||||
headers.push('MIME-Version: 1.0');
|
|
||||||
return headers.join('\r\n') + '\r\n\r\n' + (email.text || '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractMessageId(response: string): string | undefined {
|
|
||||||
// Try to extract message ID from server response
|
|
||||||
const match = response.match(/queued as ([^\s]+)/i) ||
|
|
||||||
response.match(/id=([^\s]+)/i) ||
|
|
||||||
response.match(/Message-ID: <([^>]+)>/i);
|
|
||||||
return match ? match[1] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupEventForwarding(): void {
|
|
||||||
// Forward events from connection manager
|
|
||||||
this.connectionManager.on('connection', (connection) => {
|
|
||||||
this.emit('connection', connection);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.connectionManager.on('disconnect', (connection) => {
|
|
||||||
this.emit('disconnect', connection);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.connectionManager.on('error', (error) => {
|
|
||||||
this.emit('error', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Client TLS Handler
|
|
||||||
* TLS and STARTTLS client functionality
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as tls from 'node:tls';
|
|
||||||
import * as net from 'node:net';
|
|
||||||
import { DEFAULTS } from './constants.js';
|
|
||||||
import type {
|
|
||||||
ISmtpConnection,
|
|
||||||
ISmtpClientOptions,
|
|
||||||
ConnectionState
|
|
||||||
} from './interfaces.js';
|
|
||||||
import { CONNECTION_STATES } from './constants.js';
|
|
||||||
import { logTLS, logDebug } from './utils/logging.js';
|
|
||||||
import { isSuccessCode } from './utils/helpers.js';
|
|
||||||
import type { CommandHandler } from './command-handler.js';
|
|
||||||
|
|
||||||
export class TlsHandler {
|
|
||||||
private options: ISmtpClientOptions;
|
|
||||||
private commandHandler: CommandHandler;
|
|
||||||
|
|
||||||
constructor(options: ISmtpClientOptions, commandHandler: CommandHandler) {
|
|
||||||
this.options = options;
|
|
||||||
this.commandHandler = commandHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upgrade connection to TLS using STARTTLS
|
|
||||||
*/
|
|
||||||
public async upgradeToTLS(connection: ISmtpConnection): Promise<void> {
|
|
||||||
if (connection.secure) {
|
|
||||||
logDebug('Connection already secure', this.options);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if STARTTLS is supported
|
|
||||||
if (!connection.capabilities?.starttls) {
|
|
||||||
throw new Error('Server does not support STARTTLS');
|
|
||||||
}
|
|
||||||
|
|
||||||
logTLS('starttls_start', this.options);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Send STARTTLS command
|
|
||||||
const response = await this.commandHandler.sendStartTls(connection);
|
|
||||||
|
|
||||||
if (!isSuccessCode(response.code)) {
|
|
||||||
throw new Error(`STARTTLS command failed: ${response.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upgrade the socket to TLS
|
|
||||||
await this.performTLSUpgrade(connection);
|
|
||||||
|
|
||||||
// Clear capabilities as they may have changed after TLS
|
|
||||||
connection.capabilities = undefined;
|
|
||||||
connection.secure = true;
|
|
||||||
|
|
||||||
logTLS('starttls_success', this.options);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logTLS('starttls_failure', this.options, { error });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a direct TLS connection
|
|
||||||
*/
|
|
||||||
public async createTLSConnection(host: string, port: number): Promise<tls.TLSSocket> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT;
|
|
||||||
|
|
||||||
const tlsOptions: tls.ConnectionOptions = {
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
...this.options.tls,
|
|
||||||
// Default TLS options for email
|
|
||||||
secureProtocol: 'TLS_method',
|
|
||||||
ciphers: 'HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA',
|
|
||||||
rejectUnauthorized: this.options.tls?.rejectUnauthorized !== false
|
|
||||||
};
|
|
||||||
|
|
||||||
logTLS('tls_connected', this.options, { host, port });
|
|
||||||
|
|
||||||
const socket = tls.connect(tlsOptions);
|
|
||||||
|
|
||||||
const timeoutHandler = setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
reject(new Error(`TLS connection timeout after ${timeout}ms`));
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
socket.once('secureConnect', () => {
|
|
||||||
clearTimeout(timeoutHandler);
|
|
||||||
|
|
||||||
if (!socket.authorized && this.options.tls?.rejectUnauthorized !== false) {
|
|
||||||
socket.destroy();
|
|
||||||
reject(new Error(`TLS certificate verification failed: ${socket.authorizationError}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logDebug('TLS connection established', this.options, {
|
|
||||||
authorized: socket.authorized,
|
|
||||||
protocol: socket.getProtocol(),
|
|
||||||
cipher: socket.getCipher()
|
|
||||||
});
|
|
||||||
|
|
||||||
resolve(socket);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.once('error', (error) => {
|
|
||||||
clearTimeout(timeoutHandler);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate TLS certificate
|
|
||||||
*/
|
|
||||||
public validateCertificate(socket: tls.TLSSocket): boolean {
|
|
||||||
if (!socket.authorized) {
|
|
||||||
logDebug('TLS certificate not authorized', this.options, {
|
|
||||||
error: socket.authorizationError
|
|
||||||
});
|
|
||||||
|
|
||||||
// Allow self-signed certificates if explicitly configured
|
|
||||||
if (this.options.tls?.rejectUnauthorized === false) {
|
|
||||||
logDebug('Accepting unauthorized certificate (rejectUnauthorized: false)', this.options);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cert = socket.getPeerCertificate();
|
|
||||||
if (!cert) {
|
|
||||||
logDebug('No peer certificate available', this.options);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional certificate validation
|
|
||||||
const now = new Date();
|
|
||||||
if (cert.valid_from && new Date(cert.valid_from) > now) {
|
|
||||||
logDebug('Certificate not yet valid', this.options, { validFrom: cert.valid_from });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cert.valid_to && new Date(cert.valid_to) < now) {
|
|
||||||
logDebug('Certificate expired', this.options, { validTo: cert.valid_to });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
logDebug('TLS certificate validated', this.options, {
|
|
||||||
subject: cert.subject,
|
|
||||||
issuer: cert.issuer,
|
|
||||||
validFrom: cert.valid_from,
|
|
||||||
validTo: cert.valid_to
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get TLS connection information
|
|
||||||
*/
|
|
||||||
public getTLSInfo(socket: tls.TLSSocket): any {
|
|
||||||
if (!(socket instanceof tls.TLSSocket)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
authorized: socket.authorized,
|
|
||||||
authorizationError: socket.authorizationError,
|
|
||||||
protocol: socket.getProtocol(),
|
|
||||||
cipher: socket.getCipher(),
|
|
||||||
peerCertificate: socket.getPeerCertificate(),
|
|
||||||
alpnProtocol: socket.alpnProtocol
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if TLS upgrade is required or recommended
|
|
||||||
*/
|
|
||||||
public shouldUseTLS(connection: ISmtpConnection): boolean {
|
|
||||||
// Already secure
|
|
||||||
if (connection.secure) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct TLS connection configured
|
|
||||||
if (this.options.secure) {
|
|
||||||
return false; // Already handled in connection establishment
|
|
||||||
}
|
|
||||||
|
|
||||||
// STARTTLS available and not explicitly disabled
|
|
||||||
if (connection.capabilities?.starttls) {
|
|
||||||
return this.options.tls !== null && this.options.tls !== undefined; // Use TLS if configured
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async performTLSUpgrade(connection: ISmtpConnection): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const plainSocket = connection.socket as net.Socket;
|
|
||||||
const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT;
|
|
||||||
|
|
||||||
const tlsOptions: tls.ConnectionOptions = {
|
|
||||||
socket: plainSocket,
|
|
||||||
host: this.options.host,
|
|
||||||
...this.options.tls,
|
|
||||||
// Default TLS options for STARTTLS
|
|
||||||
secureProtocol: 'TLS_method',
|
|
||||||
ciphers: 'HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA',
|
|
||||||
rejectUnauthorized: this.options.tls?.rejectUnauthorized !== false
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeoutHandler = setTimeout(() => {
|
|
||||||
reject(new Error(`TLS upgrade timeout after ${timeout}ms`));
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
// Create TLS socket from existing connection
|
|
||||||
const tlsSocket = tls.connect(tlsOptions);
|
|
||||||
|
|
||||||
tlsSocket.once('secureConnect', () => {
|
|
||||||
clearTimeout(timeoutHandler);
|
|
||||||
|
|
||||||
// Validate certificate if required
|
|
||||||
if (!this.validateCertificate(tlsSocket)) {
|
|
||||||
tlsSocket.destroy();
|
|
||||||
reject(new Error('TLS certificate validation failed'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace the socket in the connection
|
|
||||||
connection.socket = tlsSocket;
|
|
||||||
connection.secure = true;
|
|
||||||
|
|
||||||
logDebug('STARTTLS upgrade completed', this.options, {
|
|
||||||
protocol: tlsSocket.getProtocol(),
|
|
||||||
cipher: tlsSocket.getCipher()
|
|
||||||
});
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.once('error', (error) => {
|
|
||||||
clearTimeout(timeoutHandler);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Client Helper Functions
|
|
||||||
* Protocol helper functions and utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { SMTP_CODES, REGEX_PATTERNS, LINE_ENDINGS } from '../constants.js';
|
|
||||||
import type { ISmtpResponse, ISmtpCapabilities } from '../interfaces.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse SMTP server response
|
|
||||||
*/
|
|
||||||
export function parseSmtpResponse(data: string): ISmtpResponse {
|
|
||||||
const lines = data.trim().split(/\r?\n/);
|
|
||||||
const firstLine = lines[0];
|
|
||||||
const match = firstLine.match(REGEX_PATTERNS.RESPONSE_CODE);
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return {
|
|
||||||
code: 500,
|
|
||||||
message: 'Invalid server response',
|
|
||||||
raw: data
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = parseInt(match[1], 10);
|
|
||||||
const separator = match[2];
|
|
||||||
const message = lines.map(line => line.substring(4)).join(' ');
|
|
||||||
|
|
||||||
// Check for enhanced status code
|
|
||||||
const enhancedMatch = message.match(REGEX_PATTERNS.ENHANCED_STATUS);
|
|
||||||
const enhancedCode = enhancedMatch ? enhancedMatch[1] : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
message: enhancedCode ? message.substring(enhancedCode.length + 1) : message,
|
|
||||||
enhancedCode,
|
|
||||||
raw: data
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse EHLO response and extract capabilities
|
|
||||||
*/
|
|
||||||
export function parseEhloResponse(response: string): ISmtpCapabilities {
|
|
||||||
const lines = response.trim().split(/\r?\n/);
|
|
||||||
const capabilities: ISmtpCapabilities = {
|
|
||||||
extensions: new Set(),
|
|
||||||
authMethods: new Set(),
|
|
||||||
pipelining: false,
|
|
||||||
starttls: false,
|
|
||||||
eightBitMime: false
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const line of lines.slice(1)) { // Skip first line (greeting)
|
|
||||||
const extensionLine = line.substring(4); // Remove "250-" or "250 "
|
|
||||||
const parts = extensionLine.split(/\s+/);
|
|
||||||
const extension = parts[0].toUpperCase();
|
|
||||||
|
|
||||||
capabilities.extensions.add(extension);
|
|
||||||
|
|
||||||
switch (extension) {
|
|
||||||
case 'PIPELINING':
|
|
||||||
capabilities.pipelining = true;
|
|
||||||
break;
|
|
||||||
case 'STARTTLS':
|
|
||||||
capabilities.starttls = true;
|
|
||||||
break;
|
|
||||||
case '8BITMIME':
|
|
||||||
capabilities.eightBitMime = true;
|
|
||||||
break;
|
|
||||||
case 'SIZE':
|
|
||||||
if (parts[1]) {
|
|
||||||
capabilities.maxSize = parseInt(parts[1], 10);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'AUTH':
|
|
||||||
// Parse authentication methods
|
|
||||||
for (let i = 1; i < parts.length; i++) {
|
|
||||||
capabilities.authMethods.add(parts[i].toUpperCase());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return capabilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format SMTP command with proper line ending
|
|
||||||
*/
|
|
||||||
export function formatCommand(command: string, ...args: string[]): string {
|
|
||||||
const fullCommand = args.length > 0 ? `${command} ${args.join(' ')}` : command;
|
|
||||||
return fullCommand + LINE_ENDINGS.CRLF;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode authentication string for AUTH PLAIN
|
|
||||||
*/
|
|
||||||
export function encodeAuthPlain(username: string, password: string): string {
|
|
||||||
const authString = `\0${username}\0${password}`;
|
|
||||||
return Buffer.from(authString, 'utf8').toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode authentication string for AUTH LOGIN
|
|
||||||
*/
|
|
||||||
export function encodeAuthLogin(value: string): string {
|
|
||||||
return Buffer.from(value, 'utf8').toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate OAuth2 authentication string
|
|
||||||
*/
|
|
||||||
export function generateOAuth2String(username: string, accessToken: string): string {
|
|
||||||
const authString = `user=${username}\x01auth=Bearer ${accessToken}\x01\x01`;
|
|
||||||
return Buffer.from(authString, 'utf8').toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if response code indicates success
|
|
||||||
*/
|
|
||||||
export function isSuccessCode(code: number): boolean {
|
|
||||||
return code >= 200 && code < 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if response code indicates temporary failure
|
|
||||||
*/
|
|
||||||
export function isTemporaryFailure(code: number): boolean {
|
|
||||||
return code >= 400 && code < 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if response code indicates permanent failure
|
|
||||||
*/
|
|
||||||
export function isPermanentFailure(code: number): boolean {
|
|
||||||
return code >= 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape email address for SMTP commands
|
|
||||||
*/
|
|
||||||
export function escapeEmailAddress(email: string): string {
|
|
||||||
return `<${email.trim()}>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract email address from angle brackets
|
|
||||||
*/
|
|
||||||
export function extractEmailAddress(email: string): string {
|
|
||||||
const match = email.match(/^<(.+)>$/);
|
|
||||||
return match ? match[1] : email.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate unique connection ID
|
|
||||||
*/
|
|
||||||
export function generateConnectionId(): string {
|
|
||||||
return `smtp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format timeout duration for human readability
|
|
||||||
*/
|
|
||||||
export function formatTimeout(milliseconds: number): string {
|
|
||||||
if (milliseconds < 1000) {
|
|
||||||
return `${milliseconds}ms`;
|
|
||||||
} else if (milliseconds < 60000) {
|
|
||||||
return `${Math.round(milliseconds / 1000)}s`;
|
|
||||||
} else {
|
|
||||||
return `${Math.round(milliseconds / 60000)}m`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and normalize email data size
|
|
||||||
*/
|
|
||||||
export function validateEmailSize(emailData: string, maxSize?: number): boolean {
|
|
||||||
const size = Buffer.byteLength(emailData, 'utf8');
|
|
||||||
return !maxSize || size <= maxSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean sensitive data from logs
|
|
||||||
*/
|
|
||||||
export function sanitizeForLogging(data: any): any {
|
|
||||||
if (typeof data !== 'object' || data === null) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitized = { ...data };
|
|
||||||
const sensitiveFields = ['password', 'pass', 'accessToken', 'refreshToken', 'clientSecret'];
|
|
||||||
|
|
||||||
for (const field of sensitiveFields) {
|
|
||||||
if (field in sanitized) {
|
|
||||||
sanitized[field] = '[REDACTED]';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate exponential backoff delay
|
|
||||||
*/
|
|
||||||
export function calculateBackoffDelay(attempt: number, baseDelay: number = 1000): number {
|
|
||||||
return Math.min(baseDelay * Math.pow(2, attempt - 1), 30000); // Max 30 seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse enhanced status code
|
|
||||||
*/
|
|
||||||
export function parseEnhancedStatusCode(code: string): { class: number; subject: number; detail: number } | null {
|
|
||||||
const match = code.match(/^(\d)\.(\d)\.(\d)$/);
|
|
||||||
if (!match) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
class: parseInt(match[1], 10),
|
|
||||||
subject: parseInt(match[2], 10),
|
|
||||||
detail: parseInt(match[3], 10)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Client Logging Utilities
|
|
||||||
* Client-side logging utilities for SMTP operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { logger } from '../../../../logger.js';
|
|
||||||
import type { ISmtpResponse, ISmtpClientOptions } from '../interfaces.js';
|
|
||||||
|
|
||||||
export interface ISmtpClientLogData {
|
|
||||||
component: string;
|
|
||||||
host?: string;
|
|
||||||
port?: number;
|
|
||||||
secure?: boolean;
|
|
||||||
command?: string;
|
|
||||||
response?: ISmtpResponse;
|
|
||||||
error?: Error;
|
|
||||||
connectionId?: string;
|
|
||||||
messageId?: string;
|
|
||||||
duration?: number;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log SMTP client connection events
|
|
||||||
*/
|
|
||||||
export function logConnection(
|
|
||||||
event: 'connecting' | 'connected' | 'disconnected' | 'error',
|
|
||||||
options: ISmtpClientOptions,
|
|
||||||
data?: Partial<ISmtpClientLogData>
|
|
||||||
): void {
|
|
||||||
const logData: ISmtpClientLogData = {
|
|
||||||
component: 'smtp-client',
|
|
||||||
event,
|
|
||||||
host: options.host,
|
|
||||||
port: options.port,
|
|
||||||
secure: options.secure,
|
|
||||||
...data
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (event) {
|
|
||||||
case 'connecting':
|
|
||||||
logger.info('SMTP client connecting', logData);
|
|
||||||
break;
|
|
||||||
case 'connected':
|
|
||||||
logger.info('SMTP client connected', logData);
|
|
||||||
break;
|
|
||||||
case 'disconnected':
|
|
||||||
logger.info('SMTP client disconnected', logData);
|
|
||||||
break;
|
|
||||||
case 'error':
|
|
||||||
logger.error('SMTP client connection error', logData);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log SMTP command execution
|
|
||||||
*/
|
|
||||||
export function logCommand(
|
|
||||||
command: string,
|
|
||||||
response?: ISmtpResponse,
|
|
||||||
options?: ISmtpClientOptions,
|
|
||||||
data?: Partial<ISmtpClientLogData>
|
|
||||||
): void {
|
|
||||||
const logData: ISmtpClientLogData = {
|
|
||||||
component: 'smtp-client',
|
|
||||||
command,
|
|
||||||
response,
|
|
||||||
host: options?.host,
|
|
||||||
port: options?.port,
|
|
||||||
...data
|
|
||||||
};
|
|
||||||
|
|
||||||
if (response && response.code >= 400) {
|
|
||||||
logger.warn('SMTP command failed', logData);
|
|
||||||
} else {
|
|
||||||
logger.debug('SMTP command executed', logData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log authentication events
|
|
||||||
*/
|
|
||||||
export function logAuthentication(
|
|
||||||
event: 'start' | 'success' | 'failure',
|
|
||||||
method: string,
|
|
||||||
options: ISmtpClientOptions,
|
|
||||||
data?: Partial<ISmtpClientLogData>
|
|
||||||
): void {
|
|
||||||
const logData: ISmtpClientLogData = {
|
|
||||||
component: 'smtp-client',
|
|
||||||
event: `auth_${event}`,
|
|
||||||
authMethod: method,
|
|
||||||
host: options.host,
|
|
||||||
port: options.port,
|
|
||||||
...data
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (event) {
|
|
||||||
case 'start':
|
|
||||||
logger.debug('SMTP authentication started', logData);
|
|
||||||
break;
|
|
||||||
case 'success':
|
|
||||||
logger.info('SMTP authentication successful', logData);
|
|
||||||
break;
|
|
||||||
case 'failure':
|
|
||||||
logger.error('SMTP authentication failed', logData);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log TLS/STARTTLS events
|
|
||||||
*/
|
|
||||||
export function logTLS(
|
|
||||||
event: 'starttls_start' | 'starttls_success' | 'starttls_failure' | 'tls_connected',
|
|
||||||
options: ISmtpClientOptions,
|
|
||||||
data?: Partial<ISmtpClientLogData>
|
|
||||||
): void {
|
|
||||||
const logData: ISmtpClientLogData = {
|
|
||||||
component: 'smtp-client',
|
|
||||||
event,
|
|
||||||
host: options.host,
|
|
||||||
port: options.port,
|
|
||||||
...data
|
|
||||||
};
|
|
||||||
|
|
||||||
if (event.includes('failure')) {
|
|
||||||
logger.error('SMTP TLS operation failed', logData);
|
|
||||||
} else {
|
|
||||||
logger.info('SMTP TLS operation', logData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log email sending events
|
|
||||||
*/
|
|
||||||
export function logEmailSend(
|
|
||||||
event: 'start' | 'success' | 'failure',
|
|
||||||
recipients: string[],
|
|
||||||
options: ISmtpClientOptions,
|
|
||||||
data?: Partial<ISmtpClientLogData>
|
|
||||||
): void {
|
|
||||||
const logData: ISmtpClientLogData = {
|
|
||||||
component: 'smtp-client',
|
|
||||||
event: `send_${event}`,
|
|
||||||
recipientCount: recipients.length,
|
|
||||||
recipients: recipients.slice(0, 5), // Only log first 5 recipients for privacy
|
|
||||||
host: options.host,
|
|
||||||
port: options.port,
|
|
||||||
...data
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (event) {
|
|
||||||
case 'start':
|
|
||||||
logger.info('SMTP email send started', logData);
|
|
||||||
break;
|
|
||||||
case 'success':
|
|
||||||
logger.info('SMTP email send successful', logData);
|
|
||||||
break;
|
|
||||||
case 'failure':
|
|
||||||
logger.error('SMTP email send failed', logData);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log performance metrics
|
|
||||||
*/
|
|
||||||
export function logPerformance(
|
|
||||||
operation: string,
|
|
||||||
duration: number,
|
|
||||||
options: ISmtpClientOptions,
|
|
||||||
data?: Partial<ISmtpClientLogData>
|
|
||||||
): void {
|
|
||||||
const logData: ISmtpClientLogData = {
|
|
||||||
component: 'smtp-client',
|
|
||||||
operation,
|
|
||||||
duration,
|
|
||||||
host: options.host,
|
|
||||||
port: options.port,
|
|
||||||
...data
|
|
||||||
};
|
|
||||||
|
|
||||||
if (duration > 10000) { // Log slow operations (>10s)
|
|
||||||
logger.warn('SMTP slow operation detected', logData);
|
|
||||||
} else {
|
|
||||||
logger.debug('SMTP operation performance', logData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log debug information (only when debug is enabled)
|
|
||||||
*/
|
|
||||||
export function logDebug(
|
|
||||||
message: string,
|
|
||||||
options: ISmtpClientOptions,
|
|
||||||
data?: Partial<ISmtpClientLogData>
|
|
||||||
): void {
|
|
||||||
if (!options.debug) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const logData: ISmtpClientLogData = {
|
|
||||||
component: 'smtp-client-debug',
|
|
||||||
host: options.host,
|
|
||||||
port: options.port,
|
|
||||||
...data
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.debug(`[SMTP Client Debug] ${message}`, logData);
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
/**
|
|
||||||
* SMTP Client Validation Utilities
|
|
||||||
* Input validation functions for SMTP client operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { REGEX_PATTERNS } from '../constants.js';
|
|
||||||
import type { ISmtpClientOptions, ISmtpAuthOptions } from '../interfaces.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate email address format
|
|
||||||
* Supports RFC-compliant addresses including empty return paths for bounces
|
|
||||||
*/
|
|
||||||
export function validateEmailAddress(email: string): boolean {
|
|
||||||
if (typeof email !== 'string') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = email.trim();
|
|
||||||
|
|
||||||
// Handle empty return path for bounce messages (RFC 5321)
|
|
||||||
if (trimmed === '' || trimmed === '<>') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle display name formats
|
|
||||||
const angleMatch = trimmed.match(/<([^>]+)>/);
|
|
||||||
if (angleMatch) {
|
|
||||||
return REGEX_PATTERNS.EMAIL_ADDRESS.test(angleMatch[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular email validation
|
|
||||||
return REGEX_PATTERNS.EMAIL_ADDRESS.test(trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate SMTP client options
|
|
||||||
*/
|
|
||||||
export function validateClientOptions(options: ISmtpClientOptions): string[] {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
// Required fields
|
|
||||||
if (!options.host || typeof options.host !== 'string') {
|
|
||||||
errors.push('Host is required and must be a string');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.port || typeof options.port !== 'number' || options.port < 1 || options.port > 65535) {
|
|
||||||
errors.push('Port must be a number between 1 and 65535');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional field validation
|
|
||||||
if (options.connectionTimeout !== undefined) {
|
|
||||||
if (typeof options.connectionTimeout !== 'number' || options.connectionTimeout < 1000) {
|
|
||||||
errors.push('Connection timeout must be a number >= 1000ms');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.socketTimeout !== undefined) {
|
|
||||||
if (typeof options.socketTimeout !== 'number' || options.socketTimeout < 1000) {
|
|
||||||
errors.push('Socket timeout must be a number >= 1000ms');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.maxConnections !== undefined) {
|
|
||||||
if (typeof options.maxConnections !== 'number' || options.maxConnections < 1) {
|
|
||||||
errors.push('Max connections must be a positive number');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.maxMessages !== undefined) {
|
|
||||||
if (typeof options.maxMessages !== 'number' || options.maxMessages < 1) {
|
|
||||||
errors.push('Max messages must be a positive number');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate authentication options
|
|
||||||
if (options.auth) {
|
|
||||||
errors.push(...validateAuthOptions(options.auth));
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate authentication options
|
|
||||||
*/
|
|
||||||
export function validateAuthOptions(auth: ISmtpAuthOptions): string[] {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
if (auth.method && !['PLAIN', 'LOGIN', 'OAUTH2', 'AUTO'].includes(auth.method)) {
|
|
||||||
errors.push('Invalid authentication method');
|
|
||||||
}
|
|
||||||
|
|
||||||
// For basic auth, require user and pass
|
|
||||||
if ((auth.user || auth.pass) && (!auth.user || !auth.pass)) {
|
|
||||||
errors.push('Both user and pass are required for basic authentication');
|
|
||||||
}
|
|
||||||
|
|
||||||
// For OAuth2, validate required fields
|
|
||||||
if (auth.oauth2) {
|
|
||||||
const oauth = auth.oauth2;
|
|
||||||
if (!oauth.user || !oauth.clientId || !oauth.clientSecret || !oauth.refreshToken) {
|
|
||||||
errors.push('OAuth2 requires user, clientId, clientSecret, and refreshToken');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oauth.user && !validateEmailAddress(oauth.user)) {
|
|
||||||
errors.push('OAuth2 user must be a valid email address');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate hostname format
|
|
||||||
*/
|
|
||||||
export function validateHostname(hostname: string): boolean {
|
|
||||||
if (!hostname || typeof hostname !== 'string') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic hostname validation (allow IP addresses and domain names)
|
|
||||||
const hostnameRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$|^(?:\d{1,3}\.){3}\d{1,3}$|^\[(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\]$/;
|
|
||||||
return hostnameRegex.test(hostname);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate port number
|
|
||||||
*/
|
|
||||||
export function validatePort(port: number): boolean {
|
|
||||||
return typeof port === 'number' && port >= 1 && port <= 65535;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize and validate domain name for EHLO
|
|
||||||
*/
|
|
||||||
export function validateAndSanitizeDomain(domain: string): string {
|
|
||||||
if (!domain || typeof domain !== 'string') {
|
|
||||||
return 'localhost';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitized = domain.trim().toLowerCase();
|
|
||||||
if (validateHostname(sanitized)) {
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'localhost';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate recipient list
|
|
||||||
*/
|
|
||||||
export function validateRecipients(recipients: string | string[]): string[] {
|
|
||||||
const errors: string[] = [];
|
|
||||||
const recipientList = Array.isArray(recipients) ? recipients : [recipients];
|
|
||||||
|
|
||||||
for (const recipient of recipientList) {
|
|
||||||
if (!validateEmailAddress(recipient)) {
|
|
||||||
errors.push(`Invalid email address: ${recipient}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate sender address
|
|
||||||
*/
|
|
||||||
export function validateSender(sender: string): boolean {
|
|
||||||
return validateEmailAddress(sender);
|
|
||||||
}
|
|
||||||
@@ -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,43 +11,13 @@ 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';
|
||||||
import { DomainRegistry } from './classes.domain.registry.js';
|
import { DomainRegistry } from './classes.domain.registry.js';
|
||||||
import { DnsManager } from './classes.dns.manager.js';
|
import { DnsManager } from './classes.dns.manager.js';
|
||||||
import { BounceManager, BounceType, BounceCategory } from '../core/classes.bouncemanager.js';
|
import { BounceManager, BounceType, BounceCategory } from '../core/classes.bouncemanager.js';
|
||||||
import { createPooledSmtpClient } from '../delivery/smtpclient/create-client.js';
|
import type { ISmtpSendResult, IOutboundEmail } from '../../security/classes.rustsecuritybridge.js';
|
||||||
import type { SmtpClient } from '../delivery/smtpclient/smtp-client.js';
|
|
||||||
import { MultiModeDeliverySystem, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
|
import { MultiModeDeliverySystem, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
|
||||||
import { UnifiedDeliveryQueue, type IQueueOptions } from '../delivery/classes.delivery.queue.js';
|
import { UnifiedDeliveryQueue, type IQueueOptions } from '../delivery/classes.delivery.queue.js';
|
||||||
import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
|
import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
|
||||||
@@ -128,10 +98,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,13 +162,10 @@ 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
|
||||||
private dkimKeys: Map<string, string> = new Map(); // domain -> private key
|
private dkimKeys: Map<string, string> = new Map(); // domain -> private key
|
||||||
private smtpClients: Map<string, SmtpClient> = new Map(); // host:port -> client
|
|
||||||
|
|
||||||
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
|
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -239,11 +202,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);
|
||||||
|
|
||||||
@@ -282,17 +240,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
bounceHandler: {
|
bounceHandler: {
|
||||||
processSmtpFailure: this.processSmtpFailure.bind(this)
|
processSmtpFailure: this.processSmtpFailure.bind(this)
|
||||||
},
|
},
|
||||||
onDeliverySuccess: async (item, _result) => {
|
onDeliverySuccess: async (_item, _result) => {
|
||||||
// Record delivery success event for reputation monitoring
|
// Delivery success recorded via delivery system
|
||||||
const email = item.processingResult as Email;
|
|
||||||
const senderDomain = email.from.split('@')[1];
|
|
||||||
|
|
||||||
if (senderDomain) {
|
|
||||||
this.recordReputationEvent(senderDomain, {
|
|
||||||
type: 'delivered',
|
|
||||||
count: email.to.length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -321,34 +270,50 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create an SMTP client for the given host and port
|
* Send an outbound email via the Rust SMTP client.
|
||||||
* Uses connection pooling for efficiency
|
* Uses connection pooling in the Rust binary for efficiency.
|
||||||
*/
|
*/
|
||||||
public getSmtpClient(host: string, port: number = 25): SmtpClient {
|
public async sendOutboundEmail(host: string, port: number, email: Email, options?: {
|
||||||
const clientKey = `${host}:${port}`;
|
auth?: { user: string; pass: string };
|
||||||
|
dkimDomain?: string;
|
||||||
// Check if we already have a client for this destination
|
dkimSelector?: string;
|
||||||
let client = this.smtpClients.get(clientKey);
|
}): Promise<ISmtpSendResult> {
|
||||||
|
// Build DKIM config if domain has keys
|
||||||
if (!client) {
|
let dkim: { domain: string; selector: string; privateKey: string } | undefined;
|
||||||
// Create a new pooled SMTP client
|
if (options?.dkimDomain) {
|
||||||
client = createPooledSmtpClient({
|
try {
|
||||||
host,
|
const { privateKey } = await this.dkimCreator.readDKIMKeys(options.dkimDomain);
|
||||||
port,
|
dkim = { domain: options.dkimDomain, selector: options.dkimSelector || 'default', privateKey };
|
||||||
secure: port === 465,
|
} catch (err) {
|
||||||
connectionTimeout: this.options.outbound?.connectionTimeout || 30000,
|
logger.log('warn', `Failed to read DKIM keys for ${options.dkimDomain}: ${(err as Error).message}`);
|
||||||
socketTimeout: this.options.outbound?.socketTimeout || 120000,
|
}
|
||||||
maxConnections: this.options.outbound?.maxConnections || 10,
|
|
||||||
maxMessages: 1000, // Messages per connection before reconnect
|
|
||||||
pool: true,
|
|
||||||
debug: false
|
|
||||||
});
|
|
||||||
|
|
||||||
this.smtpClients.set(clientKey, client);
|
|
||||||
logger.log('info', `Created new SMTP client pool for ${clientKey}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return client;
|
// Serialize the Email to the outbound format
|
||||||
|
const outboundEmail: IOutboundEmail = {
|
||||||
|
from: email.from,
|
||||||
|
to: email.to,
|
||||||
|
cc: email.cc || [],
|
||||||
|
bcc: email.bcc || [],
|
||||||
|
subject: email.subject || '',
|
||||||
|
text: email.text || '',
|
||||||
|
html: email.html || undefined,
|
||||||
|
headers: email.headers as Record<string, string> || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.rustBridge.sendOutboundEmail({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure: port === 465,
|
||||||
|
domain: this.options.hostname,
|
||||||
|
auth: options?.auth,
|
||||||
|
email: outboundEmail,
|
||||||
|
dkim,
|
||||||
|
connectionTimeoutSecs: Math.floor((this.options.outbound?.connectionTimeout || 30000) / 1000),
|
||||||
|
socketTimeoutSecs: Math.floor((this.options.outbound?.socketTimeout || 120000) / 1000),
|
||||||
|
poolKey: `${host}:${port}`,
|
||||||
|
maxPoolConnections: this.options.outbound?.maxConnections || 10,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -373,6 +338,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 +386,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)
|
||||||
await this.rustBridge.sendEmailProcessingResult({
|
try {
|
||||||
correlationId: data.correlationId,
|
await this.rustBridge.sendEmailProcessingResult({
|
||||||
accepted: false,
|
correlationId: data.correlationId,
|
||||||
smtpCode: 451,
|
accepted: false,
|
||||||
smtpMessage: 'Internal processing error',
|
smtpCode: 451,
|
||||||
});
|
smtpMessage: 'Internal processing error',
|
||||||
|
});
|
||||||
|
} catch (sendErr) {
|
||||||
|
logger.log('warn', `Could not send rejection back to Rust: ${(sendErr as Error).message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -429,11 +405,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}`);
|
||||||
await this.rustBridge.sendAuthResult({
|
try {
|
||||||
correlationId: data.correlationId,
|
await this.rustBridge.sendAuthResult({
|
||||||
success: false,
|
correlationId: data.correlationId,
|
||||||
message: 'Internal auth error',
|
success: false,
|
||||||
});
|
message: 'Internal auth error',
|
||||||
|
});
|
||||||
|
} catch (sendErr) {
|
||||||
|
logger.log('warn', `Could not send auth rejection back to Rust: ${(sendErr as Error).message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -495,7 +475,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
|
||||||
@@ -510,16 +491,12 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
logger.log('info', 'Email delivery queue shut down');
|
logger.log('info', 'Email delivery queue shut down');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close all SMTP client connections
|
// Close all Rust SMTP client connection pools
|
||||||
for (const [clientKey, client] of this.smtpClients) {
|
try {
|
||||||
try {
|
await this.rustBridge.closeSmtpPool();
|
||||||
await client.close();
|
} catch {
|
||||||
logger.log('info', `Closed SMTP client pool for ${clientKey}`);
|
// Bridge may already be stopped
|
||||||
} catch (error) {
|
|
||||||
logger.log('warn', `Error closing SMTP client for ${clientKey}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.smtpClients.clear();
|
|
||||||
|
|
||||||
logger.log('info', 'UnifiedEmailServer stopped successfully');
|
logger.log('info', 'UnifiedEmailServer stopped successfully');
|
||||||
this.emit('stopped');
|
this.emit('stopped');
|
||||||
@@ -653,7 +630,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,
|
||||||
@@ -850,13 +827,12 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
email.headers['X-Forwarded-To'] = email.to.join(', ');
|
email.headers['X-Forwarded-To'] = email.to.join(', ');
|
||||||
email.headers['X-Forwarded-Date'] = new Date().toISOString();
|
email.headers['X-Forwarded-Date'] = new Date().toISOString();
|
||||||
|
|
||||||
// Get SMTP client
|
|
||||||
const client = this.getSmtpClient(host, port);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Send email
|
// Send email via Rust SMTP client
|
||||||
await client.sendMail(email);
|
await this.sendOutboundEmail(host, port, email, {
|
||||||
|
auth: auth as { user: string; pass: string } | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
logger.log('info', `Successfully forwarded email to ${host}:${port}`);
|
logger.log('info', `Successfully forwarded email to ${host}:${port}`);
|
||||||
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
@@ -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];
|
||||||
@@ -1524,15 +1297,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
// Queue the email for delivery
|
// Queue the email for delivery
|
||||||
await this.deliveryQueue.enqueue(email, mode, route);
|
await this.deliveryQueue.enqueue(email, mode, route);
|
||||||
|
|
||||||
// Record 'sent' event for domain reputation monitoring
|
|
||||||
const senderDomain = email.from.split('@')[1];
|
|
||||||
if (senderDomain) {
|
|
||||||
this.recordReputationEvent(senderDomain, {
|
|
||||||
type: 'sent',
|
|
||||||
count: email.to.length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('info', `Email queued with ID: ${id}`);
|
logger.log('info', `Email queued with ID: ${id}`);
|
||||||
return id;
|
return id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1597,15 +1361,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
// Notify any registered listeners about the bounce
|
// Notify any registered listeners about the bounce
|
||||||
this.emit('bounceProcessed', bounceRecord);
|
this.emit('bounceProcessed', bounceRecord);
|
||||||
|
|
||||||
// Record bounce event for domain reputation tracking
|
|
||||||
if (bounceRecord.domain) {
|
|
||||||
this.recordReputationEvent(bounceRecord.domain, {
|
|
||||||
type: 'bounce',
|
|
||||||
hardBounce: bounceRecord.bounceCategory === BounceCategory.HARD,
|
|
||||||
receivingDomain: bounceRecord.recipient.split('@')[1]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log security event
|
// Log security event
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
level: SecurityLogLevel.INFO,
|
level: SecurityLogLevel.INFO,
|
||||||
@@ -1677,15 +1432,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
// Notify any registered listeners about the bounce
|
// Notify any registered listeners about the bounce
|
||||||
this.emit('bounceProcessed', bounceRecord);
|
this.emit('bounceProcessed', bounceRecord);
|
||||||
|
|
||||||
// Record bounce event for domain reputation tracking
|
|
||||||
if (bounceRecord.domain) {
|
|
||||||
this.recordReputationEvent(bounceRecord.domain, {
|
|
||||||
type: 'bounce',
|
|
||||||
hardBounce: bounceRecord.bounceCategory === BounceCategory.HARD,
|
|
||||||
receivingDomain: bounceRecord.recipient.split('@')[1]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log security event
|
// Log security event
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
level: SecurityLogLevel.INFO,
|
level: SecurityLogLevel.INFO,
|
||||||
@@ -1793,157 +1539,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
logger.log('info', `Removed ${email} from suppression list`);
|
logger.log('info', `Removed ${email} from suppression list`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the status of IP warmup process
|
|
||||||
* @param ipAddress Optional specific IP to check
|
|
||||||
* @returns Status of IP warmup
|
|
||||||
*/
|
|
||||||
public getIPWarmupStatus(ipAddress?: string): any {
|
|
||||||
return this.ipWarmupManager.getWarmupStatus(ipAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new IP address to the warmup process
|
|
||||||
* @param ipAddress IP address to add
|
|
||||||
*/
|
|
||||||
public addIPToWarmup(ipAddress: string): void {
|
|
||||||
this.ipWarmupManager.addIPToWarmup(ipAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove an IP address from the warmup process
|
|
||||||
* @param ipAddress IP address to remove
|
|
||||||
*/
|
|
||||||
public removeIPFromWarmup(ipAddress: string): void {
|
|
||||||
this.ipWarmupManager.removeIPFromWarmup(ipAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update metrics for an IP in the warmup process
|
|
||||||
* @param ipAddress IP address
|
|
||||||
* @param metrics Metrics to update
|
|
||||||
*/
|
|
||||||
public updateIPWarmupMetrics(
|
|
||||||
ipAddress: string,
|
|
||||||
metrics: { openRate?: number; bounceRate?: number; complaintRate?: number }
|
|
||||||
): void {
|
|
||||||
this.ipWarmupManager.updateMetrics(ipAddress, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an IP can send more emails today
|
|
||||||
* @param ipAddress IP address to check
|
|
||||||
* @returns Whether the IP can send more today
|
|
||||||
*/
|
|
||||||
public canIPSendMoreToday(ipAddress: string): boolean {
|
|
||||||
return this.ipWarmupManager.canSendMoreToday(ipAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an IP can send more emails in the current hour
|
|
||||||
* @param ipAddress IP address to check
|
|
||||||
* @returns Whether the IP can send more this hour
|
|
||||||
*/
|
|
||||||
public canIPSendMoreThisHour(ipAddress: string): boolean {
|
|
||||||
return this.ipWarmupManager.canSendMoreThisHour(ipAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the best IP to use for sending an email based on warmup status
|
|
||||||
* @param emailInfo Information about the email being sent
|
|
||||||
* @returns Best IP to use or null
|
|
||||||
*/
|
|
||||||
public getBestIPForSending(emailInfo: {
|
|
||||||
from: string;
|
|
||||||
to: string[];
|
|
||||||
domain: string;
|
|
||||||
isTransactional?: boolean;
|
|
||||||
}): string | null {
|
|
||||||
return this.ipWarmupManager.getBestIPForSending(emailInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the active IP allocation policy for warmup
|
|
||||||
* @param policyName Name of the policy to set
|
|
||||||
*/
|
|
||||||
public setIPAllocationPolicy(policyName: string): void {
|
|
||||||
this.ipWarmupManager.setActiveAllocationPolicy(policyName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record that an email was sent using a specific IP
|
|
||||||
* @param ipAddress IP address used for sending
|
|
||||||
*/
|
|
||||||
public recordIPSend(ipAddress: string): void {
|
|
||||||
this.ipWarmupManager.recordSend(ipAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get reputation data for a domain
|
|
||||||
* @param domain Domain to get reputation for
|
|
||||||
* @returns Domain reputation metrics
|
|
||||||
*/
|
|
||||||
public getDomainReputationData(domain: string): any {
|
|
||||||
return this.senderReputationMonitor.getReputationData(domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get summary reputation data for all monitored domains
|
|
||||||
* @returns Summary data for all domains
|
|
||||||
*/
|
|
||||||
public getReputationSummary(): any {
|
|
||||||
return this.senderReputationMonitor.getReputationSummary();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a domain to the reputation monitoring system
|
|
||||||
* @param domain Domain to add
|
|
||||||
*/
|
|
||||||
public addDomainToMonitoring(domain: string): void {
|
|
||||||
this.senderReputationMonitor.addDomain(domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a domain from the reputation monitoring system
|
|
||||||
* @param domain Domain to remove
|
|
||||||
*/
|
|
||||||
public removeDomainFromMonitoring(domain: string): void {
|
|
||||||
this.senderReputationMonitor.removeDomain(domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record an email event for domain reputation tracking
|
|
||||||
* @param domain Domain sending the email
|
|
||||||
* @param event Event details
|
|
||||||
*/
|
|
||||||
public recordReputationEvent(domain: string, event: {
|
|
||||||
type: 'sent' | 'delivered' | 'bounce' | 'complaint' | 'open' | 'click';
|
|
||||||
count?: number;
|
|
||||||
hardBounce?: boolean;
|
|
||||||
receivingDomain?: string;
|
|
||||||
}): void {
|
|
||||||
this.senderReputationMonitor.recordSendEvent(domain, event);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if DKIM key exists for a domain
|
|
||||||
* @param domain Domain to check
|
|
||||||
*/
|
|
||||||
public hasDkimKey(domain: string): boolean {
|
|
||||||
return this.dkimKeys.has(domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record successful email delivery
|
|
||||||
* @param domain Sending domain
|
|
||||||
*/
|
|
||||||
public recordDelivery(domain: string): void {
|
|
||||||
this.recordReputationEvent(domain, {
|
|
||||||
type: 'delivered',
|
|
||||||
count: 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record email bounce
|
* Record email bounce
|
||||||
* @param domain Sending domain
|
* @param domain Sending domain
|
||||||
@@ -1952,7 +1547,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
* @param reason Bounce reason
|
* @param reason Bounce reason
|
||||||
*/
|
*/
|
||||||
public recordBounce(domain: string, receivingDomain: string, bounceType: 'hard' | 'soft', reason: string): void {
|
public recordBounce(domain: string, receivingDomain: string, bounceType: 'hard' | 'soft', reason: string): void {
|
||||||
// Record bounce in bounce manager
|
|
||||||
const bounceRecord = {
|
const bounceRecord = {
|
||||||
id: `bounce_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
id: `bounce_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||||
recipient: `user@${receivingDomain}`,
|
recipient: `user@${receivingDomain}`,
|
||||||
@@ -1966,17 +1560,8 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
statusCode: bounceType === 'hard' ? '550' : '450',
|
statusCode: bounceType === 'hard' ? '550' : '450',
|
||||||
processed: false
|
processed: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Process the bounce
|
|
||||||
this.bounceManager.processBounce(bounceRecord);
|
this.bounceManager.processBounce(bounceRecord);
|
||||||
|
|
||||||
// Record reputation event
|
|
||||||
this.recordReputationEvent(domain, {
|
|
||||||
type: 'bounce',
|
|
||||||
count: 1,
|
|
||||||
hardBounce: bounceType === 'hard',
|
|
||||||
receivingDomain
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,61 +21,21 @@ export {
|
|||||||
util,
|
util,
|
||||||
}
|
}
|
||||||
|
|
||||||
// @serve.zone scope
|
|
||||||
import * as servezoneInterfaces from '@serve.zone/interfaces';
|
|
||||||
|
|
||||||
export {
|
|
||||||
servezoneInterfaces
|
|
||||||
}
|
|
||||||
|
|
||||||
// @api.global scope
|
|
||||||
import * as typedrequest from '@api.global/typedrequest';
|
|
||||||
import * as typedserver from '@api.global/typedserver';
|
|
||||||
import * as typedsocket from '@api.global/typedsocket';
|
|
||||||
|
|
||||||
export {
|
|
||||||
typedrequest,
|
|
||||||
typedserver,
|
|
||||||
typedsocket,
|
|
||||||
}
|
|
||||||
|
|
||||||
// @push.rocks scope
|
// @push.rocks scope
|
||||||
import * as projectinfo from '@push.rocks/projectinfo';
|
|
||||||
import * as qenv from '@push.rocks/qenv';
|
|
||||||
import * as smartacme from '@push.rocks/smartacme';
|
|
||||||
import * as smartdata from '@push.rocks/smartdata';
|
|
||||||
import * as smartdns from '@push.rocks/smartdns';
|
|
||||||
import * as smartfile from '@push.rocks/smartfile';
|
import * as smartfile from '@push.rocks/smartfile';
|
||||||
import { SmartFs, SmartFsProviderNode } from '@push.rocks/smartfs';
|
import { SmartFs, SmartFsProviderNode } from '@push.rocks/smartfs';
|
||||||
import * as smartguard from '@push.rocks/smartguard';
|
|
||||||
import * as smartjwt from '@push.rocks/smartjwt';
|
|
||||||
import * as smartlog from '@push.rocks/smartlog';
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
import * as smartmail from '@push.rocks/smartmail';
|
import * as smartmail from '@push.rocks/smartmail';
|
||||||
import * as smartmetrics from '@push.rocks/smartmetrics';
|
|
||||||
import * as smartnetwork from '@push.rocks/smartnetwork';
|
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
import * as smartproxy from '@push.rocks/smartproxy';
|
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
|
||||||
import * as smartrequest from '@push.rocks/smartrequest';
|
|
||||||
import * as smartrule from '@push.rocks/smartrule';
|
|
||||||
import * as smartrust from '@push.rocks/smartrust';
|
import * as smartrust from '@push.rocks/smartrust';
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
|
||||||
import * as smartunique from '@push.rocks/smartunique';
|
|
||||||
|
|
||||||
export const smartfs = new SmartFs(new SmartFsProviderNode());
|
export const smartfs = new SmartFs(new SmartFsProviderNode());
|
||||||
|
|
||||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, SmartFs, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrust, smartrx, smartunique };
|
export { smartfile, SmartFs, smartlog, smartmail, smartpath, smartrust };
|
||||||
|
|
||||||
// Define SmartLog types for use in error handling
|
// Define SmartLog types for use in error handling
|
||||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||||
|
|
||||||
// apiclient.xyz scope
|
|
||||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
|
||||||
|
|
||||||
export {
|
|
||||||
cloudflare,
|
|
||||||
}
|
|
||||||
|
|
||||||
// tsclass scope
|
// tsclass scope
|
||||||
import * as tsclass from '@tsclass/tsclass';
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -66,6 +67,71 @@ interface IContentScanResult {
|
|||||||
scannedElements: string[];
|
scannedElements: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SMTP Client types ---
|
||||||
|
|
||||||
|
interface IOutboundEmail {
|
||||||
|
from: string;
|
||||||
|
to: string[];
|
||||||
|
cc?: string[];
|
||||||
|
bcc?: string[];
|
||||||
|
subject?: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISmtpSendResult {
|
||||||
|
accepted: string[];
|
||||||
|
rejected: string[];
|
||||||
|
messageId?: string;
|
||||||
|
response: string;
|
||||||
|
envelope: { from: string; to: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISmtpSendOptions {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure?: boolean;
|
||||||
|
domain?: string;
|
||||||
|
auth?: { user: string; pass: string; method?: string };
|
||||||
|
email: IOutboundEmail;
|
||||||
|
dkim?: { domain: string; selector: string; privateKey: string };
|
||||||
|
connectionTimeoutSecs?: number;
|
||||||
|
socketTimeoutSecs?: number;
|
||||||
|
poolKey?: string;
|
||||||
|
maxPoolConnections?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISmtpSendRawOptions {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure?: boolean;
|
||||||
|
domain?: string;
|
||||||
|
auth?: { user: string; pass: string; method?: string };
|
||||||
|
envelopeFrom: string;
|
||||||
|
envelopeTo: string[];
|
||||||
|
rawMessageBase64: string;
|
||||||
|
poolKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISmtpVerifyOptions {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure?: boolean;
|
||||||
|
domain?: string;
|
||||||
|
auth?: { user: string; pass: string; method?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISmtpVerifyResult {
|
||||||
|
reachable: boolean;
|
||||||
|
greeting?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISmtpPoolStatus {
|
||||||
|
pools: Record<string, { total: number; active: number; idle: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
interface IVersionInfo {
|
interface IVersionInfo {
|
||||||
bin: string;
|
bin: string;
|
||||||
core: string;
|
core: string;
|
||||||
@@ -211,6 +277,55 @@ type TMailerCommands = {
|
|||||||
params: IRateLimitConfig;
|
params: IRateLimitConfig;
|
||||||
result: { configured: boolean };
|
result: { configured: boolean };
|
||||||
};
|
};
|
||||||
|
sendEmail: {
|
||||||
|
params: ISmtpSendOptions;
|
||||||
|
result: ISmtpSendResult;
|
||||||
|
};
|
||||||
|
sendRawEmail: {
|
||||||
|
params: ISmtpSendRawOptions;
|
||||||
|
result: ISmtpSendResult;
|
||||||
|
};
|
||||||
|
verifySmtpConnection: {
|
||||||
|
params: ISmtpVerifyOptions;
|
||||||
|
result: ISmtpVerifyResult;
|
||||||
|
};
|
||||||
|
closeSmtpPool: {
|
||||||
|
params: { poolKey?: string };
|
||||||
|
result: { closed: boolean };
|
||||||
|
};
|
||||||
|
getSmtpPoolStatus: {
|
||||||
|
params: Record<string, never>;
|
||||||
|
result: ISmtpPoolStatus;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -222,14 +337,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 +379,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 +393,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 +405,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 +481,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 +679,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 +690,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 +713,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 +724,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,9 +741,44 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// SMTP Client — outbound email delivery via Rust
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Send a structured email via the Rust SMTP client. */
|
||||||
|
public async sendOutboundEmail(opts: ISmtpSendOptions): Promise<ISmtpSendResult> {
|
||||||
|
this.ensureRunning();
|
||||||
|
return this.bridge.sendCommand('sendEmail', opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a pre-formatted raw email via the Rust SMTP client. */
|
||||||
|
public async sendRawEmail(opts: ISmtpSendRawOptions): Promise<ISmtpSendResult> {
|
||||||
|
this.ensureRunning();
|
||||||
|
return this.bridge.sendCommand('sendRawEmail', opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify connectivity to an SMTP server. */
|
||||||
|
public async verifySmtpConnection(opts: ISmtpVerifyOptions): Promise<ISmtpVerifyResult> {
|
||||||
|
this.ensureRunning();
|
||||||
|
return this.bridge.sendCommand('verifySmtpConnection', opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close a specific connection pool (or all pools if no key is given). */
|
||||||
|
public async closeSmtpPool(poolKey?: string): Promise<void> {
|
||||||
|
this.ensureRunning();
|
||||||
|
await this.bridge.sendCommand('closeSmtpPool', poolKey ? { poolKey } : ({} as any));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get status of all SMTP client connection pools. */
|
||||||
|
public async getSmtpPoolStatus(): Promise<ISmtpPoolStatus> {
|
||||||
|
this.ensureRunning();
|
||||||
|
return this.bridge.sendCommand('getSmtpPoolStatus', {} as any);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// SMTP Server lifecycle
|
// SMTP Server lifecycle
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -408,12 +789,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 +813,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 +825,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,4 +882,11 @@ export type {
|
|||||||
IEmailData,
|
IEmailData,
|
||||||
IEmailReceivedEvent,
|
IEmailReceivedEvent,
|
||||||
IAuthRequestEvent,
|
IAuthRequestEvent,
|
||||||
|
IOutboundEmail,
|
||||||
|
ISmtpSendResult,
|
||||||
|
ISmtpSendOptions,
|
||||||
|
ISmtpSendRawOptions,
|
||||||
|
ISmtpVerifyOptions,
|
||||||
|
ISmtpVerifyResult,
|
||||||
|
ISmtpPoolStatus,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
RustSecurityBridge,
|
RustSecurityBridge,
|
||||||
|
BridgeState,
|
||||||
|
type IBridgeResilienceConfig,
|
||||||
type IDkimVerificationResult,
|
type IDkimVerificationResult,
|
||||||
type ISpfResult,
|
type ISpfResult,
|
||||||
type IDmarcResult,
|
type IDmarcResult,
|
||||||
@@ -30,4 +32,9 @@ export {
|
|||||||
type IBounceDetection,
|
type IBounceDetection,
|
||||||
type IRustReputationResult,
|
type IRustReputationResult,
|
||||||
type IVersionInfo,
|
type IVersionInfo,
|
||||||
|
type IOutboundEmail,
|
||||||
|
type ISmtpSendResult,
|
||||||
|
type ISmtpSendOptions,
|
||||||
|
type ISmtpVerifyResult,
|
||||||
|
type ISmtpPoolStatus,
|
||||||
} from './classes.rustsecuritybridge.js';
|
} from './classes.rustsecuritybridge.js';
|
||||||
Reference in New Issue
Block a user