feat(mailer-smtp): add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements

This commit is contained in:
2026-02-11 10:11:43 +00:00
parent 7908cbaefa
commit b10597fd5e
28 changed files with 1849 additions and 153 deletions

View File

@@ -21,3 +21,4 @@ hickory-resolver.workspace = true
dashmap.workspace = true
base64.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 mailer_smtp::connection::{
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult,
AuthResult, CallbackRegistry, ConnectionEvent, EmailProcessingResult, ScramCredentialResult,
};
/// mailer-bin: Rust-powered email security tools
@@ -114,10 +114,11 @@ struct IpcEvent {
// --- 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 {
email: DashMap<String, oneshot::Sender<EmailProcessingResult>>,
auth: DashMap<String, oneshot::Sender<AuthResult>>,
scram: DashMap<String, oneshot::Sender<ScramCredentialResult>>,
}
impl PendingCallbacks {
@@ -125,6 +126,7 @@ impl PendingCallbacks {
Self {
email: DashMap::new(),
auth: DashMap::new(),
scram: DashMap::new(),
}
}
}
@@ -147,9 +149,22 @@ impl CallbackRegistry for PendingCallbacks {
self.auth.insert(correlation_id.to_string(), tx);
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() {
// 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();
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")
.and_then(|v| v.as_str())
.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 {
id: req.id.clone(),
success: true,
@@ -825,6 +861,10 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
handle_auth_result(req, state)
}
"scramCredentialResult" => {
handle_scram_credential_result(req, state)
}
"configureRateLimits" => {
// Rate limit configuration is set at startSmtpServer time.
// 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.
fn parse_smtp_config(
params: &serde_json::Value,
@@ -1075,6 +1165,27 @@ fn parse_smtp_config(
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)
}
@@ -1187,11 +1298,12 @@ async fn handle_send_email(req: &IpcRequest, state: &ManagementState) -> IpcResp
// Optional DKIM signing
if let Some(dkim_val) = req.params.get("dkim") {
if let Ok(dkim_config) = serde_json::from_value::<mailer_smtp::client::DkimSignConfig>(dkim_val.clone()) {
match mailer_security::sign_dkim(
match mailer_security::sign_dkim_auto(
&raw_message,
&dkim_config.domain,
&dkim_config.selector,
&dkim_config.private_key,
&dkim_config.key_type,
) {
Ok(header) => {
// Prepend DKIM header to the message