use mailparse::MailAddr; use std::path::PathBuf; use std::str::FromStr; /// Extracts the first email address found in SMTP command or email header. /// /// Return `None` if parsing fails. /// /// Returns the first address if multiple are present. pub fn extract_address(input: &str) -> Option { let input_lower = input.to_lowercase(); let mut trimmed = input_lower .trim_start_matches("mail from:") .trim_start_matches("rcpt to:"); let addr_end = trimmed.find('>').unwrap_or(trimmed.len() - 1); trimmed = trimmed .split_at_checked(addr_end + 1) .map(|(address_raw, _)| address_raw) .unwrap_or(trimmed); mailparse::addrparse(trimmed) .ok() .and_then(|addr| match addr.first() { Some(MailAddr::Single(single)) => Some(single.addr.clone()), Some(MailAddr::Group(group)) => group.addrs.first().map(|single| single.addr.clone()), None => None, }) } /// Domain part of an email address, either a domain-literal (IP address in square brackets with /// optional protocol prefix) or a regular domain name. #[derive(Debug, PartialEq, Eq)] pub enum AddressDomain { /// Domain literal, e.g. /// - `192.0.2.0` in `test@[192.0.2.0]`, /// - `2001:db8::1` in `test@[IPv6:2001:db8::1]`. Literal(String), /// Regular domain name, e.g. `example.org` in `test@example.org`. Name(String), } impl FromStr for AddressDomain { type Err = crate::error::Error; /// Extracts the domain part from an email address and returns it as an [`AddressDomain`]. /// /// Returns an [`Error`] if `address` is not a valid email address. /// /// [`Error`]: crate::error::Error fn from_str(address: &str) -> Result { let parts: Vec<&str> = address.split('@').collect(); if parts.len() == 2 && let Some(domain) = parts.get(1) { // domain literals if domain.starts_with('[') && domain.ends_with(']') { let mut address_trimmed = domain .get(1..domain.len() - 1) .unwrap_or(domain) .to_lowercase(); address_trimmed = address_trimmed .strip_prefix("ipv6:") .unwrap_or(&address_trimmed) .to_string(); return Ok(AddressDomain::Literal(address_trimmed.to_string())); } Ok(AddressDomain::Name(domain.to_string())) } else { Err(crate::error::Error::InvalidEmailAddress( address.to_string(), )) } } } /// Logs email to `/tmp/filtermail-rejected//.eml` /// and returns the file path. /// /// Returns [`crate::error::Error`] on IO error. pub async fn log_eml(reason: &str, data: &[u8]) -> Result { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); let filename = format!("{timestamp}.eml"); let mut path = PathBuf::from(format!("/tmp/filtermail-rejected/{reason}")); tokio::fs::create_dir_all(&path).await?; path.push(filename); tokio::fs::write(&path, data).await?; Ok(path) } #[cfg(test)] mod tests { use super::*; use rstest::*; #[rstest] #[case("MAIL FROM:", Some("t1@example.org".to_string()))] #[case("MAIL FROM: SOMETHING=SOMETHING OTHER=OTHER", Some("t2@example.org".to_string()))] #[case("MAIL FROM: abc=def", Some("srs1=hhh=example.com==hhh=tt=example.org=alice@example.net".to_string()))] #[case("MAIL FROM: abc=def", Some("abc+alice@example.net".to_string()))] #[case("RCPT TO:", Some("t3@example.org".to_string()))] #[case("mail from:", Some("t4@example.org".to_string()))] #[case("Foo Bar ", Some("t5@example.org".to_string()))] #[case("t6@example.org", Some("t6@example.org".to_string()))] #[case("t7@[192.0.2.0]", Some("t7@[192.0.2.0]".to_string()))] #[case("", Some("t7@[192.0.2.0]".to_string()))] // This is a bug in mailparse, it refuses to parse IPv6 without "<>" around. // https://github.com/staktrace/mailparse/issues/137 #[case("t8@[IPv6:2001:db8::1]", None)] #[case("", Some("t8@[ipv6:2001:db8::1]".to_string()))] fn test_extract_address(#[case] input: &str, #[case] expected: Option) { let result = extract_address(input); assert_eq!(result, expected) } #[rstest] #[case("t1@example.org", Some(AddressDomain::Name("example.org".to_string())))] #[case("SRS1=HHH=example.com==HHH=TT=example.org=alice@example.net", Some(AddressDomain::Name("example.net".to_string())))] #[case("t7@[192.0.2.0]", Some(AddressDomain::Literal("192.0.2.0".to_string())))] #[case("t8@[IPv6:2001:db8::1]", Some(AddressDomain::Literal("2001:db8::1".to_string())))] #[case("invalid", None)] #[case("invalid@address@com", None)] fn test_get_domain_from_address(#[case] input: &str, #[case] expected: Option) { let result = AddressDomain::from_str(input).ok(); assert_eq!(result, expected); } }