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!(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        // Build up sender profile as important
641        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        // reply_to is None -> follow-up
695
696        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        // First email from alice
734        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        // Second email from alice
743        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    // --- L5: SenderProfile response rate edge cases ---
786
787    #[test]
788    fn test_sender_profile_response_rate_zero_replies() {
789        let mut profile = SenderProfile::new("test@example.com");
790        // Initial rate is 0.5 (50%)
791        assert_eq!(profile.response_rate, 0.5);
792
793        // 10 non-replies should drive it toward 0
794        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        // Start at 0.5, one reply should nudge it slightly up
808        profile.update_response_rate(true);
809        assert!(profile.response_rate > 0.5);
810        // Should be 0.5 * 0.9 + 1.0 * 0.1 = 0.55
811        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        // 100 consecutive replies should converge close to 1.0
818        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        // EMA should always stay in [0.0, 1.0] range
832        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    // --- S14: NaN/Inf Guard Tests ---
846
847    #[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        // Should have recovered from NaN to 0.5, then applied EMA
853        assert!(profile.response_rate.is_finite());
854        // 0.5 * 0.9 + 1.0 * 0.1 = 0.55
855        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        // 0.5 * 0.9 + 0.0 * 0.1 = 0.45
869        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        // 0.7 * 0.9 + 1.0 * 0.1 = 0.73
892        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}