feat(rust): implement mailer-core and mailer-security crates with CLI
Some checks failed
CI / Type Check & Lint (push) Failing after 4s
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Build All Platforms (push) Failing after 4s

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:
2026-02-10 16:06:04 +00:00
parent 91b49182bb
commit 199b9b79d2
15 changed files with 3113 additions and 261 deletions

View File

@@ -0,0 +1,148 @@
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 rustls_pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, pem::PemObject};
use serde::{Deserialize, Serialize};
use crate::error::{Result, SecurityError};
/// Result of DKIM verification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DkimVerificationResult {
/// Whether the DKIM signature is valid.
pub is_valid: bool,
/// The signing domain (d= tag).
pub domain: Option<String>,
/// The selector (s= tag).
pub selector: Option<String>,
/// Result status: "pass", "fail", "permerror", "temperror", "none".
pub status: String,
/// Human-readable details.
pub details: Option<String>,
}
/// Verify DKIM signatures on a raw email message.
///
/// Uses the `mail-auth` crate which performs full RFC 6376 verification
/// including DNS lookups for the public key.
pub async fn verify_dkim(
raw_message: &[u8],
authenticator: &MessageAuthenticator,
) -> Result<Vec<DkimVerificationResult>> {
let message = AuthenticatedMessage::parse(raw_message)
.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)
}
/// Sign a raw email message with DKIM (RSA-SHA256).
///
/// * `raw_message` - The raw RFC 5322 message bytes
/// * `domain` - The signing domain (d= tag)
/// * `selector` - The DKIM selector (s= tag)
/// * `private_key_pem` - RSA private key in PEM format (PKCS#1 or PKCS#8)
///
/// Returns the DKIM-Signature header string to prepend to the message.
pub fn sign_dkim(
raw_message: &[u8],
domain: &str,
selector: &str,
private_key_pem: &str,
) -> Result<String> {
// Try PKCS#1 PEM first, then PKCS#8
let key_der = PrivatePkcs1KeyDer::from_pem_slice(private_key_pem.as_bytes())
.map(PrivateKeyDer::Pkcs1)
.or_else(|_| {
// Try PKCS#8
rustls_pki_types::PrivatePkcs8KeyDer::from_pem_slice(private_key_pem.as_bytes())
.map(PrivateKeyDer::Pkcs8)
})
.map_err(|e| SecurityError::Key(format!("Failed to parse private key PEM: {}", e)))?;
let rsa_key = RsaKey::<Sha256>::from_key_der(key_der)
.map_err(|e| SecurityError::Key(format!("Failed to load RSA key: {}", e)))?;
let signature = DkimSigner::from_key(rsa_key)
.domain(domain)
.selector(selector)
.headers(["From", "To", "Subject", "Date", "Message-ID", "MIME-Version", "Content-Type"])
.header_canonicalization(Canonicalization::Relaxed)
.body_canonicalization(Canonicalization::Relaxed)
.sign(raw_message)
.map_err(|e| SecurityError::Dkim(format!("DKIM signing failed: {}", e)))?;
Ok(signature.to_header())
}
/// Generate a DKIM DNS TXT record value for a given public key.
///
/// Returns the value for a TXT record at `{selector}._domainkey.{domain}`.
pub fn dkim_dns_record_value(public_key_pem: &str) -> String {
// Extract the base64 content from PEM
let key_b64: String = public_key_pem
.lines()
.filter(|line| !line.starts_with("-----"))
.collect::<Vec<_>>()
.join("");
format!("v=DKIM1; h=sha256; k=rsa; p={}", key_b64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dkim_dns_record_value() {
let pem = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBg==\n-----END PUBLIC KEY-----";
let record = dkim_dns_record_value(pem);
assert!(record.starts_with("v=DKIM1; h=sha256; k=rsa; p="));
assert!(record.contains("MIIBIjANBg=="));
}
#[test]
fn test_sign_dkim_invalid_key() {
let result = sign_dkim(b"From: test@example.com\r\n\r\nBody", "example.com", "mta", "not a key");
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,127 @@
use mail_auth::dmarc::verify::DmarcParameters;
use mail_auth::dmarc::Policy;
use mail_auth::{
AuthenticatedMessage, DkimOutput, DmarcResult as MailAuthDmarcResult, MessageAuthenticator,
SpfOutput,
};
use serde::{Deserialize, Serialize};
use crate::error::{Result, SecurityError};
/// DMARC policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DmarcPolicy {
None,
Quarantine,
Reject,
}
impl From<Policy> for DmarcPolicy {
fn from(p: Policy) -> Self {
match p {
Policy::None | Policy::Unspecified => DmarcPolicy::None,
Policy::Quarantine => DmarcPolicy::Quarantine,
Policy::Reject => DmarcPolicy::Reject,
}
}
}
/// DMARC verification result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DmarcResult {
/// Whether DMARC verification passed overall.
pub passed: bool,
/// The evaluated policy.
pub policy: DmarcPolicy,
/// The domain that was checked.
pub domain: String,
/// DKIM alignment result: "pass", "fail", etc.
pub dkim_result: String,
/// SPF alignment result: "pass", "fail", etc.
pub spf_result: String,
/// Recommended action: "pass", "quarantine", "reject".
pub action: String,
/// Human-readable details.
pub details: Option<String>,
}
/// Check DMARC for an email, given prior DKIM and SPF results.
///
/// * `raw_message` - The raw RFC 5322 message bytes
/// * `dkim_output` - DKIM verification results from `verify_dkim`
/// * `spf_output` - SPF verification output from `check_spf`
/// * `mail_from_domain` - The MAIL FROM domain (RFC 5321)
/// * `authenticator` - The MessageAuthenticator for DNS lookups
pub async fn check_dmarc<'x>(
raw_message: &'x [u8],
dkim_output: &'x [DkimOutput<'x>],
spf_output: &'x SpfOutput,
mail_from_domain: &'x str,
authenticator: &MessageAuthenticator,
) -> Result<DmarcResult> {
let message = AuthenticatedMessage::parse(raw_message)
.ok_or_else(|| SecurityError::Parse("Failed to parse email for DMARC check".into()))?;
let dmarc_output = authenticator
.verify_dmarc(
DmarcParameters::new(&message, dkim_output, mail_from_domain, spf_output)
.with_domain_suffix_fn(|domain| psl::domain_str(domain).unwrap_or(domain)),
)
.await;
let policy = DmarcPolicy::from(dmarc_output.policy());
let domain = dmarc_output.domain().to_string();
let dkim_result_str = dmarc_result_to_string(dmarc_output.dkim_result());
let spf_result_str = dmarc_result_to_string(dmarc_output.spf_result());
let dkim_passed = matches!(dmarc_output.dkim_result(), MailAuthDmarcResult::Pass);
let spf_passed = matches!(dmarc_output.spf_result(), MailAuthDmarcResult::Pass);
let passed = dkim_passed || spf_passed;
let action = if passed {
"pass".to_string()
} else {
match policy {
DmarcPolicy::None => "pass".to_string(), // p=none means monitor only
DmarcPolicy::Quarantine => "quarantine".to_string(),
DmarcPolicy::Reject => "reject".to_string(),
}
};
Ok(DmarcResult {
passed,
policy,
domain,
dkim_result: dkim_result_str,
spf_result: spf_result_str,
action,
details: None,
})
}
fn dmarc_result_to_string(result: &MailAuthDmarcResult) -> String {
match result {
MailAuthDmarcResult::Pass => "pass".to_string(),
MailAuthDmarcResult::Fail(err) => format!("fail: {}", err),
MailAuthDmarcResult::TempError(err) => format!("temperror: {}", err),
MailAuthDmarcResult::PermError(err) => format!("permerror: {}", err),
MailAuthDmarcResult::None => "none".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dmarc_policy_from() {
assert_eq!(DmarcPolicy::from(Policy::None), DmarcPolicy::None);
assert_eq!(
DmarcPolicy::from(Policy::Quarantine),
DmarcPolicy::Quarantine
);
assert_eq!(DmarcPolicy::from(Policy::Reject), DmarcPolicy::Reject);
}
}

View File

@@ -0,0 +1,31 @@
use thiserror::Error;
/// Security-related error types.
#[derive(Debug, Error)]
pub enum SecurityError {
#[error("DKIM error: {0}")]
Dkim(String),
#[error("SPF error: {0}")]
Spf(String),
#[error("DMARC error: {0}")]
Dmarc(String),
#[error("DNS resolution error: {0}")]
Dns(String),
#[error("key error: {0}")]
Key(String),
#[error("IP reputation error: {0}")]
IpReputation(String),
#[error("parse error: {0}")]
Parse(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, SecurityError>;

View File

@@ -0,0 +1,280 @@
use hickory_resolver::TokioResolver;
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr};
use crate::error::Result;
/// Default DNSBL servers to check, same as the TypeScript IPReputationChecker.
pub const DEFAULT_DNSBL_SERVERS: &[&str] = &[
"zen.spamhaus.org",
"bl.spamcop.net",
"b.barracudacentral.org",
"spam.dnsbl.sorbs.net",
"dnsbl.sorbs.net",
"cbl.abuseat.org",
"xbl.spamhaus.org",
"pbl.spamhaus.org",
"dnsbl-1.uceprotect.net",
"psbl.surriel.com",
];
/// Result of a DNSBL check.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnsblResult {
/// IP address that was checked.
pub ip: String,
/// Number of DNSBL servers that list this IP.
pub listed_count: usize,
/// Names of DNSBL servers that list this IP.
pub listed_on: Vec<String>,
/// Total number of DNSBL servers checked.
pub total_checked: usize,
}
/// Result of a full IP reputation check.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReputationResult {
/// Reputation score: 0 (worst) to 100 (best).
pub score: u8,
/// Whether the IP is considered spam source.
pub is_spam: bool,
/// IP address that was checked.
pub ip: String,
/// DNSBL results.
pub dnsbl: DnsblResult,
/// Heuristic IP type classification.
pub ip_type: IpType,
}
/// Heuristic IP type classification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IpType {
Residential,
Datacenter,
Proxy,
Tor,
Vpn,
Unknown,
}
/// Risk level based on reputation score.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskLevel {
/// Score < 20
High,
/// Score 20-49
Medium,
/// Score 50-79
Low,
/// Score >= 80
Trusted,
}
/// Get the risk level for a reputation score.
pub fn risk_level(score: u8) -> RiskLevel {
match score {
0..=19 => RiskLevel::High,
20..=49 => RiskLevel::Medium,
50..=79 => RiskLevel::Low,
_ => RiskLevel::Trusted,
}
}
/// Check an IP against DNSBL servers.
///
/// * `ip` - The IP address to check (must be IPv4)
/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults)
/// * `resolver` - DNS resolver to use
pub async fn check_dnsbl(
ip: IpAddr,
dnsbl_servers: &[&str],
resolver: &TokioResolver,
) -> Result<DnsblResult> {
let ipv4 = match ip {
IpAddr::V4(v4) => v4,
IpAddr::V6(_) => {
// IPv6 DNSBL is less common; return clean result
return Ok(DnsblResult {
ip: ip.to_string(),
listed_count: 0,
listed_on: Vec::new(),
total_checked: 0,
});
}
};
let reversed = reverse_ipv4(ipv4);
let total = dnsbl_servers.len();
// Query all DNSBL servers in parallel
let mut handles = Vec::with_capacity(total);
for &server in dnsbl_servers {
let query = format!("{}.{}", reversed, server);
let resolver = resolver.clone();
let server_name = server.to_string();
handles.push(tokio::spawn(async move {
match resolver.lookup_ip(&query).await {
Ok(_) => Some(server_name), // IP is listed
Err(_) => None, // IP is not listed (NXDOMAIN)
}
}));
}
let mut listed_on = Vec::new();
for handle in handles {
match handle.await {
Ok(Some(server)) => listed_on.push(server),
_ => {}
}
}
Ok(DnsblResult {
ip: ip.to_string(),
listed_count: listed_on.len(),
listed_on,
total_checked: total,
})
}
/// Full IP reputation check: DNSBL + heuristic classification + scoring.
pub async fn check_reputation(
ip: IpAddr,
dnsbl_servers: &[&str],
resolver: &TokioResolver,
) -> Result<ReputationResult> {
let dnsbl = check_dnsbl(ip, dnsbl_servers, resolver).await?;
let ip_type = classify_ip(ip);
// Scoring: start at 100
let mut score: i16 = 100;
// Subtract 10 per DNSBL listing
score -= (dnsbl.listed_count as i16) * 10;
// Subtract 30 for suspicious IP types
match ip_type {
IpType::Proxy | IpType::Tor | IpType::Vpn => {
score -= 30;
}
_ => {}
}
let score = score.clamp(0, 100) as u8;
let is_spam = score < 50;
Ok(ReputationResult {
score,
is_spam,
ip: ip.to_string(),
dnsbl,
ip_type,
})
}
/// Reverse IPv4 octets for DNSBL queries: "1.2.3.4" -> "4.3.2.1".
fn reverse_ipv4(ip: Ipv4Addr) -> String {
let octets = ip.octets();
format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0])
}
/// Heuristic IP type classification based on well-known prefix ranges.
/// Same heuristics as the TypeScript IPReputationChecker.
fn classify_ip(ip: IpAddr) -> IpType {
let ip_str = ip.to_string();
// Known Tor exit node prefixes
if ip_str.starts_with("171.25.")
|| ip_str.starts_with("185.220.")
|| ip_str.starts_with("95.216.")
{
return IpType::Tor;
}
// Known VPN provider prefixes
if ip_str.starts_with("185.156.") || ip_str.starts_with("37.120.") {
return IpType::Vpn;
}
// Known proxy prefixes
if ip_str.starts_with("34.92.") || ip_str.starts_with("34.206.") {
return IpType::Proxy;
}
// Major cloud provider prefixes (datacenter)
if ip_str.starts_with("13.")
|| ip_str.starts_with("35.")
|| ip_str.starts_with("52.")
|| ip_str.starts_with("34.")
|| ip_str.starts_with("104.")
{
return IpType::Datacenter;
}
IpType::Residential
}
/// Validate an IPv4 address string.
pub fn is_valid_ipv4(ip: &str) -> bool {
ip.parse::<Ipv4Addr>().is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_reverse_ipv4() {
let ip: Ipv4Addr = "1.2.3.4".parse().unwrap();
assert_eq!(reverse_ipv4(ip), "4.3.2.1");
let ip: Ipv4Addr = "192.168.1.100".parse().unwrap();
assert_eq!(reverse_ipv4(ip), "100.1.168.192");
}
#[test]
fn test_classify_ip() {
assert_eq!(
classify_ip("171.25.193.20".parse().unwrap()),
IpType::Tor
);
assert_eq!(
classify_ip("185.156.73.1".parse().unwrap()),
IpType::Vpn
);
assert_eq!(
classify_ip("34.92.1.1".parse().unwrap()),
IpType::Proxy
);
assert_eq!(
classify_ip("52.0.0.1".parse().unwrap()),
IpType::Datacenter
);
assert_eq!(
classify_ip("203.0.113.1".parse().unwrap()),
IpType::Residential
);
}
#[test]
fn test_risk_level() {
assert_eq!(risk_level(10), RiskLevel::High);
assert_eq!(risk_level(30), RiskLevel::Medium);
assert_eq!(risk_level(60), RiskLevel::Low);
assert_eq!(risk_level(90), RiskLevel::Trusted);
}
#[test]
fn test_is_valid_ipv4() {
assert!(is_valid_ipv4("1.2.3.4"));
assert!(is_valid_ipv4("255.255.255.255"));
assert!(!is_valid_ipv4("999.999.999.999"));
assert!(!is_valid_ipv4("not-an-ip"));
}
#[test]
fn test_default_dnsbl_servers() {
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);
assert!(DEFAULT_DNSBL_SERVERS.contains(&"zen.spamhaus.org"));
}
}

View File

@@ -1,12 +1,36 @@
//! mailer-security: DKIM, SPF, and DMARC verification.
//! mailer-security: DKIM, SPF, DMARC verification, and IP reputation checking.
pub mod dkim;
pub mod dmarc;
pub mod error;
pub mod ip_reputation;
pub mod spf;
// Re-exports for convenience
pub use dkim::{dkim_dns_record_value, sign_dkim, verify_dkim, DkimVerificationResult};
pub use dmarc::{check_dmarc, DmarcPolicy, DmarcResult};
pub use error::{Result, SecurityError};
pub use ip_reputation::{
check_dnsbl, check_reputation, risk_level, DnsblResult, IpType, ReputationResult, RiskLevel,
DEFAULT_DNSBL_SERVERS,
};
pub use spf::{check_spf, check_spf_ehlo, received_spf_header, SpfResult};
// Re-export mail-auth's MessageAuthenticator for callers to construct
pub use mail_auth::MessageAuthenticator;
pub use mailer_core;
/// Placeholder for DKIM/SPF/DMARC implementation.
/// Crate version.
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
/// Create a MessageAuthenticator using Cloudflare DNS over TLS.
pub fn default_authenticator() -> std::result::Result<MessageAuthenticator, Box<dyn std::error::Error>> {
Ok(MessageAuthenticator::new_cloudflare_tls()?)
}
#[cfg(test)]
mod tests {
use super::*;

View 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"));
}
}