8 Commits

Author SHA1 Message Date
9e722874b4 v5.1.2
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 4s
Release / build-and-release (push) Failing after 4s
2026-02-11 10:20:19 +00:00
873af43ef2 fix(readme): adjust ASCII architecture diagram alignment in README 2026-02-11 10:20:19 +00:00
76d898b648 v5.1.1
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 10:16:30 +00:00
b422639c34 fix(release): no changes 2026-02-11 10:16:30 +00:00
c45ba2a7b4 v5.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 7s
Publish to npm / npm-publish (push) Failing after 6s
Release / build-and-release (push) Failing after 4s
2026-02-11 10:11:43 +00:00
b10597fd5e feat(mailer-smtp): add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements 2026-02-11 10:11:43 +00:00
7908cbaefa v5.0.0
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s
Publish to npm / npm-publish (push) Failing after 5s
Release / build-and-release (push) Failing after 4s
2026-02-11 07:55:28 +00:00
526dcb4dac BREAKING CHANGE(mail): remove DMARC and DKIM verifier implementations and MTA error classes; introduce DkimManager and EmailActionExecutor; simplify SPF verifier and update routing exports and tests 2026-02-11 07:55:28 +00:00
40 changed files with 2496 additions and 2039 deletions

View File

@@ -1,5 +1,44 @@
# Changelog # Changelog
## 2026-02-11 - 5.1.2 - fix(readme)
adjust ASCII architecture diagram alignment in README
- Whitespace and alignment tweaks to the ASCII architecture diagram in readme.md
- No code or behavior changes; documentation-only edit
## 2026-02-11 - 5.1.1 - fix(release)
no changes
- No files changed in this commit.
- Current package version remains 5.1.0 (from package.json).
## 2026-02-11 - 5.1.0 - feat(mailer-smtp)
add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements
- Add server-side SCRAM-SHA-256 implementation in Rust (scram.rs) and wire up SCRAM credential request/response between Rust and TypeScript bridge (ScramCredentialRequest / scramCredentialResult).
- Support SCRAM-SHA-256 auth mechanism in SMTP command parsing and advertise AUTH PLAIN LOGIN SCRAM-SHA-256 capability.
- Add opportunistic TLS mode for MTA-to-MTA delivery: configurable tls_opportunistic flag, an OpportunisticVerifier that skips cert verification per RFC 7435, and plumbing into connect/upgrade TLS paths.
- Add pipelined envelope support for MAIL FROM + multiple RCPT TO (send_pipelined_envelope) and use pipelining when server advertises PIPELINING to improve outbound performance.
- Add Ed25519 DKIM signing support and auto-dispatch: sign_dkim_ed25519, sign_dkim_auto, dkim_dns_record_value_typed, and TS changes to detect key type and call the auto signing API.
- Expose additional per-domain TLS certs (additionalTlsCerts) and implement SNI-based certificate resolver on the server to select certs by hostname; parsing helpers and fallback default cert handling included.
- Install ring crypto provider early in mailer-bin main for rustls operations and add related rust dependencies (sha2, hmac, pbkdf2) and workspace entries.
- TypeScript delivery and server bridge changes: group recipients by domain, MX resolution fallback to A record, MTA delivery loop over MX hosts, DKIM options propagation, TLS opportunistic option passed to outbound client, SCRAM credential computation in TS using PBKDF2/HMAC/SHA256 and sending results back to Rust.
- Add new tests and utilities: IPv6 DNSBL support and tests, SCRAM unit tests, DKIM Ed25519 tests, node-level MTA delivery integration test, and various test updates.
- Public API additions on the Rust <-> TS bridge: signDkim accepts keyType, new scram credential result command, onScramCredentialRequest/onScramCredentialResult helpers and sendScramCredentialResult.
- Various refactors and safety/feature improvements across mailer-core/smtp/security: envelope handling, stream buffering detection, and error handling for auth flows.
## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mail)
remove DMARC and DKIM verifier implementations and MTA error classes; introduce DkimManager and EmailActionExecutor; simplify SPF verifier and update routing exports and tests
- Removed ts/mail/security/classes.dmarcverifier.ts and ts/mail/security/classes.dkimverifier.ts — DMARC and DKIM verifier implementations deleted
- Removed ts/errors/index.ts — MTA-specific error classes removed
- Added ts/mail/routing/classes.dkim.manager.ts — new DKIM key management and rotation logic
- Added ts/mail/routing/classes.email.action.executor.ts — centralized email action execution (forward/process/deliver/reject)
- Updated ts/mail/security/classes.spfverifier.ts to retain SPF parsing but removed verify/verifyAndApply logic delegating to Rust bridge
- Updated ts/mail/routing/index.ts to export new routing classes and adjusted import paths (e.g. delivery queue import updated)
- Tests trimmed: DMARC tests and rate limiter tests removed; SPF parsing test retained and simplified
- This set of changes alters public exports and removes previously available verifier APIs — major version bump recommended
## 2026-02-11 - 4.1.1 - fix(readme) ## 2026-02-11 - 4.1.1 - fix(readme)
clarify architecture and IPC, document outbound flow and testing, and update module and crate descriptions in README clarify architecture and IPC, document outbound flow and testing, and update module and crate descriptions in README

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartmta", "name": "@push.rocks/smartmta",
"version": "4.1.1", "version": "5.1.2",
"description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.", "description": "A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.",
"keywords": [ "keywords": [
"mta", "mta",

4
rust/Cargo.lock generated
View File

@@ -982,6 +982,7 @@ dependencies = [
"mailer-core", "mailer-core",
"mailer-security", "mailer-security",
"mailer-smtp", "mailer-smtp",
"rustls",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -1025,15 +1026,18 @@ dependencies = [
"base64", "base64",
"dashmap", "dashmap",
"hickory-resolver 0.25.2", "hickory-resolver 0.25.2",
"hmac",
"mailer-core", "mailer-core",
"mailer-security", "mailer-security",
"mailparse", "mailparse",
"pbkdf2",
"regex", "regex",
"rustls", "rustls",
"rustls-pemfile", "rustls-pemfile",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",

View File

@@ -29,3 +29,6 @@ uuid = { version = "1", features = ["v4"] }
rustls-pki-types = "1" rustls-pki-types = "1"
psl = "2" psl = "2"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
sha2 = "0.10"
hmac = "0.12"
pbkdf2 = { version = "0.12", default-features = false }

View File

@@ -21,3 +21,4 @@ hickory-resolver.workspace = true
dashmap.workspace = true dashmap.workspace = true
base64.workspace = true base64.workspace = true
uuid.workspace = true uuid.workspace = true
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }

View File

@@ -14,7 +14,7 @@ use std::sync::Arc;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use mailer_smtp::connection::{ use mailer_smtp::connection::{
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult, AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult, ScramCredentialResult,
}; };
/// mailer-bin: Rust-powered email security tools /// mailer-bin: Rust-powered email security tools
@@ -114,10 +114,11 @@ struct IpcEvent {
// --- Pending callbacks for correlation-ID based reverse calls --- // --- Pending callbacks for correlation-ID based reverse calls ---
/// Stores oneshot senders for pending email processing and auth callbacks. /// Stores oneshot senders for pending email processing, auth, and SCRAM callbacks.
struct PendingCallbacks { struct PendingCallbacks {
email: DashMap<String, oneshot::Sender<EmailProcessingResult>>, email: DashMap<String, oneshot::Sender<EmailProcessingResult>>,
auth: DashMap<String, oneshot::Sender<AuthResult>>, auth: DashMap<String, oneshot::Sender<AuthResult>>,
scram: DashMap<String, oneshot::Sender<ScramCredentialResult>>,
} }
impl PendingCallbacks { impl PendingCallbacks {
@@ -125,6 +126,7 @@ impl PendingCallbacks {
Self { Self {
email: DashMap::new(), email: DashMap::new(),
auth: DashMap::new(), auth: DashMap::new(),
scram: DashMap::new(),
} }
} }
} }
@@ -147,9 +149,22 @@ impl CallbackRegistry for PendingCallbacks {
self.auth.insert(correlation_id.to_string(), tx); self.auth.insert(correlation_id.to_string(), tx);
rx rx
} }
fn register_scram_callback(
&self,
correlation_id: &str,
) -> oneshot::Receiver<ScramCredentialResult> {
let (tx, rx) = oneshot::channel();
self.scram.insert(correlation_id.to_string(), tx);
rx
}
} }
fn main() { fn main() {
// Install the ring CryptoProvider for rustls TLS operations (STARTTLS, implicit TLS).
// This must happen before any TLS connection is attempted.
let _ = rustls::crypto::ring::default_provider().install_default();
let cli = Cli::parse(); let cli = Cli::parse();
if cli.management { if cli.management {
@@ -494,6 +509,22 @@ fn handle_smtp_event(event: ConnectionEvent) {
}), }),
); );
} }
ConnectionEvent::ScramCredentialRequest {
correlation_id,
session_id,
username,
remote_addr,
} => {
emit_event(
"scramCredentialRequest",
serde_json::json!({
"correlationId": correlation_id,
"sessionId": session_id,
"username": username,
"remoteAddr": remote_addr,
}),
);
}
} }
} }
@@ -642,8 +673,13 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
.get("privateKey") .get("privateKey")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
let key_type = req
.params
.get("keyType")
.and_then(|v| v.as_str())
.unwrap_or("rsa");
match mailer_security::sign_dkim(raw_message.as_bytes(), domain, selector, private_key) { match mailer_security::sign_dkim_auto(raw_message.as_bytes(), domain, selector, private_key, key_type) {
Ok(header) => IpcResponse { Ok(header) => IpcResponse {
id: req.id.clone(), id: req.id.clone(),
success: true, success: true,
@@ -825,6 +861,10 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
handle_auth_result(req, state) handle_auth_result(req, state)
} }
"scramCredentialResult" => {
handle_scram_credential_result(req, state)
}
"configureRateLimits" => { "configureRateLimits" => {
// Rate limit configuration is set at startSmtpServer time. // Rate limit configuration is set at startSmtpServer time.
// This command allows runtime updates, but for now we acknowledge it. // This command allows runtime updates, but for now we acknowledge it.
@@ -1010,6 +1050,56 @@ fn handle_auth_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse
} }
} }
/// Handle scramCredentialResult IPC command — resolves a pending SCRAM credential callback.
fn handle_scram_credential_result(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
let correlation_id = req
.params
.get("correlationId")
.and_then(|v| v.as_str())
.unwrap_or("");
let found = req.params.get("found").and_then(|v| v.as_bool()).unwrap_or(false);
let result = ScramCredentialResult {
found,
salt: req.params.get("salt")
.and_then(|v| v.as_str())
.and_then(|s| BASE64.decode(s.as_bytes()).ok()),
iterations: req.params.get("iterations")
.and_then(|v| v.as_u64())
.map(|n| n as u32),
stored_key: req.params.get("storedKey")
.and_then(|v| v.as_str())
.and_then(|s| BASE64.decode(s.as_bytes()).ok()),
server_key: req.params.get("serverKey")
.and_then(|v| v.as_str())
.and_then(|s| BASE64.decode(s.as_bytes()).ok()),
};
if let Some((_, tx)) = state.callbacks.scram.remove(correlation_id) {
let _ = tx.send(result);
IpcResponse {
id: req.id.clone(),
success: true,
result: Some(serde_json::json!({"resolved": true})),
error: None,
}
} else {
IpcResponse {
id: req.id.clone(),
success: false,
result: None,
error: Some(format!(
"No pending SCRAM credential callback for correlationId: {}",
correlation_id
)),
}
}
}
/// Parse SmtpServerConfig from IPC params JSON. /// Parse SmtpServerConfig from IPC params JSON.
fn parse_smtp_config( fn parse_smtp_config(
params: &serde_json::Value, params: &serde_json::Value,
@@ -1075,6 +1165,27 @@ fn parse_smtp_config(
config.processing_timeout_secs = timeout; config.processing_timeout_secs = timeout;
} }
// Parse additional TLS certs for SNI
if let Some(certs_arr) = params.get("additionalTlsCerts").and_then(|v| v.as_array()) {
for cert_val in certs_arr {
if let (Some(domains_arr), Some(cert_pem), Some(key_pem)) = (
cert_val.get("domains").and_then(|v| v.as_array()),
cert_val.get("certPem").and_then(|v| v.as_str()),
cert_val.get("keyPem").and_then(|v| v.as_str()),
) {
let domains: Vec<String> = domains_arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
config.additional_tls_certs.push(mailer_smtp::config::TlsDomainCert {
domains,
cert_pem: cert_pem.to_string(),
key_pem: key_pem.to_string(),
});
}
}
}
Ok(config) Ok(config)
} }
@@ -1187,11 +1298,12 @@ async fn handle_send_email(req: &IpcRequest, state: &ManagementState) -> IpcResp
// Optional DKIM signing // Optional DKIM signing
if let Some(dkim_val) = req.params.get("dkim") { 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()) { if let Ok(dkim_config) = serde_json::from_value::<mailer_smtp::client::DkimSignConfig>(dkim_val.clone()) {
match mailer_security::sign_dkim( match mailer_security::sign_dkim_auto(
&raw_message, &raw_message,
&dkim_config.domain, &dkim_config.domain,
&dkim_config.selector, &dkim_config.selector,
&dkim_config.private_key, &dkim_config.private_key,
&dkim_config.key_type,
) { ) {
Ok(header) => { Ok(header) => {
// Prepend DKIM header to the message // Prepend DKIM header to the message

View File

@@ -1,4 +1,4 @@
use mail_auth::common::crypto::{RsaKey, Sha256}; use mail_auth::common::crypto::{Ed25519Key, RsaKey, Sha256};
use mail_auth::common::headers::HeaderWriter; use mail_auth::common::headers::HeaderWriter;
use mail_auth::dkim::{Canonicalization, DkimSigner}; use mail_auth::dkim::{Canonicalization, DkimSigner};
use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator}; use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator};
@@ -118,9 +118,62 @@ pub fn sign_dkim(
Ok(signature.to_header()) Ok(signature.to_header())
} }
/// Sign a raw email message with DKIM using Ed25519-SHA256 (RFC 8463).
///
/// * `raw_message` - The raw RFC 5322 message bytes
/// * `domain` - The signing domain (d= tag)
/// * `selector` - The DKIM selector (s= tag)
/// * `private_key_pkcs8_der` - Ed25519 private key in PKCS#8 DER format
///
/// Returns the DKIM-Signature header string to prepend to the message.
pub fn sign_dkim_ed25519(
raw_message: &[u8],
domain: &str,
selector: &str,
private_key_pkcs8_der: &[u8],
) -> Result<String> {
let ed_key = Ed25519Key::from_pkcs8_maybe_unchecked_der(private_key_pkcs8_der)
.map_err(|e| SecurityError::Key(format!("Failed to load Ed25519 key: {}", e)))?;
let signature = DkimSigner::from_key(ed_key)
.domain(domain)
.selector(selector)
.headers(["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type"])
.header_canonicalization(Canonicalization::Relaxed)
.body_canonicalization(Canonicalization::Relaxed)
.sign(raw_message)
.map_err(|e| SecurityError::Dkim(format!("Ed25519 DKIM signing failed: {}", e)))?;
Ok(signature.to_header())
}
/// Sign a raw email message with DKIM, auto-selecting RSA or Ed25519 based on `key_type`.
///
/// * `key_type` - `"rsa"` (default) or `"ed25519"`
/// * For RSA: `private_key_pem` is a PEM-encoded RSA key
/// * For Ed25519: `private_key_pem` is a PEM-encoded PKCS#8 Ed25519 key
pub fn sign_dkim_auto(
raw_message: &[u8],
domain: &str,
selector: &str,
private_key_pem: &str,
key_type: &str,
) -> Result<String> {
match key_type.to_lowercase().as_str() {
"ed25519" => {
// Parse PEM to DER for Ed25519
let der = rustls_pki_types::PrivatePkcs8KeyDer::from_pem_slice(private_key_pem.as_bytes())
.map_err(|e| SecurityError::Key(format!("Failed to parse Ed25519 PEM: {}", e)))?;
sign_dkim_ed25519(raw_message, domain, selector, der.secret_pkcs8_der())
}
_ => sign_dkim(raw_message, domain, selector, private_key_pem),
}
}
/// Generate a DKIM DNS TXT record value for a given public key. /// Generate a DKIM DNS TXT record value for a given public key.
/// ///
/// Returns the value for a TXT record at `{selector}._domainkey.{domain}`. /// Returns the value for a TXT record at `{selector}._domainkey.{domain}`.
/// `key_type` should be `"rsa"` or `"ed25519"`.
pub fn dkim_dns_record_value(public_key_pem: &str) -> String { pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
// Extract the base64 content from PEM // Extract the base64 content from PEM
let key_b64: String = public_key_pem let key_b64: String = public_key_pem
@@ -132,6 +185,24 @@ pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
format!("v=DKIM1; h=sha256; k=rsa; p={}", key_b64) format!("v=DKIM1; h=sha256; k=rsa; p={}", key_b64)
} }
/// Generate a DKIM DNS TXT record value with explicit key type.
///
/// * `key_type` - `"rsa"` or `"ed25519"`
pub fn dkim_dns_record_value_typed(public_key_pem: &str, key_type: &str) -> String {
let key_b64: String = public_key_pem
.lines()
.filter(|line| !line.starts_with("-----"))
.collect::<Vec<_>>()
.join("");
let k = match key_type.to_lowercase().as_str() {
"ed25519" => "ed25519",
_ => "rsa",
};
format!("v=DKIM1; h=sha256; k={}; p={}", k, key_b64)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -149,4 +220,42 @@ mod tests {
let result = sign_dkim(b"From: test@example.com\r\n\r\nBody", "example.com", "mta", "not a key"); let result = sign_dkim(b"From: test@example.com\r\n\r\nBody", "example.com", "mta", "not a key");
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn test_sign_dkim_ed25519() {
// Generate an Ed25519 key pair using mail-auth
let pkcs8_der = Ed25519Key::generate_pkcs8().expect("generate ed25519 key");
let ed_key = Ed25519Key::from_pkcs8_der(&pkcs8_der).expect("parse ed25519 key");
let _pub_key = ed_key.public_key();
let msg = b"From: test@example.com\r\nTo: rcpt@example.com\r\nSubject: Test\r\n\r\nBody";
let result = sign_dkim_ed25519(msg, "example.com", "ed25519sel", &pkcs8_der);
assert!(result.is_ok());
let header = result.unwrap();
assert!(header.contains("a=ed25519-sha256"));
assert!(header.contains("d=example.com"));
assert!(header.contains("s=ed25519sel"));
}
#[test]
fn test_sign_dkim_auto_dispatches() {
// RSA with invalid key should error
let msg = b"From: test@example.com\r\n\r\nBody";
let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "rsa");
assert!(result.is_err());
// Ed25519 with invalid PEM should error
let result = sign_dkim_auto(msg, "example.com", "mta", "not a key", "ed25519");
assert!(result.is_err());
}
#[test]
fn test_dkim_dns_record_value_typed() {
let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg==\n-----END PUBLIC KEY-----";
let rsa_record = dkim_dns_record_value_typed(pem, "rsa");
assert!(rsa_record.contains("k=rsa"));
let ed_record = dkim_dns_record_value_typed(pem, "ed25519");
assert!(ed_record.contains("k=ed25519"));
}
} }

View File

@@ -1,6 +1,6 @@
use hickory_resolver::TokioResolver; use hickory_resolver::TokioResolver;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use crate::error::Result; use crate::error::Result;
@@ -83,7 +83,7 @@ pub fn risk_level(score: u8) -> RiskLevel {
/// Check an IP against DNSBL servers. /// Check an IP against DNSBL servers.
/// ///
/// * `ip` - The IP address to check (must be IPv4) /// * `ip` - The IP address to check (IPv4 or IPv6)
/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults) /// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults)
/// * `resolver` - DNS resolver to use /// * `resolver` - DNS resolver to use
pub async fn check_dnsbl( pub async fn check_dnsbl(
@@ -91,20 +91,10 @@ pub async fn check_dnsbl(
dnsbl_servers: &[&str], dnsbl_servers: &[&str],
resolver: &TokioResolver, resolver: &TokioResolver,
) -> Result<DnsblResult> { ) -> Result<DnsblResult> {
let ipv4 = match ip { let reversed = match ip {
IpAddr::V4(v4) => v4, IpAddr::V4(v4) => reverse_ipv4(v4),
IpAddr::V6(_) => { IpAddr::V6(v6) => reverse_ipv6(v6),
// IPv6 DNSBL is less common; return clean result
return Ok(DnsblResult {
ip: ip.to_string(),
listed_count: 0,
listed_on: Vec::new(),
total_checked: 0,
});
}
}; };
let reversed = reverse_ipv4(ipv4);
let total = dnsbl_servers.len(); let total = dnsbl_servers.len();
// Query all DNSBL servers in parallel // Query all DNSBL servers in parallel
@@ -178,6 +168,21 @@ fn reverse_ipv4(ip: Ipv4Addr) -> String {
format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0]) format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0])
} }
/// Reverse IPv6 address to nibble format for DNSBL queries.
///
/// Expands to full 32-nibble hex, reverses, and dot-separates each nibble.
/// E.g. `2001:db8::1` -> `1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2`
fn reverse_ipv6(ip: Ipv6Addr) -> String {
let segments = ip.segments();
let full_hex: String = segments.iter().map(|s| format!("{:04x}", s)).collect();
full_hex
.chars()
.rev()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(".")
}
/// Heuristic IP type classification based on well-known prefix ranges. /// Heuristic IP type classification based on well-known prefix ranges.
/// Same heuristics as the TypeScript IPReputationChecker. /// Same heuristics as the TypeScript IPReputationChecker.
fn classify_ip(ip: IpAddr) -> IpType { fn classify_ip(ip: IpAddr) -> IpType {
@@ -272,6 +277,38 @@ mod tests {
assert!(!is_valid_ipv4("not-an-ip")); assert!(!is_valid_ipv4("not-an-ip"));
} }
#[test]
fn test_reverse_ipv6() {
let ip: Ipv6Addr = "2001:0db8:0000:0000:0000:0000:0000:0001".parse().unwrap();
assert_eq!(
reverse_ipv6(ip),
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2"
);
}
#[test]
fn test_reverse_ipv6_loopback() {
let ip: Ipv6Addr = "::1".parse().unwrap();
assert_eq!(
reverse_ipv6(ip),
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0"
);
}
#[tokio::test]
async fn test_check_dnsbl_ipv6_runs() {
// Verify IPv6 actually goes through DNSBL queries (not skipped)
let resolver = hickory_resolver::TokioResolver::builder_tokio()
.map(|b| b.build())
.unwrap();
let ip: IpAddr = "::1".parse().unwrap();
let result = check_dnsbl(ip, DEFAULT_DNSBL_SERVERS, &resolver).await.unwrap();
// Loopback should not be listed on any DNSBL
assert_eq!(result.listed_count, 0);
// But total_checked should be > 0 — proving IPv6 was actually queried
assert_eq!(result.total_checked, DEFAULT_DNSBL_SERVERS.len());
}
#[test] #[test]
fn test_default_dnsbl_servers() { fn test_default_dnsbl_servers() {
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10); assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);

View File

@@ -9,7 +9,7 @@ pub mod spf;
pub mod verify; pub mod verify;
// Re-exports for convenience // Re-exports for convenience
pub use dkim::{dkim_dns_record_value, dkim_outputs_to_results, sign_dkim, verify_dkim, DkimVerificationResult}; pub use dkim::{dkim_dns_record_value, dkim_dns_record_value_typed, dkim_outputs_to_results, sign_dkim, sign_dkim_auto, sign_dkim_ed25519, verify_dkim, DkimVerificationResult};
pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult}; pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult};
pub use verify::{verify_email_security, EmailSecurityResult}; pub use verify::{verify_email_security, EmailSecurityResult};
pub use error::{Result, SecurityError}; pub use error::{Result, SecurityError};

View File

@@ -23,3 +23,6 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "logg
rustls-pemfile = "2" rustls-pemfile = "2"
mailparse.workspace = true mailparse.workspace = true
webpki-roots = "0.26" webpki-roots = "0.26"
sha2.workspace = true
hmac.workspace = true
pbkdf2.workspace = true

View File

@@ -37,6 +37,12 @@ pub struct SmtpClientConfig {
/// Maximum connections per pool. Default: 10. /// Maximum connections per pool. Default: 10.
#[serde(default = "default_max_pool_connections")] #[serde(default = "default_max_pool_connections")]
pub max_pool_connections: usize, pub max_pool_connections: usize,
/// Accept invalid TLS certificates (expired, self-signed, wrong hostname).
/// Standard for MTA-to-MTA opportunistic TLS per RFC 7435.
/// Default: false.
#[serde(default)]
pub tls_opportunistic: bool,
} }
/// Authentication configuration. /// Authentication configuration.
@@ -60,8 +66,15 @@ pub struct DkimSignConfig {
pub domain: String, pub domain: String,
/// DKIM selector (e.g. "default" or "mta"). /// DKIM selector (e.g. "default" or "mta").
pub selector: String, pub selector: String,
/// PEM-encoded RSA private key. /// PEM-encoded private key (RSA or Ed25519 PKCS#8).
pub private_key: String, pub private_key: String,
/// Key type: "rsa" (default) or "ed25519".
#[serde(default = "default_key_type")]
pub key_type: String,
}
fn default_key_type() -> String {
"rsa".to_string()
} }
impl SmtpClientConfig { impl SmtpClientConfig {

View File

@@ -117,6 +117,7 @@ pub async fn connect_tls(
host: &str, host: &str,
port: u16, port: u16,
timeout_secs: u64, timeout_secs: u64,
tls_opportunistic: bool,
) -> Result<ClientSmtpStream, SmtpClientError> { ) -> Result<ClientSmtpStream, SmtpClientError> {
debug!("Connecting to {}:{} (implicit TLS)", host, port); debug!("Connecting to {}:{} (implicit TLS)", host, port);
let addr = format!("{host}:{port}"); let addr = format!("{host}:{port}");
@@ -130,7 +131,7 @@ pub async fn connect_tls(
message: format!("Failed to connect to {addr}: {e}"), message: format!("Failed to connect to {addr}: {e}"),
})?; })?;
let tls_stream = perform_tls_handshake(tcp_stream, host).await?; let tls_stream = perform_tls_handshake(tcp_stream, host, tls_opportunistic).await?;
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream))) Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
} }
@@ -138,24 +139,77 @@ pub async fn connect_tls(
pub async fn upgrade_to_tls( pub async fn upgrade_to_tls(
stream: ClientSmtpStream, stream: ClientSmtpStream,
hostname: &str, hostname: &str,
tls_opportunistic: bool,
) -> Result<ClientSmtpStream, SmtpClientError> { ) -> Result<ClientSmtpStream, SmtpClientError> {
debug!("Upgrading connection to TLS (STARTTLS) for {}", hostname); debug!("Upgrading connection to TLS (STARTTLS) for {}", hostname);
let tcp_stream = stream.into_tcp_stream()?; let tcp_stream = stream.into_tcp_stream()?;
let tls_stream = perform_tls_handshake(tcp_stream, hostname).await?; let tls_stream = perform_tls_handshake(tcp_stream, hostname, tls_opportunistic).await?;
Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream))) Ok(ClientSmtpStream::Tls(BufReader::new(tls_stream)))
} }
/// A TLS certificate verifier that accepts any certificate.
/// Used for MTA-to-MTA opportunistic TLS per RFC 7435.
#[derive(Debug)]
struct OpportunisticVerifier;
impl rustls::client::danger::ServerCertVerifier for OpportunisticVerifier {
fn verify_server_cert(
&self,
_end_entity: &rustls_pki_types::CertificateDer<'_>,
_intermediates: &[rustls_pki_types::CertificateDer<'_>],
_server_name: &rustls_pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls_pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
/// Perform the TLS handshake on a TCP stream using webpki-roots. /// Perform the TLS handshake on a TCP stream using webpki-roots.
/// When `tls_opportunistic` is true, certificate verification is skipped
/// (standard for MTA-to-MTA delivery per RFC 7435).
async fn perform_tls_handshake( async fn perform_tls_handshake(
tcp_stream: TcpStream, tcp_stream: TcpStream,
hostname: &str, hostname: &str,
tls_opportunistic: bool,
) -> Result<TlsStream<TcpStream>, SmtpClientError> { ) -> Result<TlsStream<TcpStream>, SmtpClientError> {
let tls_config = if tls_opportunistic {
debug!("Using opportunistic TLS (no cert verification) for {}", hostname);
rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(OpportunisticVerifier))
.with_no_client_auth()
} else {
let mut root_store = rustls::RootCertStore::empty(); let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
rustls::ClientConfig::builder()
let tls_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store) .with_root_certificates(root_store)
.with_no_client_auth(); .with_no_client_auth()
};
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config)); 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| { let server_name = rustls_pki_types::ServerName::try_from(hostname.to_string()).map_err(|e| {
@@ -190,7 +244,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_connect_tls_refused() { async fn test_connect_tls_refused() {
let result = connect_tls("127.0.0.1", 19998, 2).await; let result = connect_tls("127.0.0.1", 19998, 2, false).await;
assert!(result.is_err()); assert!(result.is_err());
} }

View File

@@ -116,6 +116,7 @@ impl ConnectionPool {
&self.config.host, &self.config.host,
self.config.port, self.config.port,
self.config.connection_timeout_secs, self.config.connection_timeout_secs,
self.config.tls_opportunistic,
) )
.await? .await?
} else { } else {
@@ -139,7 +140,7 @@ impl ConnectionPool {
if !self.config.secure && capabilities.starttls { if !self.config.secure && capabilities.starttls {
protocol::send_starttls(&mut stream, self.config.socket_timeout_secs).await?; protocol::send_starttls(&mut stream, self.config.socket_timeout_secs).await?;
stream = stream =
super::connection::upgrade_to_tls(stream, &self.config.host).await?; super::connection::upgrade_to_tls(stream, &self.config.host, self.config.tls_opportunistic).await?;
// Re-EHLO after STARTTLS — use updated capabilities for auth // Re-EHLO after STARTTLS — use updated capabilities for auth
capabilities = protocol::send_ehlo( capabilities = protocol::send_ehlo(
@@ -244,9 +245,10 @@ impl SmtpClientManager {
protocol::send_rset(&mut conn.stream, config.socket_timeout_secs).await?; protocol::send_rset(&mut conn.stream, config.socket_timeout_secs).await?;
} }
// Perform the SMTP transaction // Perform the SMTP transaction (use pipelining if server supports it)
let pipelining = conn.capabilities.pipelining;
let result = let result =
Self::perform_send(&mut conn.stream, sender, recipients, message, config).await; Self::perform_send(&mut conn.stream, sender, recipients, message, config, pipelining).await;
// Re-acquire the pool lock and release the connection // Re-acquire the pool lock and release the connection
let mut pool = pool_arc.lock().await; let mut pool = pool_arc.lock().await;
@@ -268,13 +270,20 @@ impl SmtpClientManager {
recipients: &[String], recipients: &[String],
message: &[u8], message: &[u8],
config: &SmtpClientConfig, config: &SmtpClientConfig,
pipelining: bool,
) -> Result<SmtpSendResult, SmtpClientError> { ) -> Result<SmtpSendResult, SmtpClientError> {
let timeout_secs = config.socket_timeout_secs; let timeout_secs = config.socket_timeout_secs;
// MAIL FROM let (accepted, rejected) = if pipelining {
// Use pipelined envelope: MAIL FROM + all RCPT TO in one batch
let (_mail_ok, acc, rej) = protocol::send_pipelined_envelope(
stream, sender, recipients, timeout_secs,
).await?;
(acc, rej)
} else {
// Sequential: MAIL FROM, then each RCPT TO
protocol::send_mail_from(stream, sender, timeout_secs).await?; protocol::send_mail_from(stream, sender, timeout_secs).await?;
// RCPT TO for each recipient
let mut accepted = Vec::new(); let mut accepted = Vec::new();
let mut rejected = Vec::new(); let mut rejected = Vec::new();
@@ -292,6 +301,8 @@ impl SmtpClientManager {
} }
} }
} }
(accepted, rejected)
};
// If no recipients were accepted, fail // If no recipients were accepted, fail
if accepted.is_empty() { if accepted.is_empty() {
@@ -339,6 +350,7 @@ impl SmtpClientManager {
&config.host, &config.host,
config.port, config.port,
config.connection_timeout_secs, config.connection_timeout_secs,
config.tls_opportunistic,
) )
.await? .await?
} else { } else {

View File

@@ -318,6 +318,54 @@ pub async fn send_rcpt_to(
Ok(resp) Ok(resp)
} }
/// Send MAIL FROM + RCPT TO commands in a single pipelined batch.
///
/// Writes all envelope commands at once, then reads responses in order.
/// Returns `(mail_from_ok, accepted_recipients, rejected_recipients)`.
pub async fn send_pipelined_envelope(
stream: &mut ClientSmtpStream,
sender: &str,
recipients: &[String],
timeout_secs: u64,
) -> Result<(bool, Vec<String>, Vec<String>), SmtpClientError> {
// Build the full pipelined command batch
let mut batch = format!("MAIL FROM:<{sender}>\r\n");
for rcpt in recipients {
batch.push_str(&format!("RCPT TO:<{rcpt}>\r\n"));
}
// Send all commands at once
debug!("SMTP C (pipelined): MAIL FROM + {} RCPT TO", recipients.len());
stream.write_all(batch.as_bytes()).await?;
stream.flush().await?;
// Read MAIL FROM response
let mail_resp = read_response(stream, timeout_secs).await?;
if !mail_resp.is_success() {
return Err(mail_resp.to_error());
}
// Read RCPT TO responses
let mut accepted = Vec::new();
let mut rejected = Vec::new();
for rcpt in recipients {
match read_response(stream, timeout_secs).await {
Ok(resp) => {
if resp.is_success() {
accepted.push(rcpt.clone());
} else {
rejected.push(rcpt.clone());
}
}
Err(_) => {
rejected.push(rcpt.clone());
}
}
}
Ok((true, accepted, rejected))
}
/// Send DATA command, followed by the message body with dot-stuffing. /// Send DATA command, followed by the message body with dot-stuffing.
pub async fn send_data( pub async fn send_data(
stream: &mut ClientSmtpStream, stream: &mut ClientSmtpStream,

View File

@@ -50,6 +50,7 @@ pub enum SmtpCommand {
pub enum AuthMechanism { pub enum AuthMechanism {
Plain, Plain,
Login, Login,
ScramSha256,
} }
/// Errors that can occur during command parsing. /// Errors that can occur during command parsing.
@@ -218,6 +219,7 @@ fn parse_auth(rest: &str) -> Result<SmtpCommand, ParseError> {
let mechanism = match mech_str.to_ascii_uppercase().as_str() { let mechanism = match mech_str.to_ascii_uppercase().as_str() {
"PLAIN" => AuthMechanism::Plain, "PLAIN" => AuthMechanism::Plain,
"LOGIN" => AuthMechanism::Login, "LOGIN" => AuthMechanism::Login,
"SCRAM-SHA-256" => AuthMechanism::ScramSha256,
other => { other => {
return Err(ParseError::SyntaxError(format!( return Err(ParseError::SyntaxError(format!(
"unsupported AUTH mechanism: {other}" "unsupported AUTH mechanism: {other}"

View File

@@ -2,6 +2,17 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Per-domain TLS certificate for SNI-based cert selection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsDomainCert {
/// Domain names this certificate covers (matched against SNI hostname).
pub domains: Vec<String>,
/// Certificate chain in PEM format.
pub cert_pem: String,
/// Private key in PEM format.
pub key_pem: String,
}
/// Configuration for an SMTP server instance. /// Configuration for an SMTP server instance.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmtpServerConfig { pub struct SmtpServerConfig {
@@ -11,10 +22,13 @@ pub struct SmtpServerConfig {
pub ports: Vec<u16>, pub ports: Vec<u16>,
/// Port for implicit TLS (e.g. 465). None = no implicit TLS port. /// Port for implicit TLS (e.g. 465). None = no implicit TLS port.
pub secure_port: Option<u16>, pub secure_port: Option<u16>,
/// TLS certificate chain in PEM format. /// TLS certificate chain in PEM format (default cert).
pub tls_cert_pem: Option<String>, pub tls_cert_pem: Option<String>,
/// TLS private key in PEM format. /// TLS private key in PEM format (default key).
pub tls_key_pem: Option<String>, pub tls_key_pem: Option<String>,
/// Additional per-domain TLS certificates for SNI-based selection.
#[serde(default)]
pub additional_tls_certs: Vec<TlsDomainCert>,
/// Maximum message size in bytes. /// Maximum message size in bytes.
pub max_message_size: u64, pub max_message_size: u64,
/// Maximum number of concurrent connections. /// Maximum number of concurrent connections.
@@ -43,6 +57,7 @@ impl Default for SmtpServerConfig {
secure_port: None, secure_port: None,
tls_cert_pem: None, tls_cert_pem: None,
tls_key_pem: None, tls_key_pem: None,
additional_tls_certs: Vec::new(),
max_message_size: 10 * 1024 * 1024, // 10 MB max_message_size: 10 * 1024 * 1024, // 10 MB
max_connections: 100, max_connections: 100,
max_recipients: 100, max_recipients: 100,

View File

@@ -9,6 +9,7 @@ use crate::config::SmtpServerConfig;
use crate::data::{DataAccumulator, DataAction}; use crate::data::{DataAccumulator, DataAction};
use crate::rate_limiter::RateLimiter; use crate::rate_limiter::RateLimiter;
use crate::response::{build_capabilities, SmtpResponse}; use crate::response::{build_capabilities, SmtpResponse};
use crate::scram::{ScramCredentials, ScramServer};
use crate::session::{AuthState, SmtpSession}; use crate::session::{AuthState, SmtpSession};
use crate::validation; use crate::validation;
@@ -52,6 +53,13 @@ pub enum ConnectionEvent {
password: String, password: String,
remote_addr: String, remote_addr: String,
}, },
/// A SCRAM credential request — Rust needs stored credentials from TS.
ScramCredentialRequest {
correlation_id: String,
session_id: String,
username: String,
remote_addr: String,
},
} }
/// How email data is transported from Rust to TS. /// How email data is transported from Rust to TS.
@@ -81,6 +89,16 @@ pub struct AuthResult {
pub message: Option<String>, pub message: Option<String>,
} }
/// Result of TS returning SCRAM credentials for a user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScramCredentialResult {
pub found: bool,
pub salt: Option<Vec<u8>>,
pub iterations: Option<u32>,
pub stored_key: Option<Vec<u8>>,
pub server_key: Option<Vec<u8>>,
}
/// Abstraction over plain and TLS streams. /// Abstraction over plain and TLS streams.
pub enum SmtpStream { pub enum SmtpStream {
Plain(BufReader<TcpStream>), Plain(BufReader<TcpStream>),
@@ -133,6 +151,14 @@ impl SmtpStream {
} }
} }
/// Check if the internal buffer has unread data (pipelined commands).
pub fn has_buffered_data(&self) -> bool {
match self {
SmtpStream::Plain(reader) => !reader.buffer().is_empty(),
SmtpStream::Tls(reader) => !reader.buffer().is_empty(),
}
}
/// Unwrap to get the raw TcpStream for STARTTLS upgrade. /// Unwrap to get the raw TcpStream for STARTTLS upgrade.
/// Only works on Plain streams. /// Only works on Plain streams.
pub fn into_tcp_stream(self) -> Option<TcpStream> { pub fn into_tcp_stream(self) -> Option<TcpStream> {
@@ -212,7 +238,7 @@ pub async fn handle_connection(
break; break;
} }
Ok(Ok(_)) => { Ok(Ok(_)) => {
// Process command // Process the first command
let response = process_line( let response = process_line(
&line, &line,
&mut session, &mut session,
@@ -227,21 +253,92 @@ pub async fn handle_connection(
) )
.await; .await;
// Check for pipelined commands in the buffer.
// Collect pipelinable responses into a batch for single write.
let mut response_batch: Vec<u8> = Vec::new();
let mut should_break = false;
let mut starttls_signal = false;
match response { match response {
LineResult::Response(resp) => { LineResult::Response(resp) => {
if stream.write_all(&resp.to_bytes()).await.is_err() { response_batch.extend_from_slice(&resp.to_bytes());
}
LineResult::Quit(resp) => {
let _ = stream.write_all(&resp.to_bytes()).await;
let _ = stream.flush().await;
should_break = true;
}
LineResult::StartTlsSignal => {
starttls_signal = true;
}
LineResult::NoResponse => {}
LineResult::Disconnect => {
should_break = true;
}
}
if should_break {
break;
}
// Process additional pipelined commands from the buffer
if !starttls_signal {
while stream.has_buffered_data() {
let mut next_line = String::new();
match stream.read_line(&mut next_line, 4096).await {
Ok(0) | Err(_) => break,
Ok(_) => {
let next_response = process_line(
&next_line,
&mut session,
&mut stream,
&config,
&rate_limiter,
&event_tx,
callback_register.as_ref(),
&tls_acceptor,
&authenticator,
&resolver,
)
.await;
match next_response {
LineResult::Response(resp) => {
response_batch.extend_from_slice(&resp.to_bytes());
}
LineResult::Quit(resp) => {
response_batch.extend_from_slice(&resp.to_bytes());
should_break = true;
break;
}
LineResult::StartTlsSignal | LineResult::Disconnect => {
// Non-pipelinable: flush batch and handle
starttls_signal = matches!(next_response, LineResult::StartTlsSignal);
should_break = matches!(next_response, LineResult::Disconnect);
break;
}
LineResult::NoResponse => {}
}
}
}
}
}
// Flush the accumulated response batch in one write
if !response_batch.is_empty() {
if stream.write_all(&response_batch).await.is_err() {
break; break;
} }
if stream.flush().await.is_err() { if stream.flush().await.is_err() {
break; break;
} }
} }
LineResult::Quit(resp) => {
let _ = stream.write_all(&resp.to_bytes()).await; if should_break {
let _ = stream.flush().await;
break; break;
} }
LineResult::StartTlsSignal => {
if starttls_signal {
// Send 220 Ready response // Send 220 Ready response
let resp = SmtpResponse::new(220, "Ready to start TLS"); let resp = SmtpResponse::new(220, "Ready to start TLS");
if stream.write_all(&resp.to_bytes()).await.is_err() { if stream.write_all(&resp.to_bytes()).await.is_err() {
@@ -257,7 +354,6 @@ pub async fn handle_connection(
Ok(tls_stream) => { Ok(tls_stream) => {
stream = SmtpStream::Tls(BufReader::new(tls_stream)); stream = SmtpStream::Tls(BufReader::new(tls_stream));
session.secure = true; session.secure = true;
// Client must re-EHLO after STARTTLS
session.state = crate::state::SmtpState::Connected; session.state = crate::state::SmtpState::Connected;
session.client_hostname = None; session.client_hostname = None;
session.esmtp = false; session.esmtp = false;
@@ -274,12 +370,6 @@ pub async fn handle_connection(
break; break;
} }
} else { } else {
// Already TLS — shouldn't happen
break;
}
}
LineResult::NoResponse => {}
LineResult::Disconnect => {
break; break;
} }
} }
@@ -322,6 +412,12 @@ pub trait CallbackRegistry: Send + Sync {
&self, &self,
correlation_id: &str, correlation_id: &str,
) -> oneshot::Receiver<AuthResult>; ) -> oneshot::Receiver<AuthResult>;
/// Register a callback for SCRAM credential lookup and return a receiver.
fn register_scram_callback(
&self,
correlation_id: &str,
) -> oneshot::Receiver<ScramCredentialResult>;
} }
/// Process a single input line from the client. /// Process a single input line from the client.
@@ -406,6 +502,18 @@ async fn process_line(
mechanism, mechanism,
initial_response, initial_response,
} => { } => {
if matches!(mechanism, AuthMechanism::ScramSha256) {
handle_auth_scram(
initial_response,
session,
stream,
config,
rate_limiter,
event_tx,
callback_registry,
)
.await
} else {
handle_auth( handle_auth(
mechanism, mechanism,
initial_response, initial_response,
@@ -417,6 +525,7 @@ async fn process_line(
) )
.await .await
} }
}
SmtpCommand::Help(_) => { SmtpCommand::Help(_) => {
LineResult::Response(SmtpResponse::new( LineResult::Response(SmtpResponse::new(
@@ -832,6 +941,217 @@ async fn handle_auth(
)) ))
} }
} }
AuthMechanism::ScramSha256 => {
// SCRAM is handled separately in process_line; this should not be reached.
LineResult::Response(SmtpResponse::not_implemented())
}
}
}
/// Handle AUTH SCRAM-SHA-256 — full exchange in a single async function.
///
/// SCRAM is a multi-step challenge-response protocol:
/// 1. Client sends client-first-message (in initial_response or after 334)
/// 2. Server requests SCRAM credentials from TS
/// 3. Server sends server-first-message (334 challenge)
/// 4. Client sends client-final-message (proof)
/// 5. Server verifies proof and responds with 235 or 535
async fn handle_auth_scram(
initial_response: Option<String>,
session: &mut SmtpSession,
stream: &mut SmtpStream,
config: &SmtpServerConfig,
rate_limiter: &RateLimiter,
event_tx: &mpsc::Sender<ConnectionEvent>,
callback_registry: &dyn CallbackRegistry,
) -> LineResult {
if !config.auth_enabled {
return LineResult::Response(SmtpResponse::not_implemented());
}
if session.is_authenticated() {
return LineResult::Response(SmtpResponse::bad_sequence("Already authenticated"));
}
if !session.state.can_auth() {
return LineResult::Response(SmtpResponse::bad_sequence("Send EHLO first"));
}
// Step 1: Get client-first-message
let client_first_b64 = match initial_response {
Some(s) if !s.is_empty() => s,
_ => {
// No initial response — send empty 334 challenge
let resp = SmtpResponse::auth_challenge("");
if stream.write_all(&resp.to_bytes()).await.is_err() {
return LineResult::Disconnect;
}
if stream.flush().await.is_err() {
return LineResult::Disconnect;
}
// Read client-first-message
let mut line = String::new();
let socket_timeout = Duration::from_secs(config.socket_timeout_secs);
match timeout(socket_timeout, stream.read_line(&mut line, 4096)).await {
Err(_) | Ok(Err(_)) | Ok(Ok(0)) => return LineResult::Disconnect,
Ok(Ok(_)) => {}
}
let trimmed = line.trim().to_string();
if trimmed == "*" {
return LineResult::Response(SmtpResponse::new(501, "Authentication cancelled"));
}
trimmed
}
};
// Decode base64 client-first-message
let client_first_bytes = match BASE64.decode(client_first_b64.as_bytes()) {
Ok(b) => b,
Err(_) => {
return LineResult::Response(SmtpResponse::param_error("Invalid base64 encoding"));
}
};
let client_first = match String::from_utf8(client_first_bytes) {
Ok(s) => s,
Err(_) => {
return LineResult::Response(SmtpResponse::param_error("Invalid UTF-8 in SCRAM message"));
}
};
// Parse client-first-message
let mut scram = match ScramServer::from_client_first(&client_first) {
Ok(s) => s,
Err(e) => {
debug!(error = %e, "SCRAM client-first-message parse error");
return LineResult::Response(SmtpResponse::param_error(
"Invalid SCRAM client-first-message",
));
}
};
// Step 2: Request SCRAM credentials from TS
let correlation_id = uuid::Uuid::new_v4().to_string();
let rx = callback_registry.register_scram_callback(&correlation_id);
let event = ConnectionEvent::ScramCredentialRequest {
correlation_id: correlation_id.clone(),
session_id: session.id.clone(),
username: scram.username.clone(),
remote_addr: session.remote_addr.clone(),
};
if event_tx.send(event).await.is_err() {
return LineResult::Response(SmtpResponse::local_error("Internal processing error"));
}
// Wait for credentials from TS
let cred_timeout = Duration::from_secs(5);
let cred_result = match timeout(cred_timeout, rx).await {
Ok(Ok(result)) => result,
Ok(Err(_)) => {
warn!(correlation_id = %correlation_id, "SCRAM credential callback dropped");
return LineResult::Response(SmtpResponse::local_error("Internal processing error"));
}
Err(_) => {
warn!(correlation_id = %correlation_id, "SCRAM credential request timed out");
return LineResult::Response(SmtpResponse::local_error("Internal processing error"));
}
};
if !cred_result.found {
// User not found — fail auth (don't reveal that user doesn't exist)
session.auth_state = AuthState::None;
let exceeded = session.record_auth_failure(config.max_auth_failures);
if exceeded {
return LineResult::Quit(SmtpResponse::service_unavailable(
&config.hostname,
"Too many authentication failures",
));
}
return LineResult::Response(SmtpResponse::auth_failed());
}
let creds = ScramCredentials {
salt: cred_result.salt.unwrap_or_default(),
iterations: cred_result.iterations.unwrap_or(4096),
stored_key: cred_result.stored_key.unwrap_or_default(),
server_key: cred_result.server_key.unwrap_or_default(),
};
// Step 3: Generate and send server-first-message
let server_first = scram.server_first_message(creds);
let server_first_b64 = BASE64.encode(server_first.as_bytes());
let challenge = SmtpResponse::auth_challenge(&server_first_b64);
if stream.write_all(&challenge.to_bytes()).await.is_err() {
return LineResult::Disconnect;
}
if stream.flush().await.is_err() {
return LineResult::Disconnect;
}
// Step 4: Read client-final-message
let mut client_final_line = String::new();
let socket_timeout = Duration::from_secs(config.socket_timeout_secs);
match timeout(socket_timeout, stream.read_line(&mut client_final_line, 4096)).await {
Err(_) | Ok(Err(_)) | Ok(Ok(0)) => return LineResult::Disconnect,
Ok(Ok(_)) => {}
}
let client_final_b64 = client_final_line.trim();
// Cancel if *
if client_final_b64 == "*" {
session.auth_state = AuthState::None;
return LineResult::Response(SmtpResponse::new(501, "Authentication cancelled"));
}
// Decode base64 client-final-message
let client_final_bytes = match BASE64.decode(client_final_b64.as_bytes()) {
Ok(b) => b,
Err(_) => {
session.auth_state = AuthState::None;
return LineResult::Response(SmtpResponse::param_error("Invalid base64 encoding"));
}
};
let client_final = match String::from_utf8(client_final_bytes) {
Ok(s) => s,
Err(_) => {
session.auth_state = AuthState::None;
return LineResult::Response(SmtpResponse::param_error("Invalid UTF-8 in SCRAM message"));
}
};
// Step 5: Verify proof
match scram.process_client_final(&client_final) {
Ok(server_final) => {
let server_final_b64 = BASE64.encode(server_final.as_bytes());
session.auth_state = AuthState::Authenticated {
username: scram.username.clone(),
};
LineResult::Response(SmtpResponse::new(
235,
format!("2.7.0 Authentication successful {}", server_final_b64),
))
}
Err(e) => {
debug!(error = %e, "SCRAM proof verification failed");
session.auth_state = AuthState::None;
let exceeded = session.record_auth_failure(config.max_auth_failures);
if exceeded {
if !rate_limiter.check_auth_failure(&session.remote_addr) {
return LineResult::Quit(SmtpResponse::service_unavailable(
&config.hostname,
"Too many authentication failures",
));
}
return LineResult::Quit(SmtpResponse::service_unavailable(
&config.hostname,
"Too many authentication failures",
));
}
LineResult::Response(SmtpResponse::auth_failed())
}
} }
} }

View File

@@ -19,6 +19,7 @@ pub mod connection;
pub mod data; pub mod data;
pub mod rate_limiter; pub mod rate_limiter;
pub mod response; pub mod response;
pub mod scram;
pub mod server; pub mod server;
pub mod session; pub mod session;
pub mod state; pub mod state;

View File

@@ -196,7 +196,7 @@ pub fn build_capabilities(
caps.push("STARTTLS".to_string()); caps.push("STARTTLS".to_string());
} }
if auth_available { if auth_available {
caps.push("AUTH PLAIN LOGIN".to_string()); caps.push("AUTH PLAIN LOGIN SCRAM-SHA-256".to_string());
} }
caps caps
} }
@@ -253,7 +253,7 @@ mod tests {
let caps = build_capabilities(10485760, true, false, true); let caps = build_capabilities(10485760, true, false, true);
assert!(caps.contains(&"SIZE 10485760".to_string())); assert!(caps.contains(&"SIZE 10485760".to_string()));
assert!(caps.contains(&"STARTTLS".to_string())); assert!(caps.contains(&"STARTTLS".to_string()));
assert!(caps.contains(&"AUTH PLAIN LOGIN".to_string())); assert!(caps.contains(&"AUTH PLAIN LOGIN SCRAM-SHA-256".to_string()));
assert!(caps.contains(&"PIPELINING".to_string())); assert!(caps.contains(&"PIPELINING".to_string()));
} }
@@ -262,7 +262,7 @@ mod tests {
// When already secure, STARTTLS should NOT be advertised // When already secure, STARTTLS should NOT be advertised
let caps = build_capabilities(10485760, true, true, false); let caps = build_capabilities(10485760, true, true, false);
assert!(!caps.contains(&"STARTTLS".to_string())); assert!(!caps.contains(&"STARTTLS".to_string()));
assert!(!caps.contains(&"AUTH PLAIN LOGIN".to_string())); assert!(!caps.contains(&"AUTH PLAIN LOGIN SCRAM-SHA-256".to_string()));
} }
#[test] #[test]

View File

@@ -0,0 +1,342 @@
//! SCRAM-SHA-256 server-side implementation (RFC 5802 + RFC 7677).
//!
//! Implements the server side of the SCRAM-SHA-256 SASL mechanism,
//! a challenge-response protocol that avoids transmitting cleartext passwords.
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
/// Pre-computed SCRAM credentials for a user (derived from password).
#[derive(Debug, Clone)]
pub struct ScramCredentials {
pub salt: Vec<u8>,
pub iterations: u32,
pub stored_key: Vec<u8>,
pub server_key: Vec<u8>,
}
/// Server-side SCRAM state machine.
pub struct ScramServer {
/// Username extracted from client-first-message.
pub username: String,
/// Full combined nonce (client + server).
combined_nonce: String,
/// Server nonce portion (used in tests for verification).
#[allow(dead_code)]
server_nonce: String,
/// Stored credentials (set after TS responds).
credentials: Option<ScramCredentials>,
/// The server-first-message (for auth message construction).
server_first: String,
/// The client-first-message-bare (for auth message construction).
client_first_bare: String,
}
impl ScramServer {
/// Process the client-first-message.
///
/// Parses the client nonce and username, generates a server nonce,
/// and returns a partial state that needs credentials to produce the
/// server-first-message.
pub fn from_client_first(client_first: &str) -> Result<Self, String> {
// client-first-message = gs2-header client-first-message-bare
// gs2-header = "n,," (no channel binding)
// client-first-message-bare = "n=username,r=nonce"
let bare = if let Some(rest) = client_first.strip_prefix("n,,") {
rest
} else if let Some(rest) = client_first.strip_prefix("y,,") {
rest
} else {
return Err("Invalid SCRAM gs2-header".into());
};
let mut username = String::new();
let mut client_nonce = String::new();
for part in bare.split(',') {
if let Some(val) = part.strip_prefix("n=") {
username = val.to_string();
} else if let Some(val) = part.strip_prefix("r=") {
client_nonce = val.to_string();
}
}
if username.is_empty() || client_nonce.is_empty() {
return Err("Missing username or nonce in client-first-message".into());
}
// Generate server nonce
let server_nonce: String = (0..24)
.map(|_| {
let idx = (rand_byte() as usize) % 62;
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[idx] as char
})
.collect();
let combined_nonce = format!("{}{}", client_nonce, server_nonce);
Ok(ScramServer {
username,
combined_nonce,
server_nonce,
credentials: None,
server_first: String::new(),
client_first_bare: bare.to_string(),
})
}
/// Set the credentials and produce the server-first-message.
pub fn server_first_message(&mut self, creds: ScramCredentials) -> String {
let salt_b64 = BASE64.encode(&creds.salt);
let server_first = format!(
"r={},s={},i={}",
self.combined_nonce, salt_b64, creds.iterations
);
self.server_first = server_first.clone();
self.credentials = Some(creds);
server_first
}
/// Process the client-final-message and verify the proof.
///
/// Returns the server-final-message (containing ServerSignature) on success,
/// or an error string on failure.
pub fn process_client_final(&mut self, client_final: &str) -> Result<String, String> {
let creds = self.credentials.as_ref().ok_or("No credentials set")?;
// Parse client-final-message
// Format: c=biws,r=<combined_nonce>,p=<client_proof>
let mut channel_binding = String::new();
let mut nonce = String::new();
let mut proof_b64 = String::new();
for part in client_final.split(',') {
if let Some(val) = part.strip_prefix("c=") {
channel_binding = val.to_string();
} else if let Some(val) = part.strip_prefix("r=") {
nonce = val.to_string();
} else if let Some(val) = part.strip_prefix("p=") {
proof_b64 = val.to_string();
}
}
// Verify nonce matches
if nonce != self.combined_nonce {
return Err("Nonce mismatch".into());
}
// Build the client-final-message-without-proof
let client_final_without_proof = format!("c={},r={}", channel_binding, nonce);
// Complete the auth message
let auth_message = format!(
"{},{},{}",
self.client_first_bare, self.server_first, client_final_without_proof
);
// Verify client proof
let client_proof = BASE64.decode(proof_b64.as_bytes())
.map_err(|_| "Invalid base64 in client proof")?;
// ClientSignature = HMAC(StoredKey, AuthMessage)
let client_signature = hmac_sha256(&creds.stored_key, auth_message.as_bytes());
// ClientKey = ClientProof XOR ClientSignature
if client_proof.len() != client_signature.len() {
return Err("Client proof length mismatch".into());
}
let client_key: Vec<u8> = client_proof
.iter()
.zip(client_signature.iter())
.map(|(a, b)| a ^ b)
.collect();
// StoredKey = H(ClientKey)
let computed_stored_key = sha256(&client_key);
// Verify: computed StoredKey must match the stored StoredKey
if computed_stored_key != creds.stored_key {
return Err("Authentication failed".into());
}
// Generate ServerSignature for mutual authentication
let server_signature = hmac_sha256(&creds.server_key, auth_message.as_bytes());
let server_sig_b64 = BASE64.encode(&server_signature);
Ok(format!("v={}", server_sig_b64))
}
}
/// Compute SCRAM credentials from a plaintext password (for TS to pre-compute).
pub fn compute_scram_credentials(password: &str, salt: &[u8], iterations: u32) -> ScramCredentials {
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations)
let mut salted_password = [0u8; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(
password.as_bytes(),
salt,
iterations,
&mut salted_password,
);
// ClientKey = HMAC(SaltedPassword, "Client Key")
let client_key = hmac_sha256(&salted_password, b"Client Key");
// StoredKey = H(ClientKey)
let stored_key = sha256(&client_key);
// ServerKey = HMAC(SaltedPassword, "Server Key")
let server_key = hmac_sha256(&salted_password, b"Server Key");
ScramCredentials {
salt: salt.to_vec(),
iterations,
stored_key,
server_key,
}
}
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
fn sha256(data: &[u8]) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().to_vec()
}
/// Simple random byte using system randomness.
fn rand_byte() -> u8 {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let state = RandomState::new();
let mut hasher = state.build_hasher();
hasher.write_u64(std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64);
hasher.finish() as u8
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scram_full_exchange() {
let password = "pencil";
let salt = b"test-salt-1234";
let iterations = 4096;
// Pre-compute server-side credentials from password
let creds = compute_scram_credentials(password, salt, iterations);
// 1. Client sends client-first-message
let client_first = "n,,n=user,r=rOprNGfwEbeRWgbNEkqO";
let mut server = ScramServer::from_client_first(client_first).unwrap();
assert_eq!(server.username, "user");
// 2. Server responds with server-first-message
let server_first = server.server_first_message(creds.clone());
assert!(server_first.starts_with(&format!("r=rOprNGfwEbeRWgbNEkqO{}", server.server_nonce)));
assert!(server_first.contains("s="));
assert!(server_first.contains("i=4096"));
// 3. Client computes proof
// SaltedPassword
let mut salted_password = [0u8; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(
password.as_bytes(),
salt,
iterations,
&mut salted_password,
);
let client_key = hmac_sha256(&salted_password, b"Client Key");
let stored_key = sha256(&client_key);
let client_first_bare = "n=user,r=rOprNGfwEbeRWgbNEkqO";
let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce);
let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof);
let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
let client_proof: Vec<u8> = client_key
.iter()
.zip(client_signature.iter())
.map(|(a, b)| a ^ b)
.collect();
let proof_b64 = BASE64.encode(&client_proof);
let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64);
// 4. Server verifies proof
let result = server.process_client_final(&client_final);
assert!(result.is_ok(), "SCRAM verification failed: {:?}", result.err());
let server_final = result.unwrap();
assert!(server_final.starts_with("v="));
}
#[test]
fn test_scram_wrong_password() {
let password = "pencil";
let wrong_password = "wrong";
let salt = b"test-salt";
let iterations = 4096;
let creds = compute_scram_credentials(password, salt, iterations);
let client_first = "n,,n=user,r=clientnonce123";
let mut server = ScramServer::from_client_first(client_first).unwrap();
let server_first = server.server_first_message(creds);
// Client computes proof with wrong password
let mut salted_password = [0u8; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(
wrong_password.as_bytes(),
salt,
iterations,
&mut salted_password,
);
let client_key = hmac_sha256(&salted_password, b"Client Key");
let stored_key = sha256(&client_key);
let client_first_bare = "n=user,r=clientnonce123";
let client_final_without_proof = format!("c=biws,r={}", server.combined_nonce);
let auth_message = format!("{},{},{}", client_first_bare, server_first, client_final_without_proof);
let client_signature = hmac_sha256(&stored_key, auth_message.as_bytes());
let client_proof: Vec<u8> = client_key
.iter()
.zip(client_signature.iter())
.map(|(a, b)| a ^ b)
.collect();
let proof_b64 = BASE64.encode(&client_proof);
let client_final = format!("c=biws,r={},p={}", server.combined_nonce, proof_b64);
let result = server.process_client_final(&client_final);
assert!(result.is_err());
}
#[test]
fn test_compute_scram_credentials() {
let creds = compute_scram_credentials("password", b"salt", 4096);
assert_eq!(creds.salt, b"salt");
assert_eq!(creds.iterations, 4096);
assert_eq!(creds.stored_key.len(), 32);
assert_eq!(creds.server_key.len(), 32);
}
#[test]
fn test_invalid_client_first() {
assert!(ScramServer::from_client_first("invalid").is_err());
assert!(ScramServer::from_client_first("n,,").is_err());
}
}

View File

@@ -12,6 +12,7 @@ use crate::rate_limiter::{RateLimitConfig, RateLimiter};
use hickory_resolver::TokioResolver; use hickory_resolver::TokioResolver;
use mailer_security::MessageAuthenticator; use mailer_security::MessageAuthenticator;
use rustls_pki_types::{CertificateDer, PrivateKeyDer}; use rustls_pki_types::{CertificateDer, PrivateKeyDer};
use std::collections::HashMap;
use std::io::BufReader; use std::io::BufReader;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc; use std::sync::Arc;
@@ -263,6 +264,69 @@ async fn accept_loop(
} }
} }
/// SNI-based certificate resolver that selects the appropriate TLS certificate
/// based on the client's requested hostname.
struct SniCertResolver {
/// Domain -> certified key mapping.
certs: HashMap<String, Arc<rustls::sign::CertifiedKey>>,
/// Default certificate for non-matching SNI or missing SNI.
default: Arc<rustls::sign::CertifiedKey>,
}
impl std::fmt::Debug for SniCertResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SniCertResolver")
.field("domains", &self.certs.keys().collect::<Vec<_>>())
.finish()
}
}
impl rustls::server::ResolvesServerCert for SniCertResolver {
fn resolve(
&self,
client_hello: rustls::server::ClientHello<'_>,
) -> Option<Arc<rustls::sign::CertifiedKey>> {
if let Some(sni) = client_hello.server_name() {
let sni_lower = sni.to_lowercase();
if let Some(key) = self.certs.get(&sni_lower) {
return Some(key.clone());
}
}
Some(self.default.clone())
}
}
/// Parse a PEM cert+key pair into a `CertifiedKey`.
fn parse_certified_key(
cert_pem: &str,
key_pem: &str,
) -> Result<rustls::sign::CertifiedKey, Box<dyn std::error::Error + Send + Sync>> {
let certs: Vec<CertificateDer<'static>> = {
let mut reader = BufReader::new(cert_pem.as_bytes());
rustls_pemfile::certs(&mut reader).collect::<Result<Vec<_>, _>>()?
};
if certs.is_empty() {
return Err("No certificates found in PEM".into());
}
let key: PrivateKeyDer<'static> = {
let mut reader = BufReader::new(key_pem.as_bytes());
let mut keys = Vec::new();
for item in rustls_pemfile::read_all(&mut reader) {
match item? {
rustls_pemfile::Item::Pkcs8Key(key) => keys.push(PrivateKeyDer::Pkcs8(key)),
rustls_pemfile::Item::Pkcs1Key(key) => keys.push(PrivateKeyDer::Pkcs1(key)),
rustls_pemfile::Item::Sec1Key(key) => keys.push(PrivateKeyDer::Sec1(key)),
_ => {}
}
}
keys.into_iter().next().ok_or("No private key found in PEM")?
};
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key)?;
Ok(rustls::sign::CertifiedKey::new(certs, signing_key))
}
/// Build a TLS acceptor from PEM cert/key strings. /// Build a TLS acceptor from PEM cert/key strings.
fn build_tls_acceptor( fn build_tls_acceptor(
config: &SmtpServerConfig, config: &SmtpServerConfig,
@@ -311,9 +375,42 @@ fn build_tls_acceptor(
.ok_or("No private key found in PEM")? .ok_or("No private key found in PEM")?
}; };
let tls_config = rustls::ServerConfig::builder() // If additional TLS certs are configured, use SNI-based resolution
let tls_config = if config.additional_tls_certs.is_empty() {
rustls::ServerConfig::builder()
.with_no_client_auth() .with_no_client_auth()
.with_single_cert(certs, key)?; .with_single_cert(certs, key)?
} else {
// Build default certified key
let signing_key = rustls::crypto::ring::sign::any_supported_type(&key)?;
let default_ck = Arc::new(rustls::sign::CertifiedKey::new(certs, signing_key));
// Build per-domain certs
let mut domain_certs = HashMap::new();
for domain_cert in &config.additional_tls_certs {
match parse_certified_key(&domain_cert.cert_pem, &domain_cert.key_pem) {
Ok(ck) => {
let ck = Arc::new(ck);
for domain in &domain_cert.domains {
domain_certs.insert(domain.to_lowercase(), ck.clone());
}
info!("SNI cert loaded for domains: {:?}", domain_cert.domains);
}
Err(e) => {
warn!("Failed to load SNI cert for domains {:?}: {}", domain_cert.domains, e);
}
}
}
let resolver = SniCertResolver {
certs: domain_certs,
default: default_ck,
};
rustls::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(Arc::new(resolver))
};
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(tls_config))) Ok(tokio_rustls::TlsAcceptor::from(Arc::new(tls_config)))
} }

View File

@@ -1,10 +1,8 @@
import { tap, expect } from '@git.zone/tstest/tapbundle'; import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mail/security/classes.spfverifier.js'; import { SpfVerifier, SpfQualifier, SpfMechanismType } from '../ts/mail/security/classes.spfverifier.js';
import { DmarcVerifier, DmarcPolicy, DmarcAlignment } from '../ts/mail/security/classes.dmarcverifier.js';
import { Email } from '../ts/mail/core/classes.email.js';
/** /**
* Test email authentication systems: SPF and DMARC * Test email authentication systems: SPF parsing
*/ */
// SPF Verifier Tests // SPF Verifier Tests
@@ -41,153 +39,6 @@ tap.test('SPF Verifier - should parse SPF record', async () => {
expect(invalidParsed).toBeNull(); expect(invalidParsed).toBeNull();
}); });
// DMARC Verifier Tests
tap.test('DMARC Verifier - should parse DMARC record', async () => {
const dmarcVerifier = new DmarcVerifier();
// Test valid DMARC record parsing
const record = 'v=DMARC1; p=reject; sp=quarantine; pct=50; adkim=s; aspf=r; rua=mailto:dmarc@example.com';
const parsedRecord = dmarcVerifier.parseDmarcRecord(record);
expect(parsedRecord).toBeTruthy();
expect(parsedRecord.version).toEqual('DMARC1');
expect(parsedRecord.policy).toEqual(DmarcPolicy.REJECT);
expect(parsedRecord.subdomainPolicy).toEqual(DmarcPolicy.QUARANTINE);
expect(parsedRecord.pct).toEqual(50);
expect(parsedRecord.adkim).toEqual(DmarcAlignment.STRICT);
expect(parsedRecord.aspf).toEqual(DmarcAlignment.RELAXED);
expect(parsedRecord.reportUriAggregate).toContain('dmarc@example.com');
// Test invalid record
const invalidRecord = 'not-a-dmarc-record';
const invalidParsed = dmarcVerifier.parseDmarcRecord(invalidRecord);
expect(invalidParsed).toBeNull();
});
tap.test('DMARC Verifier - should verify DMARC alignment', async () => {
const dmarcVerifier = new DmarcVerifier();
// Test email domains with DMARC alignment
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.net',
subject: 'Test DMARC alignment',
text: 'This is a test email'
});
// Test when both SPF and DKIM pass with alignment
const dmarcResult = await dmarcVerifier.verify(
email,
{ domain: 'example.com', result: true }, // SPF - aligned and passed
{ domain: 'example.com', result: true } // DKIM - aligned and passed
);
expect(dmarcResult).toBeTruthy();
expect(dmarcResult.spfPassed).toEqual(true);
expect(dmarcResult.dkimPassed).toEqual(true);
expect(dmarcResult.spfDomainAligned).toEqual(true);
expect(dmarcResult.dkimDomainAligned).toEqual(true);
expect(dmarcResult.action).toEqual('pass');
// Test when neither SPF nor DKIM is aligned
const dmarcResult2 = await dmarcVerifier.verify(
email,
{ domain: 'differentdomain.com', result: true }, // SPF - passed but not aligned
{ domain: 'anotherdomain.com', result: true } // DKIM - passed but not aligned
);
// Without a DNS manager, no DMARC record will be found
expect(dmarcResult2).toBeTruthy();
expect(dmarcResult2.spfPassed).toEqual(true);
expect(dmarcResult2.dkimPassed).toEqual(true);
expect(dmarcResult2.spfDomainAligned).toEqual(false);
expect(dmarcResult2.dkimDomainAligned).toEqual(false);
// Without a DMARC record, the default action is 'pass'
expect(dmarcResult2.hasDmarc).toEqual(false);
expect(dmarcResult2.policyEvaluated).toEqual(DmarcPolicy.NONE);
expect(dmarcResult2.actualPolicy).toEqual(DmarcPolicy.NONE);
expect(dmarcResult2.action).toEqual('pass');
});
tap.test('DMARC Verifier - should apply policy correctly', async () => {
const dmarcVerifier = new DmarcVerifier();
// Create test email
const email = new Email({
from: 'sender@example.com',
to: 'recipient@example.net',
subject: 'Test DMARC policy application',
text: 'This is a test email'
});
// Test pass action
const passResult: any = {
hasDmarc: true,
spfDomainAligned: true,
dkimDomainAligned: true,
spfPassed: true,
dkimPassed: true,
policyEvaluated: DmarcPolicy.NONE,
actualPolicy: DmarcPolicy.NONE,
appliedPercentage: 100,
action: 'pass',
details: 'DMARC passed'
};
const passApplied = dmarcVerifier.applyPolicy(email, passResult);
expect(passApplied).toEqual(true);
expect(email.mightBeSpam).toEqual(false);
expect(email.headers['X-DMARC-Result']).toEqual('DMARC passed');
// Test quarantine action
const quarantineResult: any = {
hasDmarc: true,
spfDomainAligned: false,
dkimDomainAligned: false,
spfPassed: false,
dkimPassed: false,
policyEvaluated: DmarcPolicy.QUARANTINE,
actualPolicy: DmarcPolicy.QUARANTINE,
appliedPercentage: 100,
action: 'quarantine',
details: 'DMARC failed, policy=quarantine'
};
// Reset email spam flag
email.mightBeSpam = false;
email.headers = {};
const quarantineApplied = dmarcVerifier.applyPolicy(email, quarantineResult);
expect(quarantineApplied).toEqual(true);
expect(email.mightBeSpam).toEqual(true);
expect(email.headers['X-Spam-Flag']).toEqual('YES');
expect(email.headers['X-DMARC-Result']).toEqual('DMARC failed, policy=quarantine');
// Test reject action
const rejectResult: any = {
hasDmarc: true,
spfDomainAligned: false,
dkimDomainAligned: false,
spfPassed: false,
dkimPassed: false,
policyEvaluated: DmarcPolicy.REJECT,
actualPolicy: DmarcPolicy.REJECT,
appliedPercentage: 100,
action: 'reject',
details: 'DMARC failed, policy=reject'
};
// Reset email spam flag
email.mightBeSpam = false;
email.headers = {};
const rejectApplied = dmarcVerifier.applyPolicy(email, rejectResult);
expect(rejectApplied).toEqual(false);
expect(email.mightBeSpam).toEqual(true);
});
tap.test('stop', async () => { tap.test('stop', async () => {
await tap.stopForcefully(); await tap.stopForcefully();
}); });

View File

@@ -0,0 +1,295 @@
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 * as net from 'net';
import * as dns from 'dns';
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 bridgeAvailable = false;
let mockSmtpServer: net.Server;
/**
* Create a minimal mock SMTP server that accepts any email.
*/
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;
}
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);
});
}
// Store original resolveMx so we can restore it
const originalResolveMx = dns.promises.Resolver.prototype.resolveMx;
tap.test('setup - start server and mock SMTP', async () => {
RustSecurityBridge.resetInstance();
server = new UnifiedEmailServer(mockDcRouter, {
ports: [10425],
hostname: 'test.mta.local',
domains: [
{ domain: 'mta-test.com', dnsMode: 'forward' },
],
routes: [
{
name: 'mta-route',
priority: 10,
match: { recipients: '*@mta-test.com' },
action: { type: 'deliver' },
},
{
name: 'process-route',
priority: 20,
match: { recipients: '*@process-test.com' },
action: {
type: 'process',
options: {
contentScanning: true,
scanners: [{ type: 'spam' }],
},
},
},
],
});
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;
}
mockSmtpServer = await createMockSmtpServer(10426);
});
tap.test('MX resolution for a public domain', async () => {
if (!bridgeAvailable) { console.log('SKIP'); return; }
// Use the delivery system's resolveMxForDomain via a quick DNS lookup
const resolver = new dns.promises.Resolver();
try {
const records = await resolver.resolveMx('gmail.com');
expect(records).toBeTruthy();
expect(records.length).toBeGreaterThan(0);
// Each record should have exchange and priority
for (const rec of records) {
expect(typeof rec.exchange).toEqual('string');
expect(typeof rec.priority).toEqual('number');
}
console.log(`Resolved ${records.length} MX records for gmail.com`);
} catch (err) {
console.log(`SKIP: DNS resolution failed (network may be unavailable): ${(err as Error).message}`);
}
});
tap.test('group recipients by domain', async () => {
if (!bridgeAvailable) { console.log('SKIP'); return; }
// Test the grouping logic directly
const recipients = [
'alice@example.com',
'bob@example.com',
'carol@other.org',
'dave@example.com',
'eve@other.org',
];
const groups = new Map<string, string[]>();
for (const rcpt of recipients) {
const domain = rcpt.split('@')[1]?.toLowerCase();
if (!domain) continue;
const list = groups.get(domain) || [];
list.push(rcpt);
groups.set(domain, list);
}
expect(groups.size).toEqual(2);
expect(groups.get('example.com')!.length).toEqual(3);
expect(groups.get('other.org')!.length).toEqual(2);
});
tap.test('MTA delivery to mock SMTP server via mocked MX', async () => {
if (!bridgeAvailable) { console.log('SKIP'); return; }
// Mock dns.promises.Resolver.resolveMx to return 127.0.0.1
dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) {
return [{ exchange: '127.0.0.1', priority: 10 }];
};
const email = new Email({
from: 'sender@mta-test.com',
to: 'recipient@target-domain.com',
subject: 'MTA Delivery Test',
text: 'Testing MTA delivery with MX resolution.',
});
// Use sendOutboundEmail at the resolved MX host (which is mocked to 127.0.0.1)
// But the real test is the delivery system's handleMtaDelivery, which we test
// by sending through the server's outbound path with the mock MX.
// Direct test: resolve MX then send
const resolver = new dns.promises.Resolver();
const mxRecords = await resolver.resolveMx('target-domain.com');
expect(mxRecords[0].exchange).toEqual('127.0.0.1');
// Send via the resolved MX host to the mock SMTP server on port 10425
// Note: MTA delivery uses port 25 by default, but our mock is on 10425.
// We test the sendOutboundEmail path directly with the mock MX host.
const result = await server.sendOutboundEmail('127.0.0.1', 10426, email);
expect(result).toBeTruthy();
expect(result.accepted.length).toBeGreaterThan(0);
expect(result.response).toInclude('2.0.0');
// Restore original resolveMx
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
});
tap.test('MTA delivery - connection refused to unreachable MX', async () => {
if (!bridgeAvailable) { console.log('SKIP'); return; }
const email = new Email({
from: 'sender@mta-test.com',
to: 'recipient@unreachable-domain.com',
subject: 'Connection Refused MX Test',
text: 'This should fail — no server at the target.',
});
// Send to a port that nothing is listening on
try {
await server.sendOutboundEmail('127.0.0.1', 59789, email);
throw new Error('Expected sendOutboundEmail to fail');
} catch (err: any) {
expect(err).toBeTruthy();
expect(err.message.length).toBeGreaterThan(0);
console.log(`Got expected error: ${err.message}`);
}
});
tap.test('MTA delivery with multiple recipients across domains', async () => {
if (!bridgeAvailable) { console.log('SKIP'); return; }
// Mock MX to return 127.0.0.1 for all domains
dns.promises.Resolver.prototype.resolveMx = async function (_hostname: string) {
return [{ exchange: '127.0.0.1', priority: 10 }];
};
const email = new Email({
from: 'sender@mta-test.com',
to: ['alice@domain-a.com', 'bob@domain-b.com'],
subject: 'Multi-Domain MTA Test',
text: 'Testing delivery to multiple domains.',
});
// Send to each recipient's domain individually (simulating MTA behavior)
for (const recipient of email.to) {
const singleEmail = new Email({
from: email.from,
to: recipient,
subject: email.subject,
text: email.text,
});
const result = await server.sendOutboundEmail('127.0.0.1', 10426, singleEmail);
expect(result.accepted.length).toEqual(1);
}
// Restore original resolveMx
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
});
tap.test('E2E: send real email to hello@task.vc via MX resolution', async () => {
if (!bridgeAvailable) { console.log('SKIP'); return; }
// Resolve real MX records for task.vc
const resolver = new dns.promises.Resolver();
const mxRecords = await resolver.resolveMx('task.vc');
expect(mxRecords.length).toBeGreaterThan(0);
const mxHost = mxRecords.sort((a, b) => a.priority - b.priority)[0].exchange;
console.log(`Resolved MX for task.vc: ${mxHost} (priority ${mxRecords[0].priority})`);
const email = new Email({
from: 'test@mta-test.com',
to: 'hello@task.vc',
subject: `MTA E2E Test — ${new Date().toISOString()}`,
text: 'This is an automated E2E test from @serve.zone/mailer verifying real MX resolution and outbound SMTP delivery.',
});
const result = await server.sendOutboundEmail(mxHost, 25, email);
expect(result).toBeTruthy();
expect(result.accepted).toBeTruthy();
expect(result.accepted.length).toEqual(1);
expect(result.accepted[0]).toEqual('hello@task.vc');
expect(result.response).toInclude('2.0.0');
console.log(`Email delivered to hello@task.vc via ${mxHost}: ${result.response}`);
});
tap.test('cleanup - stop server and mock SMTP', async () => {
// Restore MX resolver in case it wasn't restored
dns.promises.Resolver.prototype.resolveMx = originalResolveMx;
// Force-close mock server (destroy all open sockets)
if (mockSmtpServer) {
mockSmtpServer.close();
}
if (bridgeAvailable) {
await server.stop();
}
await tap.stopForcefully();
});
export default tap.start();

View File

@@ -1,141 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { RateLimiter } from '../ts/mail/delivery/classes.ratelimiter.js';
tap.test('RateLimiter - should be instantiable', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 10,
periodMs: 1000,
perKey: true
});
expect(limiter).toBeTruthy();
});
tap.test('RateLimiter - should allow requests within rate limit', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 5,
periodMs: 1000,
perKey: true
});
// Should allow 5 requests
for (let i = 0; i < 5; i++) {
expect(limiter.isAllowed('test')).toEqual(true);
}
// 6th request should be denied
expect(limiter.isAllowed('test')).toEqual(false);
});
tap.test('RateLimiter - should enforce per-key limits', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 3,
periodMs: 1000,
perKey: true
});
// Should allow 3 requests for key1
for (let i = 0; i < 3; i++) {
expect(limiter.isAllowed('key1')).toEqual(true);
}
// 4th request for key1 should be denied
expect(limiter.isAllowed('key1')).toEqual(false);
// But key2 should still be allowed
expect(limiter.isAllowed('key2')).toEqual(true);
});
tap.test('RateLimiter - should refill tokens over time', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 2,
periodMs: 100, // Short period for testing
perKey: true
});
// Use all tokens
expect(limiter.isAllowed('test')).toEqual(true);
expect(limiter.isAllowed('test')).toEqual(true);
expect(limiter.isAllowed('test')).toEqual(false);
// Wait for refill
await new Promise(resolve => setTimeout(resolve, 150));
// Should have tokens again
expect(limiter.isAllowed('test')).toEqual(true);
});
tap.test('RateLimiter - should support burst allowance', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 2,
periodMs: 100,
perKey: true,
burstTokens: 2, // Allow 2 extra tokens for bursts
initialTokens: 4 // Start with max + burst tokens
});
// Should allow 4 requests (2 regular + 2 burst)
for (let i = 0; i < 4; i++) {
expect(limiter.isAllowed('test')).toEqual(true);
}
// 5th request should be denied
expect(limiter.isAllowed('test')).toEqual(false);
// Wait for refill
await new Promise(resolve => setTimeout(resolve, 150));
// Should have 2 tokens again (rate-limited to normal max, not burst)
expect(limiter.isAllowed('test')).toEqual(true);
expect(limiter.isAllowed('test')).toEqual(true);
// 3rd request after refill should fail (only normal max is refilled, not burst)
expect(limiter.isAllowed('test')).toEqual(false);
});
tap.test('RateLimiter - should return correct stats', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 10,
periodMs: 1000,
perKey: true
});
// Make some requests
limiter.isAllowed('test');
limiter.isAllowed('test');
limiter.isAllowed('test');
// Get stats
const stats = limiter.getStats('test');
expect(stats.remaining).toEqual(7);
expect(stats.limit).toEqual(10);
expect(stats.allowed).toEqual(3);
expect(stats.denied).toEqual(0);
});
tap.test('RateLimiter - should reset limits', async () => {
const limiter = new RateLimiter({
maxPerPeriod: 3,
periodMs: 1000,
perKey: true
});
// Use all tokens
expect(limiter.isAllowed('test')).toEqual(true);
expect(limiter.isAllowed('test')).toEqual(true);
expect(limiter.isAllowed('test')).toEqual(true);
expect(limiter.isAllowed('test')).toEqual(false);
// Reset
limiter.reset('test');
// Should have tokens again
expect(limiter.isAllowed('test')).toEqual(true);
});
tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();

View File

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

View File

@@ -1,119 +0,0 @@
/**
* MTA error classes for SMTP client operations
*/
export class MtaConnectionError extends Error {
public code: string;
public details?: any;
constructor(message: string, detailsOrCode?: any) {
super(message);
this.name = 'MtaConnectionError';
if (typeof detailsOrCode === 'string') {
this.code = detailsOrCode;
} else {
this.code = 'CONNECTION_ERROR';
this.details = detailsOrCode;
}
}
static timeout(host: string, port: number, timeoutMs?: number): MtaConnectionError {
return new MtaConnectionError(`Connection to ${host}:${port} timed out${timeoutMs ? ` after ${timeoutMs}ms` : ''}`, 'TIMEOUT');
}
static refused(host: string, port: number): MtaConnectionError {
return new MtaConnectionError(`Connection to ${host}:${port} refused`, 'REFUSED');
}
static dnsError(host: string, err?: any): MtaConnectionError {
const errMsg = typeof err === 'string' ? err : err?.message || '';
return new MtaConnectionError(`DNS resolution failed for ${host}${errMsg ? `: ${errMsg}` : ''}`, 'DNS_ERROR');
}
}
export class MtaAuthenticationError extends Error {
public code: string;
public details?: any;
constructor(message: string, detailsOrCode?: any) {
super(message);
this.name = 'MtaAuthenticationError';
if (typeof detailsOrCode === 'string') {
this.code = detailsOrCode;
} else {
this.code = 'AUTH_ERROR';
this.details = detailsOrCode;
}
}
static invalidCredentials(host?: string, user?: string): MtaAuthenticationError {
const detail = host && user ? `${user}@${host}` : host || user || '';
return new MtaAuthenticationError(`Authentication failed${detail ? `: ${detail}` : ''}`, 'INVALID_CREDENTIALS');
}
}
export class MtaDeliveryError extends Error {
public code: string;
public responseCode?: number;
public details?: any;
constructor(message: string, detailsOrCode?: any, responseCode?: number) {
super(message);
this.name = 'MtaDeliveryError';
if (typeof detailsOrCode === 'string') {
this.code = detailsOrCode;
this.responseCode = responseCode;
} else {
this.code = 'DELIVERY_ERROR';
this.details = detailsOrCode;
}
}
static temporary(message: string, ...args: any[]): MtaDeliveryError {
return new MtaDeliveryError(message, 'TEMPORARY');
}
static permanent(message: string, ...args: any[]): MtaDeliveryError {
return new MtaDeliveryError(message, 'PERMANENT');
}
}
export class MtaConfigurationError extends Error {
public code: string;
public details?: any;
constructor(message: string, detailsOrCode?: any) {
super(message);
this.name = 'MtaConfigurationError';
if (typeof detailsOrCode === 'string') {
this.code = detailsOrCode;
} else {
this.code = 'CONFIG_ERROR';
this.details = detailsOrCode;
}
}
}
export class MtaTimeoutError extends Error {
public code: string;
public details?: any;
constructor(message: string, detailsOrCode?: any) {
super(message);
this.name = 'MtaTimeoutError';
if (typeof detailsOrCode === 'string') {
this.code = detailsOrCode;
} else {
this.code = 'TIMEOUT';
this.details = detailsOrCode;
}
}
static commandTimeout(command: string, hostOrTimeout?: any, timeoutMs?: number): MtaTimeoutError {
const timeout = typeof hostOrTimeout === 'number' ? hostOrTimeout : timeoutMs;
return new MtaTimeoutError(`Command '${command}' timed out${timeout ? ` after ${timeout}ms` : ''}`, 'COMMAND_TIMEOUT');
}
}
export class MtaProtocolError extends Error {
public code: string;
public details?: any;
constructor(message: string, detailsOrCode?: any) {
super(message);
this.name = 'MtaProtocolError';
if (typeof detailsOrCode === 'string') {
this.code = detailsOrCode;
} else {
this.code = 'PROTOCOL_ERROR';
this.details = detailsOrCode;
}
}
}

View File

@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import { type EmailProcessingMode } from '../routing/classes.email.config.js'; import { type EmailProcessingMode } from './interfaces.js';
import type { IEmailRoute } from '../routing/interfaces.js'; import type { IEmailRoute } from '../routing/interfaces.js';
/** /**

View File

@@ -7,10 +7,12 @@ import {
SecurityEventType SecurityEventType
} from '../../security/index.js'; } from '../../security/index.js';
import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js'; import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.js';
import type { Email } from '../core/classes.email.js'; import { Email } from '../core/classes.email.js';
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js'; import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js'; import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
const dns = plugins.dns;
/** /**
* Delivery status enumeration * Delivery status enumeration
*/ */
@@ -480,6 +482,36 @@ export class MultiModeDeliverySystem extends EventEmitter {
} }
} }
/**
* Resolve MX records for a domain, sorted by priority (lowest first).
* Falls back to the domain itself as an A record per RFC 5321.
*/
private async resolveMxForDomain(domain: string): Promise<Array<{ exchange: string; priority: number }>> {
const resolver = new dns.promises.Resolver();
try {
const mxRecords = await resolver.resolveMx(domain);
return mxRecords.sort((a, b) => a.priority - b.priority);
} catch (err) {
logger.log('warn', `No MX records for ${domain}, falling back to A record`);
return [{ exchange: domain, priority: 0 }];
}
}
/**
* Group recipient addresses by their domain part.
*/
private groupRecipientsByDomain(recipients: string[]): Map<string, string[]> {
const groups = new Map<string, string[]>();
for (const rcpt of recipients) {
const domain = rcpt.split('@')[1]?.toLowerCase();
if (!domain) continue;
const list = groups.get(domain) || [];
list.push(rcpt);
groups.set(domain, list);
}
return groups;
}
/** /**
* Default handler for MTA mode delivery * Default handler for MTA mode delivery
* @param item Queue item * @param item Queue item
@@ -488,33 +520,83 @@ export class MultiModeDeliverySystem extends EventEmitter {
logger.log('info', `MTA delivery for item ${item.id}`); logger.log('info', `MTA delivery for item ${item.id}`);
const email = item.processingResult as Email; const email = item.processingResult as Email;
const route = item.route;
if (!this.emailServer) {
throw new Error('No email server available for MTA delivery');
}
// 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';
const allRecipients = email.getAllRecipients();
if (allRecipients.length === 0) {
throw new Error('No recipients specified for MTA delivery');
}
const domainGroups = this.groupRecipientsByDomain(allRecipients);
const results: Array<{ domain: string; success: boolean; error?: string; accepted?: string[]; rejected?: string[] }> = [];
for (const [domain, recipients] of domainGroups) {
const mxHosts = await this.resolveMxForDomain(domain);
let delivered = false;
let lastError: string | undefined;
for (const mx of mxHosts) {
try { try {
// Apply DKIM signing if configured in the route logger.log('info', `MTA: trying MX ${mx.exchange}:25 for domain ${domain} (priority ${mx.priority})`);
if (item.route?.action.options?.mtaOptions?.dkimSign) {
await this.applyDkimSigning(email, item.route.action.options.mtaOptions);
}
// In a full implementation, this would use the MTA service // Create a temporary Email scoped to this domain's recipients
// For now, we'll simulate a successful delivery const domainEmail = new Email({
from: email.from,
logger.log('info', `Email processed by MTA: ${email.subject} to ${email.getAllRecipients().join(', ')}`); to: recipients.filter(r => email.to.includes(r)),
cc: recipients.filter(r => (email.cc || []).includes(r)),
// Note: The MTA implementation would handle actual local delivery bcc: recipients.filter(r => (email.bcc || []).includes(r)),
// Simulate successful delivery
return {
recipients: email.getAllRecipients().length,
subject: email.subject, subject: email.subject,
dkimSigned: !!item.route?.action.options?.mtaOptions?.dkimSign text: email.text,
}; html: email.html,
} catch (error: any) { });
logger.log('error', `Failed to process email in MTA mode: ${error.message}`);
throw error; const result = await this.emailServer.sendOutboundEmail(mx.exchange, 25, domainEmail, {
dkimDomain,
dkimSelector,
});
results.push({
domain,
success: true,
accepted: result.accepted,
rejected: result.rejected,
});
delivered = true;
logger.log('info', `MTA: delivered to ${domain} via ${mx.exchange}`);
break;
} catch (err: any) {
lastError = err.message;
logger.log('warn', `MTA: MX ${mx.exchange} failed for ${domain}: ${err.message}`);
} }
} }
if (!delivered) {
results.push({ domain, success: false, error: lastError });
logger.log('error', `MTA: all MX hosts failed for ${domain}`);
}
}
const allFailed = results.every(r => !r.success);
if (allFailed) {
const summary = results.map(r => `${r.domain}: ${r.error}`).join('; ');
throw new Error(`MTA delivery failed for all domains: ${summary}`);
}
return {
recipients: allRecipients.length,
domainResults: results,
};
}
/** /**
* Default handler for process mode delivery * Default handler for process mode delivery
* @param item Queue item * @param item Queue item
@@ -584,16 +666,10 @@ export class MultiModeDeliverySystem extends EventEmitter {
await this.applyDkimSigning(email, item.route.action.options?.mtaOptions || {}); await this.applyDkimSigning(email, item.route.action.options?.mtaOptions || {});
} }
logger.log('info', `Email successfully processed in store-and-forward mode`); logger.log('info', `Email successfully processed in store-and-forward mode, delivering via MTA`);
// Simulate successful delivery // After scanning + transformations, deliver via MTA
return { return await this.handleMtaDelivery(item);
recipients: email.getAllRecipients().length,
subject: email.subject,
scanned: !!route?.action.options?.contentScanning,
transformed: !!(route?.action.options?.transformations && route?.action.options?.transformations.length > 0),
dkimSigned: !!(item.route?.action.options?.mtaOptions?.dkimSign || item.route?.action.process?.dkim)
};
} catch (error: any) { } catch (error: any) {
logger.log('error', `Failed to process email: ${error.message}`); logger.log('error', `Failed to process email: ${error.message}`);
throw error; throw error;

View File

@@ -0,0 +1,157 @@
import { logger } from '../../logger.js';
import { DKIMCreator } from '../security/classes.dkimcreator.js';
import { DomainRegistry } from './classes.domain.registry.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
import { Email } from '../core/classes.email.js';
/** External DcRouter interface shape used by DkimManager */
interface DcRouter {
storageManager: any;
dnsServer?: any;
}
/**
* Manages DKIM key setup, rotation, and signing for all configured domains
*/
export class DkimManager {
private dkimKeys: Map<string, string> = new Map();
constructor(
private dkimCreator: DKIMCreator,
private domainRegistry: DomainRegistry,
private dcRouter: DcRouter,
private rustBridge: RustSecurityBridge,
) {}
async setupDkimForDomains(): Promise<void> {
const domainConfigs = this.domainRegistry.getAllConfigs();
if (domainConfigs.length === 0) {
logger.log('warn', 'No domains configured for DKIM');
return;
}
for (const domainConfig of domainConfigs) {
const domain = domainConfig.domain;
const selector = domainConfig.dkim?.selector || 'default';
try {
let keyPair: { privateKey: string; publicKey: string };
try {
keyPair = await this.dkimCreator.readDKIMKeys(domain);
logger.log('info', `Using existing DKIM keys for domain: ${domain}`);
} catch (error) {
keyPair = await this.dkimCreator.createDKIMKeys();
await this.dkimCreator.createAndStoreDKIMKeys(domain);
logger.log('info', `Generated new DKIM keys for domain: ${domain}`);
}
this.dkimKeys.set(domain, keyPair.privateKey);
logger.log('info', `DKIM keys loaded for domain: ${domain} with selector: ${selector}`);
} catch (error) {
logger.log('error', `Failed to set up DKIM for domain ${domain}: ${error.message}`);
}
}
}
async checkAndRotateDkimKeys(): Promise<void> {
const domainConfigs = this.domainRegistry.getAllConfigs();
for (const domainConfig of domainConfigs) {
const domain = domainConfig.domain;
const selector = domainConfig.dkim?.selector || 'default';
const rotateKeys = domainConfig.dkim?.rotateKeys || false;
const rotationInterval = domainConfig.dkim?.rotationInterval || 90;
const keySize = domainConfig.dkim?.keySize || 2048;
if (!rotateKeys) {
logger.log('debug', `DKIM key rotation disabled for ${domain}`);
continue;
}
try {
const needsRotation = await this.dkimCreator.needsRotation(domain, selector, rotationInterval);
if (needsRotation) {
logger.log('info', `DKIM keys need rotation for ${domain} (selector: ${selector})`);
const newSelector = await this.dkimCreator.rotateDkimKeys(domain, selector, keySize);
domainConfig.dkim = {
...domainConfig.dkim,
selector: newSelector
};
if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) {
const keyPair = await this.dkimCreator.readDKIMKeysForSelector(domain, newSelector);
const publicKeyBase64 = keyPair.publicKey
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
.replace(/-----END PUBLIC KEY-----/g, '')
.replace(/\s/g, '');
const ttl = domainConfig.dns?.internal?.ttl || 3600;
this.dcRouter.dnsServer.registerHandler(
`${newSelector}._domainkey.${domain}`,
['TXT'],
() => ({
name: `${newSelector}._domainkey.${domain}`,
type: 'TXT',
class: 'IN',
ttl: ttl,
data: `v=DKIM1; k=rsa; p=${publicKeyBase64}`
})
);
logger.log('info', `DKIM DNS handler registered for new selector: ${newSelector}._domainkey.${domain}`);
await this.dcRouter.storageManager.set(
`/email/dkim/${domain}/public.key`,
keyPair.publicKey
);
}
this.dkimCreator.cleanupOldKeys(domain, 30).catch(error => {
logger.log('warn', `Failed to cleanup old DKIM keys for ${domain}: ${error.message}`);
});
} else {
logger.log('debug', `DKIM keys for ${domain} are up to date`);
}
} catch (error) {
logger.log('error', `Failed to check/rotate DKIM keys for ${domain}: ${error.message}`);
}
}
}
async handleDkimSigning(email: Email, domain: string, selector: string): Promise<void> {
try {
await this.dkimCreator.handleDKIMKeysForDomain(domain);
const { privateKey } = await this.dkimCreator.readDKIMKeys(domain);
const rawEmail = email.toRFC822String();
// Detect key type from PEM header
const keyType = privateKey.includes('ED25519') ? 'ed25519' : 'rsa';
const signResult = await this.rustBridge.signDkim({
rawMessage: rawEmail,
domain,
selector,
privateKey,
keyType,
});
if (signResult.header) {
email.addHeader('DKIM-Signature', signResult.header);
logger.log('info', `Successfully added DKIM signature for ${domain}`);
}
} catch (error) {
logger.log('error', `Failed to sign email with DKIM: ${error.message}`);
}
}
getDkimKey(domain: string): string | undefined {
return this.dkimKeys.get(domain);
}
}

View File

@@ -0,0 +1,175 @@
import { logger } from '../../logger.js';
import {
SecurityLogger,
SecurityLogLevel,
SecurityEventType
} from '../../security/index.js';
import type { IEmailAction, IEmailContext } from './interfaces.js';
import { Email } from '../core/classes.email.js';
import { BounceManager } from '../core/classes.bouncemanager.js';
import { UnifiedDeliveryQueue } from '../delivery/classes.delivery.queue.js';
import type { ISmtpSendResult } from '../../security/classes.rustsecuritybridge.js';
/**
* Dependencies injected from UnifiedEmailServer to avoid circular imports
*/
export interface IActionExecutorDeps {
sendOutboundEmail: (host: string, port: number, email: Email, options?: {
auth?: { user: string; pass: string };
dkimDomain?: string;
dkimSelector?: string;
tlsOpportunistic?: boolean;
}) => Promise<ISmtpSendResult>;
bounceManager: BounceManager;
deliveryQueue: UnifiedDeliveryQueue;
}
/**
* Executes email routing actions (forward, process, deliver, reject)
*/
export class EmailActionExecutor {
constructor(private deps: IActionExecutorDeps) {}
async executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
switch (action.type) {
case 'forward':
await this.handleForwardAction(action, email, context);
break;
case 'process':
await this.handleProcessAction(action, email, context);
break;
case 'deliver':
await this.handleDeliverAction(action, email, context);
break;
case 'reject':
await this.handleRejectAction(action, email, context);
break;
default:
throw new Error(`Unknown action type: ${(action as any).type}`);
}
}
private async handleForwardAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
if (!action.forward) {
throw new Error('Forward action requires forward configuration');
}
const { host, port = 25, auth, addHeaders } = action.forward;
logger.log('info', `Forwarding email to ${host}:${port}`);
// Add forwarding headers
if (addHeaders) {
for (const [key, value] of Object.entries(addHeaders)) {
email.headers[key] = value;
}
}
// Add standard forwarding headers
email.headers['X-Forwarded-For'] = context.session.remoteAddress || 'unknown';
email.headers['X-Forwarded-To'] = email.to.join(', ');
email.headers['X-Forwarded-Date'] = new Date().toISOString();
try {
// Send email via Rust SMTP client
await this.deps.sendOutboundEmail(host, port, email, {
auth: auth as { user: string; pass: string } | undefined,
});
logger.log('info', `Successfully forwarded email to ${host}:${port}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
type: SecurityEventType.EMAIL_FORWARDING,
message: 'Email forwarded successfully',
ipAddress: context.session.remoteAddress,
details: {
sessionId: context.session.id,
routeName: context.session.matchedRoute?.name,
targetHost: host,
targetPort: port,
recipients: email.to
},
success: true
});
} catch (error) {
logger.log('error', `Failed to forward email: ${error.message}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.EMAIL_FORWARDING,
message: 'Email forwarding failed',
ipAddress: context.session.remoteAddress,
details: {
sessionId: context.session.id,
routeName: context.session.matchedRoute?.name,
targetHost: host,
targetPort: port,
error: error.message
},
success: false
});
// Handle as bounce
for (const recipient of email.getAllRecipients()) {
await this.deps.bounceManager.processSmtpFailure(recipient, error.message, {
sender: email.from,
originalEmailId: email.headers['Message-ID'] as string
});
}
throw error;
}
}
private async handleProcessAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
logger.log('info', `Processing email with action options`);
// Apply scanning if requested
if (action.process?.scan) {
logger.log('info', 'Content scanning requested');
}
// Queue for delivery
const queue = action.process?.queue || 'normal';
await this.deps.deliveryQueue.enqueue(email, 'process', context.session.matchedRoute!);
logger.log('info', `Email queued for delivery in ${queue} queue`);
}
private async handleDeliverAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
logger.log('info', `Delivering email locally`);
// Queue for local delivery
await this.deps.deliveryQueue.enqueue(email, 'mta', context.session.matchedRoute!);
logger.log('info', 'Email queued for local delivery');
}
private async handleRejectAction(action: IEmailAction, _email: Email, context: IEmailContext): Promise<void> {
const code = action.reject?.code || 550;
const message = action.reject?.message || 'Message rejected';
logger.log('info', `Rejecting email with code ${code}: ${message}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.WARN,
type: SecurityEventType.EMAIL_PROCESSING,
message: 'Email rejected by routing rule',
ipAddress: context.session.remoteAddress,
details: {
sessionId: context.session.id,
routeName: context.session.matchedRoute?.name,
rejectCode: code,
rejectMessage: message,
from: _email.from,
to: _email.to
},
success: false
});
// Throw error with SMTP code and message
const error = new Error(message);
(error as any).responseCode = code;
throw error;
}
}

View File

@@ -1,82 +0,0 @@
import type { EmailProcessingMode } from '../delivery/interfaces.js';
// Re-export EmailProcessingMode type
export type { EmailProcessingMode };
/**
* Domain rule interface for pattern-based routing
*/
export interface IDomainRule {
// Domain pattern (e.g., "*@example.com", "*@*.example.net")
pattern: string;
// Handling mode for this pattern
mode: EmailProcessingMode;
// Forward mode configuration
target?: {
server: string;
port?: number;
useTls?: boolean;
authentication?: {
user?: string;
pass?: string;
};
};
// MTA mode configuration
mtaOptions?: IMtaOptions;
// Process mode configuration
contentScanning?: boolean;
scanners?: IContentScanner[];
transformations?: ITransformation[];
// Rate limits for this domain
rateLimits?: {
maxMessagesPerMinute?: number;
maxRecipientsPerMessage?: number;
};
}
/**
* MTA options interface
*/
export interface IMtaOptions {
domain?: string;
allowLocalDelivery?: boolean;
localDeliveryPath?: string;
dkimSign?: boolean;
dkimOptions?: {
domainName: string;
keySelector: string;
privateKey?: string;
};
smtpBanner?: string;
maxConnections?: number;
connTimeout?: number;
spoolDir?: string;
}
/**
* Content scanner interface
*/
export interface IContentScanner {
type: 'spam' | 'virus' | 'attachment';
threshold?: number;
action: 'tag' | 'reject';
blockedExtensions?: string[];
}
/**
* Transformation interface
*/
export interface ITransformation {
type: string;
header?: string;
value?: string;
domains?: string[];
append?: boolean;
[key: string]: any;
}

View File

@@ -8,7 +8,6 @@ import {
SecurityEventType SecurityEventType
} from '../../security/index.js'; } from '../../security/index.js';
import { DKIMCreator } from '../security/classes.dkimcreator.js'; import { DKIMCreator } from '../security/classes.dkimcreator.js';
import { IPReputationChecker } from '../../security/classes.ipreputationchecker.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js'; import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js'; import type { IEmailReceivedEvent, IAuthRequestEvent, IEmailData } from '../../security/classes.rustsecuritybridge.js';
import { EmailRouter } from './classes.email.router.js'; import { EmailRouter } from './classes.email.router.js';
@@ -23,6 +22,10 @@ import { UnifiedDeliveryQueue, type IQueueOptions } from '../delivery/classes.de
import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js'; import { UnifiedRateLimiter, type IHierarchicalRateLimits } from '../delivery/classes.unified.rate.limiter.js';
import { SmtpState } from '../delivery/interfaces.js'; import { SmtpState } from '../delivery/interfaces.js';
import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../delivery/interfaces.js'; import type { EmailProcessingMode, ISmtpSession as IBaseSmtpSession } from '../delivery/interfaces.js';
import { EmailActionExecutor } from './classes.email.action.executor.js';
import { DkimManager } from './classes.dkim.manager.js';
/** External DcRouter interface shape used by UnifiedEmailServer */ /** External DcRouter interface shape used by UnifiedEmailServer */
interface DcRouter { interface DcRouter {
storageManager: any; storageManager: any;
@@ -160,12 +163,14 @@ export class UnifiedEmailServer extends EventEmitter {
// Add components needed for sending and securing emails // Add components needed for sending and securing emails
public dkimCreator: DKIMCreator; public dkimCreator: DKIMCreator;
private rustBridge: RustSecurityBridge; private rustBridge: RustSecurityBridge;
private ipReputationChecker: IPReputationChecker;
private bounceManager: BounceManager; private bounceManager: BounceManager;
public deliveryQueue: UnifiedDeliveryQueue; public deliveryQueue: UnifiedDeliveryQueue;
public deliverySystem: MultiModeDeliverySystem; public deliverySystem: MultiModeDeliverySystem;
private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers private rateLimiter: UnifiedRateLimiter; // TODO: Implement rate limiting in SMTP server handlers
private dkimKeys: Map<string, string> = new Map(); // domain -> private key
// Extracted subsystems
private actionExecutor: EmailActionExecutor;
private dkimManager: DkimManager;
constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) { constructor(dcRouter: DcRouter, options: IUnifiedEmailServerOptions) {
super(); super();
@@ -188,13 +193,6 @@ export class UnifiedEmailServer extends EventEmitter {
// Initialize DKIM creator with storage manager // Initialize DKIM creator with storage manager
this.dkimCreator = new DKIMCreator(paths.keysDir, dcRouter.storageManager); this.dkimCreator = new DKIMCreator(paths.keysDir, dcRouter.storageManager);
// Initialize IP reputation checker with storage manager
this.ipReputationChecker = IPReputationChecker.getInstance({
enableLocalCache: true,
enableDNSBL: true,
enableIPInfo: true
}, dcRouter.storageManager);
// Initialize bounce manager with storage manager // Initialize bounce manager with storage manager
this.bounceManager = new BounceManager({ this.bounceManager = new BounceManager({
maxCacheSize: 10000, maxCacheSize: 10000,
@@ -247,6 +245,16 @@ export class UnifiedEmailServer extends EventEmitter {
this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this); this.deliverySystem = new MultiModeDeliverySystem(this.deliveryQueue, deliveryOptions, this);
// Initialize action executor
this.actionExecutor = new EmailActionExecutor({
sendOutboundEmail: this.sendOutboundEmail.bind(this),
bounceManager: this.bounceManager,
deliveryQueue: this.deliveryQueue,
});
// Initialize DKIM manager
this.dkimManager = new DkimManager(this.dkimCreator, this.domainRegistry, dcRouter, this.rustBridge);
// Initialize statistics // Initialize statistics
this.stats = { this.stats = {
startTime: new Date(), startTime: new Date(),
@@ -277,6 +285,7 @@ export class UnifiedEmailServer extends EventEmitter {
auth?: { user: string; pass: string }; auth?: { user: string; pass: string };
dkimDomain?: string; dkimDomain?: string;
dkimSelector?: string; dkimSelector?: string;
tlsOpportunistic?: boolean;
}): Promise<ISmtpSendResult> { }): Promise<ISmtpSendResult> {
// Build DKIM config if domain has keys // Build DKIM config if domain has keys
let dkim: { domain: string; selector: string; privateKey: string } | undefined; let dkim: { domain: string; selector: string; privateKey: string } | undefined;
@@ -313,6 +322,7 @@ export class UnifiedEmailServer extends EventEmitter {
socketTimeoutSecs: Math.floor((this.options.outbound?.socketTimeout || 120000) / 1000), socketTimeoutSecs: Math.floor((this.options.outbound?.socketTimeout || 120000) / 1000),
poolKey: `${host}:${port}`, poolKey: `${host}:${port}`,
maxPoolConnections: this.options.outbound?.maxConnections || 10, maxPoolConnections: this.options.outbound?.maxConnections || 10,
tlsOpportunistic: options?.tlsOpportunistic ?? (port === 25),
}); });
} }
@@ -323,70 +333,62 @@ export class UnifiedEmailServer extends EventEmitter {
logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`); logger.log('info', `Starting UnifiedEmailServer on ports: ${(this.options.ports as number[]).join(', ')}`);
try { try {
// Initialize the delivery queue await this.startDeliveryPipeline();
await this.startRustBridge();
await this.initializeDkimAndDns();
this.registerBridgeEventHandlers();
await this.startSmtpServer();
logger.log('info', 'UnifiedEmailServer started successfully');
this.emit('started');
} catch (error) {
logger.log('error', `Failed to start UnifiedEmailServer: ${error.message}`);
throw error;
}
}
private async startDeliveryPipeline(): Promise<void> {
await this.deliveryQueue.initialize(); await this.deliveryQueue.initialize();
logger.log('info', 'Email delivery queue initialized'); logger.log('info', 'Email delivery queue initialized');
// Start the delivery system
await this.deliverySystem.start(); await this.deliverySystem.start();
logger.log('info', 'Email delivery system started'); logger.log('info', 'Email delivery system started');
}
// Start Rust security bridge — required for all security operations private async startRustBridge(): Promise<void> {
const bridgeOk = await this.rustBridge.start(); const bridgeOk = await this.rustBridge.start();
if (!bridgeOk) { if (!bridgeOk) {
throw new Error('Rust security bridge failed to start. The mailer-bin binary is required. Run "pnpm build" to compile it.'); throw new Error('Rust security bridge failed to start. The mailer-bin binary is required. Run "pnpm build" to compile it.');
} }
logger.log('info', 'Rust security bridge started — Rust is the primary security backend'); logger.log('info', 'Rust security bridge started — Rust is the primary security backend');
// Listen for bridge state changes to propagate resilience events
this.rustBridge.on('stateChange', ({ oldState, newState }: { oldState: string; newState: string }) => { this.rustBridge.on('stateChange', ({ oldState, newState }: { oldState: string; newState: string }) => {
if (newState === 'failed') this.emit('bridgeFailed'); if (newState === 'failed') this.emit('bridgeFailed');
else if (newState === 'restarting') this.emit('bridgeRestarting'); else if (newState === 'restarting') this.emit('bridgeRestarting');
else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered'); else if (newState === 'running' && oldState === 'restarting') this.emit('bridgeRecovered');
}); });
}
// Set up DKIM for all domains private async initializeDkimAndDns(): Promise<void> {
await this.setupDkimForDomains(); await this.dkimManager.setupDkimForDomains();
logger.log('info', 'DKIM configuration completed for all domains'); logger.log('info', 'DKIM configuration completed for all domains');
// Create DNS manager and ensure all DNS records are created
const dnsManager = new DnsManager(this.dcRouter); const dnsManager = new DnsManager(this.dcRouter);
await dnsManager.ensureDnsRecords(this.domainRegistry.getAllConfigs(), this.dkimCreator); await dnsManager.ensureDnsRecords(this.domainRegistry.getAllConfigs(), this.dkimCreator);
logger.log('info', 'DNS records ensured for all configured domains'); logger.log('info', 'DNS records ensured for all configured domains');
// Apply per-domain rate limits
this.applyDomainRateLimits(); this.applyDomainRateLimits();
logger.log('info', 'Per-domain rate limits configured'); logger.log('info', 'Per-domain rate limits configured');
// Check and rotate DKIM keys if needed await this.dkimManager.checkAndRotateDkimKeys();
await this.checkAndRotateDkimKeys();
logger.log('info', 'DKIM key rotation check completed'); logger.log('info', 'DKIM key rotation check completed');
// Ensure we have the necessary TLS options
const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath;
// Prepare the certificate and key if available
let tlsCertPem: string | undefined;
let tlsKeyPem: string | undefined;
if (hasTlsConfig) {
try {
tlsKeyPem = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
tlsCertPem = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8');
logger.log('info', 'TLS certificates loaded successfully');
} catch (error) {
logger.log('warn', `Failed to load TLS certificates: ${error.message}`);
}
} }
// --- Start Rust SMTP server --- private registerBridgeEventHandlers(): void {
// Register event handlers for email reception and auth
this.rustBridge.onEmailReceived(async (data) => { this.rustBridge.onEmailReceived(async (data) => {
try { try {
await this.handleRustEmailReceived(data); await this.handleRustEmailReceived(data);
} catch (err) { } catch (err) {
logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`); logger.log('error', `Error handling email from Rust SMTP: ${(err as Error).message}`);
// Send rejection back to Rust (may fail if bridge is restarting)
try { try {
await this.rustBridge.sendEmailProcessingResult({ await this.rustBridge.sendEmailProcessingResult({
correlationId: data.correlationId, correlationId: data.correlationId,
@@ -417,7 +419,38 @@ export class UnifiedEmailServer extends EventEmitter {
} }
}); });
// Determine which ports need STARTTLS and which need implicit TLS this.rustBridge.onScramCredentialRequest(async (data) => {
try {
await this.handleScramCredentialRequest(data);
} catch (err) {
logger.log('error', `Error handling SCRAM credential request: ${(err as Error).message}`);
try {
await this.rustBridge.sendScramCredentialResult({
correlationId: data.correlationId,
found: false,
});
} catch (sendErr) {
logger.log('warn', `Could not send SCRAM credential rejection: ${(sendErr as Error).message}`);
}
}
});
}
private async startSmtpServer(): Promise<void> {
const hasTlsConfig = this.options.tls?.keyPath && this.options.tls?.certPath;
let tlsCertPem: string | undefined;
let tlsKeyPem: string | undefined;
if (hasTlsConfig) {
try {
tlsKeyPem = plugins.fs.readFileSync(this.options.tls.keyPath!, 'utf8');
tlsCertPem = plugins.fs.readFileSync(this.options.tls.certPath!, 'utf8');
logger.log('info', 'TLS certificates loaded successfully');
} catch (error) {
logger.log('warn', `Failed to load TLS certificates: ${error.message}`);
}
}
const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465); const smtpPorts = (this.options.ports as number[]).filter(p => p !== 465);
const securePort = (this.options.ports as number[]).find(p => p === 465); const securePort = (this.options.ports as number[]).find(p => p === 465);
@@ -449,12 +482,6 @@ export class UnifiedEmailServer extends EventEmitter {
} }
logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`); logger.log('info', `Rust SMTP server listening on ports: ${smtpPorts.join(', ')}${securePort ? ` + ${securePort} (TLS)` : ''}`);
logger.log('info', 'UnifiedEmailServer started successfully');
this.emit('started');
} catch (error) {
logger.log('error', `Failed to start UnifiedEmailServer: ${error.message}`);
throw error;
}
} }
/** /**
@@ -512,8 +539,6 @@ export class UnifiedEmailServer extends EventEmitter {
/** /**
* Handle an emailReceived event from the Rust SMTP server. * Handle an emailReceived event from the Rust SMTP server.
* Decodes the email data, processes it through the routing system,
* and sends back the result via the correlation-ID callback.
*/ */
private async handleRustEmailReceived(data: IEmailReceivedEvent): Promise<void> { private async handleRustEmailReceived(data: IEmailReceivedEvent): Promise<void> {
const { correlationId, mailFrom, rcptTo, remoteAddr, clientHostname, secure, authenticatedUser } = data; const { correlationId, mailFrom, rcptTo, remoteAddr, clientHostname, secure, authenticatedUser } = data;
@@ -588,7 +613,6 @@ export class UnifiedEmailServer extends EventEmitter {
/** /**
* Handle an authRequest event from the Rust SMTP server. * Handle an authRequest event from the Rust SMTP server.
* Validates credentials and sends back the result.
*/ */
private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> { private async handleRustAuthRequest(data: IAuthRequestEvent): Promise<void> {
const { correlationId, username, password, remoteAddr } = data; const { correlationId, username, password, remoteAddr } = data;
@@ -616,6 +640,53 @@ export class UnifiedEmailServer extends EventEmitter {
} }
} }
/**
* Handle a SCRAM credential request from the Rust SMTP server.
* Computes SCRAM-SHA-256 credentials from the stored password for the given user.
*/
private async handleScramCredentialRequest(data: { correlationId: string; username: string; remoteAddr: string }): Promise<void> {
const { correlationId, username, remoteAddr } = data;
const crypto = await import('crypto');
logger.log('info', `SCRAM credential request for user=${username} from=${remoteAddr}`);
const users = this.options.auth?.users || [];
const matched = users.find(u => u.username === username);
if (!matched) {
await this.rustBridge.sendScramCredentialResult({
correlationId,
found: false,
});
return;
}
// Compute SCRAM-SHA-256 credentials from plaintext password
const salt = crypto.randomBytes(16);
const iterations = 4096;
// SaltedPassword = PBKDF2-HMAC-SHA256(password, salt, iterations, 32)
const saltedPassword = crypto.pbkdf2Sync(matched.password, salt, iterations, 32, 'sha256');
// ClientKey = HMAC-SHA256(SaltedPassword, "Client Key")
const clientKey = crypto.createHmac('sha256', saltedPassword).update('Client Key').digest();
// StoredKey = SHA256(ClientKey)
const storedKey = crypto.createHash('sha256').update(clientKey).digest();
// ServerKey = HMAC-SHA256(SaltedPassword, "Server Key")
const serverKey = crypto.createHmac('sha256', saltedPassword).update('Server Key').digest();
await this.rustBridge.sendScramCredentialResult({
correlationId,
found: true,
salt: salt.toString('base64'),
iterations,
storedKey: storedKey.toString('base64'),
serverKey: serverKey.toString('base64'),
});
}
/** /**
* Verify inbound email security (DKIM/SPF/DMARC) using pre-computed Rust results * Verify inbound email security (DKIM/SPF/DMARC) using pre-computed Rust results
* or falling back to IPC call if no pre-computed results are available. * or falling back to IPC call if no pre-computed results are available.
@@ -740,14 +811,12 @@ export class UnifiedEmailServer extends EventEmitter {
} }
// First check if this is a bounce notification email // First check if this is a bounce notification email
// Look for common bounce notification subject patterns
const subject = email.subject || ''; const subject = email.subject || '';
const isBounceLike = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject); const isBounceLike = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject);
if (isBounceLike) { if (isBounceLike) {
logger.log('info', `Email subject matches bounce notification pattern: "${subject}"`); logger.log('info', `Email subject matches bounce notification pattern: "${subject}"`);
// Try to process as a bounce
const isBounce = await this.processBounceNotification(email); const isBounce = await this.processBounceNotification(email);
if (isBounce) { if (isBounce) {
@@ -763,7 +832,6 @@ export class UnifiedEmailServer extends EventEmitter {
const route = await this.emailRouter.evaluateRoutes(context); const route = await this.emailRouter.evaluateRoutes(context);
if (!route) { if (!route) {
// No matching route - reject
throw new Error('No matching route for email'); throw new Error('No matching route for email');
} }
@@ -771,221 +839,12 @@ export class UnifiedEmailServer extends EventEmitter {
session.matchedRoute = route; session.matchedRoute = route;
// Execute action based on route // Execute action based on route
await this.executeAction(route.action, email, context); await this.actionExecutor.executeAction(route.action, email, context);
// Return the processed email // Return the processed email
return email; return email;
} }
/**
* Execute action based on route configuration
*/
private async executeAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
switch (action.type) {
case 'forward':
await this.handleForwardAction(action, email, context);
break;
case 'process':
await this.handleProcessAction(action, email, context);
break;
case 'deliver':
await this.handleDeliverAction(action, email, context);
break;
case 'reject':
await this.handleRejectAction(action, email, context);
break;
default:
throw new Error(`Unknown action type: ${(action as any).type}`);
}
}
/**
* Handle forward action
*/
private async handleForwardAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
if (!_action.forward) {
throw new Error('Forward action requires forward configuration');
}
const { host, port = 25, auth, addHeaders } = _action.forward;
logger.log('info', `Forwarding email to ${host}:${port}`);
// Add forwarding headers
if (addHeaders) {
for (const [key, value] of Object.entries(addHeaders)) {
email.headers[key] = value;
}
}
// Add standard forwarding headers
email.headers['X-Forwarded-For'] = context.session.remoteAddress || 'unknown';
email.headers['X-Forwarded-To'] = email.to.join(', ');
email.headers['X-Forwarded-Date'] = new Date().toISOString();
try {
// 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}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO,
type: SecurityEventType.EMAIL_FORWARDING,
message: 'Email forwarded successfully',
ipAddress: context.session.remoteAddress,
details: {
sessionId: context.session.id,
routeName: context.session.matchedRoute?.name,
targetHost: host,
targetPort: port,
recipients: email.to
},
success: true
});
} catch (error) {
logger.log('error', `Failed to forward email: ${error.message}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.EMAIL_FORWARDING,
message: 'Email forwarding failed',
ipAddress: context.session.remoteAddress,
details: {
sessionId: context.session.id,
routeName: context.session.matchedRoute?.name,
targetHost: host,
targetPort: port,
error: error.message
},
success: false
});
// Handle as bounce
for (const recipient of email.getAllRecipients()) {
await this.bounceManager.processSmtpFailure(recipient, error.message, {
sender: email.from,
originalEmailId: email.headers['Message-ID'] as string
});
}
throw error;
}
}
/**
* Handle process action
*/
private async handleProcessAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
logger.log('info', `Processing email with action options`);
// Apply scanning if requested
if (action.process?.scan) {
// Use existing content scanner
// Note: ContentScanner integration would go here
logger.log('info', 'Content scanning requested');
}
// Note: DKIM signing will be applied at delivery time to ensure signature validity
// Queue for delivery
const queue = action.process?.queue || 'normal';
await this.deliveryQueue.enqueue(email, 'process', context.session.matchedRoute!);
logger.log('info', `Email queued for delivery in ${queue} queue`);
}
/**
* Handle deliver action
*/
private async handleDeliverAction(_action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
logger.log('info', `Delivering email locally`);
// Queue for local delivery
await this.deliveryQueue.enqueue(email, 'mta', context.session.matchedRoute!);
logger.log('info', 'Email queued for local delivery');
}
/**
* Handle reject action
*/
private async handleRejectAction(action: IEmailAction, email: Email, context: IEmailContext): Promise<void> {
const code = action.reject?.code || 550;
const message = action.reject?.message || 'Message rejected';
logger.log('info', `Rejecting email with code ${code}: ${message}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.WARN,
type: SecurityEventType.EMAIL_PROCESSING,
message: 'Email rejected by routing rule',
ipAddress: context.session.remoteAddress,
details: {
sessionId: context.session.id,
routeName: context.session.matchedRoute?.name,
rejectCode: code,
rejectMessage: message,
from: email.from,
to: email.to
},
success: false
});
// Throw error with SMTP code and message
const error = new Error(message);
(error as any).responseCode = code;
throw error;
}
/**
* Set up DKIM configuration for all domains
*/
private async setupDkimForDomains(): Promise<void> {
const domainConfigs = this.domainRegistry.getAllConfigs();
if (domainConfigs.length === 0) {
logger.log('warn', 'No domains configured for DKIM');
return;
}
for (const domainConfig of domainConfigs) {
const domain = domainConfig.domain;
const selector = domainConfig.dkim?.selector || 'default';
try {
// Check if DKIM keys already exist for this domain
let keyPair: { privateKey: string; publicKey: string };
try {
// Try to read existing keys
keyPair = await this.dkimCreator.readDKIMKeys(domain);
logger.log('info', `Using existing DKIM keys for domain: ${domain}`);
} catch (error) {
// Generate new keys if they don't exist
keyPair = await this.dkimCreator.createDKIMKeys();
// Store them for future use
await this.dkimCreator.createAndStoreDKIMKeys(domain);
logger.log('info', `Generated new DKIM keys for domain: ${domain}`);
}
// Store the private key for signing
this.dkimKeys.set(domain, keyPair.privateKey);
// DNS record creation is now handled by DnsManager
logger.log('info', `DKIM keys loaded for domain: ${domain} with selector: ${selector}`);
} catch (error) {
logger.log('error', `Failed to set up DKIM for domain ${domain}: ${error.message}`);
}
}
}
/** /**
* Apply per-domain rate limits from domain configurations * Apply per-domain rate limits from domain configurations
*/ */
@@ -997,12 +856,10 @@ export class UnifiedEmailServer extends EventEmitter {
const domain = domainConfig.domain; const domain = domainConfig.domain;
const rateLimitConfig: any = {}; const rateLimitConfig: any = {};
// Convert domain-specific rate limits to the format expected by UnifiedRateLimiter
if (domainConfig.rateLimits.outbound) { if (domainConfig.rateLimits.outbound) {
if (domainConfig.rateLimits.outbound.messagesPerMinute) { if (domainConfig.rateLimits.outbound.messagesPerMinute) {
rateLimitConfig.maxMessagesPerMinute = domainConfig.rateLimits.outbound.messagesPerMinute; rateLimitConfig.maxMessagesPerMinute = domainConfig.rateLimits.outbound.messagesPerMinute;
} }
// Note: messagesPerHour and messagesPerDay would need additional implementation in rate limiter
} }
if (domainConfig.rateLimits.inbound) { if (domainConfig.rateLimits.inbound) {
@@ -1017,7 +874,6 @@ export class UnifiedEmailServer extends EventEmitter {
} }
} }
// Apply the rate limits if we have any
if (Object.keys(rateLimitConfig).length > 0) { if (Object.keys(rateLimitConfig).length > 0) {
this.rateLimiter.applyDomainLimits(domain, rateLimitConfig); this.rateLimiter.applyDomainLimits(domain, rateLimitConfig);
logger.log('info', `Applied rate limits for domain ${domain}:`, rateLimitConfig); logger.log('info', `Applied rate limits for domain ${domain}:`, rateLimitConfig);
@@ -1026,88 +882,6 @@ export class UnifiedEmailServer extends EventEmitter {
} }
} }
/**
* Check and rotate DKIM keys if needed
*/
private async checkAndRotateDkimKeys(): Promise<void> {
const domainConfigs = this.domainRegistry.getAllConfigs();
for (const domainConfig of domainConfigs) {
const domain = domainConfig.domain;
const selector = domainConfig.dkim?.selector || 'default';
const rotateKeys = domainConfig.dkim?.rotateKeys || false;
const rotationInterval = domainConfig.dkim?.rotationInterval || 90;
const keySize = domainConfig.dkim?.keySize || 2048;
if (!rotateKeys) {
logger.log('debug', `DKIM key rotation disabled for ${domain}`);
continue;
}
try {
// Check if keys need rotation
const needsRotation = await this.dkimCreator.needsRotation(domain, selector, rotationInterval);
if (needsRotation) {
logger.log('info', `DKIM keys need rotation for ${domain} (selector: ${selector})`);
// Rotate the keys
const newSelector = await this.dkimCreator.rotateDkimKeys(domain, selector, keySize);
// Update the domain config with new selector
domainConfig.dkim = {
...domainConfig.dkim,
selector: newSelector
};
// Re-register DNS handler for new selector if internal-dns mode
if (domainConfig.dnsMode === 'internal-dns' && this.dcRouter.dnsServer) {
// Get new public key
const keyPair = await this.dkimCreator.readDKIMKeysForSelector(domain, newSelector);
const publicKeyBase64 = keyPair.publicKey
.replace(/-----BEGIN PUBLIC KEY-----/g, '')
.replace(/-----END PUBLIC KEY-----/g, '')
.replace(/\s/g, '');
const ttl = domainConfig.dns?.internal?.ttl || 3600;
// Register new selector
this.dcRouter.dnsServer.registerHandler(
`${newSelector}._domainkey.${domain}`,
['TXT'],
() => ({
name: `${newSelector}._domainkey.${domain}`,
type: 'TXT',
class: 'IN',
ttl: ttl,
data: `v=DKIM1; k=rsa; p=${publicKeyBase64}`
})
);
logger.log('info', `DKIM DNS handler registered for new selector: ${newSelector}._domainkey.${domain}`);
// Store the updated public key in storage
await this.dcRouter.storageManager.set(
`/email/dkim/${domain}/public.key`,
keyPair.publicKey
);
}
// Clean up old keys after grace period (async, don't wait)
this.dkimCreator.cleanupOldKeys(domain, 30).catch(error => {
logger.log('warn', `Failed to cleanup old DKIM keys for ${domain}: ${error.message}`);
});
} else {
logger.log('debug', `DKIM keys for ${domain} are up to date`);
}
} catch (error) {
logger.log('error', `Failed to check/rotate DKIM keys for ${domain}: ${error.message}`);
}
}
}
/** /**
* Generate SmartProxy routes for email ports * Generate SmartProxy routes for email ports
*/ */
@@ -1121,26 +895,24 @@ export class UnifiedEmailServer extends EventEmitter {
const actualPortMapping = portMapping || defaultPortMapping; const actualPortMapping = portMapping || defaultPortMapping;
// Generate routes for each configured port
for (const externalPort of this.options.ports) { for (const externalPort of this.options.ports) {
const internalPort = actualPortMapping[externalPort] || externalPort + 10000; const internalPort = actualPortMapping[externalPort] || externalPort + 10000;
let routeName = 'email-route'; let routeName = 'email-route';
let tlsMode = 'passthrough'; let tlsMode = 'passthrough';
// Configure based on port
switch (externalPort) { switch (externalPort) {
case 25: case 25:
routeName = 'smtp-route'; routeName = 'smtp-route';
tlsMode = 'passthrough'; // STARTTLS tlsMode = 'passthrough';
break; break;
case 587: case 587:
routeName = 'submission-route'; routeName = 'submission-route';
tlsMode = 'passthrough'; // STARTTLS tlsMode = 'passthrough';
break; break;
case 465: case 465:
routeName = 'smtps-route'; routeName = 'smtps-route';
tlsMode = 'terminate'; // Implicit TLS tlsMode = 'terminate';
break; break;
default: default:
routeName = `email-port-${externalPort}-route`; routeName = `email-port-${externalPort}-route`;
@@ -1171,7 +943,6 @@ export class UnifiedEmailServer extends EventEmitter {
* Update server configuration * Update server configuration
*/ */
public updateOptions(options: Partial<IUnifiedEmailServerOptions>): void { public updateOptions(options: Partial<IUnifiedEmailServerOptions>): void {
// Stop the server if changing ports
const portsChanged = options.ports && const portsChanged = options.ports &&
(!this.options.ports || (!this.options.ports ||
JSON.stringify(options.ports) !== JSON.stringify(this.options.ports)); JSON.stringify(options.ports) !== JSON.stringify(this.options.ports));
@@ -1182,15 +953,12 @@ export class UnifiedEmailServer extends EventEmitter {
this.start(); this.start();
}); });
} else { } else {
// Update options without restart
this.options = { ...this.options, ...options }; this.options = { ...this.options, ...options };
// Update domain registry if domains changed
if (options.domains) { if (options.domains) {
this.domainRegistry = new DomainRegistry(options.domains, options.defaults || this.options.defaults); this.domainRegistry = new DomainRegistry(options.domains, options.defaults || this.options.defaults);
} }
// Update email router if routes changed
if (options.routes) { if (options.routes) {
this.emailRouter.updateRoutes(options.routes); this.emailRouter.updateRoutes(options.routes);
} }
@@ -1219,21 +987,8 @@ export class UnifiedEmailServer extends EventEmitter {
return this.domainRegistry; return this.domainRegistry;
} }
/**
* Update email routes dynamically
*/
public updateRoutes(routes: IEmailRoute[]): void {
this.emailRouter.setRoutes(routes);
logger.log('info', `Updated email routes with ${routes.length} routes`);
}
/** /**
* Send an email through the delivery system * Send an email through the delivery system
* @param email The email to send
* @param mode The processing mode to use
* @param rule Optional rule to apply
* @param options Optional sending options
* @returns The ID of the queued email
*/ */
public async sendEmail( public async sendEmail(
email: Email, email: Email,
@@ -1248,7 +1003,6 @@ export class UnifiedEmailServer extends EventEmitter {
logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`); logger.log('info', `Sending email: ${email.subject} to ${email.to.join(', ')}`);
try { try {
// Validate the email
if (!email.from) { if (!email.from) {
throw new Error('Email must have a sender address'); throw new Error('Email must have a sender address');
} }
@@ -1257,12 +1011,11 @@ export class UnifiedEmailServer extends EventEmitter {
throw new Error('Email must have at least one recipient'); throw new Error('Email must have at least one recipient');
} }
// Check if any recipients are on the suppression list (unless explicitly skipped) // Check if any recipients are on the suppression list
if (!options?.skipSuppressionCheck) { if (!options?.skipSuppressionCheck) {
const suppressedRecipients = email.to.filter(recipient => this.isEmailSuppressed(recipient)); const suppressedRecipients = email.to.filter(recipient => this.isEmailSuppressed(recipient));
if (suppressedRecipients.length > 0) { if (suppressedRecipients.length > 0) {
// Filter out suppressed recipients
const originalCount = email.to.length; const originalCount = email.to.length;
const suppressed = suppressedRecipients.map(recipient => { const suppressed = suppressedRecipients.map(recipient => {
const info = this.getSuppressionInfo(recipient); const info = this.getSuppressionInfo(recipient);
@@ -1275,26 +1028,21 @@ export class UnifiedEmailServer extends EventEmitter {
logger.log('warn', `Filtering out ${suppressedRecipients.length} suppressed recipient(s)`, { suppressed }); logger.log('warn', `Filtering out ${suppressedRecipients.length} suppressed recipient(s)`, { suppressed });
// If all recipients are suppressed, throw an error
if (suppressedRecipients.length === originalCount) { if (suppressedRecipients.length === originalCount) {
throw new Error('All recipients are on the suppression list'); throw new Error('All recipients are on the suppression list');
} }
// Filter the recipients list to only include non-suppressed addresses
email.to = email.to.filter(recipient => !this.isEmailSuppressed(recipient)); email.to = email.to.filter(recipient => !this.isEmailSuppressed(recipient));
} }
} }
// Check if the sender domain has DKIM keys and sign the email if needed // Sign with DKIM if configured
if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) { if (mode === 'mta' && route?.action.options?.mtaOptions?.dkimSign) {
const domain = email.from.split('@')[1]; const domain = email.from.split('@')[1];
await this.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'mta'); await this.dkimManager.handleDkimSigning(email, domain, route.action.options.mtaOptions.dkimOptions?.keySelector || 'mta');
} }
// Generate a unique ID for this email
const id = plugins.uuid.v4(); const id = plugins.uuid.v4();
// Queue the email for delivery
await this.deliveryQueue.enqueue(email, mode, route); await this.deliveryQueue.enqueue(email, mode, route);
logger.log('info', `Email queued with ID: ${id}`); logger.log('info', `Email queued with ID: ${id}`);
@@ -1305,51 +1053,14 @@ export class UnifiedEmailServer extends EventEmitter {
} }
} }
/** // -----------------------------------------------------------------------
* Handle DKIM signing for an email // Bounce/suppression methods
* @param email The email to sign // -----------------------------------------------------------------------
* @param domain The domain to sign with
* @param selector The DKIM selector
*/
private async handleDkimSigning(email: Email, domain: string, selector: string): Promise<void> {
try {
// Ensure we have DKIM keys for this domain
await this.dkimCreator.handleDKIMKeysForDomain(domain);
// Get the private key
const { privateKey } = await this.dkimCreator.readDKIMKeys(domain);
// Convert Email to raw format for signing
const rawEmail = email.toRFC822String();
// Sign the email via Rust bridge
const signResult = await this.rustBridge.signDkim({
rawMessage: rawEmail,
domain,
selector,
privateKey,
});
if (signResult.header) {
email.addHeader('DKIM-Signature', signResult.header);
logger.log('info', `Successfully added DKIM signature for ${domain}`);
}
} catch (error) {
logger.log('error', `Failed to sign email with DKIM: ${error.message}`);
// Continue without DKIM rather than failing the send
}
}
/**
* Process a bounce notification email
* @param bounceEmail The email containing bounce notification information
* @returns Processed bounce record or null if not a bounce
*/
public async processBounceNotification(bounceEmail: Email): Promise<boolean> { public async processBounceNotification(bounceEmail: Email): Promise<boolean> {
logger.log('info', 'Processing potential bounce notification email'); logger.log('info', 'Processing potential bounce notification email');
try { try {
// Process as a bounce notification (no conversion needed anymore)
const bounceRecord = await this.bounceManager.processBounceEmail(bounceEmail); const bounceRecord = await this.bounceManager.processBounceEmail(bounceEmail);
if (bounceRecord) { if (bounceRecord) {
@@ -1358,10 +1069,8 @@ export class UnifiedEmailServer extends EventEmitter {
bounceCategory: bounceRecord.bounceCategory bounceCategory: bounceRecord.bounceCategory
}); });
// Notify any registered listeners about the bounce
this.emit('bounceProcessed', bounceRecord); this.emit('bounceProcessed', bounceRecord);
// Log security event
SecurityLogger.getInstance().logEvent({ SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO, level: SecurityLogLevel.INFO,
type: SecurityEventType.EMAIL_VALIDATION, type: SecurityEventType.EMAIL_VALIDATION,
@@ -1387,10 +1096,7 @@ export class UnifiedEmailServer extends EventEmitter {
level: SecurityLogLevel.ERROR, level: SecurityLogLevel.ERROR,
type: SecurityEventType.EMAIL_VALIDATION, type: SecurityEventType.EMAIL_VALIDATION,
message: 'Failed to process bounce notification', message: 'Failed to process bounce notification',
details: { details: { error: error.message, subject: bounceEmail.subject },
error: error.message,
subject: bounceEmail.subject
},
success: false success: false
}); });
@@ -1398,41 +1104,22 @@ export class UnifiedEmailServer extends EventEmitter {
} }
} }
/**
* Process an SMTP failure as a bounce
* @param recipient Recipient email that failed
* @param smtpResponse SMTP error response
* @param options Additional options for bounce processing
* @returns Processed bounce record
*/
public async processSmtpFailure( public async processSmtpFailure(
recipient: string, recipient: string,
smtpResponse: string, smtpResponse: string,
options: { options: { sender?: string; originalEmailId?: string; statusCode?: string; headers?: Record<string, string> } = {}
sender?: string;
originalEmailId?: string;
statusCode?: string;
headers?: Record<string, string>;
} = {}
): Promise<boolean> { ): Promise<boolean> {
logger.log('info', `Processing SMTP failure for ${recipient}: ${smtpResponse}`); logger.log('info', `Processing SMTP failure for ${recipient}: ${smtpResponse}`);
try { try {
// Process the SMTP failure through the bounce manager const bounceRecord = await this.bounceManager.processSmtpFailure(recipient, smtpResponse, options);
const bounceRecord = await this.bounceManager.processSmtpFailure(
recipient,
smtpResponse,
options
);
logger.log('info', `Successfully processed SMTP failure for ${recipient} as ${bounceRecord.bounceCategory} bounce`, { logger.log('info', `Successfully processed SMTP failure for ${recipient} as ${bounceRecord.bounceCategory} bounce`, {
bounceType: bounceRecord.bounceType bounceType: bounceRecord.bounceType
}); });
// Notify any registered listeners about the bounce
this.emit('bounceProcessed', bounceRecord); this.emit('bounceProcessed', bounceRecord);
// Log security event
SecurityLogger.getInstance().logEvent({ SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.INFO, level: SecurityLogLevel.INFO,
type: SecurityEventType.EMAIL_VALIDATION, type: SecurityEventType.EMAIL_VALIDATION,
@@ -1455,11 +1142,7 @@ export class UnifiedEmailServer extends EventEmitter {
level: SecurityLogLevel.ERROR, level: SecurityLogLevel.ERROR,
type: SecurityEventType.EMAIL_VALIDATION, type: SecurityEventType.EMAIL_VALIDATION,
message: 'Failed to process SMTP failure', message: 'Failed to process SMTP failure',
details: { details: { recipient, smtpResponse, error: error.message },
recipient,
smtpResponse,
error: error.message
},
success: false success: false
}); });
@@ -1467,85 +1150,36 @@ export class UnifiedEmailServer extends EventEmitter {
} }
} }
/**
* Check if an email address is suppressed (has bounced previously)
* @param email Email address to check
* @returns Whether the email is suppressed
*/
public isEmailSuppressed(email: string): boolean { public isEmailSuppressed(email: string): boolean {
return this.bounceManager.isEmailSuppressed(email); return this.bounceManager.isEmailSuppressed(email);
} }
/** public getSuppressionInfo(email: string): { reason: string; timestamp: number; expiresAt?: number } | null {
* Get suppression information for an email
* @param email Email address to check
* @returns Suppression information or null if not suppressed
*/
public getSuppressionInfo(email: string): {
reason: string;
timestamp: number;
expiresAt?: number;
} | null {
return this.bounceManager.getSuppressionInfo(email); return this.bounceManager.getSuppressionInfo(email);
} }
/** public getBounceHistory(email: string): { lastBounce: number; count: number; type: BounceType; category: BounceCategory } | null {
* Get bounce history information for an email
* @param email Email address to check
* @returns Bounce history or null if no bounces
*/
public getBounceHistory(email: string): {
lastBounce: number;
count: number;
type: BounceType;
category: BounceCategory;
} | null {
return this.bounceManager.getBounceInfo(email); return this.bounceManager.getBounceInfo(email);
} }
/**
* Get all suppressed email addresses
* @returns Array of suppressed email addresses
*/
public getSuppressionList(): string[] { public getSuppressionList(): string[] {
return this.bounceManager.getSuppressionList(); return this.bounceManager.getSuppressionList();
} }
/**
* Get all hard bounced email addresses
* @returns Array of hard bounced email addresses
*/
public getHardBouncedAddresses(): string[] { public getHardBouncedAddresses(): string[] {
return this.bounceManager.getHardBouncedAddresses(); return this.bounceManager.getHardBouncedAddresses();
} }
/**
* Add an email to the suppression list
* @param email Email address to suppress
* @param reason Reason for suppression
* @param expiresAt Optional expiration time (undefined for permanent)
*/
public addToSuppressionList(email: string, reason: string, expiresAt?: number): void { public addToSuppressionList(email: string, reason: string, expiresAt?: number): void {
this.bounceManager.addToSuppressionList(email, reason, expiresAt); this.bounceManager.addToSuppressionList(email, reason, expiresAt);
logger.log('info', `Added ${email} to suppression list: ${reason}`); logger.log('info', `Added ${email} to suppression list: ${reason}`);
} }
/**
* Remove an email from the suppression list
* @param email Email address to remove from suppression
*/
public removeFromSuppressionList(email: string): void { public removeFromSuppressionList(email: string): void {
this.bounceManager.removeFromSuppressionList(email); this.bounceManager.removeFromSuppressionList(email);
logger.log('info', `Removed ${email} from suppression list`); logger.log('info', `Removed ${email} from suppression list`);
} }
/**
* Record email bounce
* @param domain Sending domain
* @param receivingDomain Receiving domain that bounced
* @param bounceType Type of bounce (hard/soft)
* @param reason Bounce reason
*/
public recordBounce(domain: string, receivingDomain: string, bounceType: 'hard' | 'soft', reason: string): void { public recordBounce(domain: string, receivingDomain: string, bounceType: 'hard' | 'soft', reason: string): void {
const bounceRecord = { const bounceRecord = {
id: `bounce_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, id: `bounce_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
@@ -1566,7 +1200,6 @@ export class UnifiedEmailServer extends EventEmitter {
/** /**
* Get the rate limiter instance * Get the rate limiter instance
* @returns The unified rate limiter
*/ */
public getRateLimiter(): UnifiedRateLimiter { public getRateLimiter(): UnifiedRateLimiter {
return this.rateLimiter; return this.rateLimiter;

View File

@@ -4,3 +4,6 @@ export * from './classes.unified.email.server.js';
export * from './classes.dns.manager.js'; export * from './classes.dns.manager.js';
export * from './interfaces.js'; export * from './interfaces.js';
export * from './classes.domain.registry.js'; export * from './classes.domain.registry.js';
export * from './classes.email.action.executor.js';
export * from './classes.dkim.manager.js';

View File

@@ -115,7 +115,7 @@ export class DKIMCreator {
} }
} }
// Create a DKIM key pair - changed to public for API access // Create an RSA DKIM key pair - changed to public for API access
public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> { public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> {
const { privateKey, publicKey } = await generateKeyPair('rsa', { const { privateKey, publicKey } = await generateKeyPair('rsa', {
modulusLength: 2048, modulusLength: 2048,
@@ -126,6 +126,16 @@ export class DKIMCreator {
return { privateKey, publicKey }; return { privateKey, publicKey };
} }
// Create an Ed25519 DKIM key pair (RFC 8463)
public async createEd25519Keys(): Promise<{ privateKey: string; publicKey: string }> {
const { privateKey, publicKey } = await generateKeyPair('ed25519', {
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
return { privateKey, publicKey };
}
// Store a DKIM key pair - uses storage manager if available, else disk // Store a DKIM key pair - uses storage manager if available, else disk
public async storeDKIMKeys( public async storeDKIMKeys(
privateKey: string, privateKey: string,
@@ -176,8 +186,11 @@ export class DKIMCreator {
.replace(pemFooter, '') .replace(pemFooter, '')
.replace(/\n/g, ''); .replace(/\n/g, '');
// Detect key type from PEM header
const keyAlgo = keys.privateKey.includes('ED25519') || keys.publicKey.length < 200 ? 'ed25519' : 'rsa';
// Now generate the DKIM DNS TXT record // Now generate the DKIM DNS TXT record
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`; const dnsRecordValue = `v=DKIM1; h=sha256; k=${keyAlgo}; p=${keyContents}`;
return { return {
name: `mta._domainkey.${domainArg}`, name: `mta._domainkey.${domainArg}`,
@@ -375,8 +388,11 @@ export class DKIMCreator {
.replace(pemFooter, '') .replace(pemFooter, '')
.replace(/\n/g, ''); .replace(/\n/g, '');
// Detect key type from PEM header
const keyAlgo = keys.privateKey.includes('ED25519') || keys.publicKey.length < 200 ? 'ed25519' : 'rsa';
// Generate the DKIM DNS TXT record // Generate the DKIM DNS TXT record
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`; const dnsRecordValue = `v=DKIM1; h=sha256; k=${keyAlgo}; p=${keyContents}`;
return { return {
name: `${selector}._domainkey.${domain}`, name: `${selector}._domainkey.${domain}`,

View File

@@ -1,86 +0,0 @@
import { logger } from '../../logger.js';
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
/**
* Result of a DKIM verification
*/
export interface IDkimVerificationResult {
isValid: boolean;
domain?: string;
selector?: string;
status?: string;
details?: any;
errorMessage?: string;
signatureFields?: Record<string, string>;
}
/**
* DKIM verifier — delegates to the Rust security bridge.
*/
export class DKIMVerifier {
constructor() {}
/**
* Verify DKIM signature for an email via Rust bridge
*/
public async verify(
emailData: string,
options: {
useCache?: boolean;
returnDetails?: boolean;
} = {}
): Promise<IDkimVerificationResult> {
try {
const bridge = RustSecurityBridge.getInstance();
const results = await bridge.verifyDkim(emailData);
const first = results[0];
const result: IDkimVerificationResult = {
isValid: first?.is_valid ?? false,
domain: first?.domain ?? undefined,
selector: first?.selector ?? undefined,
status: first?.status ?? 'none',
details: options.returnDetails ? results : undefined,
};
SecurityLogger.getInstance().logEvent({
level: result.isValid ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
type: SecurityEventType.DKIM,
message: `DKIM verification ${result.isValid ? 'passed' : 'failed'} for domain ${result.domain || 'unknown'}`,
details: { selector: result.selector, status: result.status },
domain: result.domain || 'unknown',
success: result.isValid
});
logger.log(result.isValid ? 'info' : 'warn',
`DKIM verification: ${result.status} for domain ${result.domain || 'unknown'}`);
return result;
} catch (error) {
logger.log('error', `DKIM verification failed: ${error.message}`);
SecurityLogger.getInstance().logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.DKIM,
message: `DKIM verification error`,
details: { error: error.message },
success: false
});
return {
isValid: false,
status: 'temperror',
errorMessage: `Verification error: ${error.message}`
};
}
}
/** No-op — Rust bridge handles its own caching */
public clearCache(): void {}
/** Always 0 — cache is managed by the Rust side */
public getCacheSize(): number {
return 0;
}
}

View File

@@ -1,475 +0,0 @@
import { logger } from '../../logger.js';
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
import type { Email } from '../core/classes.email.js';
/**
* DMARC policy types
*/
export enum DmarcPolicy {
NONE = 'none',
QUARANTINE = 'quarantine',
REJECT = 'reject'
}
/**
* DMARC alignment modes
*/
export enum DmarcAlignment {
RELAXED = 'r',
STRICT = 's'
}
/**
* DMARC record fields
*/
export interface DmarcRecord {
// Required fields
version: string;
policy: DmarcPolicy;
// Optional fields
subdomainPolicy?: DmarcPolicy;
pct?: number;
adkim?: DmarcAlignment;
aspf?: DmarcAlignment;
reportInterval?: number;
failureOptions?: string;
reportUriAggregate?: string[];
reportUriForensic?: string[];
}
/**
* DMARC verification result
*/
export interface DmarcResult {
hasDmarc: boolean;
record?: DmarcRecord;
spfDomainAligned: boolean;
dkimDomainAligned: boolean;
spfPassed: boolean;
dkimPassed: boolean;
policyEvaluated: DmarcPolicy;
actualPolicy: DmarcPolicy;
appliedPercentage: number;
action: 'pass' | 'quarantine' | 'reject';
details: string;
error?: string;
}
/**
* Class for verifying and enforcing DMARC policies
*/
export class DmarcVerifier {
// DNS Manager reference for verifying records
private dnsManager?: any;
constructor(dnsManager?: any) {
this.dnsManager = dnsManager;
}
/**
* Parse a DMARC record from a TXT record string
* @param record DMARC TXT record string
* @returns Parsed DMARC record or null if invalid
*/
public parseDmarcRecord(record: string): DmarcRecord | null {
if (!record.startsWith('v=DMARC1')) {
return null;
}
try {
// Initialize record with default values
const dmarcRecord: DmarcRecord = {
version: 'DMARC1',
policy: DmarcPolicy.NONE,
pct: 100,
adkim: DmarcAlignment.RELAXED,
aspf: DmarcAlignment.RELAXED
};
// Split the record into tag/value pairs
const parts = record.split(';').map(part => part.trim());
for (const part of parts) {
if (!part || !part.includes('=')) continue;
const [tag, value] = part.split('=').map(p => p.trim());
// Process based on tag
switch (tag.toLowerCase()) {
case 'v':
dmarcRecord.version = value;
break;
case 'p':
dmarcRecord.policy = value as DmarcPolicy;
break;
case 'sp':
dmarcRecord.subdomainPolicy = value as DmarcPolicy;
break;
case 'pct':
const pctValue = parseInt(value, 10);
if (!isNaN(pctValue) && pctValue >= 0 && pctValue <= 100) {
dmarcRecord.pct = pctValue;
}
break;
case 'adkim':
dmarcRecord.adkim = value as DmarcAlignment;
break;
case 'aspf':
dmarcRecord.aspf = value as DmarcAlignment;
break;
case 'ri':
const interval = parseInt(value, 10);
if (!isNaN(interval) && interval > 0) {
dmarcRecord.reportInterval = interval;
}
break;
case 'fo':
dmarcRecord.failureOptions = value;
break;
case 'rua':
dmarcRecord.reportUriAggregate = value.split(',').map(uri => {
if (uri.startsWith('mailto:')) {
return uri.substring(7).trim();
}
return uri.trim();
});
break;
case 'ruf':
dmarcRecord.reportUriForensic = value.split(',').map(uri => {
if (uri.startsWith('mailto:')) {
return uri.substring(7).trim();
}
return uri.trim();
});
break;
}
}
// Ensure subdomain policy is set if not explicitly provided
if (!dmarcRecord.subdomainPolicy) {
dmarcRecord.subdomainPolicy = dmarcRecord.policy;
}
return dmarcRecord;
} catch (error) {
logger.log('error', `Error parsing DMARC record: ${error.message}`, {
record,
error: error.message
});
return null;
}
}
/**
* Check if domains are aligned according to DMARC policy
* @param headerDomain Domain from header (From)
* @param authDomain Domain from authentication (SPF, DKIM)
* @param alignment Alignment mode
* @returns Whether the domains are aligned
*/
private isDomainAligned(
headerDomain: string,
authDomain: string,
alignment: DmarcAlignment
): boolean {
if (!headerDomain || !authDomain) {
return false;
}
// For strict alignment, domains must match exactly
if (alignment === DmarcAlignment.STRICT) {
return headerDomain.toLowerCase() === authDomain.toLowerCase();
}
// For relaxed alignment, the authenticated domain must be a subdomain of the header domain
// or the same as the header domain
const headerParts = headerDomain.toLowerCase().split('.');
const authParts = authDomain.toLowerCase().split('.');
// Ensures we have at least two parts (domain and TLD)
if (headerParts.length < 2 || authParts.length < 2) {
return false;
}
// Get organizational domain (last two parts)
const headerOrgDomain = headerParts.slice(-2).join('.');
const authOrgDomain = authParts.slice(-2).join('.');
return headerOrgDomain === authOrgDomain;
}
/**
* Extract domain from an email address
* @param email Email address
* @returns Domain part of the email
*/
private getDomainFromEmail(email: string): string {
if (!email) return '';
// Handle name + email format: "John Doe <john@example.com>"
const matches = email.match(/<([^>]+)>/);
const address = matches ? matches[1] : email;
const parts = address.split('@');
return parts.length > 1 ? parts[1] : '';
}
/**
* Check if DMARC verification should be applied based on percentage
* @param record DMARC record
* @returns Whether DMARC verification should be applied
*/
private shouldApplyDmarc(record: DmarcRecord): boolean {
if (record.pct === undefined || record.pct === 100) {
return true;
}
// Apply DMARC randomly based on percentage
const random = Math.floor(Math.random() * 100) + 1;
return random <= record.pct;
}
/**
* Determine the action to take based on DMARC policy
* @param policy DMARC policy
* @returns Action to take
*/
private determineAction(policy: DmarcPolicy): 'pass' | 'quarantine' | 'reject' {
switch (policy) {
case DmarcPolicy.REJECT:
return 'reject';
case DmarcPolicy.QUARANTINE:
return 'quarantine';
case DmarcPolicy.NONE:
default:
return 'pass';
}
}
/**
* Verify DMARC for an incoming email
* @param email Email to verify
* @param spfResult SPF verification result
* @param dkimResult DKIM verification result
* @returns DMARC verification result
*/
public async verify(
email: Email,
spfResult: { domain: string; result: boolean },
dkimResult: { domain: string; result: boolean }
): Promise<DmarcResult> {
const securityLogger = SecurityLogger.getInstance();
// Initialize result
const result: DmarcResult = {
hasDmarc: false,
spfDomainAligned: false,
dkimDomainAligned: false,
spfPassed: spfResult.result,
dkimPassed: dkimResult.result,
policyEvaluated: DmarcPolicy.NONE,
actualPolicy: DmarcPolicy.NONE,
appliedPercentage: 100,
action: 'pass',
details: 'DMARC not configured'
};
try {
// Extract From domain
const fromHeader = email.getFromEmail();
const fromDomain = this.getDomainFromEmail(fromHeader);
if (!fromDomain) {
result.error = 'Invalid From domain';
return result;
}
// Check alignment
result.spfDomainAligned = this.isDomainAligned(
fromDomain,
spfResult.domain,
DmarcAlignment.RELAXED
);
result.dkimDomainAligned = this.isDomainAligned(
fromDomain,
dkimResult.domain,
DmarcAlignment.RELAXED
);
// Lookup DMARC record
const dmarcVerificationResult = this.dnsManager ?
await this.dnsManager.verifyDmarcRecord(fromDomain) :
{ found: false, valid: false, error: 'DNS Manager not available' };
// If DMARC record exists and is valid
if (dmarcVerificationResult.found && dmarcVerificationResult.valid) {
result.hasDmarc = true;
// Parse DMARC record
const parsedRecord = this.parseDmarcRecord(dmarcVerificationResult.value);
if (parsedRecord) {
result.record = parsedRecord;
result.actualPolicy = parsedRecord.policy;
result.appliedPercentage = parsedRecord.pct || 100;
// Override alignment modes if specified in record
if (parsedRecord.adkim) {
result.dkimDomainAligned = this.isDomainAligned(
fromDomain,
dkimResult.domain,
parsedRecord.adkim
);
}
if (parsedRecord.aspf) {
result.spfDomainAligned = this.isDomainAligned(
fromDomain,
spfResult.domain,
parsedRecord.aspf
);
}
// Determine DMARC compliance
const spfAligned = result.spfPassed && result.spfDomainAligned;
const dkimAligned = result.dkimPassed && result.dkimDomainAligned;
// Email passes DMARC if either SPF or DKIM passes with alignment
const dmarcPass = spfAligned || dkimAligned;
// Use record percentage to determine if policy should be applied
const applyPolicy = this.shouldApplyDmarc(parsedRecord);
if (!dmarcPass) {
// DMARC failed, apply policy
result.policyEvaluated = applyPolicy ? parsedRecord.policy : DmarcPolicy.NONE;
result.action = this.determineAction(result.policyEvaluated);
result.details = `DMARC failed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}, policy=${result.policyEvaluated}`;
} else {
result.policyEvaluated = DmarcPolicy.NONE;
result.action = 'pass';
result.details = `DMARC passed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}`;
}
} else {
result.error = 'Invalid DMARC record format';
result.details = 'DMARC record invalid';
}
} else {
// No DMARC record found or invalid
result.details = dmarcVerificationResult.error || 'No DMARC record found';
}
// Log the DMARC verification
securityLogger.logEvent({
level: result.action === 'pass' ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
type: SecurityEventType.DMARC,
message: result.details,
domain: fromDomain,
details: {
fromDomain,
spfDomain: spfResult.domain,
dkimDomain: dkimResult.domain,
spfPassed: result.spfPassed,
dkimPassed: result.dkimPassed,
spfAligned: result.spfDomainAligned,
dkimAligned: result.dkimDomainAligned,
dmarcPolicy: result.policyEvaluated,
action: result.action
},
success: result.action === 'pass'
});
return result;
} catch (error) {
logger.log('error', `Error verifying DMARC: ${error.message}`, {
error: error.message,
emailId: email.getMessageId()
});
result.error = `DMARC verification error: ${error.message}`;
// Log error
securityLogger.logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.DMARC,
message: `DMARC verification failed with error`,
details: {
error: error.message,
emailId: email.getMessageId()
},
success: false
});
return result;
}
}
/**
* Apply DMARC policy to an email
* @param email Email to apply policy to
* @param dmarcResult DMARC verification result
* @returns Whether the email should be accepted
*/
public applyPolicy(email: Email, dmarcResult: DmarcResult): boolean {
// Apply action based on DMARC verification result
switch (dmarcResult.action) {
case 'reject':
// Reject the email
email.mightBeSpam = true;
logger.log('warn', `Email rejected due to DMARC policy: ${dmarcResult.details}`, {
emailId: email.getMessageId(),
from: email.getFromEmail(),
subject: email.subject
});
return false;
case 'quarantine':
// Quarantine the email (mark as spam)
email.mightBeSpam = true;
// Add spam header
if (!email.headers['X-Spam-Flag']) {
email.headers['X-Spam-Flag'] = 'YES';
}
// Add DMARC reason header
email.headers['X-DMARC-Result'] = dmarcResult.details;
logger.log('warn', `Email quarantined due to DMARC policy: ${dmarcResult.details}`, {
emailId: email.getMessageId(),
from: email.getFromEmail(),
subject: email.subject
});
return true;
case 'pass':
default:
// Accept the email
// Add DMARC result header for information
email.headers['X-DMARC-Result'] = dmarcResult.details;
return true;
}
}
/**
* End-to-end DMARC verification and policy application
* This method should be called after SPF and DKIM verification
* @param email Email to verify
* @param spfResult SPF verification result
* @param dkimResult DKIM verification result
* @returns Whether the email should be accepted
*/
public async verifyAndApply(
email: Email,
spfResult: { domain: string; result: boolean },
dkimResult: { domain: string; result: boolean }
): Promise<boolean> {
// Verify DMARC
const dmarcResult = await this.verify(email, spfResult, dkimResult);
// Apply DMARC policy
return this.applyPolicy(email, dmarcResult);
}
}

View File

@@ -1,8 +1,4 @@
import * as plugins from '../../plugins.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
import type { Email } from '../core/classes.email.js';
/** /**
* SPF result qualifiers * SPF result qualifiers
@@ -127,107 +123,4 @@ export class SpfVerifier {
return null; return null;
} }
} }
/**
* Verify SPF for a given email — delegates to Rust bridge
*/
public async verify(
email: Email,
ip: string,
heloDomain: string
): Promise<SpfResult> {
const securityLogger = SecurityLogger.getInstance();
const mailFrom = email.from || '';
const domain = mailFrom.split('@')[1] || '';
try {
const bridge = RustSecurityBridge.getInstance();
const result = await bridge.checkSpf({
ip,
heloDomain,
hostname: plugins.os.hostname(),
mailFrom,
});
const spfResult: SpfResult = {
result: result.result as SpfResult['result'],
domain: result.domain,
ip: result.ip,
explanation: result.explanation ?? undefined,
};
securityLogger.logEvent({
level: spfResult.result === 'pass' ? SecurityLogLevel.INFO :
(spfResult.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO),
type: SecurityEventType.SPF,
message: `SPF ${spfResult.result} for ${spfResult.domain} from IP ${ip}`,
domain: spfResult.domain,
details: { ip, heloDomain, result: spfResult.result, explanation: spfResult.explanation },
success: spfResult.result === 'pass'
});
return spfResult;
} catch (error) {
logger.log('error', `SPF verification error: ${error.message}`, { domain, ip, error: error.message });
securityLogger.logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.SPF,
message: `SPF verification error for ${domain}`,
domain,
details: { ip, error: error.message },
success: false
});
return {
result: 'temperror',
explanation: `Error verifying SPF: ${error.message}`,
domain,
ip,
error: error.message
};
}
}
/**
* Check if email passes SPF verification and apply headers
*/
public async verifyAndApply(
email: Email,
ip: string,
heloDomain: string
): Promise<boolean> {
const result = await this.verify(email, ip, heloDomain);
email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation || ''}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`;
switch (result.result) {
case 'fail':
email.mightBeSpam = true;
logger.log('warn', `SPF failed for ${result.domain} from ${ip}: ${result.explanation}`);
return false;
case 'softfail':
email.mightBeSpam = true;
logger.log('info', `SPF softfailed for ${result.domain} from ${ip}: ${result.explanation}`);
return true;
case 'neutral':
case 'none':
logger.log('info', `SPF ${result.result} for ${result.domain} from ${ip}: ${result.explanation}`);
return true;
case 'pass':
logger.log('info', `SPF passed for ${result.domain} from ${ip}: ${result.explanation}`);
return true;
case 'temperror':
case 'permerror':
logger.log('error', `SPF error for ${result.domain} from ${ip}: ${result.explanation}`);
return true;
default:
return true;
}
}
} }

View File

@@ -1,5 +1,3 @@
// Email security components // Email security components
export * from './classes.dkimcreator.js'; export * from './classes.dkimcreator.js';
export * from './classes.dkimverifier.js';
export * from './classes.dmarcverifier.js';
export * from './classes.spfverifier.js'; export * from './classes.spfverifier.js';

View File

@@ -95,11 +95,12 @@ interface ISmtpSendOptions {
domain?: string; domain?: string;
auth?: { user: string; pass: string; method?: string }; auth?: { user: string; pass: string; method?: string };
email: IOutboundEmail; email: IOutboundEmail;
dkim?: { domain: string; selector: string; privateKey: string }; dkim?: { domain: string; selector: string; privateKey: string; keyType?: string };
connectionTimeoutSecs?: number; connectionTimeoutSecs?: number;
socketTimeoutSecs?: number; socketTimeoutSecs?: number;
poolKey?: string; poolKey?: string;
maxPoolConnections?: number; maxPoolConnections?: number;
tlsOpportunistic?: boolean;
} }
interface ISmtpSendRawOptions { interface ISmtpSendRawOptions {
@@ -147,6 +148,7 @@ interface ISmtpServerConfig {
securePort?: number; securePort?: number;
tlsCertPem?: string; tlsCertPem?: string;
tlsKeyPem?: string; tlsKeyPem?: string;
additionalTlsCerts?: Array<{ domains: string[]; certPem: string; keyPem: string }>;
maxMessageSize?: number; maxMessageSize?: number;
maxConnections?: number; maxConnections?: number;
maxRecipients?: number; maxRecipients?: number;
@@ -193,6 +195,13 @@ interface IAuthRequestEvent {
remoteAddr: string; remoteAddr: string;
} }
interface IScramCredentialRequestEvent {
correlationId: string;
sessionId: string;
username: string;
remoteAddr: string;
}
/** /**
* Type-safe command map for the mailer-bin IPC bridge. * Type-safe command map for the mailer-bin IPC bridge.
*/ */
@@ -222,7 +231,7 @@ type TMailerCommands = {
result: IDkimVerificationResult[]; result: IDkimVerificationResult[];
}; };
signDkim: { signDkim: {
params: { rawMessage: string; domain: string; selector?: string; privateKey: string }; params: { rawMessage: string; domain: string; selector?: string; privateKey: string; keyType?: string };
result: { header: string; signedMessage: string }; result: { header: string; signedMessage: string };
}; };
checkSpf: { checkSpf: {
@@ -273,6 +282,17 @@ type TMailerCommands = {
}; };
result: { resolved: boolean }; result: { resolved: boolean };
}; };
scramCredentialResult: {
params: {
correlationId: string;
found: boolean;
salt?: string;
iterations?: number;
storedKey?: string;
serverKey?: string;
};
result: { resolved: boolean };
};
configureRateLimits: { configureRateLimits: {
params: IRateLimitConfig; params: IRateLimitConfig;
result: { configured: boolean }; result: { configured: boolean };
@@ -706,12 +726,13 @@ export class RustSecurityBridge extends EventEmitter {
return this.bridge.sendCommand('verifyDkim', { rawMessage }); return this.bridge.sendCommand('verifyDkim', { rawMessage });
} }
/** Sign an email with DKIM. */ /** Sign an email with DKIM (RSA or Ed25519). */
public async signDkim(opts: { public async signDkim(opts: {
rawMessage: string; rawMessage: string;
domain: string; domain: string;
selector?: string; selector?: string;
privateKey: string; privateKey: string;
keyType?: string;
}): Promise<{ header: string; signedMessage: string }> { }): Promise<{ header: string; signedMessage: string }> {
this.ensureRunning(); this.ensureRunning();
return this.bridge.sendCommand('signDkim', opts); return this.bridge.sendCommand('signDkim', opts);
@@ -829,6 +850,22 @@ export class RustSecurityBridge extends EventEmitter {
await this.bridge.sendCommand('authResult', opts); await this.bridge.sendCommand('authResult', opts);
} }
/**
* Send SCRAM credentials back to the Rust SMTP server.
* Values (salt, storedKey, serverKey) must be base64-encoded.
*/
public async sendScramCredentialResult(opts: {
correlationId: string;
found: boolean;
salt?: string;
iterations?: number;
storedKey?: string;
serverKey?: string;
}): Promise<void> {
this.ensureRunning();
await this.bridge.sendCommand('scramCredentialResult', opts);
}
/** Update rate limit configuration at runtime. */ /** Update rate limit configuration at runtime. */
public async configureRateLimits(config: IRateLimitConfig): Promise<void> { public async configureRateLimits(config: IRateLimitConfig): Promise<void> {
this.ensureRunning(); this.ensureRunning();
@@ -855,6 +892,14 @@ export class RustSecurityBridge extends EventEmitter {
this.bridge.on('management:authRequest', handler); this.bridge.on('management:authRequest', handler);
} }
/**
* Register a handler for scramCredentialRequest events from the Rust SMTP server.
* The handler must call sendScramCredentialResult() with the correlationId.
*/
public onScramCredentialRequest(handler: (data: IScramCredentialRequestEvent) => void): void {
this.bridge.on('management:scramCredentialRequest', handler);
}
/** Remove an emailReceived event handler. */ /** Remove an emailReceived event handler. */
public offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void { public offEmailReceived(handler: (data: IEmailReceivedEvent) => void): void {
this.bridge.off('management:emailReceived', handler); this.bridge.off('management:emailReceived', handler);
@@ -864,6 +909,11 @@ export class RustSecurityBridge extends EventEmitter {
public offAuthRequest(handler: (data: IAuthRequestEvent) => void): void { public offAuthRequest(handler: (data: IAuthRequestEvent) => void): void {
this.bridge.off('management:authRequest', handler); this.bridge.off('management:authRequest', handler);
} }
/** Remove a scramCredentialRequest event handler. */
public offScramCredentialRequest(handler: (data: IScramCredentialRequestEvent) => void): void {
this.bridge.off('management:scramCredentialRequest', handler);
}
} }
// Re-export interfaces for consumers // Re-export interfaces for consumers
@@ -882,6 +932,7 @@ export type {
IEmailData, IEmailData,
IEmailReceivedEvent, IEmailReceivedEvent,
IAuthRequestEvent, IAuthRequestEvent,
IScramCredentialRequestEvent,
IOutboundEmail, IOutboundEmail,
ISmtpSendResult, ISmtpSendResult,
ISmtpSendOptions, ISmtpSendOptions,