1use super::intelligence::{ClassifiedMessage, MessageType};
14use super::types::{ChannelMessage, MessageContent};
15use crate::config::MessagePriority;
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub enum EmailCategory {
23 NeedsReply,
25 ActionRequired,
27 FYI,
29 Newsletter,
31 Automated,
33 PersonalImportant,
35 Unknown,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub enum ThreadPosition {
42 NewThread,
44 Reply,
46 FollowUp,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SenderProfile {
53 pub address: String,
55 pub name: Option<String>,
57 pub typical_priority: MessagePriority,
59 pub response_rate: f32,
61 pub avg_response_time_secs: Option<u64>,
63 pub labels: Vec<String>,
65 pub message_count: usize,
67 pub last_seen: DateTime<Utc>,
69 #[serde(default)]
72 pub verified: bool,
73}
74
75impl SenderProfile {
76 pub fn new(address: impl Into<String>) -> Self {
78 Self {
79 address: address.into(),
80 name: None,
81 typical_priority: MessagePriority::Normal,
82 response_rate: 0.5, avg_response_time_secs: None,
84 labels: Vec::new(),
85 message_count: 0,
86 last_seen: Utc::now(),
87 verified: false,
88 }
89 }
90
91 pub fn with_name(mut self, name: impl Into<String>) -> Self {
93 self.name = Some(name.into());
94 self
95 }
96
97 pub fn record_message(&mut self) {
99 self.message_count += 1;
100 self.last_seen = Utc::now();
101 }
102
103 pub fn update_response_rate(&mut self, replied: bool) {
105 if !self.response_rate.is_finite() {
107 self.response_rate = 0.5; }
109 let value = if replied { 1.0 } else { 0.0 };
111 self.response_rate = (self.response_rate * 0.9 + value * 0.1).clamp(0.0, 1.0);
112 }
113
114 pub fn is_important(&self) -> bool {
119 if self.verified {
120 self.response_rate > 0.7 || self.message_count > 20
121 } else {
122 self.response_rate > 0.7 || self.message_count > 50
123 }
124 }
125
126 pub fn add_label(&mut self, label: impl Into<String>) {
128 let label = label.into();
129 if !self.labels.iter().any(|l| l.eq_ignore_ascii_case(&label)) {
130 self.labels.push(label);
131 }
132 }
133}
134
135#[derive(Debug, Clone)]
137pub struct EmailClassification {
138 pub base: ClassifiedMessage,
140 pub category: EmailCategory,
142 pub has_attachments: bool,
144 pub thread_position: ThreadPosition,
146 pub suggested_labels: Vec<String>,
148}
149
150pub struct EmailIntelligence {
155 known_senders: HashMap<String, SenderProfile>,
157 newsletter_patterns: Vec<String>,
159}
160
161impl EmailIntelligence {
162 pub fn new() -> Self {
164 Self {
165 known_senders: HashMap::new(),
166 newsletter_patterns: default_newsletter_patterns(),
167 }
168 }
169
170 pub fn classify_email(&mut self, classified: ClassifiedMessage) -> EmailClassification {
172 let sender_address = classified.original.sender.id.clone();
173 let has_attachments = self.detect_attachments(&classified.original);
174 let thread_position = self.detect_thread_position(&classified.original);
175
176 {
178 let profile = self
179 .known_senders
180 .entry(sender_address.clone())
181 .or_insert_with(|| SenderProfile::new(&sender_address));
182 profile.record_message();
183 if let Some(name) = &classified.original.sender.display_name {
184 profile.name = Some(name.clone());
185 }
186 }
187
188 let profile_snapshot = self.known_senders.get(&sender_address).cloned().unwrap();
190
191 let category = self.categorize_email(&classified, &profile_snapshot, &thread_position);
193
194 let suggested_labels = self.suggest_labels(&classified, &category, &profile_snapshot);
196
197 EmailClassification {
198 base: classified,
199 category,
200 has_attachments,
201 thread_position,
202 suggested_labels,
203 }
204 }
205
206 fn categorize_email(
208 &self,
209 classified: &ClassifiedMessage,
210 profile: &SenderProfile,
211 _thread_position: &ThreadPosition,
212 ) -> EmailCategory {
213 let text = extract_email_text(&classified.original);
214 let text_lower = text.to_lowercase();
215
216 if self.is_newsletter(&classified.original) {
218 return EmailCategory::Newsletter;
219 }
220
221 if self.is_automated(&text_lower, &classified.original) {
222 return EmailCategory::Automated;
223 }
224
225 if profile.is_important() {
227 return EmailCategory::PersonalImportant;
228 }
229
230 match classified.message_type {
232 MessageType::Question => EmailCategory::NeedsReply,
233 MessageType::ActionRequired => EmailCategory::ActionRequired,
234 MessageType::Notification => EmailCategory::FYI,
235 _ => {
236 if has_reply_indicators(&text_lower) {
238 EmailCategory::NeedsReply
239 } else {
240 EmailCategory::FYI
241 }
242 }
243 }
244 }
245
246 fn is_newsletter(&self, msg: &ChannelMessage) -> bool {
248 if msg.metadata.contains_key("list-unsubscribe")
251 || msg.metadata.contains_key("list-id")
252 || msg.metadata.get("precedence").is_some_and(|p| {
253 let clean = clean_header_value(p);
254 clean == "bulk" || clean == "list"
255 })
256 {
257 return true;
258 }
259
260 let sender_lower = clean_header_value(&msg.sender.id).to_lowercase();
262 self.newsletter_patterns
263 .iter()
264 .any(|p| sender_lower.contains(p))
265 }
266
267 fn is_automated(&self, text_lower: &str, msg: &ChannelMessage) -> bool {
269 let sender_lower = clean_header_value(&msg.sender.id).to_lowercase();
271 let automated_patterns = [
272 "noreply",
273 "no-reply",
274 "notifications@",
275 "alerts@",
276 "monitoring@",
277 "ci@",
278 "builds@",
279 "deploy@",
280 "system@",
281 "automation@",
282 "mailer-daemon",
283 ];
284 if automated_patterns.iter().any(|p| sender_lower.contains(p)) {
285 return true;
286 }
287
288 let auto_content = [
290 "this is an automated message",
291 "do not reply to this email",
292 "this email was sent automatically",
293 "automated notification",
294 ];
295 auto_content.iter().any(|p| text_lower.contains(p))
296 }
297
298 fn detect_attachments(&self, msg: &ChannelMessage) -> bool {
300 matches!(msg.content, MessageContent::File { .. })
301 || msg
302 .metadata
303 .get("has_attachments")
304 .is_some_and(|v| v == "true")
305 }
306
307 fn detect_thread_position(&self, msg: &ChannelMessage) -> ThreadPosition {
309 if msg.thread_id.is_some() {
310 if msg.reply_to.is_some() {
311 ThreadPosition::Reply
312 } else {
313 ThreadPosition::FollowUp
314 }
315 } else {
316 ThreadPosition::NewThread
317 }
318 }
319
320 fn suggest_labels(
322 &self,
323 classified: &ClassifiedMessage,
324 category: &EmailCategory,
325 profile: &SenderProfile,
326 ) -> Vec<String> {
327 let mut labels = Vec::new();
328
329 match category {
330 EmailCategory::NeedsReply => labels.push("needs-reply".to_string()),
331 EmailCategory::ActionRequired => labels.push("action-required".to_string()),
332 EmailCategory::Newsletter => labels.push("newsletter".to_string()),
333 EmailCategory::Automated => labels.push("automated".to_string()),
334 EmailCategory::PersonalImportant => labels.push("important".to_string()),
335 EmailCategory::FYI => labels.push("fyi".to_string()),
336 EmailCategory::Unknown => {}
337 }
338
339 if classified.priority >= MessagePriority::High {
340 labels.push("priority".to_string());
341 }
342
343 for label in &profile.labels {
345 if !labels.contains(label) {
346 labels.push(label.clone());
347 }
348 }
349
350 labels
351 }
352
353 pub fn get_sender_profile(&self, address: &str) -> Option<&SenderProfile> {
355 self.known_senders.get(address)
356 }
357
358 pub fn get_sender_profile_mut(&mut self, address: &str) -> Option<&mut SenderProfile> {
360 self.known_senders.get_mut(address)
361 }
362
363 pub fn update_sender_profile(&mut self, profile: SenderProfile) {
365 self.known_senders.insert(profile.address.clone(), profile);
366 }
367
368 pub fn known_sender_count(&self) -> usize {
370 self.known_senders.len()
371 }
372}
373
374impl Default for EmailIntelligence {
375 fn default() -> Self {
376 Self::new()
377 }
378}
379
380fn extract_email_text(msg: &ChannelMessage) -> String {
384 match &msg.content {
385 MessageContent::Text { text } => text.clone(),
386 MessageContent::Command { command, args } => format!("/{} {}", command, args.join(" ")),
387 _ => String::new(),
388 }
389}
390
391fn clean_header_value(value: &str) -> String {
396 value.chars().filter(|c| *c != '\r' && *c != '\n').collect()
397}
398
399fn has_reply_indicators(text: &str) -> bool {
401 const REPLY_INDICATORS: &[&str] = &[
402 "please reply",
403 "please respond",
404 "your thoughts",
405 "your opinion",
406 "let me know",
407 "get back to me",
408 "waiting for your",
409 "your feedback",
410 "your input",
411 "please confirm",
412 "please advise",
413 ];
414 REPLY_INDICATORS.iter().any(|p| text.contains(p))
415}
416
417fn default_newsletter_patterns() -> Vec<String> {
419 vec![
420 "newsletter".to_string(),
421 "digest@".to_string(),
422 "updates@".to_string(),
423 "marketing@".to_string(),
424 "info@".to_string(),
425 "news@".to_string(),
426 "weekly@".to_string(),
427 "daily@".to_string(),
428 ]
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use crate::channels::intelligence::{ClassifiedMessage, MessageType, SuggestedAction};
435 use crate::channels::types::{
436 ChannelMessage, ChannelType, ChannelUser, MessageContent, MessageId, ThreadId,
437 };
438 use crate::config::MessagePriority;
439
440 fn make_email_message(text: &str, sender: &str) -> ChannelMessage {
441 ChannelMessage {
442 id: MessageId::random(),
443 channel_type: ChannelType::Email,
444 channel_id: "inbox".to_string(),
445 sender: ChannelUser::new(sender, ChannelType::Email).with_name(sender),
446 content: MessageContent::Text {
447 text: text.to_string(),
448 },
449 timestamp: Utc::now(),
450 reply_to: None,
451 thread_id: None,
452 metadata: HashMap::new(),
453 }
454 }
455
456 fn make_classified_email(
457 text: &str,
458 sender: &str,
459 priority: MessagePriority,
460 msg_type: MessageType,
461 ) -> ClassifiedMessage {
462 ClassifiedMessage {
463 original: make_email_message(text, sender),
464 priority,
465 message_type: msg_type,
466 suggested_action: SuggestedAction::AddToDigest,
467 confidence: 0.8,
468 reasoning: "test".to_string(),
469 classified_at: Utc::now(),
470 }
471 }
472
473 #[test]
476 fn test_sender_profile_new() {
477 let profile = SenderProfile::new("alice@example.com");
478 assert_eq!(profile.address, "alice@example.com");
479 assert_eq!(profile.message_count, 0);
480 assert!(!profile.is_important());
481 }
482
483 #[test]
484 fn test_sender_profile_record_message() {
485 let mut profile = SenderProfile::new("alice@example.com");
486 profile.record_message();
487 profile.record_message();
488 assert_eq!(profile.message_count, 2);
489 }
490
491 #[test]
492 fn test_sender_profile_response_rate() {
493 let mut profile = SenderProfile::new("alice@example.com");
494 for _ in 0..20 {
496 profile.update_response_rate(true);
497 }
498 assert!(profile.response_rate > 0.7);
499 assert!(profile.is_important());
500 }
501
502 #[test]
503 fn test_sender_profile_important_by_count_verified() {
504 let mut profile = SenderProfile::new("alice@example.com");
505 profile.verified = true;
506 profile.message_count = 25;
507 assert!(profile.is_important());
508 }
509
510 #[test]
511 fn test_sender_profile_important_by_count_unverified() {
512 let mut profile = SenderProfile::new("alice@example.com");
513 profile.message_count = 25;
515 assert!(
516 !profile.is_important(),
517 "25 messages should not be enough for unverified sender"
518 );
519 profile.message_count = 55;
520 assert!(
521 profile.is_important(),
522 "55 messages should be enough for unverified sender"
523 );
524 }
525
526 #[test]
527 fn test_sender_profile_add_label() {
528 let mut profile = SenderProfile::new("alice@example.com");
529 profile.add_label("work");
530 profile.add_label("Work"); profile.add_label("personal");
532 assert_eq!(profile.labels.len(), 2);
533 }
534
535 #[test]
538 fn test_email_intelligence_new() {
539 let intel = EmailIntelligence::new();
540 assert_eq!(intel.known_sender_count(), 0);
541 }
542
543 #[test]
544 fn test_classify_question_email() {
545 let mut intel = EmailIntelligence::new();
546 let classified = make_classified_email(
547 "Can you review the latest proposal?",
548 "boss@corp.com",
549 MessagePriority::Normal,
550 MessageType::Question,
551 );
552 let result = intel.classify_email(classified);
553 assert_eq!(result.category, EmailCategory::NeedsReply);
554 assert!(result.suggested_labels.contains(&"needs-reply".to_string()));
555 }
556
557 #[test]
558 fn test_classify_action_email() {
559 let mut intel = EmailIntelligence::new();
560 let classified = make_classified_email(
561 "Please approve the budget by EOD",
562 "manager@corp.com",
563 MessagePriority::High,
564 MessageType::ActionRequired,
565 );
566 let result = intel.classify_email(classified);
567 assert_eq!(result.category, EmailCategory::ActionRequired);
568 assert!(
569 result
570 .suggested_labels
571 .contains(&"action-required".to_string())
572 );
573 assert!(result.suggested_labels.contains(&"priority".to_string()));
574 }
575
576 #[test]
577 fn test_classify_newsletter_by_metadata() {
578 let mut intel = EmailIntelligence::new();
579 let mut msg = make_email_message("This week in tech...", "newsletter@techweekly.com");
580 msg.metadata.insert(
581 "list-unsubscribe".to_string(),
582 "mailto:unsub@techweekly.com".to_string(),
583 );
584
585 let classified = ClassifiedMessage {
586 original: msg,
587 priority: MessagePriority::Low,
588 message_type: MessageType::Notification,
589 suggested_action: SuggestedAction::AddToDigest,
590 confidence: 0.8,
591 reasoning: "test".to_string(),
592 classified_at: Utc::now(),
593 };
594
595 let result = intel.classify_email(classified);
596 assert_eq!(result.category, EmailCategory::Newsletter);
597 }
598
599 #[test]
600 fn test_classify_newsletter_by_sender() {
601 let mut intel = EmailIntelligence::new();
602 let classified = make_classified_email(
603 "Your weekly update...",
604 "newsletter@company.com",
605 MessagePriority::Low,
606 MessageType::Notification,
607 );
608 let result = intel.classify_email(classified);
609 assert_eq!(result.category, EmailCategory::Newsletter);
610 }
611
612 #[test]
613 fn test_classify_automated_by_sender() {
614 let mut intel = EmailIntelligence::new();
615 let classified = make_classified_email(
616 "Build #456 succeeded",
617 "noreply@github.com",
618 MessagePriority::Low,
619 MessageType::Notification,
620 );
621 let result = intel.classify_email(classified);
622 assert_eq!(result.category, EmailCategory::Automated);
623 }
624
625 #[test]
626 fn test_classify_automated_by_content() {
627 let mut intel = EmailIntelligence::new();
628 let classified = make_classified_email(
629 "This is an automated message. Your deployment completed successfully.",
630 "deploy@corp.com",
631 MessagePriority::Normal,
632 MessageType::Notification,
633 );
634 let result = intel.classify_email(classified);
635 assert_eq!(result.category, EmailCategory::Automated);
636 }
637
638 #[test]
639 fn test_classify_important_sender() {
640 let mut intel = EmailIntelligence::new();
641
642 let mut profile = SenderProfile::new("important@corp.com");
644 profile.message_count = 50;
645 profile.response_rate = 0.9;
646 intel.update_sender_profile(profile);
647
648 let classified = make_classified_email(
649 "Hey, quick sync?",
650 "important@corp.com",
651 MessagePriority::Normal,
652 MessageType::Notification,
653 );
654 let result = intel.classify_email(classified);
655 assert_eq!(result.category, EmailCategory::PersonalImportant);
656 }
657
658 #[test]
659 fn test_thread_position_new() {
660 let mut intel = EmailIntelligence::new();
661 let classified = make_classified_email(
662 "Starting a new thread",
663 "alice@example.com",
664 MessagePriority::Normal,
665 MessageType::Notification,
666 );
667 let result = intel.classify_email(classified);
668 assert_eq!(result.thread_position, ThreadPosition::NewThread);
669 }
670
671 #[test]
672 fn test_thread_position_reply() {
673 let mut intel = EmailIntelligence::new();
674 let mut msg = make_email_message("Re: Previous subject", "alice@example.com");
675 msg.thread_id = Some(ThreadId::new("thread-1"));
676 msg.reply_to = Some(MessageId::new("msg-1"));
677
678 let classified = ClassifiedMessage {
679 original: msg,
680 priority: MessagePriority::Normal,
681 message_type: MessageType::Notification,
682 suggested_action: SuggestedAction::AddToDigest,
683 confidence: 0.8,
684 reasoning: "test".to_string(),
685 classified_at: Utc::now(),
686 };
687 let result = intel.classify_email(classified);
688 assert_eq!(result.thread_position, ThreadPosition::Reply);
689 }
690
691 #[test]
692 fn test_thread_position_followup() {
693 let mut intel = EmailIntelligence::new();
694 let mut msg = make_email_message("Following up on this", "alice@example.com");
695 msg.thread_id = Some(ThreadId::new("thread-1"));
696 let classified = ClassifiedMessage {
699 original: msg,
700 priority: MessagePriority::Normal,
701 message_type: MessageType::Notification,
702 suggested_action: SuggestedAction::AddToDigest,
703 confidence: 0.8,
704 reasoning: "test".to_string(),
705 classified_at: Utc::now(),
706 };
707 let result = intel.classify_email(classified);
708 assert_eq!(result.thread_position, ThreadPosition::FollowUp);
709 }
710
711 #[test]
712 fn test_detect_attachments_from_metadata() {
713 let mut intel = EmailIntelligence::new();
714 let mut msg = make_email_message("See attached", "alice@example.com");
715 msg.metadata
716 .insert("has_attachments".to_string(), "true".to_string());
717
718 let classified = ClassifiedMessage {
719 original: msg,
720 priority: MessagePriority::Normal,
721 message_type: MessageType::Notification,
722 suggested_action: SuggestedAction::AddToDigest,
723 confidence: 0.8,
724 reasoning: "test".to_string(),
725 classified_at: Utc::now(),
726 };
727 let result = intel.classify_email(classified);
728 assert!(result.has_attachments);
729 }
730
731 #[test]
732 fn test_sender_profile_tracked() {
733 let mut intel = EmailIntelligence::new();
734
735 let classified1 = make_classified_email(
737 "First message",
738 "alice@example.com",
739 MessagePriority::Normal,
740 MessageType::Notification,
741 );
742 intel.classify_email(classified1);
743
744 let classified2 = make_classified_email(
746 "Second message",
747 "alice@example.com",
748 MessagePriority::Normal,
749 MessageType::Notification,
750 );
751 intel.classify_email(classified2);
752
753 let profile = intel.get_sender_profile("alice@example.com").unwrap();
754 assert_eq!(profile.message_count, 2);
755 }
756
757 #[test]
758 fn test_reply_indicators() {
759 assert!(has_reply_indicators(
760 "please reply at your earliest convenience"
761 ));
762 assert!(has_reply_indicators("let me know what you think"));
763 assert!(has_reply_indicators("waiting for your response"));
764 assert!(!has_reply_indicators("just wanted to say thanks"));
765 }
766
767 #[test]
768 fn test_suggested_labels_include_sender_labels() {
769 let mut intel = EmailIntelligence::new();
770
771 let mut profile = SenderProfile::new("team@corp.com");
772 profile.add_label("team");
773 profile.add_label("work");
774 intel.update_sender_profile(profile);
775
776 let classified = make_classified_email(
777 "Team update",
778 "team@corp.com",
779 MessagePriority::Normal,
780 MessageType::Notification,
781 );
782 let result = intel.classify_email(classified);
783 assert!(result.suggested_labels.contains(&"team".to_string()));
784 assert!(result.suggested_labels.contains(&"work".to_string()));
785 }
786
787 #[test]
790 fn test_sender_profile_response_rate_zero_replies() {
791 let mut profile = SenderProfile::new("test@example.com");
792 assert_eq!(profile.response_rate, 0.5);
794
795 for _ in 0..10 {
797 profile.update_response_rate(false);
798 }
799 assert!(
800 profile.response_rate < 0.2,
801 "Rate should drop below 0.2 after 10 non-replies, got {}",
802 profile.response_rate
803 );
804 }
805
806 #[test]
807 fn test_sender_profile_response_rate_single_reply() {
808 let mut profile = SenderProfile::new("test@example.com");
809 profile.update_response_rate(true);
811 assert!(profile.response_rate > 0.5);
812 assert!((profile.response_rate - 0.55).abs() < 0.001);
814 }
815
816 #[test]
817 fn test_sender_profile_response_rate_rapid_fire() {
818 let mut profile = SenderProfile::new("test@example.com");
819 for _ in 0..100 {
821 profile.update_response_rate(true);
822 }
823 assert!(
824 profile.response_rate > 0.99,
825 "Rate should converge near 1.0 after 100 replies, got {}",
826 profile.response_rate
827 );
828 }
829
830 #[test]
831 fn test_sender_profile_response_rate_bounded() {
832 let mut profile = SenderProfile::new("test@example.com");
833 for _ in 0..1000 {
835 profile.update_response_rate(true);
836 }
837 assert!(profile.response_rate <= 1.0);
838 assert!(profile.response_rate >= 0.0);
839
840 for _ in 0..1000 {
841 profile.update_response_rate(false);
842 }
843 assert!(profile.response_rate >= 0.0);
844 assert!(profile.response_rate <= 1.0);
845 }
846
847 #[test]
850 fn test_sender_profile_nan_recovery() {
851 let mut profile = SenderProfile::new("test@example.com");
852 profile.response_rate = f32::NAN;
853 profile.update_response_rate(true);
854 assert!(profile.response_rate.is_finite());
856 assert!(
858 (profile.response_rate - 0.55).abs() < 0.001,
859 "Expected ~0.55 after NaN recovery + reply, got {}",
860 profile.response_rate
861 );
862 }
863
864 #[test]
865 fn test_sender_profile_infinity_recovery() {
866 let mut profile = SenderProfile::new("test@example.com");
867 profile.response_rate = f32::INFINITY;
868 profile.update_response_rate(false);
869 assert!(profile.response_rate.is_finite());
870 assert!(
872 (profile.response_rate - 0.45).abs() < 0.001,
873 "Expected ~0.45 after Inf recovery + non-reply, got {}",
874 profile.response_rate
875 );
876 }
877
878 #[test]
879 fn test_sender_profile_neg_infinity_recovery() {
880 let mut profile = SenderProfile::new("test@example.com");
881 profile.response_rate = f32::NEG_INFINITY;
882 profile.update_response_rate(true);
883 assert!(profile.response_rate.is_finite());
884 assert!(profile.response_rate >= 0.0);
885 assert!(profile.response_rate <= 1.0);
886 }
887
888 #[test]
889 fn test_sender_profile_normal_values_unchanged() {
890 let mut profile = SenderProfile::new("test@example.com");
891 profile.response_rate = 0.7;
892 profile.update_response_rate(true);
893 assert!(
895 (profile.response_rate - 0.73).abs() < 0.001,
896 "Normal values should not be affected by NaN guard, got {}",
897 profile.response_rate
898 );
899 }
900}