1use avila_regex::Regex;
4
5pub 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 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 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 for pattern in &self.spam_patterns {
39 if pattern.is_match(&text) {
40 spam_score += 1;
41 }
42 }
43
44 for pattern in &self.ham_patterns {
46 if pattern.is_match(&text) {
47 ham_score += 1;
48 }
49 }
50
51 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; }
57
58 if text.contains("!!!") || text.matches('!').count() > 5 {
59 spam_score += 2; }
61
62 if spam_score >= 3 {
63 EmailClass::Spam(spam_score)
64 } else {
65 EmailClass::Ham(ham_score)
66 }
67 }
68
69 pub fn add_spam_pattern(&mut self, pattern: Regex) {
71 self.spam_patterns.push(pattern);
72 }
73
74 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
92pub struct EmailThreader;
94
95impl EmailThreader {
96 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 for thread in &mut threads {
105 if thread.belongs(&email) {
106 thread.add(email.clone());
107 found = true;
108 break;
109 }
110 }
111
112 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 if normalized == self.root_subject {
150 return true;
151 }
152
153 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 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}