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!(result
569 .suggested_labels
570 .contains(&"action-required".to_string()));
571 assert!(result.suggested_labels.contains(&"priority".to_string()));
572 }
573
574 #[test]
575 fn test_classify_newsletter_by_metadata() {
576 let mut intel = EmailIntelligence::new();
577 let mut msg = make_email_message("This week in tech...", "newsletter@techweekly.com");
578 msg.metadata.insert(
579 "list-unsubscribe".to_string(),
580 "mailto:unsub@techweekly.com".to_string(),
581 );
582
583 let classified = ClassifiedMessage {
584 original: msg,
585 priority: MessagePriority::Low,
586 message_type: MessageType::Notification,
587 suggested_action: SuggestedAction::AddToDigest,
588 confidence: 0.8,
589 reasoning: "test".to_string(),
590 classified_at: Utc::now(),
591 };
592
593 let result = intel.classify_email(classified);
594 assert_eq!(result.category, EmailCategory::Newsletter);
595 }
596
597 #[test]
598 fn test_classify_newsletter_by_sender() {
599 let mut intel = EmailIntelligence::new();
600 let classified = make_classified_email(
601 "Your weekly update...",
602 "newsletter@company.com",
603 MessagePriority::Low,
604 MessageType::Notification,
605 );
606 let result = intel.classify_email(classified);
607 assert_eq!(result.category, EmailCategory::Newsletter);
608 }
609
610 #[test]
611 fn test_classify_automated_by_sender() {
612 let mut intel = EmailIntelligence::new();
613 let classified = make_classified_email(
614 "Build #456 succeeded",
615 "noreply@github.com",
616 MessagePriority::Low,
617 MessageType::Notification,
618 );
619 let result = intel.classify_email(classified);
620 assert_eq!(result.category, EmailCategory::Automated);
621 }
622
623 #[test]
624 fn test_classify_automated_by_content() {
625 let mut intel = EmailIntelligence::new();
626 let classified = make_classified_email(
627 "This is an automated message. Your deployment completed successfully.",
628 "deploy@corp.com",
629 MessagePriority::Normal,
630 MessageType::Notification,
631 );
632 let result = intel.classify_email(classified);
633 assert_eq!(result.category, EmailCategory::Automated);
634 }
635
636 #[test]
637 fn test_classify_important_sender() {
638 let mut intel = EmailIntelligence::new();
639
640 let mut profile = SenderProfile::new("important@corp.com");
642 profile.message_count = 50;
643 profile.response_rate = 0.9;
644 intel.update_sender_profile(profile);
645
646 let classified = make_classified_email(
647 "Hey, quick sync?",
648 "important@corp.com",
649 MessagePriority::Normal,
650 MessageType::Notification,
651 );
652 let result = intel.classify_email(classified);
653 assert_eq!(result.category, EmailCategory::PersonalImportant);
654 }
655
656 #[test]
657 fn test_thread_position_new() {
658 let mut intel = EmailIntelligence::new();
659 let classified = make_classified_email(
660 "Starting a new thread",
661 "alice@example.com",
662 MessagePriority::Normal,
663 MessageType::Notification,
664 );
665 let result = intel.classify_email(classified);
666 assert_eq!(result.thread_position, ThreadPosition::NewThread);
667 }
668
669 #[test]
670 fn test_thread_position_reply() {
671 let mut intel = EmailIntelligence::new();
672 let mut msg = make_email_message("Re: Previous subject", "alice@example.com");
673 msg.thread_id = Some(ThreadId::new("thread-1"));
674 msg.reply_to = Some(MessageId::new("msg-1"));
675
676 let classified = ClassifiedMessage {
677 original: msg,
678 priority: MessagePriority::Normal,
679 message_type: MessageType::Notification,
680 suggested_action: SuggestedAction::AddToDigest,
681 confidence: 0.8,
682 reasoning: "test".to_string(),
683 classified_at: Utc::now(),
684 };
685 let result = intel.classify_email(classified);
686 assert_eq!(result.thread_position, ThreadPosition::Reply);
687 }
688
689 #[test]
690 fn test_thread_position_followup() {
691 let mut intel = EmailIntelligence::new();
692 let mut msg = make_email_message("Following up on this", "alice@example.com");
693 msg.thread_id = Some(ThreadId::new("thread-1"));
694 let classified = ClassifiedMessage {
697 original: msg,
698 priority: MessagePriority::Normal,
699 message_type: MessageType::Notification,
700 suggested_action: SuggestedAction::AddToDigest,
701 confidence: 0.8,
702 reasoning: "test".to_string(),
703 classified_at: Utc::now(),
704 };
705 let result = intel.classify_email(classified);
706 assert_eq!(result.thread_position, ThreadPosition::FollowUp);
707 }
708
709 #[test]
710 fn test_detect_attachments_from_metadata() {
711 let mut intel = EmailIntelligence::new();
712 let mut msg = make_email_message("See attached", "alice@example.com");
713 msg.metadata
714 .insert("has_attachments".to_string(), "true".to_string());
715
716 let classified = ClassifiedMessage {
717 original: msg,
718 priority: MessagePriority::Normal,
719 message_type: MessageType::Notification,
720 suggested_action: SuggestedAction::AddToDigest,
721 confidence: 0.8,
722 reasoning: "test".to_string(),
723 classified_at: Utc::now(),
724 };
725 let result = intel.classify_email(classified);
726 assert!(result.has_attachments);
727 }
728
729 #[test]
730 fn test_sender_profile_tracked() {
731 let mut intel = EmailIntelligence::new();
732
733 let classified1 = make_classified_email(
735 "First message",
736 "alice@example.com",
737 MessagePriority::Normal,
738 MessageType::Notification,
739 );
740 intel.classify_email(classified1);
741
742 let classified2 = make_classified_email(
744 "Second message",
745 "alice@example.com",
746 MessagePriority::Normal,
747 MessageType::Notification,
748 );
749 intel.classify_email(classified2);
750
751 let profile = intel.get_sender_profile("alice@example.com").unwrap();
752 assert_eq!(profile.message_count, 2);
753 }
754
755 #[test]
756 fn test_reply_indicators() {
757 assert!(has_reply_indicators(
758 "please reply at your earliest convenience"
759 ));
760 assert!(has_reply_indicators("let me know what you think"));
761 assert!(has_reply_indicators("waiting for your response"));
762 assert!(!has_reply_indicators("just wanted to say thanks"));
763 }
764
765 #[test]
766 fn test_suggested_labels_include_sender_labels() {
767 let mut intel = EmailIntelligence::new();
768
769 let mut profile = SenderProfile::new("team@corp.com");
770 profile.add_label("team");
771 profile.add_label("work");
772 intel.update_sender_profile(profile);
773
774 let classified = make_classified_email(
775 "Team update",
776 "team@corp.com",
777 MessagePriority::Normal,
778 MessageType::Notification,
779 );
780 let result = intel.classify_email(classified);
781 assert!(result.suggested_labels.contains(&"team".to_string()));
782 assert!(result.suggested_labels.contains(&"work".to_string()));
783 }
784
785 #[test]
788 fn test_sender_profile_response_rate_zero_replies() {
789 let mut profile = SenderProfile::new("test@example.com");
790 assert_eq!(profile.response_rate, 0.5);
792
793 for _ in 0..10 {
795 profile.update_response_rate(false);
796 }
797 assert!(
798 profile.response_rate < 0.2,
799 "Rate should drop below 0.2 after 10 non-replies, got {}",
800 profile.response_rate
801 );
802 }
803
804 #[test]
805 fn test_sender_profile_response_rate_single_reply() {
806 let mut profile = SenderProfile::new("test@example.com");
807 profile.update_response_rate(true);
809 assert!(profile.response_rate > 0.5);
810 assert!((profile.response_rate - 0.55).abs() < 0.001);
812 }
813
814 #[test]
815 fn test_sender_profile_response_rate_rapid_fire() {
816 let mut profile = SenderProfile::new("test@example.com");
817 for _ in 0..100 {
819 profile.update_response_rate(true);
820 }
821 assert!(
822 profile.response_rate > 0.99,
823 "Rate should converge near 1.0 after 100 replies, got {}",
824 profile.response_rate
825 );
826 }
827
828 #[test]
829 fn test_sender_profile_response_rate_bounded() {
830 let mut profile = SenderProfile::new("test@example.com");
831 for _ in 0..1000 {
833 profile.update_response_rate(true);
834 }
835 assert!(profile.response_rate <= 1.0);
836 assert!(profile.response_rate >= 0.0);
837
838 for _ in 0..1000 {
839 profile.update_response_rate(false);
840 }
841 assert!(profile.response_rate >= 0.0);
842 assert!(profile.response_rate <= 1.0);
843 }
844
845 #[test]
848 fn test_sender_profile_nan_recovery() {
849 let mut profile = SenderProfile::new("test@example.com");
850 profile.response_rate = f32::NAN;
851 profile.update_response_rate(true);
852 assert!(profile.response_rate.is_finite());
854 assert!(
856 (profile.response_rate - 0.55).abs() < 0.001,
857 "Expected ~0.55 after NaN recovery + reply, got {}",
858 profile.response_rate
859 );
860 }
861
862 #[test]
863 fn test_sender_profile_infinity_recovery() {
864 let mut profile = SenderProfile::new("test@example.com");
865 profile.response_rate = f32::INFINITY;
866 profile.update_response_rate(false);
867 assert!(profile.response_rate.is_finite());
868 assert!(
870 (profile.response_rate - 0.45).abs() < 0.001,
871 "Expected ~0.45 after Inf recovery + non-reply, got {}",
872 profile.response_rate
873 );
874 }
875
876 #[test]
877 fn test_sender_profile_neg_infinity_recovery() {
878 let mut profile = SenderProfile::new("test@example.com");
879 profile.response_rate = f32::NEG_INFINITY;
880 profile.update_response_rate(true);
881 assert!(profile.response_rate.is_finite());
882 assert!(profile.response_rate >= 0.0);
883 assert!(profile.response_rate <= 1.0);
884 }
885
886 #[test]
887 fn test_sender_profile_normal_values_unchanged() {
888 let mut profile = SenderProfile::new("test@example.com");
889 profile.response_rate = 0.7;
890 profile.update_response_rate(true);
891 assert!(
893 (profile.response_rate - 0.73).abs() < 0.001,
894 "Normal values should not be affected by NaN guard, got {}",
895 profile.response_rate
896 );
897 }
898}