4 Commits

Author SHA1 Message Date
7853ef67b6 v4.1.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-11 07:31:08 +00:00
f7af8c4534 feat(e2e-tests): add Node.js end-to-end tests covering server lifecycle, inbound SMTP handling, outbound delivery and routing actions 2026-02-11 07:31:08 +00:00
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
54 changed files with 3069 additions and 6738 deletions

View File

@@ -1,5 +1,22 @@
# Changelog
## 2026-02-11 - 4.1.0 - feat(e2e-tests)
add Node.js end-to-end tests covering server lifecycle, inbound SMTP handling, outbound delivery and routing actions
- Adds four end-to-end test files: test.e2e.server-lifecycle.node.ts, test.e2e.inbound-smtp.node.ts, test.e2e.outbound-delivery.node.ts, test.e2e.routing-actions.node.ts
- Tests exercise UnifiedEmailServer start/stop, SMTP handshake and transactions, outbound delivery via a mock SMTP server, routing actions (process, deliver, reject, forward), concurrency, and RSET handling mid-session
- Introduces a minimal mock SMTP server to avoid IPC deadlock with the Rust SMTP client during outbound delivery tests
- Tests will skip when the Rust bridge or server cannot start (binary build required)
## 2026-02-11 - 4.0.0 - BREAKING CHANGE(smtp-client)
Replace the legacy TypeScript SMTP client with a new Rust-based SMTP client and IPC bridge for outbound delivery
- Introduce a Rust SMTP client crate with connection handling, TLS, protocol engine, and connection pooling (new modules: connection, pool, protocol, error, config).
- Add IPC handlers and management commands in the Rust binary: sendEmail, sendRawEmail, verifySmtpConnection, closeSmtpPool, getSmtpPoolStatus and integrate a SmtpClientManager into the runtime.
- Update TypeScript bridge (RustSecurityBridge) with new types and methods (ISmtpSendOptions, ISmtpSendResult, verifySmtpConnection, sendOutboundEmail, sendRawEmail, getSmtpPoolStatus, closeSmtpPool) and rework UnifiedEmailServer to use the Rust bridge for outbound delivery and DKIM signing.
- Remove the previous TypeScript SMTP client implementation and associated tests/utilities (many ts/mail/delivery/smtpclient modules and tests deleted) in favor of the Rust implementation.
- Bump dependencies and cargo config: @push.rocks/smartrust to ^1.2.0 in package.json and add/require crates (uuid, base64, webpki-roots) in Rust Cargo files.
## 2026-02-10 - 3.0.0 - BREAKING CHANGE(security)
implement resilience and lifecycle management for RustSecurityBridge (auto-restart, health checks, state machine and eventing); remove legacy TS SMTP test helper and DNSManager; remove deliverability IP-warmup/sender-reputation integrations and related types; drop unused dependencies

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartmta",
"version": "3.0.0",
"version": "4.1.0",
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
"keywords": [
"mta",
@@ -44,32 +44,12 @@
"tsx": "^4.21.0"
},
"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/smartfs": "^1.3.1",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.1.8",
"@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/smartpromise": "^4.0.3",
"@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",
"@push.rocks/smartrust": "^1.2.0",
"@tsclass/tsclass": "^9.2.0",
"lru-cache": "^11.2.5",
"mailparser": "^3.9.3",

1759
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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"
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]]
name = "cpufeatures"
version = "0.2.17"
@@ -356,16 +347,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "ctor"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "dashmap"
version = "6.1.0"
@@ -913,16 +894,6 @@ version = "0.2.181"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "linux-raw-sys"
version = "0.11.0"
@@ -1004,6 +975,7 @@ dependencies = [
name = "mailer-bin"
version = "0.1.0"
dependencies = [
"base64",
"clap",
"dashmap",
"hickory-resolver 0.25.2",
@@ -1014,6 +986,7 @@ dependencies = [
"serde_json",
"tokio",
"tracing",
"uuid",
]
[[package]]
@@ -1021,48 +994,28 @@ name = "mailer-core"
version = "0.1.0"
dependencies = [
"base64",
"bytes",
"mailparse",
"regex",
"serde",
"serde_json",
"thiserror",
"tracing",
"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]]
name = "mailer-security"
version = "0.1.0"
dependencies = [
"hickory-resolver 0.25.2",
"ipnet",
"mail-auth",
"mailer-core",
"psl",
"regex",
"ring",
"rustls-pki-types",
"serde",
"serde_json",
"thiserror",
"tokio",
"tracing",
]
[[package]]
@@ -1070,7 +1023,6 @@ name = "mailer-smtp"
version = "0.1.0"
dependencies = [
"base64",
"bytes",
"dashmap",
"hickory-resolver 0.25.2",
"mailer-core",
@@ -1087,6 +1039,7 @@ dependencies = [
"tokio-rustls",
"tracing",
"uuid",
"webpki-roots 0.26.11",
]
[[package]]
@@ -1144,66 +1097,6 @@ dependencies = [
"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]]
name = "num-conv"
version = "0.2.0"
@@ -1543,12 +1436,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
@@ -1859,12 +1746,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -1972,6 +1853,24 @@ dependencies = [
"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]]
name = "widestring"
version = "1.2.1"

View File

@@ -4,7 +4,6 @@ members = [
"crates/mailer-core",
"crates/mailer-smtp",
"crates/mailer-security",
"crates/mailer-napi",
"crates/mailer-bin",
]
@@ -19,19 +18,14 @@ tokio-rustls = "0.26"
hickory-resolver = "0.25"
mail-auth = "0.7"
mailparse = "0.16"
napi = { version = "2", features = ["napi9", "async", "serde-json"] }
napi-derive = "2"
ring = "0.17"
dashmap = "6"
thiserror = "2"
tracing = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bytes = "1"
regex = "1"
base64 = "0.22"
uuid = { version = "1", features = ["v4"] }
ipnet = "2"
rustls-pki-types = "1"
psl = "2"
clap = { version = "4", features = ["derive"] }

View File

@@ -19,3 +19,5 @@ serde_json.workspace = true
clap.workspace = true
hickory-resolver.workspace = true
dashmap.workspace = true
base64.workspace = true
uuid.workspace = true

View File

@@ -327,6 +327,7 @@ struct ManagementState {
callbacks: Arc<PendingCallbacks>,
smtp_handle: Option<mailer_smtp::server::SmtpServerHandle>,
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.
@@ -349,10 +350,12 @@ fn run_management_mode() {
let rt = tokio::runtime::Runtime::new().unwrap();
let callbacks = Arc::new(PendingCallbacks::new());
let smtp_client_manager = Arc::new(mailer_smtp::client::SmtpClientManager::new());
let mut state = ManagementState {
callbacks: callbacks.clone(),
smtp_handle: None,
smtp_event_rx: None,
smtp_client_manager: smtp_client_manager.clone(),
};
// 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 {
id: req.id.clone(),
success: false,
@@ -1052,3 +1077,297 @@ fn parse_smtp_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_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
bytes.workspace = true
mailparse.workspace = true
regex.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]
mailer-core = { path = "../mailer-core" }
mail-auth.workspace = true
ring.workspace = true
thiserror.workspace = true
tracing.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
hickory-resolver.workspace = true
ipnet.workspace = true
rustls-pki-types.workspace = true
psl.workspace = true
regex.workspace = true

View File

@@ -111,16 +111,18 @@ static MACRO_DOCUMENT_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(||
// 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.
fn extract_text_from_html(html: &str) -> String {
// Remove style and script blocks first
let no_style = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
let no_script = Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap();
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, " ");
let text = HTML_STYLE_RE.replace_all(html, " ");
let text = HTML_SCRIPT_RE.replace_all(&text, " ");
let text = HTML_TAG_RE.replace_all(&text, " ");
text.replace("&nbsp;", " ")
.replace("&lt;", "<")

View File

@@ -13,13 +13,13 @@ hickory-resolver.workspace = true
dashmap.workspace = true
thiserror.workspace = true
tracing.workspace = true
bytes.workspace = true
serde.workspace = true
serde_json = "1"
regex = "1"
uuid = { version = "1", features = ["v4"] }
serde_json.workspace = true
regex.workspace = true
uuid.workspace = true
base64.workspace = true
rustls-pki-types.workspace = true
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
rustls-pemfile = "2"
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`)
//! - Connection handling (`connection`)
pub mod client;
pub mod command;
pub mod config;
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,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,239 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
import {
connectToSmtp,
waitForGreeting,
sendSmtpCommand,
performSmtpHandshake,
createConcurrentConnections,
createMimeMessage,
} from './helpers/utils.js';
const storageMap = new Map<string, string>();
const mockDcRouter = {
storageManager: {
get: async (key: string) => storageMap.get(key) || null,
set: async (key: string, value: string) => { storageMap.set(key, value); },
},
};
let server: UnifiedEmailServer;
let bridge: RustSecurityBridge;
let bridgeAvailable = false;
tap.test('setup - start server on port 10125', async () => {
RustSecurityBridge.resetInstance();
bridge = RustSecurityBridge.getInstance();
server = new UnifiedEmailServer(mockDcRouter, {
ports: [10125],
hostname: 'test.inbound.local',
domains: [
{
domain: 'testdomain.com',
dnsMode: 'forward',
},
],
routes: [
{
name: 'catch-all',
priority: 0,
match: {
recipients: '*@testdomain.com',
},
action: {
type: 'process',
},
},
],
});
try {
await server.start();
bridgeAvailable = true;
} catch (err) {
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
console.log('Build the Rust binary with: cd rust && cargo build --release');
}
});
tap.test('EHLO and capability discovery', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
const capabilities = await performSmtpHandshake(socket, 'test-client.local');
// Verify we received capabilities from the EHLO response
expect(capabilities.length).toBeGreaterThan(0);
// The server hostname should be in the first capability line
const firstLine = capabilities[0];
expect(firstLine).toBeTruthy();
socket.destroy();
});
tap.test('send valid email - full SMTP transaction', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
await waitForGreeting(socket, 10000);
// EHLO
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
// MAIL FROM
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
// RCPT TO
await sendSmtpCommand(socket, 'RCPT TO:<user@testdomain.com>', '250', 10000);
// DATA
await sendSmtpCommand(socket, 'DATA', '354', 10000);
// Send MIME message
const mimeMessage = createMimeMessage({
from: 'sender@example.com',
to: 'user@testdomain.com',
subject: 'E2E Test Email',
text: 'This is an end-to-end test email.',
});
// Send the message data followed by the terminator
await new Promise<void>((resolve, reject) => {
let buffer = '';
const timer = setTimeout(() => {
socket.removeAllListeners('data');
reject(new Error('DATA response timeout'));
}, 10000);
const onData = (data: Buffer) => {
buffer += data.toString();
if (buffer.includes('250')) {
clearTimeout(timer);
socket.removeListener('data', onData);
resolve();
}
};
socket.on('data', onData);
socket.write(mimeMessage + '\r\n.\r\n');
});
// QUIT
try {
await sendSmtpCommand(socket, 'QUIT', '221', 5000);
} catch {
// Ignore QUIT errors
}
socket.destroy();
// Verify the email was queued for processing
const stats = server.deliveryQueue.getStats();
expect(stats.queueSize).toBeGreaterThan(0);
});
tap.test('multiple recipients', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
await waitForGreeting(socket, 10000);
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
await sendSmtpCommand(socket, 'RCPT TO:<user1@testdomain.com>', '250', 10000);
await sendSmtpCommand(socket, 'RCPT TO:<user2@testdomain.com>', '250', 10000);
await sendSmtpCommand(socket, 'DATA', '354', 10000);
const mimeMessage = createMimeMessage({
from: 'sender@example.com',
to: 'user1@testdomain.com',
subject: 'Multi-recipient Test',
text: 'Testing multiple recipients.',
});
await new Promise<void>((resolve, reject) => {
let buffer = '';
const timer = setTimeout(() => {
socket.removeAllListeners('data');
reject(new Error('DATA response timeout'));
}, 10000);
const onData = (data: Buffer) => {
buffer += data.toString();
if (buffer.includes('250')) {
clearTimeout(timer);
socket.removeListener('data', onData);
resolve();
}
};
socket.on('data', onData);
socket.write(mimeMessage + '\r\n.\r\n');
});
socket.destroy();
});
tap.test('concurrent connections', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const sockets = await createConcurrentConnections('127.0.0.1', 10125, 3, 10000);
expect(sockets.length).toEqual(3);
// Perform EHLO on each connection
for (const socket of sockets) {
await waitForGreeting(socket, 10000);
await sendSmtpCommand(socket, 'EHLO concurrent-client.local', '250', 10000);
}
// Close all connections
for (const socket of sockets) {
socket.destroy();
}
});
tap.test('RSET mid-session', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const socket = await connectToSmtp('127.0.0.1', 10125, 10000);
await waitForGreeting(socket, 10000);
await sendSmtpCommand(socket, 'EHLO test-client.local', '250', 10000);
// Start a transaction
await sendSmtpCommand(socket, 'MAIL FROM:<sender@example.com>', '250', 10000);
// Reset the transaction
await sendSmtpCommand(socket, 'RSET', '250', 10000);
// Start a new transaction after RSET
await sendSmtpCommand(socket, 'MAIL FROM:<other@example.com>', '250', 10000);
socket.destroy();
});
tap.test('cleanup - stop server', async () => {
if (bridgeAvailable) {
await server.stop();
}
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,196 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
import type { ISmtpPoolStatus } from '../ts/security/classes.rustsecuritybridge.js';
import { Email } from '../ts/mail/core/classes.email.js';
import * as net from 'net';
const storageMap = new Map<string, string>();
const mockDcRouter = {
storageManager: {
get: async (key: string) => storageMap.get(key) || null,
set: async (key: string, value: string) => { storageMap.set(key, value); },
},
};
let server: UnifiedEmailServer;
let bridge: RustSecurityBridge;
let bridgeAvailable = false;
let mockSmtpServer: net.Server;
/**
* Create a minimal mock SMTP server that accepts any email.
* This avoids the IPC deadlock that occurs when the Rust SMTP client
* sends to the same Rust process's SMTP server (the IPC stdin reader
* blocks on the sendEmail command and can't process emailProcessingResult).
*/
function createMockSmtpServer(port: number): Promise<net.Server> {
return new Promise((resolve, reject) => {
const srv = net.createServer((socket) => {
socket.write('220 mock-smtp.local ESMTP MockServer\r\n');
let inData = false;
let dataBuffer = '';
socket.on('data', (chunk) => {
const input = chunk.toString();
if (inData) {
dataBuffer += input;
if (dataBuffer.includes('\r\n.\r\n')) {
inData = false;
dataBuffer = '';
socket.write('250 2.0.0 Ok: queued\r\n');
}
return;
}
// Process SMTP commands line by line
const lines = input.split('\r\n').filter((l: string) => l.length > 0);
for (const line of lines) {
const cmd = line.toUpperCase();
if (cmd.startsWith('EHLO') || cmd.startsWith('HELO')) {
socket.write(`250-mock-smtp.local\r\n250-SIZE 10485760\r\n250 OK\r\n`);
} else if (cmd.startsWith('MAIL FROM')) {
socket.write('250 2.1.0 Ok\r\n');
} else if (cmd.startsWith('RCPT TO')) {
socket.write('250 2.1.5 Ok\r\n');
} else if (cmd === 'DATA') {
inData = true;
dataBuffer = '';
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
} else if (cmd === 'QUIT') {
socket.write('221 2.0.0 Bye\r\n');
socket.end();
} else if (cmd === 'RSET') {
socket.write('250 2.0.0 Ok\r\n');
}
}
});
});
srv.listen(port, '127.0.0.1', () => {
resolve(srv);
});
srv.on('error', reject);
});
}
tap.test('setup - start bridge and mock SMTP server', async () => {
RustSecurityBridge.resetInstance();
bridge = RustSecurityBridge.getInstance();
server = new UnifiedEmailServer(mockDcRouter, {
ports: [10325],
hostname: 'test.outbound.local',
domains: [
{
domain: 'outbound-test.com',
dnsMode: 'forward',
},
],
routes: [
{
name: 'catch-all',
priority: 0,
match: {
recipients: '*',
},
action: {
type: 'process',
},
},
],
});
try {
await server.start();
bridgeAvailable = true;
} catch (err) {
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
console.log('Build the Rust binary with: cd rust && cargo build --release');
return;
}
// Start a mock SMTP server on a separate port for outbound delivery tests
mockSmtpServer = await createMockSmtpServer(10326);
});
tap.test('send email to mock SMTP receiver', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'sender@outbound-test.com',
to: 'recipient@outbound-test.com',
subject: 'Outbound E2E Test',
text: 'Testing outbound delivery to the mock SMTP server.',
});
// Send to the mock SMTP server (port 10326), not the Rust SMTP server (port 10325)
const result = await server.sendOutboundEmail('127.0.0.1', 10326, email);
expect(result).toBeTruthy();
expect(result.accepted).toBeTruthy();
expect(result.accepted.length).toBeGreaterThan(0);
expect(result.response).toBeTruthy();
// Rust SMTP client returns enhanced status code without the 250 prefix
expect(result.response).toInclude('2.0.0');
});
tap.test('send email - connection refused', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'sender@outbound-test.com',
to: 'recipient@outbound-test.com',
subject: 'Connection Refused Test',
text: 'This should fail — no server at port 59888.',
});
try {
await server.sendOutboundEmail('127.0.0.1', 59888, email);
throw new Error('Expected sendOutboundEmail to fail on connection refused');
} catch (err: any) {
expect(err).toBeTruthy();
expect(err.message.length).toBeGreaterThan(0);
}
});
tap.test('SMTP pool status and cleanup', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const status: ISmtpPoolStatus = await bridge.getSmtpPoolStatus();
expect(status).toBeTruthy();
expect(status.pools).toBeTruthy();
expect(typeof status.pools).toEqual('object');
// Close all pools
await bridge.closeSmtpPool();
// Verify pools are empty
const statusAfter = await bridge.getSmtpPoolStatus();
const poolKeys = Object.keys(statusAfter.pools);
expect(poolKeys.length).toEqual(0);
});
tap.test('cleanup - stop server and mock', async () => {
if (mockSmtpServer) {
await new Promise<void>((resolve) => mockSmtpServer.close(() => resolve()));
}
if (bridgeAvailable) {
await server.stop();
}
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,239 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
import { Email } from '../ts/mail/core/classes.email.js';
import { SmtpState } from '../ts/mail/delivery/interfaces.js';
const storageMap = new Map<string, string>();
const mockDcRouter = {
storageManager: {
get: async (key: string) => storageMap.get(key) || null,
set: async (key: string, value: string) => { storageMap.set(key, value); },
},
};
let server: UnifiedEmailServer;
let bridge: RustSecurityBridge;
let bridgeAvailable = false;
/**
* Build a minimal SMTP session object for processEmailByMode().
*/
function buildSession(email: Email): any {
return {
id: `test-${Date.now()}-${Math.random().toString(36).substring(2)}`,
state: SmtpState.FINISHED,
mailFrom: email.from,
rcptTo: email.to,
emailData: '',
useTLS: false,
connectionEnded: false,
remoteAddress: '127.0.0.1',
clientHostname: 'test-client.local',
secure: false,
authenticated: false,
envelope: {
mailFrom: { address: email.from, args: {} },
rcptTo: email.to.map((addr: string) => ({ address: addr, args: {} })),
},
};
}
tap.test('setup - start server with routing rules on port 10225', async () => {
RustSecurityBridge.resetInstance();
bridge = RustSecurityBridge.getInstance();
server = new UnifiedEmailServer(mockDcRouter, {
ports: [10225],
hostname: 'test.routing.local',
domains: [
{ domain: 'process.com', dnsMode: 'forward' },
{ domain: 'local.com', dnsMode: 'forward' },
{ domain: 'external.com', dnsMode: 'forward' },
],
routes: [
{
name: 'reject-route',
priority: 40,
match: {
senders: '*@spammer.com',
},
action: {
type: 'reject',
reject: {
code: 550,
message: 'Spam rejected',
},
},
},
{
name: 'process-route',
priority: 30,
match: {
recipients: '*@process.com',
},
action: {
type: 'process',
process: {
scan: true,
},
},
},
{
name: 'deliver-route',
priority: 20,
match: {
recipients: '*@local.com',
},
action: {
type: 'deliver',
},
},
{
name: 'forward-route',
priority: 10,
match: {
recipients: '*@external.com',
},
action: {
type: 'forward',
forward: {
host: '127.0.0.1',
port: 59999, // No server listening — expected failure
},
},
},
],
});
try {
await server.start();
bridgeAvailable = true;
} catch (err) {
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
console.log('Build the Rust binary with: cd rust && cargo build --release');
}
});
tap.test('process action - queues email for processing', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'sender@example.com',
to: 'user@process.com',
subject: 'Process test',
text: 'This email should be queued for processing.',
});
const session = buildSession(email);
const result = await server.processEmailByMode(email, session);
expect(result).toBeTruthy();
const stats = server.deliveryQueue.getStats();
expect(stats.modes.process).toBeGreaterThan(0);
});
tap.test('deliver action - queues email for MTA delivery', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'sender@example.com',
to: 'user@local.com',
subject: 'Deliver test',
text: 'This email should be queued for local delivery.',
});
const session = buildSession(email);
const result = await server.processEmailByMode(email, session);
expect(result).toBeTruthy();
const stats = server.deliveryQueue.getStats();
expect(stats.modes.mta).toBeGreaterThan(0);
});
tap.test('reject action - throws with correct code', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'bad@spammer.com',
to: 'user@process.com',
subject: 'Spam attempt',
text: 'This should be rejected.',
});
const session = buildSession(email);
try {
await server.processEmailByMode(email, session);
throw new Error('Expected processEmailByMode to throw for rejected email');
} catch (err: any) {
expect(err.responseCode).toEqual(550);
expect(err.message).toInclude('Spam rejected');
}
});
tap.test('forward action - fails to unreachable host', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'sender@example.com',
to: 'user@external.com',
subject: 'Forward test',
text: 'This forward should fail — no server at port 59999.',
});
const session = buildSession(email);
try {
await server.processEmailByMode(email, session);
throw new Error('Expected processEmailByMode to throw for unreachable forward host');
} catch (err: any) {
// We expect an error from the failed SMTP connection
expect(err).toBeTruthy();
expect(err.message).toBeTruthy();
}
});
tap.test('no matching route - throws error', async () => {
if (!bridgeAvailable) {
console.log('SKIP: bridge not running');
return;
}
const email = new Email({
from: 'sender@example.com',
to: 'nobody@unmatched.com',
subject: 'Unmatched route test',
text: 'No route matches this recipient.',
});
const session = buildSession(email);
try {
await server.processEmailByMode(email, session);
throw new Error('Expected processEmailByMode to throw for no matching route');
} catch (err: any) {
expect(err.message).toInclude('No matching route');
}
});
tap.test('cleanup - stop server', async () => {
if (bridgeAvailable) {
await server.stop();
}
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -0,0 +1,118 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { UnifiedEmailServer } from '../ts/mail/routing/classes.unified.email.server.js';
import { RustSecurityBridge, BridgeState } from '../ts/security/classes.rustsecuritybridge.js';
import { connectToSmtp, waitForGreeting } from './helpers/utils.js';
import * as net from 'net';
// Common mock pattern for dcRouter dependency
const storageMap = new Map<string, string>();
const mockDcRouter = {
storageManager: {
get: async (key: string) => storageMap.get(key) || null,
set: async (key: string, value: string) => { storageMap.set(key, value); },
},
};
let server: UnifiedEmailServer;
let bridge: RustSecurityBridge;
tap.test('setup - reset bridge singleton', async () => {
RustSecurityBridge.resetInstance();
bridge = RustSecurityBridge.getInstance();
});
tap.test('construct server - should create UnifiedEmailServer', async () => {
server = new UnifiedEmailServer(mockDcRouter, {
ports: [10025, 10587],
hostname: 'test.e2e.local',
domains: [
{
domain: 'e2e-test.com',
dnsMode: 'forward',
},
],
routes: [
{
name: 'catch-all',
priority: 0,
match: {
recipients: '*@e2e-test.com',
},
action: {
type: 'process',
},
},
],
});
expect(server).toBeTruthy();
expect(server).toBeInstanceOf(UnifiedEmailServer);
});
tap.test('start server - should start and accept SMTP connections', async () => {
try {
await server.start();
} catch (err) {
console.log(`SKIP: Server failed to start — ${(err as Error).message}`);
console.log('Build the Rust binary with: cd rust && cargo build --release');
return;
}
expect(bridge.running).toBeTrue();
// Connect to port 10025 and verify we get a 220 greeting
const socket = await connectToSmtp('127.0.0.1', 10025, 10000);
const greeting = await waitForGreeting(socket, 10000);
expect(greeting).toInclude('220');
socket.destroy();
});
tap.test('get stats - should return server statistics', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
const stats = server.getStats();
expect(stats).toBeTruthy();
expect(stats.startTime).toBeInstanceOf(Date);
expect(stats.connections).toBeTruthy();
expect(typeof stats.connections.current).toEqual('number');
expect(typeof stats.connections.total).toEqual('number');
expect(stats.messages).toBeTruthy();
expect(typeof stats.messages.processed).toEqual('number');
});
tap.test('stop server - should stop and refuse connections', async () => {
if (!bridge.running) {
console.log('SKIP: bridge not running');
return;
}
await server.stop();
// Verify connection is refused after stop
try {
const socket = await connectToSmtp('127.0.0.1', 10025, 3000);
socket.destroy();
// If we get here, the connection was accepted — that's unexpected
throw new Error('Expected connection to be refused after server stop');
} catch (err) {
// Connection refused or timeout is expected
const msg = (err as Error).message;
expect(
msg.includes('ECONNREFUSED') || msg.includes('timeout') || msg.includes('refused')
).toBeTrue();
}
expect(bridge.state).toEqual(BridgeState.Stopped);
});
tap.test('stop', async () => {
// Clean up if not already stopped
if (bridge.running) {
await server.stop();
}
await tap.stopForcefully();
});
export default tap.start();

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 = {
name: '@push.rocks/smartmta',
version: '3.0.0',
version: '4.1.0',
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
}

View File

@@ -1,7 +1,5 @@
import * as plugins from '../../plugins.js';
import { EventEmitter } from 'node:events';
import * as net from 'node:net';
import * as tls from 'node:tls';
import { logger } from '../../logger.js';
import {
SecurityLogger,
@@ -11,7 +9,6 @@ import {
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js';
import type { Email } from '../core/classes.email.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';
/**
@@ -117,7 +114,7 @@ export class MultiModeDeliverySystem extends EventEmitter {
* Create a new multi-mode delivery system
* @param queue Unified delivery queue
* @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) {
super();
@@ -440,184 +437,45 @@ export class MultiModeDeliverySystem extends EventEmitter {
// 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');
}
logger.log('info', `Forwarding email to ${targetServer}:${targetPort}, TLS: ${useTls}`);
logger.log('info', `Forwarding email to ${targetServer}:${targetPort}`);
try {
// Get SMTP client from email server if available
if (!this.emailServer) {
// Fall back to raw socket implementation if no email server
logger.log('warn', 'No email server available, falling back to raw socket implementation');
return this.handleForwardDeliveryLegacy(item);
throw new Error('No email server available for forward delivery');
}
// Get SMTP client from UnifiedEmailServer
const smtpClient = this.emailServer.getSmtpClient(targetServer, targetPort);
// Build DKIM options from route config
const dkimDomain = item.route?.action.options?.mtaOptions?.dkimSign
? (item.route.action.options.mtaOptions.dkimOptions?.domainName || email.from.split('@')[1])
: undefined;
const dkimSelector = item.route?.action.options?.mtaOptions?.dkimOptions?.keySelector || 'default';
// Apply DKIM signing if configured in the route
if (item.route?.action.options?.mtaOptions?.dkimSign) {
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);
if (result.success) {
logger.log('info', `Email forwarded successfully to ${targetServer}:${targetPort}`);
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 via Rust SMTP client
const result = await this.emailServer.sendOutboundEmail(targetServer, targetPort, email, {
auth,
dkimDomain,
dkimSelector,
});
// Send EHLO
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}`);
logger.log('info', `Email forwarded successfully to ${targetServer}:${targetPort}`);
return {
targetServer: route?.action?.forward?.host,
targetPort: route?.action?.forward?.port || 25,
recipients: email.getAllRecipients().length
targetServer,
targetPort,
recipients: result.accepted.length,
messageId: result.messageId,
rejectedRecipients: result.rejected,
};
} catch (error: any) {
logger.log('error', `Failed to forward email: ${error.message}`);
// Close the connection
socket.destroy();
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
*/

View File

@@ -1,425 +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 {
// 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
}
}

View File

@@ -1,23 +1,5 @@
// Email delivery components
export * from './classes.emailsignjob.js';
export * from './classes.delivery.queue.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';
// 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';
// 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

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

@@ -17,8 +17,7 @@ import { Email } from '../core/classes.email.js';
import { DomainRegistry } from './classes.domain.registry.js';
import { DnsManager } from './classes.dns.manager.js';
import { BounceManager, BounceType, BounceCategory } from '../core/classes.bouncemanager.js';
import { createPooledSmtpClient } from '../delivery/smtpclient/create-client.js';
import type { SmtpClient } from '../delivery/smtpclient/smtp-client.js';
import type { ISmtpSendResult, IOutboundEmail } from '../../security/classes.rustsecuritybridge.js';
import { MultiModeDeliverySystem, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
import { UnifiedDeliveryQueue, type IQueueOptions } from '../delivery/classes.delivery.queue.js';
import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
@@ -167,7 +166,6 @@ export class UnifiedEmailServer extends EventEmitter {
public deliverySystem: MultiModeDeliverySystem;
private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers
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) {
super();
@@ -242,17 +240,8 @@ export class UnifiedEmailServer extends EventEmitter {
bounceHandler: {
processSmtpFailure: this.processSmtpFailure.bind(this)
},
onDeliverySuccess: async (item, _result) => {
// Record delivery success event for reputation monitoring
const email = item.processingResult as Email;
const senderDomain = email.from.split('@')[1];
if (senderDomain) {
this.recordReputationEvent(senderDomain, {
type: 'delivered',
count: email.to.length
});
}
onDeliverySuccess: async (_item, _result) => {
// Delivery success recorded via delivery system
}
};
@@ -281,34 +270,50 @@ export class UnifiedEmailServer extends EventEmitter {
}
/**
* Get or create an SMTP client for the given host and port
* Uses connection pooling for efficiency
* Send an outbound email via the Rust SMTP client.
* Uses connection pooling in the Rust binary for efficiency.
*/
public getSmtpClient(host: string, port: number = 25): SmtpClient {
const clientKey = `${host}:${port}`;
// Check if we already have a client for this destination
let client = this.smtpClients.get(clientKey);
if (!client) {
// Create a new pooled SMTP client
client = createPooledSmtpClient({
host,
port,
secure: port === 465,
connectionTimeout: this.options.outbound?.connectionTimeout || 30000,
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}`);
public async sendOutboundEmail(host: string, port: number, email: Email, options?: {
auth?: { user: string; pass: string };
dkimDomain?: string;
dkimSelector?: string;
}): Promise<ISmtpSendResult> {
// Build DKIM config if domain has keys
let dkim: { domain: string; selector: string; privateKey: string } | undefined;
if (options?.dkimDomain) {
try {
const { privateKey } = await this.dkimCreator.readDKIMKeys(options.dkimDomain);
dkim = { domain: options.dkimDomain, selector: options.dkimSelector || 'default', privateKey };
} catch (err) {
logger.log('warn', `Failed to read DKIM keys for ${options.dkimDomain}: ${(err as Error).message}`);
}
}
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,
});
}
/**
@@ -486,16 +491,12 @@ export class UnifiedEmailServer extends EventEmitter {
logger.log('info', 'Email delivery queue shut down');
}
// Close all SMTP client connections
for (const [clientKey, client] of this.smtpClients) {
try {
await client.close();
logger.log('info', `Closed SMTP client pool for ${clientKey}`);
} catch (error) {
logger.log('warn', `Error closing SMTP client for ${clientKey}: ${error.message}`);
}
// Close all Rust SMTP client connection pools
try {
await this.rustBridge.closeSmtpPool();
} catch {
// Bridge may already be stopped
}
this.smtpClients.clear();
logger.log('info', 'UnifiedEmailServer stopped successfully');
this.emit('stopped');
@@ -826,12 +827,11 @@ export class UnifiedEmailServer extends EventEmitter {
email.headers['X-Forwarded-To'] = email.to.join(', ');
email.headers['X-Forwarded-Date'] = new Date().toISOString();
// Get SMTP client
const client = this.getSmtpClient(host, port);
try {
// Send email
await client.sendMail(email);
// Send email via Rust SMTP client
await this.sendOutboundEmail(host, port, email, {
auth: auth as { user: string; pass: string } | undefined,
});
logger.log('info', `Successfully forwarded email to ${host}:${port}`);
@@ -1297,15 +1297,6 @@ export class UnifiedEmailServer extends EventEmitter {
// Queue the email for delivery
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}`);
return id;
} catch (error) {
@@ -1370,15 +1361,6 @@ export class UnifiedEmailServer extends EventEmitter {
// Notify any registered listeners about the bounce
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
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
@@ -1450,15 +1432,6 @@ export class UnifiedEmailServer extends EventEmitter {
// Notify any registered listeners about the bounce
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
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
@@ -1566,40 +1539,6 @@ export class UnifiedEmailServer extends EventEmitter {
logger.log('info', `Removed ${email} from suppression list`);
}
/**
* Record an email event for domain reputation tracking.
* Currently a no-op — the sender reputation monitor is not yet implemented.
* @param domain Domain sending the email
* @param event Event details
*/
public recordReputationEvent(domain: string, event: {
type: 'sent' | 'delivered' | 'bounce' | 'complaint' | 'open' | 'click';
count?: number;
hardBounce?: boolean;
receivingDomain?: string;
}): void {
logger.log('debug', `Reputation event for ${domain}: ${event.type}`);
}
/**
* 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
* @param domain Sending domain
@@ -1608,7 +1547,6 @@ export class UnifiedEmailServer extends EventEmitter {
* @param reason Bounce reason
*/
public recordBounce(domain: string, receivingDomain: string, bounceType: 'hard' | 'soft', reason: string): void {
// Record bounce in bounce manager
const bounceRecord = {
id: `bounce_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
recipient: `user@${receivingDomain}`,
@@ -1623,16 +1561,7 @@ export class UnifiedEmailServer extends EventEmitter {
processed: false
};
// Process the bounce
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,
}
// @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
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 { 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 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 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 smartrx from '@push.rocks/smartrx';
import * as smartunique from '@push.rocks/smartunique';
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
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
// apiclient.xyz scope
import * as cloudflare from '@apiclient.xyz/cloudflare';
export {
cloudflare,
}
// tsclass scope
import * as tsclass from '@tsclass/tsclass';

View File

@@ -67,6 +67,71 @@ interface IContentScanResult {
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 {
bin: string;
core: string;
@@ -212,6 +277,26 @@ type TMailerCommands = {
params: IRateLimitConfig;
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;
};
};
// ---------------------------------------------------------------------------
@@ -660,6 +745,40 @@ export class RustSecurityBridge extends EventEmitter {
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
// -----------------------------------------------------------------------
@@ -763,4 +882,11 @@ export type {
IEmailData,
IEmailReceivedEvent,
IAuthRequestEvent,
IOutboundEmail,
ISmtpSendResult,
ISmtpSendOptions,
ISmtpSendRawOptions,
ISmtpVerifyOptions,
ISmtpVerifyResult,
ISmtpPoolStatus,
};

View File

@@ -32,4 +32,9 @@ export {
type IBounceDetection,
type IRustReputationResult,
type IVersionInfo,
type IOutboundEmail,
type ISmtpSendResult,
type ISmtpSendOptions,
type ISmtpVerifyResult,
type ISmtpPoolStatus,
} from './classes.rustsecuritybridge.js';