Skip to main content

rustant_core/channels/
email_intelligence.rs

1//! Email-specific intelligence enhancements.
2//!
3//! Provides richer classification for email messages by analyzing sender
4//! patterns, thread positions, and email-specific metadata (subject lines,
5//! mailing list headers, CC counts).
6//!
7//! Integrates with the general `MessageClassifier` and adds:
8//! - Email category detection (NeedsReply, ActionRequired, FYI, Newsletter, etc.)
9//! - Sender profile learning from long-term memory
10//! - Thread position detection (new thread vs reply vs follow-up)
11//! - Background IMAP polling via the heartbeat system
12
13use 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/// Email-specific message categories beyond general classification.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub enum EmailCategory {
23    /// Requires a response from the user.
24    NeedsReply,
25    /// User needs to take a specific action (approve, review, etc.).
26    ActionRequired,
27    /// Informational only, no action needed.
28    FYI,
29    /// Automated newsletter or marketing email.
30    Newsletter,
31    /// Automated system notification (CI/CD, monitoring, etc.).
32    Automated,
33    /// From a known important contact.
34    PersonalImportant,
35    /// Unable to categorize.
36    Unknown,
37}
38
39/// Position within an email thread.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub enum ThreadPosition {
42    /// First message in a new thread.
43    NewThread,
44    /// Reply within an existing thread.
45    Reply,
46    /// Follow-up after a period of inactivity.
47    FollowUp,
48}
49
50/// Learned profile for an email sender based on historical interactions.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SenderProfile {
53    /// The sender's email address.
54    pub address: String,
55    /// Display name, if known.
56    pub name: Option<String>,
57    /// Typical priority of messages from this sender.
58    pub typical_priority: MessagePriority,
59    /// How often the user replies to this sender (0.0 - 1.0).
60    pub response_rate: f32,
61    /// Average response time in seconds, if applicable.
62    pub avg_response_time_secs: Option<u64>,
63    /// Labels/tags associated with this sender.
64    pub labels: Vec<String>,
65    /// Total number of messages received from this sender.
66    pub message_count: usize,
67    /// When the profile was last updated.
68    pub last_seen: DateTime<Utc>,
69    /// S15: Whether this sender has been verified (e.g., via DKIM/SPF authentication headers).
70    /// Unverified senders require higher thresholds to be considered "important".
71    #[serde(default)]
72    pub verified: bool,
73}
74
75impl SenderProfile {
76    /// Create a new sender profile for a first-time sender.
77    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, // Unknown, default to 50%
83            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    /// Set the display name.
92    pub fn with_name(mut self, name: impl Into<String>) -> Self {
93        self.name = Some(name.into());
94        self
95    }
96
97    /// Record a new message from this sender.
98    pub fn record_message(&mut self) {
99        self.message_count += 1;
100        self.last_seen = Utc::now();
101    }
102
103    /// Update the response rate based on whether the user replied.
104    pub fn update_response_rate(&mut self, replied: bool) {
105        // S14: Guard against NaN/Inf propagation from corrupted data
106        if !self.response_rate.is_finite() {
107            self.response_rate = 0.5; // Reset to neutral default
108        }
109        // Exponential moving average with alpha = 0.1
110        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    /// Check if this sender is considered important (high response rate or many messages).
115    ///
116    /// S15: Unverified senders require a higher message count threshold (>50) to be
117    /// considered important, reducing spoofing risk from unknown senders.
118    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    /// Add a label to this sender.
127    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/// Email-specific classification result extending the base classification.
136#[derive(Debug, Clone)]
137pub struct EmailClassification {
138    /// The base classification from the general classifier.
139    pub base: ClassifiedMessage,
140    /// Email-specific category.
141    pub category: EmailCategory,
142    /// Whether the email has file attachments.
143    pub has_attachments: bool,
144    /// Position within the email thread.
145    pub thread_position: ThreadPosition,
146    /// Suggested labels based on content analysis.
147    pub suggested_labels: Vec<String>,
148}
149
150/// Email intelligence engine for enhanced email processing.
151///
152/// **Thread safety**: This struct is designed for single-threaded use. For concurrent
153/// access (e.g., from multiple async tasks), wrap in `Arc<tokio::sync::Mutex<_>>`.
154pub struct EmailIntelligence {
155    /// Known sender profiles (keyed by email address).
156    known_senders: HashMap<String, SenderProfile>,
157    /// Newsletter/mailing list patterns (detected from headers).
158    newsletter_patterns: Vec<String>,
159}
160
161impl EmailIntelligence {
162    /// Create a new email intelligence engine.
163    pub fn new() -> Self {
164        Self {
165            known_senders: HashMap::new(),
166            newsletter_patterns: default_newsletter_patterns(),
167        }
168    }
169
170    /// Classify an email message with email-specific enhancements.
171    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        // Update sender profile
177        {
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        // Get a snapshot of the profile for classification (avoids borrow conflict)
189        let profile_snapshot = self.known_senders.get(&sender_address).cloned().unwrap();
190
191        // Determine email category
192        let category = self.categorize_email(&classified, &profile_snapshot, &thread_position);
193
194        // Generate suggested labels
195        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    /// Categorize an email based on content, sender profile, and metadata.
207    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        // Check for newsletter/automated patterns
217        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        // Check for important senders
226        if profile.is_important() {
227            return EmailCategory::PersonalImportant;
228        }
229
230        // Check based on message type
231        match classified.message_type {
232            MessageType::Question => EmailCategory::NeedsReply,
233            MessageType::ActionRequired => EmailCategory::ActionRequired,
234            MessageType::Notification => EmailCategory::FYI,
235            _ => {
236                // Fall back to checking content
237                if has_reply_indicators(&text_lower) {
238                    EmailCategory::NeedsReply
239                } else {
240                    EmailCategory::FYI
241                }
242            }
243        }
244    }
245
246    /// Check if the email appears to be a newsletter.
247    fn is_newsletter(&self, msg: &ChannelMessage) -> bool {
248        // Check metadata for mailing list headers
249        // S19: Clean header values to strip injected CRLF before comparison
250        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        // Check sender against newsletter patterns
261        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    /// Check if the email is from an automated system.
268    fn is_automated(&self, text_lower: &str, msg: &ChannelMessage) -> bool {
269        // S19: Clean sender to strip injected CRLF
270        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        // Check for automated content patterns
289        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    /// Detect if the email has file attachments based on metadata.
299    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    /// Detect the position within an email thread.
308    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    /// Suggest labels for the email based on classification.
321    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        // Include sender labels
344        for label in &profile.labels {
345            if !labels.contains(label) {
346                labels.push(label.clone());
347            }
348        }
349
350        labels
351    }
352
353    /// Get a sender profile by email address.
354    pub fn get_sender_profile(&self, address: &str) -> Option<&SenderProfile> {
355        self.known_senders.get(address)
356    }
357
358    /// Get a mutable sender profile by email address.
359    pub fn get_sender_profile_mut(&mut self, address: &str) -> Option<&mut SenderProfile> {
360        self.known_senders.get_mut(address)
361    }
362
363    /// Add or update a sender profile.
364    pub fn update_sender_profile(&mut self, profile: SenderProfile) {
365        self.known_senders.insert(profile.address.clone(), profile);
366    }
367
368    /// Get the total number of known senders.
369    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
380// --- Helper Functions ---
381
382/// Extract text content from an email ChannelMessage.
383fn 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
391/// S19: Strip CR/LF from email header values to prevent CRLF injection.
392///
393/// Email headers can contain injected line breaks that could be used to
394/// smuggle extra headers or bypass classification logic.
395fn clean_header_value(value: &str) -> String {
396    value.chars().filter(|c| *c != '\r' && *c != '\n').collect()
397}
398
399/// Check if text contains indicators that a reply is expected.
400fn 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
417/// Default newsletter sender patterns.
418fn 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    // --- SenderProfile Tests ---
474
475    #[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        // Start at 0.5 default
495        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        // S15: Unverified senders need >50 messages to be important
514        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"); // Duplicate (case insensitive)
531        profile.add_label("personal");
532        assert_eq!(profile.labels.len(), 2);
533    }
534
535    // --- EmailIntelligence Tests ---
536
537    #[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        // Build up sender profile as important
643        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        // reply_to is None -> follow-up
697
698        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        // First email from alice
736        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        // Second email from alice
745        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    // --- L5: SenderProfile response rate edge cases ---
788
789    #[test]
790    fn test_sender_profile_response_rate_zero_replies() {
791        let mut profile = SenderProfile::new("test@example.com");
792        // Initial rate is 0.5 (50%)
793        assert_eq!(profile.response_rate, 0.5);
794
795        // 10 non-replies should drive it toward 0
796        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        // Start at 0.5, one reply should nudge it slightly up
810        profile.update_response_rate(true);
811        assert!(profile.response_rate > 0.5);
812        // Should be 0.5 * 0.9 + 1.0 * 0.1 = 0.55
813        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        // 100 consecutive replies should converge close to 1.0
820        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        // EMA should always stay in [0.0, 1.0] range
834        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    // --- S14: NaN/Inf Guard Tests ---
848
849    #[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        // Should have recovered from NaN to 0.5, then applied EMA
855        assert!(profile.response_rate.is_finite());
856        // 0.5 * 0.9 + 1.0 * 0.1 = 0.55
857        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        // 0.5 * 0.9 + 0.0 * 0.1 = 0.45
871        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        // 0.7 * 0.9 + 1.0 * 0.1 = 0.73
894        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}