Skip to main content

avila_cell/
classifier.rs

1//! Email classification and spam detection using pattern matching
2
3use avila_regex::Regex;
4
5/// Email classifier
6pub struct EmailClassifier {
7    spam_patterns: Vec<Regex>,
8    ham_patterns: Vec<Regex>,
9}
10
11impl EmailClassifier {
12    pub fn new() -> Self {
13        Self {
14            spam_patterns: Self::default_spam_patterns(),
15            ham_patterns: Vec::new(),
16        }
17    }
18
19    /// Default spam detection patterns
20    fn default_spam_patterns() -> Vec<Regex> {
21        vec![
22            Regex::new(r"(?i)viagra|cialis|pharmacy").unwrap(),
23            Regex::new(r"(?i)casino|lottery|winner").unwrap(),
24            Regex::new(r"(?i)click here|act now|limited time").unwrap(),
25            Regex::new(r"(?i)nigerian prince|inheritance").unwrap(),
26            Regex::new(r"(?i)buy now|free money|get rich").unwrap(),
27            Regex::new(r"(?i)urgent|congratulations|you've won").unwrap(),
28        ]
29    }
30
31    /// Classifies email
32    pub fn classify(&self, subject: &str, body: &str) -> EmailClass {
33        let text = format!("{} {}", subject, body);
34        let mut spam_score = 0;
35        let mut ham_score = 0;
36
37        // Check spam patterns
38        for pattern in &self.spam_patterns {
39            if pattern.is_match(&text) {
40                spam_score += 1;
41            }
42        }
43
44        // Check ham patterns
45        for pattern in &self.ham_patterns {
46            if pattern.is_match(&text) {
47                ham_score += 1;
48            }
49        }
50
51        // Additional heuristics
52        let upper_count = text.chars().filter(|&c| c.is_uppercase()).count();
53        let total_chars = text.chars().filter(|c| c.is_alphabetic()).count();
54        if total_chars > 0 && upper_count > total_chars / 3 {
55            spam_score += 2; // Too many capitals
56        }
57
58        if text.contains("!!!") || text.matches('!').count() > 5 {
59            spam_score += 2; // Excessive exclamation
60        }
61
62        if spam_score >= 3 {
63            EmailClass::Spam(spam_score)
64        } else {
65            EmailClass::Ham(ham_score)
66        }
67    }
68
69    /// Adds custom spam pattern
70    pub fn add_spam_pattern(&mut self, pattern: Regex) {
71        self.spam_patterns.push(pattern);
72    }
73
74    /// Adds custom ham pattern
75    pub fn add_ham_pattern(&mut self, pattern: Regex) {
76        self.ham_patterns.push(pattern);
77    }
78}
79
80impl Default for EmailClassifier {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86#[derive(Debug, Clone, PartialEq)]
87pub enum EmailClass {
88    Spam(usize),
89    Ham(usize),
90}
91
92/// Email threading - groups related emails
93pub struct EmailThreader;
94
95impl EmailThreader {
96    /// Groups emails by conversation thread
97    pub fn thread(emails: Vec<ThreadableEmail>) -> Vec<EmailThread> {
98        let mut threads: Vec<EmailThread> = Vec::new();
99
100        for email in emails {
101            let mut found = false;
102
103            // Try to add to existing thread
104            for thread in &mut threads {
105                if thread.belongs(&email) {
106                    thread.add(email.clone());
107                    found = true;
108                    break;
109                }
110            }
111
112            // Create new thread
113            if !found {
114                threads.push(EmailThread::new(email));
115            }
116        }
117
118        threads
119    }
120}
121
122#[derive(Debug, Clone)]
123pub struct ThreadableEmail {
124    pub id: String,
125    pub subject: String,
126    pub in_reply_to: Option<String>,
127    pub references: Vec<String>,
128}
129
130#[derive(Debug, Clone)]
131pub struct EmailThread {
132    pub root_subject: String,
133    pub emails: Vec<ThreadableEmail>,
134}
135
136impl EmailThread {
137    fn new(email: ThreadableEmail) -> Self {
138        let root_subject = Self::normalize_subject(&email.subject);
139        Self {
140            root_subject,
141            emails: vec![email],
142        }
143    }
144
145    fn belongs(&self, email: &ThreadableEmail) -> bool {
146        let normalized = Self::normalize_subject(&email.subject);
147        
148        // Check subject match
149        if normalized == self.root_subject {
150            return true;
151        }
152
153        // Check references
154        for existing in &self.emails {
155            if email.in_reply_to.as_ref() == Some(&existing.id) {
156                return true;
157            }
158            if email.references.contains(&existing.id) {
159                return true;
160            }
161        }
162
163        false
164    }
165
166    fn add(&mut self, email: ThreadableEmail) {
167        self.emails.push(email);
168    }
169
170    fn normalize_subject(subject: &str) -> String {
171        subject
172            .trim()
173            .to_lowercase()
174            .replace("re:", "")
175            .replace("fwd:", "")
176            .replace("fw:", "")
177            .trim()
178            .to_string()
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_spam_classification() {
188        let mut classifier = EmailClassifier::new();
189        
190        // Add more spam keywords to trigger detection
191        let result = classifier.classify(
192            "URGENT!!! You've WON the LOTTERY",
193            "Click HERE NOW to claim your FREE MONEY from the casino! Congratulations winner! Buy now viagra!"
194        );
195
196        assert!(matches!(result, EmailClass::Spam(_)));
197    }
198
199    #[test]
200    fn test_ham_classification() {
201        let classifier = EmailClassifier::new();
202        
203        let result = classifier.classify(
204            "Team meeting tomorrow",
205            "Hi everyone, let's meet at 2pm to discuss the project."
206        );
207
208        assert!(matches!(result, EmailClass::Ham(_)));
209    }
210
211    #[test]
212    fn test_email_threading() {
213        let emails = vec![
214            ThreadableEmail {
215                id: "1".to_string(),
216                subject: "Hello".to_string(),
217                in_reply_to: None,
218                references: Vec::new(),
219            },
220            ThreadableEmail {
221                id: "2".to_string(),
222                subject: "Re: Hello".to_string(),
223                in_reply_to: Some("1".to_string()),
224                references: vec!["1".to_string()],
225            },
226        ];
227
228        let threads = EmailThreader::thread(emails);
229        assert_eq!(threads.len(), 1);
230        assert_eq!(threads[0].emails.len(), 2);
231    }
232}