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:
@@ -19,3 +19,5 @@ serde_json.workspace = true
|
||||
clap.workspace = true
|
||||
hickory-resolver.workspace = true
|
||||
dashmap.workspace = true
|
||||
base64.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user