BREAKING CHANGE(smartmta): Rebrand package to @push.rocks/smartmta, add consolidated email security verification and IPC handler

This commit is contained in:
2026-02-10 16:25:55 +00:00
parent 199b9b79d2
commit 8293663619
17 changed files with 1183 additions and 383 deletions

View File

@@ -491,6 +491,75 @@ async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
}
}
"verifyEmail" => {
let raw_message = req
.params
.get("rawMessage")
.and_then(|v| v.as_str())
.unwrap_or("");
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::verify_email_security(
raw_message.as_bytes(),
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)),
},
}
}
"checkSpf" => {
let ip_str = req.params.get("ip").and_then(|v| v.as_str()).unwrap_or("");
let helo = req

View File

@@ -1,7 +1,7 @@
use mail_auth::common::crypto::{RsaKey, Sha256};
use mail_auth::common::headers::HeaderWriter;
use mail_auth::dkim::{Canonicalization, DkimSigner};
use mail_auth::{AuthenticatedMessage, DkimResult, MessageAuthenticator};
use mail_auth::{AuthenticatedMessage, DkimOutput, DkimResult, MessageAuthenticator};
use rustls_pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, pem::PemObject};
use serde::{Deserialize, Serialize};
@@ -22,6 +22,48 @@ pub struct DkimVerificationResult {
pub details: Option<String>,
}
/// Convert raw `mail-auth` DKIM outputs to our serializable results.
///
/// This is used internally by `verify_dkim` and by the compound `verify_email_security`.
pub fn dkim_outputs_to_results(dkim_outputs: &[DkimOutput<'_>]) -> Vec<DkimVerificationResult> {
if dkim_outputs.is_empty() {
return vec![DkimVerificationResult {
is_valid: false,
domain: None,
selector: None,
status: "none".to_string(),
details: Some("No DKIM signatures found".to_string()),
}];
}
dkim_outputs
.iter()
.map(|output| {
let (is_valid, status, details) = match output.result() {
DkimResult::Pass => (true, "pass", None),
DkimResult::Neutral(err) => (false, "neutral", Some(err.to_string())),
DkimResult::Fail(err) => (false, "fail", Some(err.to_string())),
DkimResult::PermError(err) => (false, "permerror", Some(err.to_string())),
DkimResult::TempError(err) => (false, "temperror", Some(err.to_string())),
DkimResult::None => (false, "none", None),
};
let (domain, selector) = output
.signature()
.map(|sig| (Some(sig.d.clone()), Some(sig.s.clone())))
.unwrap_or((None, None));
DkimVerificationResult {
is_valid,
domain,
selector,
status: status.to_string(),
details,
}
})
.collect()
}
/// Verify DKIM signatures on a raw email message.
///
/// Uses the `mail-auth` crate which performs full RFC 6376 verification
@@ -34,45 +76,7 @@ pub async fn verify_dkim(
.ok_or_else(|| SecurityError::Parse("Failed to parse email for DKIM verification".into()))?;
let dkim_outputs = authenticator.verify_dkim(&message).await;
let mut results = Vec::new();
if dkim_outputs.is_empty() {
results.push(DkimVerificationResult {
is_valid: false,
domain: None,
selector: None,
status: "none".to_string(),
details: Some("No DKIM signatures found".to_string()),
});
return Ok(results);
}
for output in &dkim_outputs {
let (is_valid, status, details) = match output.result() {
DkimResult::Pass => (true, "pass", None),
DkimResult::Neutral(err) => (false, "neutral", Some(err.to_string())),
DkimResult::Fail(err) => (false, "fail", Some(err.to_string())),
DkimResult::PermError(err) => (false, "permerror", Some(err.to_string())),
DkimResult::TempError(err) => (false, "temperror", Some(err.to_string())),
DkimResult::None => (false, "none", None),
};
let (domain, selector) = output
.signature()
.map(|sig| (Some(sig.d.clone()), Some(sig.s.clone())))
.unwrap_or((None, None));
results.push(DkimVerificationResult {
is_valid,
domain,
selector,
status: status.to_string(),
details,
});
}
Ok(results)
Ok(dkim_outputs_to_results(&dkim_outputs))
}
/// Sign a raw email message with DKIM (RSA-SHA256).

View File

@@ -5,10 +5,12 @@ pub mod dmarc;
pub mod error;
pub mod ip_reputation;
pub mod spf;
pub mod verify;
// Re-exports for convenience
pub use dkim::{dkim_dns_record_value, sign_dkim, verify_dkim, DkimVerificationResult};
pub use dkim::{dkim_dns_record_value, dkim_outputs_to_results, sign_dkim, verify_dkim, DkimVerificationResult};
pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult};
pub use verify::{verify_email_security, EmailSecurityResult};
pub use error::{Result, SecurityError};
pub use ip_reputation::{
check_dnsbl, check_reputation, risk_level, DnsblResult, IpType, ReputationResult, RiskLevel,

View File

@@ -23,6 +23,28 @@ impl SpfResult {
pub fn passed(&self) -> bool {
self.result == "pass"
}
/// Create an `SpfResult` from a raw `mail-auth` `SpfOutput`.
///
/// Used by the compound `verify_email_security` to avoid re-doing the SPF query.
pub fn from_output(output: &mail_auth::SpfOutput, ip: IpAddr) -> Self {
let result_str = match output.result() {
MailAuthSpfResult::Pass => "pass",
MailAuthSpfResult::Fail => "fail",
MailAuthSpfResult::SoftFail => "softfail",
MailAuthSpfResult::Neutral => "neutral",
MailAuthSpfResult::TempError => "temperror",
MailAuthSpfResult::PermError => "permerror",
MailAuthSpfResult::None => "none",
};
SpfResult {
result: result_str.to_string(),
domain: output.domain().to_string(),
ip: ip.to_string(),
explanation: output.explanation().map(|s| s.to_string()),
}
}
}
/// Check SPF for a given sender IP, HELO domain, and MAIL FROM address.

View File

@@ -0,0 +1,115 @@
//! Compound email security verification.
//!
//! Runs DKIM, SPF, and DMARC verification in a single call, avoiding multiple
//! IPC round-trips and handling the internal `mail-auth` types that DMARC needs.
use mail_auth::spf::verify::SpfParameters;
use mail_auth::{AuthenticatedMessage, MessageAuthenticator};
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use crate::dkim::DkimVerificationResult;
use crate::dmarc::{check_dmarc, DmarcResult};
use crate::error::{Result, SecurityError};
use crate::spf::SpfResult;
/// Combined result of DKIM + SPF + DMARC verification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailSecurityResult {
pub dkim: Vec<DkimVerificationResult>,
pub spf: Option<SpfResult>,
pub dmarc: Option<DmarcResult>,
}
/// Run all email security checks (DKIM, SPF, DMARC) in one call.
///
/// This is the preferred entry point for inbound email verification because:
/// 1. DMARC requires raw `mail-auth` DKIM/SPF outputs (not our serialized types).
/// 2. A single call avoids 3 sequential IPC round-trips.
///
/// # Arguments
/// * `raw_message` - The raw RFC 5322 message bytes
/// * `ip` - The connecting client's IP address
/// * `helo_domain` - The domain from the SMTP EHLO/HELO command
/// * `host_domain` - Your receiving server's hostname
/// * `mail_from` - The full MAIL FROM address (e.g. "sender@example.com")
/// * `authenticator` - The `MessageAuthenticator` for DNS lookups
pub async fn verify_email_security(
raw_message: &[u8],
ip: IpAddr,
helo_domain: &str,
host_domain: &str,
mail_from: &str,
authenticator: &MessageAuthenticator,
) -> Result<EmailSecurityResult> {
// Parse the message once for all checks
let message = AuthenticatedMessage::parse(raw_message)
.ok_or_else(|| SecurityError::Parse("Failed to parse email message".into()))?;
// --- DKIM verification ---
let dkim_outputs = authenticator.verify_dkim(&message).await;
let dkim_results = crate::dkim::dkim_outputs_to_results(&dkim_outputs);
// --- SPF verification ---
let spf_output = authenticator
.verify_spf(SpfParameters::verify_mail_from(
ip,
helo_domain,
host_domain,
mail_from,
))
.await;
let spf_result = SpfResult::from_output(&spf_output, ip);
// --- DMARC verification (needs raw dkim_outputs + spf_output) ---
let mail_from_domain = mail_from
.rsplit_once('@')
.map(|(_, d)| d)
.unwrap_or(helo_domain);
let dmarc_result = check_dmarc(
raw_message,
&dkim_outputs,
&spf_output,
mail_from_domain,
authenticator,
)
.await
.ok(); // DMARC failure is non-fatal; we still return DKIM + SPF results
Ok(EmailSecurityResult {
dkim: dkim_results,
spf: Some(spf_result),
dmarc: dmarc_result,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_email_security_result_serialization() {
let result = EmailSecurityResult {
dkim: vec![DkimVerificationResult {
is_valid: false,
domain: None,
selector: None,
status: "none".to_string(),
details: Some("No DKIM signatures".to_string()),
}],
spf: Some(SpfResult {
result: "none".to_string(),
domain: "example.com".to_string(),
ip: "1.2.3.4".to_string(),
explanation: None,
}),
dmarc: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"dkim\""));
assert!(json.contains("\"spf\""));
assert!(json.contains("\"dmarc\":null"));
}
}