BREAKING CHANGE(smtp-client): Replace the legacy TypeScript SMTP client with a new Rust-based SMTP client and IPC bridge for outbound delivery

This commit is contained in:
2026-02-11 07:17:05 +00:00
parent fc4877e06b
commit 27bab5f345
50 changed files with 2268 additions and 6737 deletions

View File

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

View File

@@ -327,6 +327,7 @@ struct ManagementState {
callbacks: Arc<PendingCallbacks>,
smtp_handle: Option<mailer_smtp::server::SmtpServerHandle>,
smtp_event_rx: Option<tokio::sync::mpsc::Receiver<ConnectionEvent>>,
smtp_client_manager: Arc<mailer_smtp::client::SmtpClientManager>,
}
/// Run in management/IPC mode for smartrust bridge.
@@ -349,10 +350,12 @@ fn run_management_mode() {
let rt = tokio::runtime::Runtime::new().unwrap();
let callbacks = Arc::new(PendingCallbacks::new());
let smtp_client_manager = Arc::new(mailer_smtp::client::SmtpClientManager::new());
let mut state = ManagementState {
callbacks: callbacks.clone(),
smtp_handle: None,
smtp_event_rx: None,
smtp_client_manager: smtp_client_manager.clone(),
};
// We need to read stdin in a separate thread (blocking I/O)
@@ -833,6 +836,28 @@ async fn handle_ipc_request(req: &IpcRequest, state: &mut ManagementState) -> Ip
}
}
// --- SMTP Client commands ---
"sendEmail" => {
handle_send_email(req, state).await
}
"sendRawEmail" => {
handle_send_raw_email(req, state).await
}
"verifySmtpConnection" => {
handle_verify_smtp_connection(req, state).await
}
"closeSmtpPool" => {
handle_close_smtp_pool(req, state).await
}
"getSmtpPoolStatus" => {
handle_get_smtp_pool_status(req, state)
}
_ => IpcResponse {
id: req.id.clone(),
success: false,
@@ -1052,3 +1077,297 @@ fn parse_smtp_config(
Ok(config)
}
// ---------------------------------------------------------------------------
// SMTP Client IPC handlers
// ---------------------------------------------------------------------------
/// Structured email to build a MIME message from.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OutboundEmail {
from: String,
to: Vec<String>,
#[serde(default)]
cc: Vec<String>,
#[serde(default)]
bcc: Vec<String>,
#[serde(default)]
subject: String,
#[serde(default)]
text: String,
#[serde(default)]
html: Option<String>,
#[serde(default)]
headers: std::collections::HashMap<String, String>,
}
impl OutboundEmail {
/// Convert to `mailer_core::Email` for proper RFC 5322 MIME building.
fn to_core_email(&self) -> mailer_core::Email {
let mut email = mailer_core::Email::new(&self.from, &self.subject, &self.text);
for addr in &self.to {
email.add_to(addr);
}
for addr in &self.cc {
email.add_cc(addr);
}
for addr in &self.bcc {
email.add_bcc(addr);
}
if let Some(html) = &self.html {
email.set_html(html);
}
for (key, value) in &self.headers {
email.add_header(key, value);
}
email
}
/// Build an RFC 5322 compliant message using `mailer_core::build_rfc822`.
fn to_rfc822(&self) -> Vec<u8> {
let email = self.to_core_email();
match mailer_core::build_rfc822(&email) {
Ok(msg) => msg.into_bytes(),
Err(e) => {
eprintln!("Failed to build RFC 822 message: {e}");
// Fallback: minimal message
format!(
"From: {}\r\nTo: {}\r\nSubject: {}\r\n\r\n{}",
self.from,
self.to.join(", "),
self.subject,
self.text
)
.into_bytes()
}
}
}
/// Collect all recipients (to + cc + bcc).
fn all_recipients(&self) -> Vec<String> {
let mut all = self.to.clone();
all.extend(self.cc.clone());
all.extend(self.bcc.clone());
all
}
}
/// Handle sendEmail IPC command — build MIME, optional DKIM sign, send via pool.
async fn handle_send_email(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
// Parse client config from params
let config: mailer_smtp::client::SmtpClientConfig = match serde_json::from_value(req.params.clone()) {
Ok(c) => c,
Err(e) => {
return IpcResponse {
id: req.id.clone(),
success: false,
result: None,
error: Some(format!("Invalid config: {}", e)),
};
}
};
// Parse the email
let email: OutboundEmail = match req.params.get("email").and_then(|v| serde_json::from_value(v.clone()).ok()) {
Some(e) => e,
None => {
return IpcResponse {
id: req.id.clone(),
success: false,
result: None,
error: Some("Missing or invalid 'email' field".into()),
};
}
};
// Build raw message
let mut raw_message = email.to_rfc822();
// Optional DKIM signing
if let Some(dkim_val) = req.params.get("dkim") {
if let Ok(dkim_config) = serde_json::from_value::<mailer_smtp::client::DkimSignConfig>(dkim_val.clone()) {
match mailer_security::sign_dkim(
&raw_message,
&dkim_config.domain,
&dkim_config.selector,
&dkim_config.private_key,
) {
Ok(header) => {
// Prepend DKIM header to the message
let mut signed = header.into_bytes();
signed.extend_from_slice(&raw_message);
raw_message = signed;
}
Err(e) => {
// Log but don't fail — send unsigned
eprintln!("DKIM signing failed: {}", e);
}
}
}
}
let all_recipients = email.all_recipients();
let sender = &email.from;
match state
.smtp_client_manager
.send_message(&config, sender, &all_recipients, &raw_message)
.await
{
Ok(result) => IpcResponse {
id: req.id.clone(),
success: true,
result: Some(serde_json::to_value(&result).unwrap()),
error: None,
},
Err(e) => IpcResponse {
id: req.id.clone(),
success: false,
result: None,
error: Some(serde_json::to_string(&serde_json::json!({
"message": e.to_string(),
"errorType": e.error_type(),
"retryable": e.is_retryable(),
"smtpCode": e.smtp_code(),
}))
.unwrap()),
},
}
}
/// Handle sendRawEmail IPC command — send a pre-formatted message.
async fn handle_send_raw_email(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
// Parse client config from params
let config: mailer_smtp::client::SmtpClientConfig = match serde_json::from_value(req.params.clone()) {
Ok(c) => c,
Err(e) => {
return IpcResponse {
id: req.id.clone(),
success: false,
result: None,
error: Some(format!("Invalid config: {}", e)),
};
}
};
let envelope_from = req
.params
.get("envelopeFrom")
.and_then(|v| v.as_str())
.unwrap_or("");
let envelope_to: Vec<String> = req
.params
.get("envelopeTo")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let raw_b64 = req
.params
.get("rawMessageBase64")
.and_then(|v| v.as_str())
.unwrap_or("");
// Decode base64 message
use base64::Engine;
let raw_message = match base64::engine::general_purpose::STANDARD.decode(raw_b64) {
Ok(data) => data,
Err(e) => {
return IpcResponse {
id: req.id.clone(),
success: false,
result: None,
error: Some(format!("Invalid base64 message: {}", e)),
};
}
};
match state
.smtp_client_manager
.send_message(&config, envelope_from, &envelope_to, &raw_message)
.await
{
Ok(result) => IpcResponse {
id: req.id.clone(),
success: true,
result: Some(serde_json::to_value(&result).unwrap()),
error: None,
},
Err(e) => IpcResponse {
id: req.id.clone(),
success: false,
result: None,
error: Some(serde_json::to_string(&serde_json::json!({
"message": e.to_string(),
"errorType": e.error_type(),
"retryable": e.is_retryable(),
"smtpCode": e.smtp_code(),
}))
.unwrap()),
},
}
}
/// Handle verifySmtpConnection IPC command.
async fn handle_verify_smtp_connection(
req: &IpcRequest,
state: &ManagementState,
) -> IpcResponse {
let config: mailer_smtp::client::SmtpClientConfig = match serde_json::from_value(req.params.clone()) {
Ok(c) => c,
Err(e) => {
return IpcResponse {
id: req.id.clone(),
success: false,
result: None,
error: Some(format!("Invalid config: {}", e)),
};
}
};
match state.smtp_client_manager.verify_connection(&config).await {
Ok(result) => IpcResponse {
id: req.id.clone(),
success: true,
result: Some(serde_json::to_value(&result).unwrap()),
error: None,
},
Err(e) => IpcResponse {
id: req.id.clone(),
success: false,
result: None,
error: Some(e.to_string()),
},
}
}
/// Handle closeSmtpPool IPC command.
async fn handle_close_smtp_pool(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
if let Some(pool_key) = req.params.get("poolKey").and_then(|v| v.as_str()) {
state.smtp_client_manager.close_pool(pool_key).await;
} else {
state.smtp_client_manager.close_all_pools().await;
}
IpcResponse {
id: req.id.clone(),
success: true,
result: Some(serde_json::json!({"closed": true})),
error: None,
}
}
/// Handle getSmtpPoolStatus IPC command.
fn handle_get_smtp_pool_status(req: &IpcRequest, state: &ManagementState) -> IpcResponse {
let pools = state.smtp_client_manager.pool_status();
IpcResponse {
id: req.id.clone(),
success: true,
result: Some(serde_json::json!({"pools": pools})),
error: None,
}
}