use regex::Regex;
use std::sync::LazyLock;
use crate::models::LineNumber;
use super::host::is_good_email_domain;
use super::junk_data::classify_email;
use super::{DetectionConfig, decode_pod_angle_escapes};
#[derive(Debug, Clone, PartialEq)]
pub struct EmailDetection {
pub email: String,
pub start_line: LineNumber,
pub end_line: LineNumber,
}
static EMAILS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b[A-Z0-9._%-]+@[A-Z0-9.-]+\.[A-Z]{2,63}\b").expect("valid email regex")
});
pub fn find_emails(text: &str, config: &DetectionConfig) -> Vec<EmailDetection> {
let mut detections = Vec::new();
for (line_index, line) in text.lines().enumerate() {
let line_number = LineNumber::from_0_indexed(line_index);
let normalized_line = line.replace("\\r\\n", "\\n").replace("\\r", "\\n");
for segment in normalized_line.split("\\n") {
let segment = decode_pod_angle_escapes(segment);
let segment = segment.as_ref();
for matched in EMAILS_REGEX.find_iter(segment) {
if let Some(after_colon) = segment[matched.end()..].strip_prefix(':') {
let suffix = after_colon.split_whitespace().next().unwrap_or("");
let is_ssh_path = suffix.contains('/');
let is_port = !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit());
if is_ssh_path || is_port {
continue;
}
}
let email = matched.as_str();
if !is_good_email_domain(email) {
continue;
}
if !classify_email(email) {
continue;
}
detections.push(EmailDetection {
email: email.to_string(),
start_line: line_number,
end_line: line_number,
});
}
}
}
let mut detections = if config.unique {
let mut seen = std::collections::HashSet::<String>::new();
detections
.into_iter()
.filter(|d| seen.insert(d.email.to_lowercase()))
.collect::<Vec<_>>()
} else {
detections
};
if config.max_emails > 0 && detections.len() > config.max_emails {
let mut seen = std::collections::HashSet::<String>::new();
detections.retain(|d| seen.insert(d.email.to_lowercase()));
detections.truncate(config.max_emails);
}
detections
}
#[cfg(test)]
mod tests {
use super::*;
fn emails(text: &str) -> Vec<String> {
find_emails(text, &DetectionConfig::default())
.into_iter()
.map(|d| d.email)
.collect()
}
#[test]
fn test_find_emails_preserves_source_case() {
assert_eq!(
emails("mailto Richard.M.Bartel@ccMail.Census.GOV now"),
vec!["Richard.M.Bartel@ccMail.Census.GOV"]
);
assert_eq!(
emails("send to Paul.Green@stratus.com"),
vec!["Paul.Green@stratus.com"]
);
}
#[test]
fn test_find_emails_dedupes_case_variants_of_one_address() {
let config = DetectionConfig {
unique: true,
..DetectionConfig::default()
};
let detections = find_emails(
"Paul.Green@stratus.com\npaul.green@stratus.com\nPAUL.GREEN@STRATUS.COM\n",
&config,
);
assert_eq!(
detections
.iter()
.map(|d| d.email.as_str())
.collect::<Vec<_>>(),
vec!["Paul.Green@stratus.com"]
);
}
#[test]
fn test_find_emails_caps_on_case_insensitive_uniqueness() {
let config = DetectionConfig {
max_emails: 2,
max_urls: 0,
unique: false,
};
let detections = find_emails("a@corp.com\nA@Corp.com\nb@corp.com\nc@corp.com\n", &config);
assert_eq!(
detections
.iter()
.map(|d| d.email.as_str())
.collect::<Vec<_>>(),
vec!["a@corp.com", "b@corp.com"]
);
}
#[test]
fn test_find_emails_skips_ssh_remote_and_authority() {
assert!(emails("clone: git clone git@github.com:tonsky/FiraCode.git").is_empty());
assert!(emails("connect admin@dbhost.io:5432 now").is_empty());
assert_eq!(
emails("owner=admin@corp.com:active"),
vec!["admin@corp.com"]
);
assert_eq!(
emails("Contact real@corp.com: for help"),
vec!["real@corp.com"]
);
assert_eq!(
emails("mail jane@realcorp.io today"),
vec!["jane@realcorp.io"]
);
}
#[test]
fn test_find_emails_decodes_perl_pod_angle_escapes() {
assert_eq!(
emails("Peter John Acklam E<lt>pjacklam@gmail.comE<gt>"),
vec!["pjacklam@gmail.com"]
);
}
}