feat(rust): implement mailer-core and mailer-security crates with CLI
Rust migration Phase 1 — implements real functionality in the previously stubbed mailer-core and mailer-security crates (38 passing tests). mailer-core: Email/EmailAddress/Attachment types, RFC 5322 MIME builder, email format validation with scoring, bounce detection (14 types, 40+ regex patterns), DSN status parsing, retry delay calculation. mailer-security: DKIM signing (RSA-SHA256) and verification, SPF checking, DMARC verification with public suffix list, DNSBL IP reputation checking (10 default servers, parallel queries), all powered by mail-auth 0.7. mailer-bin: Full CLI with validate/bounce/check-ip/verify-email/dkim-sign subcommands plus --management mode for smartrust JSON-over-stdin/stdout IPC.
This commit is contained in:
@@ -1,11 +1,558 @@
|
||||
//! mailer-bin: Standalone Rust binary for the @serve.zone/mailer network stack.
|
||||
//! mailer-bin: CLI and IPC binary for the @serve.zone/mailer Rust crates.
|
||||
//!
|
||||
//! Supports two modes:
|
||||
//! 1. **CLI mode** — traditional subcommands for testing and standalone use
|
||||
//! 2. **Management mode** (`--management`) — JSON-over-stdin/stdout IPC for
|
||||
//! integration with `@push.rocks/smartrust` from TypeScript
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::net::IpAddr;
|
||||
|
||||
/// mailer-bin: Rust-powered email security tools
|
||||
#[derive(Parser)]
|
||||
#[command(name = "mailer-bin", version, about)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
|
||||
/// Run in management/IPC mode (JSON-over-stdin/stdout for smartrust bridge)
|
||||
#[arg(long)]
|
||||
management: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Print version information
|
||||
Version,
|
||||
|
||||
/// Validate an email address
|
||||
Validate {
|
||||
/// The email address to validate
|
||||
email: String,
|
||||
},
|
||||
|
||||
/// Detect bounce type from an SMTP response
|
||||
Bounce {
|
||||
/// The SMTP response or diagnostic message
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Check IP reputation via DNSBL
|
||||
CheckIp {
|
||||
/// The IP address to check
|
||||
ip: String,
|
||||
},
|
||||
|
||||
/// Verify DKIM/SPF/DMARC for an email (reads raw message from stdin)
|
||||
VerifyEmail {
|
||||
/// Sender IP address for SPF check
|
||||
#[arg(long)]
|
||||
ip: Option<String>,
|
||||
|
||||
/// HELO domain for SPF check
|
||||
#[arg(long)]
|
||||
helo: Option<String>,
|
||||
|
||||
/// Receiving server hostname
|
||||
#[arg(long, default_value = "localhost")]
|
||||
hostname: String,
|
||||
|
||||
/// MAIL FROM address for SPF check
|
||||
#[arg(long)]
|
||||
mail_from: Option<String>,
|
||||
},
|
||||
|
||||
/// Sign an email with DKIM (reads raw message from stdin)
|
||||
DkimSign {
|
||||
/// Signing domain
|
||||
#[arg(long)]
|
||||
domain: String,
|
||||
|
||||
/// DKIM selector
|
||||
#[arg(long, default_value = "mta")]
|
||||
selector: String,
|
||||
|
||||
/// Path to RSA private key PEM file
|
||||
#[arg(long)]
|
||||
key: String,
|
||||
},
|
||||
}
|
||||
|
||||
// --- IPC types for smartrust bridge ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IpcRequest {
|
||||
id: String,
|
||||
method: String,
|
||||
params: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct IpcResponse {
|
||||
id: String,
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct IpcEvent {
|
||||
event: String,
|
||||
data: serde_json::Value,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!(
|
||||
"mailer-bin v{} (core: {}, smtp: {}, security: {})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
mailer_core::version(),
|
||||
mailer_smtp::version(),
|
||||
mailer_security::version(),
|
||||
);
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.management {
|
||||
run_management_mode();
|
||||
return;
|
||||
}
|
||||
|
||||
match cli.command {
|
||||
Some(Commands::Version) | None => {
|
||||
println!(
|
||||
"mailer-bin v{} (core: {}, smtp: {}, security: {})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
mailer_core::version(),
|
||||
mailer_smtp::version(),
|
||||
mailer_security::version(),
|
||||
);
|
||||
}
|
||||
|
||||
Some(Commands::Validate { email }) => {
|
||||
let result = mailer_core::validate_email(&email);
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"email": email,
|
||||
"valid": result.is_valid,
|
||||
"formatValid": result.format_valid,
|
||||
"score": result.score,
|
||||
"error": result.error_message,
|
||||
}))
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
Some(Commands::Bounce { message }) => {
|
||||
let detection = mailer_core::detect_bounce_type(Some(&message), None, None);
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&detection).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
Some(Commands::CheckIp { ip }) => {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let ip_addr: IpAddr = match ip.parse() {
|
||||
Ok(addr) => addr,
|
||||
Err(e) => {
|
||||
eprintln!("Invalid IP address: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let resolver = hickory_resolver::TokioResolver::builder_tokio().map(|b| b.build())
|
||||
.expect("Failed to create DNS resolver");
|
||||
|
||||
match mailer_security::check_reputation(
|
||||
ip_addr,
|
||||
mailer_security::DEFAULT_DNSBL_SERVERS,
|
||||
&resolver,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
println!("{}", serde_json::to_string_pretty(&result).unwrap());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Some(Commands::VerifyEmail {
|
||||
ip,
|
||||
helo,
|
||||
hostname,
|
||||
mail_from,
|
||||
}) => {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
// Read raw message from stdin
|
||||
let mut raw_message = Vec::new();
|
||||
io::stdin()
|
||||
.lock()
|
||||
.read_to_end(&mut raw_message)
|
||||
.expect("Failed to read from stdin");
|
||||
|
||||
let authenticator = mailer_security::default_authenticator()
|
||||
.expect("Failed to create authenticator");
|
||||
|
||||
// DKIM verification
|
||||
let dkim_results = mailer_security::verify_dkim(&raw_message, &authenticator)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
vec![mailer_security::DkimVerificationResult {
|
||||
is_valid: false,
|
||||
domain: None,
|
||||
selector: None,
|
||||
status: "error".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
}]
|
||||
});
|
||||
|
||||
let mut output = serde_json::json!({
|
||||
"dkim": dkim_results,
|
||||
});
|
||||
|
||||
// SPF verification (if IP provided)
|
||||
if let (Some(ip_str), Some(helo_domain), Some(sender)) =
|
||||
(&ip, &helo, &mail_from)
|
||||
{
|
||||
if let Ok(ip_addr) = ip_str.parse::<IpAddr>() {
|
||||
match mailer_security::check_spf(
|
||||
ip_addr,
|
||||
helo_domain,
|
||||
&hostname,
|
||||
sender,
|
||||
&authenticator,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(spf_result) => {
|
||||
output["spf"] = serde_json::to_value(&spf_result).unwrap();
|
||||
}
|
||||
Err(e) => {
|
||||
output["spf"] =
|
||||
serde_json::json!({"error": e.to_string()});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", serde_json::to_string_pretty(&output).unwrap());
|
||||
});
|
||||
}
|
||||
|
||||
Some(Commands::DkimSign {
|
||||
domain,
|
||||
selector,
|
||||
key,
|
||||
}) => {
|
||||
// Read private key
|
||||
let key_pem = std::fs::read_to_string(&key).unwrap_or_else(|e| {
|
||||
eprintln!("Failed to read key file '{}': {}", key, e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// Read raw message from stdin
|
||||
let mut raw_message = Vec::new();
|
||||
io::stdin()
|
||||
.lock()
|
||||
.read_to_end(&mut raw_message)
|
||||
.expect("Failed to read from stdin");
|
||||
|
||||
match mailer_security::sign_dkim(&raw_message, &domain, &selector, &key_pem) {
|
||||
Ok(header) => {
|
||||
// Output signed message: DKIM header + original message
|
||||
print!("{}", header);
|
||||
io::stdout().write_all(&raw_message).unwrap();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("DKIM signing failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
/// Run in management/IPC mode for smartrust bridge.
|
||||
fn run_management_mode() {
|
||||
// Signal readiness
|
||||
let ready_event = IpcEvent {
|
||||
event: "ready".to_string(),
|
||||
data: serde_json::json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"core_version": mailer_core::version(),
|
||||
"security_version": mailer_security::version(),
|
||||
}),
|
||||
};
|
||||
println!("{}", serde_json::to_string(&ready_event).unwrap());
|
||||
io::stdout().flush().unwrap();
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
let stdin = io::stdin();
|
||||
for line in stdin.lock().lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let req: IpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let resp = IpcResponse {
|
||||
id: "unknown".to_string(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid request: {}", e)),
|
||||
};
|
||||
println!("{}", serde_json::to_string(&resp).unwrap());
|
||||
io::stdout().flush().unwrap();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let response = rt.block_on(handle_ipc_request(&req));
|
||||
println!("{}", serde_json::to_string(&response).unwrap());
|
||||
io::stdout().flush().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
|
||||
match req.method.as_str() {
|
||||
"ping" => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({"pong": true})),
|
||||
error: None,
|
||||
},
|
||||
|
||||
"version" => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({
|
||||
"bin": env!("CARGO_PKG_VERSION"),
|
||||
"core": mailer_core::version(),
|
||||
"security": mailer_security::version(),
|
||||
"smtp": mailer_smtp::version(),
|
||||
})),
|
||||
error: None,
|
||||
},
|
||||
|
||||
"validateEmail" => {
|
||||
let email = req.params.get("email").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let result = mailer_core::validate_email(email);
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({
|
||||
"valid": result.is_valid,
|
||||
"formatValid": result.format_valid,
|
||||
"score": result.score,
|
||||
"error": result.error_message,
|
||||
})),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
"detectBounce" => {
|
||||
let smtp_response = req.params.get("smtpResponse").and_then(|v| v.as_str());
|
||||
let diagnostic = req.params.get("diagnosticCode").and_then(|v| v.as_str());
|
||||
let status = req.params.get("statusCode").and_then(|v| v.as_str());
|
||||
let detection = mailer_core::detect_bounce_type(smtp_response, diagnostic, status);
|
||||
IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::to_value(&detection).unwrap()),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
"checkIpReputation" => {
|
||||
let ip_str = req.params.get("ip").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match ip_str.parse::<IpAddr>() {
|
||||
Ok(ip_addr) => {
|
||||
let resolver = match hickory_resolver::TokioResolver::builder_tokio().map(|b| b.build()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("DNS resolver error: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
match mailer_security::check_reputation(
|
||||
ip_addr,
|
||||
mailer_security::DEFAULT_DNSBL_SERVERS,
|
||||
&resolver,
|
||||
)
|
||||
.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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(e) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid IP address: {}", e)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
"verifyDkim" => {
|
||||
let raw_message = req
|
||||
.params
|
||||
.get("rawMessage")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let authenticator = match mailer_security::default_authenticator() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Authenticator error: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
match mailer_security::verify_dkim(raw_message.as_bytes(), &authenticator).await {
|
||||
Ok(results) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::to_value(&results).unwrap()),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
"signDkim" => {
|
||||
let raw_message = req
|
||||
.params
|
||||
.get("rawMessage")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let domain = req.params.get("domain").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let selector = req
|
||||
.params
|
||||
.get("selector")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("mta");
|
||||
let private_key = req
|
||||
.params
|
||||
.get("privateKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
match mailer_security::sign_dkim(raw_message.as_bytes(), domain, selector, private_key) {
|
||||
Ok(header) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: true,
|
||||
result: Some(serde_json::json!({
|
||||
"header": header,
|
||||
"signedMessage": format!("{}{}", header, raw_message),
|
||||
})),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
"checkSpf" => {
|
||||
let ip_str = req.params.get("ip").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let helo = req
|
||||
.params
|
||||
.get("heloDomain")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let hostname = req
|
||||
.params
|
||||
.get("hostname")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("localhost");
|
||||
let mail_from = req
|
||||
.params
|
||||
.get("mailFrom")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
match ip_str.parse::<IpAddr>() {
|
||||
Ok(ip_addr) => {
|
||||
let authenticator = match mailer_security::default_authenticator() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Authenticator error: {}", e)),
|
||||
};
|
||||
}
|
||||
};
|
||||
match mailer_security::check_spf(ip_addr, helo, hostname, mail_from, &authenticator)
|
||||
.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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(e) => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Invalid IP address: {}", e)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
_ => IpcResponse {
|
||||
id: req.id.clone(),
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(format!("Unknown method: {}", req.method)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user