BREAKING CHANGE(smartmta): Rebrand package to @push.rocks/smartmta, add consolidated email security verification and IPC handler
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
115
rust/crates/mailer-security/src/verify.rs
Normal file
115
rust/crates/mailer-security/src/verify.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user