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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user