use regex::Regex;
use std::sync::LazyLock;
use crate::models::LineNumber;
use super::DetectionConfig;
use super::host::is_good_email_domain;
use super::junk_data::classify_email;
#[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") {
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().to_lowercase();
if !is_good_email_domain(&email) {
continue;
}
if !classify_email(&email) {
continue;
}
detections.push(EmailDetection {
email,
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.clone()))
.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.clone()));
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_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"]
);
}
}