use std::collections::HashMap;
use std::time::Instant;
use parking_lot::Mutex;
#[derive(Debug)]
pub struct EmailGuardResult {
pub allowed: bool,
pub violations: Vec<EmailViolation>,
}
#[derive(Debug, Clone)]
pub enum EmailViolation {
HeaderInjection(String),
InvalidAddress(String),
BlockedDomain(String),
ExcessiveRecipients(u32),
RecipientBombing(String),
ContentInjection(String),
OversizedField { field: String, len: usize, max: usize },
EncodedPayload(String),
}
#[derive(Debug, Clone)]
pub struct EmailGuardConfig {
pub max_per_recipient: u32,
pub rate_window_secs: u64,
pub max_recipients: u32,
pub max_subject_len: usize,
pub max_body_len: usize,
pub max_name_len: usize,
pub blocked_domains: Vec<String>,
}
impl Default for EmailGuardConfig {
fn default() -> Self {
Self {
max_per_recipient: 5,
rate_window_secs: 300, max_recipients: 10,
max_subject_len: 200,
max_body_len: 10_000,
max_name_len: 100,
blocked_domains: vec![
"localhost".into(),
"127.0.0.1".into(),
"0.0.0.0".into(),
"[::1]".into(),
"internal".into(),
"local".into(),
"corp".into(),
"mailinator.com".into(),
"guerrillamail.com".into(),
"tempmail.com".into(),
"throwaway.email".into(),
"yopmail.com".into(),
"sharklasers.com".into(),
"guerrillamailblock.com".into(),
"grr.la".into(),
"dispostable.com".into(),
"trashmail.com".into(),
],
}
}
}
pub struct EmailRateLimiter {
sends: Mutex<HashMap<String, Vec<Instant>>>,
config: EmailGuardConfig,
}
impl EmailRateLimiter {
pub fn new(config: EmailGuardConfig) -> Self {
Self {
sends: Mutex::new(HashMap::new()),
config,
}
}
pub fn check_and_record(&self, recipient: &str) -> bool {
let mut sends = self.sends.lock();
let now = Instant::now();
let window = std::time::Duration::from_secs(self.config.rate_window_secs);
let entry = sends
.entry(recipient.to_lowercase())
.or_insert_with(Vec::new);
entry.retain(|t| now.duration_since(*t) < window);
if entry.len() >= self.config.max_per_recipient as usize {
false
} else {
entry.push(now);
true
}
}
pub fn prune(&self) {
let mut sends = self.sends.lock();
let now = Instant::now();
let window = std::time::Duration::from_secs(self.config.rate_window_secs);
sends.retain(|_, timestamps| {
timestamps.retain(|t| now.duration_since(*t) < window);
!timestamps.is_empty()
});
}
}
pub fn validate_email_address(addr: &str, config: &EmailGuardConfig) -> Vec<EmailViolation> {
let mut violations = Vec::new();
if has_crlf(addr) {
violations.push(EmailViolation::HeaderInjection(
"Email address contains newline characters".into(),
));
return violations; }
let parts: Vec<&str> = addr.splitn(2, '@').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
violations.push(EmailViolation::InvalidAddress(
format!("Invalid email format: {}", truncate(addr, 50)),
));
return violations;
}
let domain = parts[1].to_lowercase();
if addr.contains('\0') {
violations.push(EmailViolation::InvalidAddress(
"Email address contains null byte".into(),
));
}
if addr.len() > 254 {
violations.push(EmailViolation::InvalidAddress(
"Email address exceeds maximum length (254)".into(),
));
}
if domain.contains("..") {
violations.push(EmailViolation::InvalidAddress(
"Domain contains consecutive dots".into(),
));
}
for blocked in &config.blocked_domains {
if domain == *blocked || domain.ends_with(&format!(".{}", blocked)) {
violations.push(EmailViolation::BlockedDomain(
format!("Email domain '{}' is blocked", domain),
));
break;
}
}
if domain.starts_with('[') || domain.parse::<std::net::IpAddr>().is_ok() {
violations.push(EmailViolation::BlockedDomain(
"IP address domains are not allowed".into(),
));
}
violations
}
pub fn validate_header_field(field_name: &str, value: &str, max_len: usize) -> Vec<EmailViolation> {
let mut violations = Vec::new();
if has_crlf(value) {
violations.push(EmailViolation::HeaderInjection(
format!("'{}' contains newline characters (header injection)", field_name),
));
}
if value.contains('\0') {
violations.push(EmailViolation::HeaderInjection(
format!("'{}' contains null byte", field_name),
));
}
if value.len() > max_len {
violations.push(EmailViolation::OversizedField {
field: field_name.into(),
len: value.len(),
max: max_len,
});
}
if has_encoded_attacks(value) {
violations.push(EmailViolation::EncodedPayload(
format!("'{}' contains suspicious encoded content", field_name),
));
}
violations
}
pub fn validate_template_content(field_name: &str, value: &str, max_len: usize) -> Vec<EmailViolation> {
let mut violations = Vec::new();
if value.len() > max_len {
violations.push(EmailViolation::OversizedField {
field: field_name.into(),
len: value.len(),
max: max_len,
});
}
let lower = value.to_lowercase();
let injection_patterns = [
"<script",
"</script",
"javascript:",
"vbscript:",
"data:text/html",
"onerror=",
"onload=",
"onclick=",
"onmouseover=",
"onfocus=",
"onblur=",
"eval(",
"expression(",
"url(data:",
"<iframe",
"<object",
"<embed",
"<form",
"<input",
"<meta",
"<link",
"<base",
"<svg",
"<!--",
"srcdoc=",
];
for pattern in &injection_patterns {
if lower.contains(pattern) {
violations.push(EmailViolation::ContentInjection(
format!("'{}' contains prohibited HTML/script content", field_name),
));
break;
}
}
if has_crlf(value) && field_name != "message" && field_name != "response_body" {
violations.push(EmailViolation::HeaderInjection(
format!("'{}' contains newline characters", field_name),
));
}
violations
}
pub fn html_escape(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => result.push_str("&"),
'<' => result.push_str("<"),
'>' => result.push_str(">"),
'"' => result.push_str("""),
'\'' => result.push_str("'"),
'/' => result.push_str("/"),
'\0' => {} _ => result.push(ch),
}
}
result
}
pub fn validate_outbound_email(
to: &[&str],
subject: &str,
body_fields: &[(&str, &str)],
config: &EmailGuardConfig,
) -> Result<(), String> {
let mut all_violations = Vec::new();
if to.len() > config.max_recipients as usize {
all_violations.push(EmailViolation::ExcessiveRecipients(to.len() as u32));
}
for addr in to {
all_violations.extend(validate_email_address(addr, config));
}
all_violations.extend(validate_header_field("subject", subject, config.max_subject_len));
for (name, value) in body_fields {
all_violations.extend(validate_template_content(name, value, config.max_body_len));
}
if all_violations.is_empty() {
Ok(())
} else {
let reasons: Vec<String> = all_violations.iter().map(|v| format!("{:?}", v)).collect();
Err(reasons.join("; "))
}
}
fn has_crlf(s: &str) -> bool {
s.contains('\r') || s.contains('\n')
}
fn has_encoded_attacks(s: &str) -> bool {
let lower = s.to_lowercase();
let b64_patterns = [
"phnjcmlwdd4", "amf2yxnjcmlwddo", "phn2zw", "pgfszxj0k", ];
for pattern in &b64_patterns {
if lower.contains(pattern) {
return true;
}
}
if s.contains('\u{202E}') || s.contains('\u{200F}') || s.contains('\u{200E}') {
return true;
}
if s.contains('\u{200B}') || s.contains('\u{FEFF}') || s.contains('\u{200C}') || s.contains('\u{200D}') {
return true;
}
false
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}...", &s[..max])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> EmailGuardConfig {
EmailGuardConfig::default()
}
#[test]
fn allows_valid_email() {
let v = validate_email_address("user@example.com", &config());
assert!(v.is_empty(), "Expected no violations: {:?}", v);
}
#[test]
fn blocks_crlf_in_email() {
let v = validate_email_address("user@example.com\r\nBcc: victim@evil.com", &config());
assert!(!v.is_empty());
assert!(matches!(v[0], EmailViolation::HeaderInjection(_)));
}
#[test]
fn blocks_missing_at_sign() {
let v = validate_email_address("not-an-email", &config());
assert!(!v.is_empty());
assert!(matches!(v[0], EmailViolation::InvalidAddress(_)));
}
#[test]
fn blocks_empty_local_part() {
let v = validate_email_address("@example.com", &config());
assert!(!v.is_empty());
assert!(matches!(v[0], EmailViolation::InvalidAddress(_)));
}
#[test]
fn blocks_disposable_domain() {
let v = validate_email_address("user@mailinator.com", &config());
assert!(v.iter().any(|v| matches!(v, EmailViolation::BlockedDomain(_))));
}
#[test]
fn blocks_localhost_domain() {
let v = validate_email_address("admin@localhost", &config());
assert!(v.iter().any(|v| matches!(v, EmailViolation::BlockedDomain(_))));
}
#[test]
fn blocks_ip_domain() {
let v = validate_email_address("admin@[127.0.0.1]", &config());
assert!(v.iter().any(|v| matches!(v, EmailViolation::BlockedDomain(_))));
}
#[test]
fn blocks_crlf_in_subject() {
let v = validate_header_field("subject", "Hello\r\nBcc: hacker@evil.com", 200);
assert!(v.iter().any(|v| matches!(v, EmailViolation::HeaderInjection(_))));
}
#[test]
fn blocks_newline_in_name() {
let v = validate_header_field("username", "John\nBcc: hack@evil.com", 100);
assert!(v.iter().any(|v| matches!(v, EmailViolation::HeaderInjection(_))));
}
#[test]
fn blocks_oversized_subject() {
let long = "A".repeat(300);
let v = validate_header_field("subject", &long, 200);
assert!(v.iter().any(|v| matches!(v, EmailViolation::OversizedField { .. })));
}
#[test]
fn blocks_script_in_content() {
let v = validate_template_content("message", "Hello <script>alert('xss')</script>", 10000);
assert!(v.iter().any(|v| matches!(v, EmailViolation::ContentInjection(_))));
}
#[test]
fn blocks_javascript_protocol() {
let v = validate_template_content("message", "Click javascript:alert(1)", 10000);
assert!(v.iter().any(|v| matches!(v, EmailViolation::ContentInjection(_))));
}
#[test]
fn blocks_event_handlers() {
let v = validate_template_content("message", "<img onerror=alert(1) src=x>", 10000);
assert!(v.iter().any(|v| matches!(v, EmailViolation::ContentInjection(_))));
}
#[test]
fn blocks_svg_injection() {
let v = validate_template_content("message", "<svg onload=fetch('evil.com')>", 10000);
assert!(v.iter().any(|v| matches!(v, EmailViolation::ContentInjection(_))));
}
#[test]
fn allows_normal_text_content() {
let v = validate_template_content("message", "Hello, this is a normal support message!", 10000);
assert!(v.is_empty());
}
#[test]
fn blocks_unicode_bidi_override() {
let v = validate_header_field("username", "normal\u{202E}txe.exe", 100);
assert!(v.iter().any(|v| matches!(v, EmailViolation::EncodedPayload(_))));
}
#[test]
fn blocks_zero_width_chars() {
let v = validate_header_field("username", "adm\u{200B}in", 100);
assert!(v.iter().any(|v| matches!(v, EmailViolation::EncodedPayload(_))));
}
#[test]
fn escapes_html_special_chars() {
assert_eq!(
html_escape("<script>alert('xss')</script>"),
"<script>alert('xss')</script>"
);
}
#[test]
fn escapes_ampersand() {
assert_eq!(html_escape("AT&T"), "AT&T");
}
#[test]
fn strips_null_bytes() {
assert_eq!(html_escape("hel\0lo"), "hello");
}
#[test]
fn validates_good_outbound_email() {
let result = validate_outbound_email(
&["user@example.com"],
"Welcome!",
&[("message", "Thanks for joining")],
&config(),
);
assert!(result.is_ok());
}
#[test]
fn rejects_too_many_recipients() {
let addrs: Vec<String> = (0..15).map(|i| format!("user{}@example.com", i)).collect();
let refs: Vec<&str> = addrs.iter().map(|s| s.as_str()).collect();
let result = validate_outbound_email(
&refs,
"Hi",
&[],
&config(),
);
assert!(result.is_err());
}
#[test]
fn rejects_injection_in_subject() {
let result = validate_outbound_email(
&["user@example.com"],
"Subject\r\nBcc: evil@hacker.com",
&[],
&config(),
);
assert!(result.is_err());
}
#[test]
fn rate_limiter_allows_under_threshold() {
let limiter = EmailRateLimiter::new(EmailGuardConfig {
max_per_recipient: 3,
rate_window_secs: 300,
..Default::default()
});
assert!(limiter.check_and_record("user@example.com"));
assert!(limiter.check_and_record("user@example.com"));
assert!(limiter.check_and_record("user@example.com"));
}
#[test]
fn rate_limiter_blocks_over_threshold() {
let limiter = EmailRateLimiter::new(EmailGuardConfig {
max_per_recipient: 2,
rate_window_secs: 300,
..Default::default()
});
assert!(limiter.check_and_record("user@example.com"));
assert!(limiter.check_and_record("user@example.com"));
assert!(!limiter.check_and_record("user@example.com")); }
#[test]
fn rate_limiter_tracks_per_recipient() {
let limiter = EmailRateLimiter::new(EmailGuardConfig {
max_per_recipient: 1,
rate_window_secs: 300,
..Default::default()
});
assert!(limiter.check_and_record("a@example.com"));
assert!(limiter.check_and_record("b@example.com"));
assert!(!limiter.check_and_record("a@example.com")); assert!(!limiter.check_and_record("b@example.com")); }
}