6 Commits

Author SHA1 Message Date
a7ea1d86cb v4.0.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 6s
Release / build-and-release (push) Failing after 5s
2026-02-11 07:17:05 +00:00
27bab5f345 BREAKING CHANGE(smtp-client): Replace the legacy TypeScript SMTP client with a new Rust-based SMTP client and IPC bridge for outbound delivery 2026-02-11 07:17:05 +00:00
fc4877e06b v3.0.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 23:23:00 +00:00
36006191fc 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 2026-02-10 23:23:00 +00:00
d43fc15d8e v2.4.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-10 22:43:50 +00:00
248bfcfe78 feat(docs): document Rust-side in-process security pipeline and update README to reflect SMTP server behavior and crate/test counts 2026-02-10 22:43:50 +00:00
56 changed files with 2774 additions and 9519 deletions

View File

@@ -1,5 +1,32 @@
# Changelog # Changelog
## 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)
document Rust-side in-process security pipeline and update README to reflect SMTP server behavior and crate/test counts
- Clarifies that the Rust SMTP server accepts the full SMTP protocol and runs the security pipeline in-process (DKIM/SPF/DMARC verification, content scanning, IP reputation/DNSBL) to avoid IPC round-trips
- Notes that Rust now emits an emailReceived IPC event with pre-computed security results attached for TypeScript to use in routing/delivery decisions
- Updates mailer-smtp crate description to include the in-process security pipeline and increments its test count from 72 to 77
- Adjusts TypeScript directory comments to reflect removal/relocation of the legacy TS SMTP server and the smtpclient path
## 2026-02-10 - 2.3.2 - fix(tests) ## 2026-02-10 - 2.3.2 - fix(tests)
remove large SMTP client test suites and update SmartFile API usage remove large SMTP client test suites and update SmartFile API usage

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartmta", "name": "@push.rocks/smartmta",
"version": "2.3.2", "version": "4.0.0",
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.", "description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
"keywords": [ "keywords": [
"mta", "mta",
@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -70,10 +70,10 @@ After installation, run `pnpm build` to compile the Rust binary (`mailer-bin`).
**Data flow for inbound mail:** **Data flow for inbound mail:**
1. Rust SMTP server accepts the connection and handles the SMTP protocol 1. Rust SMTP server accepts the connection and handles the full SMTP protocol
2. On `DATA` completion, Rust emits an `emailReceived` event via IPC 2. On `DATA` completion, Rust runs the security pipeline **in-process** (DKIM/SPF/DMARC verification, content scanning, IP reputation check) — zero IPC round-trips
3. TypeScript processes the email (routing, scanning, delivery decisions) 3. Rust emits an `emailReceived` event via IPC with pre-computed security results attached
4. TypeScript sends the processing result back to Rust via IPC 4. TypeScript processes the email (routing decisions using the pre-computed results, delivery)
5. Rust sends the final SMTP response to the client 5. Rust sends the final SMTP response to the client
## Usage ## Usage
@@ -569,7 +569,7 @@ Performance-critical operations are implemented in Rust and communicate with the
|---|---|---| |---|---|---|
| `mailer-core` | ✅ Complete (26 tests) | Email types, validation, MIME building, bounce detection | | `mailer-core` | ✅ Complete (26 tests) | Email types, validation, MIME building, bounce detection |
| `mailer-security` | ✅ Complete (22 tests) | DKIM sign/verify, SPF, DMARC, IP reputation/DNSBL, content scanning | | `mailer-security` | ✅ Complete (22 tests) | DKIM sign/verify, SPF, DMARC, IP reputation/DNSBL, content scanning |
| `mailer-smtp` | ✅ Complete (72 tests) | Full SMTP protocol engine — TCP/TLS server, STARTTLS, AUTH, pipelining, rate limiting | | `mailer-smtp` | ✅ Complete (77 tests) | Full SMTP protocol engine — TCP/TLS server, STARTTLS, AUTH, pipelining, in-process security pipeline, rate limiting |
| `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — security, content scanning, SMTP server lifecycle | | `mailer-bin` | ✅ Complete | CLI + smartrust IPC bridge — security, content scanning, SMTP server lifecycle |
| `mailer-napi` | 🔜 Planned | Native Node.js addon (N-API) | | `mailer-napi` | 🔜 Planned | Native Node.js addon (N-API) |
@@ -598,8 +598,7 @@ smartmta/
│ ├── mail/ │ ├── mail/
│ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager │ │ ├── core/ # Email, EmailValidator, BounceManager, TemplateManager
│ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter │ │ ├── delivery/ # DeliverySystem, Queue, RateLimiter
│ │ │ ── smtpclient/ # SMTP client with connection pooling │ │ │ ── smtpclient/ # SMTP client with connection pooling
│ │ │ └── smtpserver/ # Legacy TS SMTP server (socket-handler fallback)
│ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager │ │ ├── routing/ # UnifiedEmailServer, EmailRouter, DomainRegistry, DnsManager
│ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier │ │ └── security/ # DKIMCreator, DKIMVerifier, SpfVerifier, DmarcVerifier
│ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge │ └── security/ # ContentScanner, IPReputationChecker, RustSecurityBridge

143
rust/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"] }

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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"

View File

@@ -1,5 +0,0 @@
extern crate napi_build;
fn main() {
napi_build::setup();
}

View File

@@ -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(),
)
}

View File

@@ -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

View File

@@ -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("&nbsp;", " ") text.replace("&nbsp;", " ")
.replace("&lt;", "<") .replace("&lt;", "<")

View File

@@ -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"

View 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"));
}
}

View 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());
}
}

View 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
);
}
}

View 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};

View 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());
}
}

View 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());
}
}

View File

@@ -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;

View File

@@ -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 ""

View File

@@ -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);
});
}

View File

@@ -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
};
}

View 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();

View File

@@ -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();

View 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();

View File

@@ -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();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartmta', name: '@push.rocks/smartmta',
version: '2.3.2', version: '4.0.0',
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.' description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
} }

View File

@@ -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
*/ */

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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
});
}

View File

@@ -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;
}
}

View File

@@ -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';

View File

@@ -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>;
}

View File

@@ -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);
});
}
}

View File

@@ -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);
});
});
}
}

View File

@@ -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)
};
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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
});
} }
/** /**

View File

@@ -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';

View File

@@ -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,
}; };

View File

@@ -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';