feat(mailer-smtp): add SCRAM-SHA-256 auth, Ed25519 DKIM, opportunistic TLS, SNI cert selection, pipelining and delivery/bridge improvements

This commit is contained in:
2026-02-11 10:11:43 +00:00
parent 7908cbaefa
commit b10597fd5e
28 changed files with 1849 additions and 153 deletions

View File

@@ -1,6 +1,6 @@
use hickory_resolver::TokioResolver;
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use crate::error::Result;
@@ -83,7 +83,7 @@ pub fn risk_level(score: u8) -> RiskLevel {
/// Check an IP against DNSBL servers.
///
/// * `ip` - The IP address to check (must be IPv4)
/// * `ip` - The IP address to check (IPv4 or IPv6)
/// * `dnsbl_servers` - DNSBL servers to query (use `DEFAULT_DNSBL_SERVERS` for defaults)
/// * `resolver` - DNS resolver to use
pub async fn check_dnsbl(
@@ -91,20 +91,10 @@ pub async fn check_dnsbl(
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 = match ip {
IpAddr::V4(v4) => reverse_ipv4(v4),
IpAddr::V6(v6) => reverse_ipv6(v6),
};
let reversed = reverse_ipv4(ipv4);
let total = dnsbl_servers.len();
// Query all DNSBL servers in parallel
@@ -178,6 +168,21 @@ fn reverse_ipv4(ip: Ipv4Addr) -> String {
format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0])
}
/// Reverse IPv6 address to nibble format for DNSBL queries.
///
/// Expands to full 32-nibble hex, reverses, and dot-separates each nibble.
/// E.g. `2001:db8::1` -> `1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2`
fn reverse_ipv6(ip: Ipv6Addr) -> String {
let segments = ip.segments();
let full_hex: String = segments.iter().map(|s| format!("{:04x}", s)).collect();
full_hex
.chars()
.rev()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(".")
}
/// Heuristic IP type classification based on well-known prefix ranges.
/// Same heuristics as the TypeScript IPReputationChecker.
fn classify_ip(ip: IpAddr) -> IpType {
@@ -272,6 +277,38 @@ mod tests {
assert!(!is_valid_ipv4("not-an-ip"));
}
#[test]
fn test_reverse_ipv6() {
let ip: Ipv6Addr = "2001:0db8:0000:0000:0000:0000:0000:0001".parse().unwrap();
assert_eq!(
reverse_ipv6(ip),
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2"
);
}
#[test]
fn test_reverse_ipv6_loopback() {
let ip: Ipv6Addr = "::1".parse().unwrap();
assert_eq!(
reverse_ipv6(ip),
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0"
);
}
#[tokio::test]
async fn test_check_dnsbl_ipv6_runs() {
// Verify IPv6 actually goes through DNSBL queries (not skipped)
let resolver = hickory_resolver::TokioResolver::builder_tokio()
.map(|b| b.build())
.unwrap();
let ip: IpAddr = "::1".parse().unwrap();
let result = check_dnsbl(ip, DEFAULT_DNSBL_SERVERS, &resolver).await.unwrap();
// Loopback should not be listed on any DNSBL
assert_eq!(result.listed_count, 0);
// But total_checked should be > 0 — proving IPv6 was actually queried
assert_eq!(result.total_checked, DEFAULT_DNSBL_SERVERS.len());
}
#[test]
fn test_default_dnsbl_servers() {
assert_eq!(DEFAULT_DNSBL_SERVERS.len(), 10);