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:
145
rust/crates/mailer-security/src/spf.rs
Normal file
145
rust/crates/mailer-security/src/spf.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use mail_auth::spf::verify::SpfParameters;
|
||||
use mail_auth::{MessageAuthenticator, SpfResult as MailAuthSpfResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// SPF verification result.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpfResult {
|
||||
/// The SPF result: "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none".
|
||||
pub result: String,
|
||||
/// The domain that was checked.
|
||||
pub domain: String,
|
||||
/// The IP address that was checked.
|
||||
pub ip: String,
|
||||
/// Optional explanation string from the SPF record.
|
||||
pub explanation: Option<String>,
|
||||
}
|
||||
|
||||
impl SpfResult {
|
||||
/// Whether the SPF check passed.
|
||||
pub fn passed(&self) -> bool {
|
||||
self.result == "pass"
|
||||
}
|
||||
}
|
||||
|
||||
/// Check SPF for a given sender IP, HELO domain, and MAIL FROM address.
|
||||
///
|
||||
/// * `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")
|
||||
pub async fn check_spf(
|
||||
ip: IpAddr,
|
||||
helo_domain: &str,
|
||||
host_domain: &str,
|
||||
mail_from: &str,
|
||||
authenticator: &MessageAuthenticator,
|
||||
) -> Result<SpfResult> {
|
||||
let output = authenticator
|
||||
.verify_spf(SpfParameters::verify_mail_from(
|
||||
ip,
|
||||
helo_domain,
|
||||
host_domain,
|
||||
mail_from,
|
||||
))
|
||||
.await;
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
Ok(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 the EHLO identity (before MAIL FROM).
|
||||
pub async fn check_spf_ehlo(
|
||||
ip: IpAddr,
|
||||
helo_domain: &str,
|
||||
host_domain: &str,
|
||||
authenticator: &MessageAuthenticator,
|
||||
) -> Result<SpfResult> {
|
||||
let output = authenticator
|
||||
.verify_spf(SpfParameters::verify_ehlo(ip, helo_domain, host_domain))
|
||||
.await;
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
Ok(SpfResult {
|
||||
result: result_str.to_string(),
|
||||
domain: helo_domain.to_string(),
|
||||
ip: ip.to_string(),
|
||||
explanation: output.explanation().map(|s| s.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a Received-SPF header value.
|
||||
pub fn received_spf_header(result: &SpfResult) -> String {
|
||||
format!(
|
||||
"{} (domain of {} designates {} as permitted sender) receiver={}; client-ip={};",
|
||||
result.result,
|
||||
result.domain,
|
||||
result.ip,
|
||||
result.domain,
|
||||
result.ip,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_spf_result_passed() {
|
||||
let result = SpfResult {
|
||||
result: "pass".to_string(),
|
||||
domain: "example.com".to_string(),
|
||||
ip: "1.2.3.4".to_string(),
|
||||
explanation: None,
|
||||
};
|
||||
assert!(result.passed());
|
||||
|
||||
let result = SpfResult {
|
||||
result: "fail".to_string(),
|
||||
domain: "example.com".to_string(),
|
||||
ip: "1.2.3.4".to_string(),
|
||||
explanation: None,
|
||||
};
|
||||
assert!(!result.passed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_received_spf_header() {
|
||||
let result = SpfResult {
|
||||
result: "pass".to_string(),
|
||||
domain: "example.com".to_string(),
|
||||
ip: "1.2.3.4".to_string(),
|
||||
explanation: None,
|
||||
};
|
||||
let header = received_spf_header(&result);
|
||||
assert!(header.contains("pass"));
|
||||
assert!(header.contains("example.com"));
|
||||
assert!(header.contains("1.2.3.4"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user