Skip to main content

rusmes_core/mailets/
oxify.rs

1//! OxiFY AI Mail Analysis mailet
2//!
3//! This mailet integrates with the OxiFY AI service to provide intelligent mail analysis:
4//! - Sentiment analysis (positive/neutral/negative with confidence score)
5//! - Category classification (work, personal, spam, urgent, newsletter, promotional)
6//! - Priority scoring (1-10 scale based on sender, subject, urgency)
7//! - Auto-tagging (AI-generated tags like #invoice, #meeting, #action-required)
8//! - Smart folder routing based on classification
9//!
10//! Headers added:
11//! - X-OxiFY-Sentiment: Sentiment classification
12//! - X-OxiFY-Sentiment-Score: Confidence score (0.0-1.0)
13//! - X-OxiFY-Categories: Comma-separated list of categories
14//! - X-OxiFY-Priority: Priority score (1-10)
15//! - X-OxiFY-Tags: Comma-separated list of tags
16
17use crate::mailet::{Mailet, MailetAction, MailetConfig};
18use async_trait::async_trait;
19use rusmes_proto::Mail;
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Duration;
24
25/// Mail category classification
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum MailCategory {
29    Work,
30    Personal,
31    Spam,
32    Urgent,
33    Newsletter,
34    Promotional,
35}
36
37impl MailCategory {
38    /// Convert to string
39    pub fn as_str(&self) -> &str {
40        match self {
41            MailCategory::Work => "work",
42            MailCategory::Personal => "personal",
43            MailCategory::Spam => "spam",
44            MailCategory::Urgent => "urgent",
45            MailCategory::Newsletter => "newsletter",
46            MailCategory::Promotional => "promotional",
47        }
48    }
49
50    /// Parse from string
51    pub fn parse(s: &str) -> Option<Self> {
52        match s.to_lowercase().as_str() {
53            "work" => Some(MailCategory::Work),
54            "personal" => Some(MailCategory::Personal),
55            "spam" => Some(MailCategory::Spam),
56            "urgent" => Some(MailCategory::Urgent),
57            "newsletter" => Some(MailCategory::Newsletter),
58            "promotional" => Some(MailCategory::Promotional),
59            _ => None,
60        }
61    }
62}
63
64/// Sentiment analysis result
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum Sentiment {
68    Positive,
69    Negative,
70    Neutral,
71}
72
73impl Sentiment {
74    /// Convert to string
75    pub fn as_str(&self) -> &str {
76        match self {
77            Sentiment::Positive => "positive",
78            Sentiment::Negative => "negative",
79            Sentiment::Neutral => "neutral",
80        }
81    }
82
83    /// Parse from string
84    pub fn parse(s: &str) -> Option<Self> {
85        match s.to_lowercase().as_str() {
86            "positive" => Some(Sentiment::Positive),
87            "negative" => Some(Sentiment::Negative),
88            "neutral" => Some(Sentiment::Neutral),
89            _ => None,
90        }
91    }
92}
93
94/// OxiFY API request
95#[derive(Debug, Clone, Serialize)]
96pub struct AnalysisRequest {
97    subject: String,
98    from: String,
99    to: Vec<String>,
100    body: String,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    max_body_size: Option<usize>,
103}
104
105/// OxiFY API response
106#[derive(Debug, Clone, Deserialize)]
107pub struct AnalysisResponse {
108    pub sentiment: Sentiment,
109    pub sentiment_score: f64,
110    pub categories: Vec<MailCategory>,
111    pub priority: u8,
112    pub tags: Vec<String>,
113    #[serde(default)]
114    pub folder: Option<String>,
115}
116
117/// OxiFY analysis result
118#[derive(Debug, Clone)]
119pub struct AnalysisResult {
120    /// Sentiment classification
121    pub sentiment: Sentiment,
122    /// Sentiment confidence score (0.0 to 1.0)
123    pub sentiment_score: f64,
124    /// Mail categories (multi-label)
125    pub categories: Vec<MailCategory>,
126    /// Priority score (1-10)
127    pub priority: u8,
128    /// AI-generated tags
129    pub tags: Vec<String>,
130    /// Suggested folder for routing
131    pub folder: Option<String>,
132}
133
134/// HTTP client trait for testability
135#[async_trait]
136pub trait HttpClient: Send + Sync {
137    async fn post_analysis(
138        &self,
139        url: &str,
140        api_key: &str,
141        request: &AnalysisRequest,
142        timeout_ms: u64,
143    ) -> Result<AnalysisResponse, OxiFYError>;
144}
145
146/// Real HTTP client implementation
147#[derive(Clone)]
148pub struct ReqwestClient {
149    client: reqwest::Client,
150}
151
152impl ReqwestClient {
153    pub fn new() -> Self {
154        Self {
155            client: reqwest::Client::new(),
156        }
157    }
158}
159
160impl Default for ReqwestClient {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166#[async_trait]
167impl HttpClient for ReqwestClient {
168    async fn post_analysis(
169        &self,
170        url: &str,
171        api_key: &str,
172        request: &AnalysisRequest,
173        timeout_ms: u64,
174    ) -> Result<AnalysisResponse, OxiFYError> {
175        let response = self
176            .client
177            .post(url)
178            .header("Authorization", format!("Bearer {}", api_key))
179            .header("Content-Type", "application/json")
180            .timeout(Duration::from_millis(timeout_ms))
181            .json(request)
182            .send()
183            .await
184            .map_err(|e| {
185                if e.is_timeout() {
186                    OxiFYError::Timeout
187                } else {
188                    OxiFYError::NetworkError(e.to_string())
189                }
190            })?;
191
192        let status = response.status();
193        if status == 429 {
194            return Err(OxiFYError::RateLimited);
195        }
196
197        if !status.is_success() {
198            return Err(OxiFYError::ApiError(status.as_u16(), status.to_string()));
199        }
200
201        let analysis = response
202            .json::<AnalysisResponse>()
203            .await
204            .map_err(|e| OxiFYError::ParseError(e.to_string()))?;
205
206        Ok(analysis)
207    }
208}
209
210/// OxiFY service errors
211#[derive(Debug, Clone, thiserror::Error)]
212pub enum OxiFYError {
213    #[error("Network error: {0}")]
214    NetworkError(String),
215    #[error("API error: HTTP {0} - {1}")]
216    ApiError(u16, String),
217    #[error("Request timeout")]
218    Timeout,
219    #[error("Rate limited (HTTP 429)")]
220    RateLimited,
221    #[error("Parse error: {0}")]
222    ParseError(String),
223    #[error("Service disabled")]
224    Disabled,
225}
226
227/// OxiFY AI service configuration
228#[derive(Debug, Clone)]
229pub struct OxiFYConfig {
230    /// API endpoint URL
231    pub api_url: String,
232    /// API authentication key
233    pub api_key: String,
234    /// Enable/disable service globally
235    pub enabled: bool,
236    /// Request timeout in milliseconds
237    pub timeout_ms: u64,
238    /// Cache TTL in seconds (for future caching implementation)
239    pub cache_ttl: u64,
240    /// Maximum body size to analyze (bytes)
241    pub max_body_size: usize,
242    /// Category to folder mapping for routing
243    pub folder_mapping: HashMap<String, String>,
244}
245
246impl Default for OxiFYConfig {
247    fn default() -> Self {
248        Self {
249            api_url: "http://localhost:8080/api/v1/analyze".to_string(),
250            api_key: String::new(),
251            enabled: false,
252            timeout_ms: 5000,
253            cache_ttl: 3600,
254            max_body_size: 50 * 1024, // 50KB
255            folder_mapping: HashMap::new(),
256        }
257    }
258}
259
260/// OxiFY AI service
261pub struct OxiFYService<C: HttpClient = ReqwestClient> {
262    config: OxiFYConfig,
263    client: Arc<C>,
264}
265
266impl OxiFYService<ReqwestClient> {
267    /// Create a new OxiFY service with default HTTP client
268    pub fn new(config: OxiFYConfig) -> Self {
269        Self {
270            config,
271            client: Arc::new(ReqwestClient::new()),
272        }
273    }
274}
275
276impl<C: HttpClient> OxiFYService<C> {
277    /// Create a new OxiFY service with custom HTTP client
278    pub fn with_client(config: OxiFYConfig, client: C) -> Self {
279        Self {
280            config,
281            client: Arc::new(client),
282        }
283    }
284
285    /// Analyze a mail message
286    pub async fn analyze(&self, mail: &Mail) -> Result<AnalysisResult, OxiFYError> {
287        if !self.config.enabled {
288            return Err(OxiFYError::Disabled);
289        }
290
291        // Extract mail attributes
292        let subject = mail
293            .get_attribute("header.Subject")
294            .and_then(|v| v.as_str())
295            .unwrap_or("")
296            .to_string();
297
298        let from = mail
299            .get_attribute("header.From")
300            .and_then(|v| v.as_str())
301            .unwrap_or("")
302            .to_string();
303
304        let to = mail
305            .get_attribute("header.To")
306            .and_then(|v| v.as_str())
307            .map(|s| vec![s.to_string()])
308            .unwrap_or_default();
309
310        let mut body = mail
311            .get_attribute("message.body")
312            .and_then(|v| v.as_str())
313            .unwrap_or("")
314            .to_string();
315
316        // Limit body size
317        if body.len() > self.config.max_body_size {
318            body.truncate(self.config.max_body_size);
319        }
320
321        let request = AnalysisRequest {
322            subject,
323            from,
324            to,
325            body,
326            max_body_size: Some(self.config.max_body_size),
327        };
328
329        // Call API
330        let response = self
331            .client
332            .post_analysis(
333                &self.config.api_url,
334                &self.config.api_key,
335                &request,
336                self.config.timeout_ms,
337            )
338            .await?;
339
340        // Validate priority range
341        let priority = response.priority.clamp(1, 10);
342
343        // Validate sentiment score range
344        let sentiment_score = response.sentiment_score.clamp(0.0, 1.0);
345
346        // Apply folder mapping if configured
347        let folder = response.folder.or_else(|| {
348            response.categories.first().and_then(|cat| {
349                self.config
350                    .folder_mapping
351                    .get(cat.as_str())
352                    .map(|f| f.to_string())
353            })
354        });
355
356        Ok(AnalysisResult {
357            sentiment: response.sentiment,
358            sentiment_score,
359            categories: response.categories,
360            priority,
361            tags: response.tags,
362            folder,
363        })
364    }
365}
366
367/// OxiFY mailet - integrates OxiFY AI service
368pub struct OxiFYMailet<C: HttpClient = ReqwestClient> {
369    name: String,
370    service: Option<OxiFYService<C>>,
371}
372
373impl OxiFYMailet<ReqwestClient> {
374    /// Create a new OxiFY mailet (service will be created on init)
375    pub fn new() -> Self {
376        Self {
377            name: "OxiFY".to_string(),
378            service: Some(OxiFYService::new(OxiFYConfig::default())),
379        }
380    }
381}
382
383impl<C: HttpClient> OxiFYMailet<C> {
384    /// Create a new OxiFY mailet with custom HTTP client
385    pub fn with_client(client: C, config: OxiFYConfig) -> Self {
386        Self {
387            name: "OxiFY".to_string(),
388            service: Some(OxiFYService::with_client(config, client)),
389        }
390    }
391
392    /// Update configuration (for generic type parameter)
393    pub fn update_config(&mut self, config: OxiFYConfig)
394    where
395        C: HttpClient + Default,
396    {
397        self.service = Some(OxiFYService::with_client(config, C::default()));
398    }
399
400    /// Apply analysis results to mail
401    fn apply_analysis(&self, mail: &mut Mail, result: AnalysisResult) {
402        // Add X-OxiFY-Sentiment header
403        mail.set_attribute(
404            "header.X-OxiFY-Sentiment",
405            result.sentiment.as_str().to_string(),
406        );
407
408        // Add X-OxiFY-Sentiment-Score header
409        mail.set_attribute(
410            "header.X-OxiFY-Sentiment-Score",
411            format!("{:.3}", result.sentiment_score),
412        );
413
414        // Add X-OxiFY-Categories header (comma-separated)
415        let categories_str = result
416            .categories
417            .iter()
418            .map(|c| c.as_str())
419            .collect::<Vec<_>>()
420            .join(",");
421        mail.set_attribute("header.X-OxiFY-Categories", categories_str.clone());
422
423        // Add X-OxiFY-Priority header
424        mail.set_attribute("header.X-OxiFY-Priority", result.priority.to_string());
425
426        // Add X-OxiFY-Tags header (comma-separated)
427        let tags_str = result.tags.join(",");
428        mail.set_attribute("header.X-OxiFY-Tags", tags_str.clone());
429
430        // Set internal attributes for use by other mailets
431        mail.set_attribute("oxify.sentiment", result.sentiment.as_str());
432        mail.set_attribute("oxify.sentiment_score", result.sentiment_score);
433        mail.set_attribute("oxify.categories", categories_str);
434        mail.set_attribute("oxify.priority", result.priority as i64);
435        mail.set_attribute("oxify.tags", tags_str);
436
437        // Set folder for routing if available
438        if let Some(folder) = result.folder {
439            mail.set_attribute("oxify.folder", folder);
440        }
441
442        // Special handling for spam
443        if result.categories.contains(&MailCategory::Spam) {
444            mail.set_attribute("oxify.is_spam", true);
445        }
446
447        // Special handling for urgent
448        if result.categories.contains(&MailCategory::Urgent) || result.priority >= 8 {
449            mail.set_attribute("oxify.is_urgent", true);
450        }
451    }
452
453    /// Process a mail message (public method for both trait impl and direct calls)
454    pub async fn service(&self, mail: &mut Mail) -> anyhow::Result<MailetAction> {
455        let service = self
456            .service
457            .as_ref()
458            .ok_or_else(|| anyhow::anyhow!("OxiFY service not initialized"))?;
459
460        tracing::debug!("Running OxiFY analysis on mail {}", mail.id());
461
462        match service.analyze(mail).await {
463            Ok(result) => {
464                tracing::debug!(
465                    "OxiFY analysis: sentiment={:?}, categories={:?}, priority={}",
466                    result.sentiment,
467                    result.categories,
468                    result.priority
469                );
470
471                self.apply_analysis(mail, result);
472                Ok(MailetAction::Continue)
473            }
474            Err(OxiFYError::Disabled) => {
475                tracing::debug!("OxiFY service is disabled, skipping analysis");
476                Ok(MailetAction::Continue)
477            }
478            Err(OxiFYError::Timeout) => {
479                tracing::warn!("OxiFY analysis timeout for mail {}", mail.id());
480                Ok(MailetAction::Continue)
481            }
482            Err(OxiFYError::RateLimited) => {
483                tracing::warn!("OxiFY rate limited for mail {}", mail.id());
484                Ok(MailetAction::Continue)
485            }
486            Err(OxiFYError::NetworkError(e)) => {
487                tracing::error!("OxiFY network error for mail {}: {}", mail.id(), e);
488                Ok(MailetAction::Continue)
489            }
490            Err(OxiFYError::ApiError(status, msg)) => {
491                tracing::error!(
492                    "OxiFY API error for mail {}: HTTP {} - {}",
493                    mail.id(),
494                    status,
495                    msg
496                );
497                Ok(MailetAction::Continue)
498            }
499            Err(OxiFYError::ParseError(e)) => {
500                tracing::error!("OxiFY parse error for mail {}: {}", mail.id(), e);
501                Ok(MailetAction::Continue)
502            }
503        }
504    }
505}
506
507impl Default for OxiFYMailet<ReqwestClient> {
508    fn default() -> Self {
509        Self::new()
510    }
511}
512
513#[async_trait]
514impl<C: HttpClient + Default + 'static> Mailet for OxiFYMailet<C> {
515    async fn init(&mut self, config: MailetConfig) -> anyhow::Result<()> {
516        // Build OxiFY configuration
517        let mut oxify_config = OxiFYConfig::default();
518
519        // API URL (required if enabled)
520        if let Some(url) = config.get_param("api_url") {
521            oxify_config.api_url = url.to_string();
522        }
523
524        // API key (required if enabled)
525        if let Some(key) = config.get_param("api_key") {
526            oxify_config.api_key = key.to_string();
527        }
528
529        // Enabled flag
530        if let Some(enabled) = config.get_param("enabled") {
531            oxify_config.enabled = enabled.parse().unwrap_or(false);
532        }
533
534        // Timeout
535        if let Some(timeout) = config.get_param("timeout_ms") {
536            oxify_config.timeout_ms = timeout.parse().unwrap_or(5000);
537        }
538
539        // Cache TTL
540        if let Some(ttl) = config.get_param("cache_ttl") {
541            oxify_config.cache_ttl = ttl.parse().unwrap_or(3600);
542        }
543
544        // Max body size
545        if let Some(size) = config.get_param("max_body_size") {
546            oxify_config.max_body_size = size.parse().unwrap_or(50 * 1024);
547        }
548
549        // Folder mapping
550        for (key, value) in config.params.iter() {
551            if let Some(category) = key.strip_prefix("folder_") {
552                oxify_config
553                    .folder_mapping
554                    .insert(category.to_string(), value.clone());
555            }
556        }
557
558        // Validate configuration if enabled
559        if oxify_config.enabled && oxify_config.api_key.is_empty() {
560            return Err(anyhow::anyhow!(
561                "OxiFY API key is required when service is enabled"
562            ));
563        }
564
565        // Update the service with new configuration
566        self.update_config(oxify_config.clone());
567
568        tracing::info!(
569            "Initialized OxiFYMailet: enabled={}, api_url={}, timeout_ms={}",
570            oxify_config.enabled,
571            oxify_config.api_url,
572            oxify_config.timeout_ms
573        );
574
575        Ok(())
576    }
577
578    async fn service(&self, mail: &mut Mail) -> anyhow::Result<MailetAction> {
579        // Delegate to the public method in the generic impl
580        OxiFYMailet::service(self, mail).await
581    }
582
583    fn name(&self) -> &str {
584        &self.name
585    }
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use bytes::Bytes;
592    use rusmes_proto::{HeaderMap, MailAddress, MessageBody, MimeMessage};
593    use std::str::FromStr;
594
595    // Mock HTTP client for testing
596    #[derive(Clone)]
597    struct MockHttpClient {
598        response: Arc<tokio::sync::Mutex<Option<MockResponse>>>,
599    }
600
601    #[derive(Clone)]
602    enum MockResponse {
603        Success(AnalysisResponse),
604        Error(MockError),
605    }
606
607    #[derive(Clone)]
608    enum MockError {
609        Network(String),
610        Timeout,
611        RateLimited,
612        Api(u16, String),
613        Parse(String),
614    }
615
616    impl From<MockError> for OxiFYError {
617        fn from(err: MockError) -> Self {
618            match err {
619                MockError::Network(msg) => OxiFYError::NetworkError(msg),
620                MockError::Timeout => OxiFYError::Timeout,
621                MockError::RateLimited => OxiFYError::RateLimited,
622                MockError::Api(code, msg) => OxiFYError::ApiError(code, msg),
623                MockError::Parse(msg) => OxiFYError::ParseError(msg),
624            }
625        }
626    }
627
628    impl MockHttpClient {
629        #[allow(dead_code)]
630        fn new() -> Self {
631            Self {
632                response: Arc::new(tokio::sync::Mutex::new(None)),
633            }
634        }
635
636        fn with_success(response: AnalysisResponse) -> Self {
637            Self {
638                response: Arc::new(tokio::sync::Mutex::new(Some(MockResponse::Success(
639                    response,
640                )))),
641            }
642        }
643
644        fn with_error(error: MockError) -> Self {
645            Self {
646                response: Arc::new(tokio::sync::Mutex::new(Some(MockResponse::Error(error)))),
647            }
648        }
649    }
650
651    #[async_trait]
652    impl HttpClient for MockHttpClient {
653        async fn post_analysis(
654            &self,
655            _url: &str,
656            _api_key: &str,
657            _request: &AnalysisRequest,
658            _timeout_ms: u64,
659        ) -> Result<AnalysisResponse, OxiFYError> {
660            match self.response.lock().await.clone() {
661                Some(MockResponse::Success(resp)) => Ok(resp),
662                Some(MockResponse::Error(err)) => Err(err.into()),
663                None => Err(OxiFYError::NetworkError("No response set".to_string())),
664            }
665        }
666    }
667
668    fn create_test_mail() -> Mail {
669        Mail::new(
670            Some(MailAddress::from_str("sender@test.com").unwrap()),
671            vec![MailAddress::from_str("rcpt@test.com").unwrap()],
672            MimeMessage::new(HeaderMap::new(), MessageBody::Small(Bytes::from("Test"))),
673            None,
674            None,
675        )
676    }
677
678    fn create_success_response() -> AnalysisResponse {
679        AnalysisResponse {
680            sentiment: Sentiment::Neutral,
681            sentiment_score: 0.5,
682            categories: vec![MailCategory::Personal],
683            priority: 5,
684            tags: vec!["test".to_string()],
685            folder: None,
686        }
687    }
688
689    #[tokio::test]
690    async fn test_oxify_mailet_init() {
691        let mut mailet = OxiFYMailet::<ReqwestClient>::new();
692        let config = MailetConfig::new("OxiFY")
693            .with_param("enabled", "false")
694            .with_param("api_url", "http://localhost:8080/api/v1/analyze")
695            .with_param("api_key", "test_key");
696
697        let result = mailet.init(config).await;
698        assert!(result.is_ok());
699        assert_eq!(mailet.name(), "OxiFY");
700    }
701
702    #[tokio::test]
703    async fn test_oxify_mailet_init_missing_api_key_when_enabled() {
704        let mut mailet = OxiFYMailet::<ReqwestClient>::new();
705        let config = MailetConfig::new("OxiFY")
706            .with_param("enabled", "true")
707            .with_param("api_url", "http://localhost:8080/api/v1/analyze");
708
709        let result = mailet.init(config).await;
710        assert!(result.is_err());
711    }
712
713    #[tokio::test]
714    async fn test_oxify_sentiment_positive() {
715        let mock = MockHttpClient::with_success(AnalysisResponse {
716            sentiment: Sentiment::Positive,
717            sentiment_score: 0.95,
718            categories: vec![MailCategory::Personal],
719            priority: 5,
720            tags: vec![],
721            folder: None,
722        });
723
724        let config = OxiFYConfig {
725            enabled: true,
726            api_key: "test_key".to_string(),
727            ..Default::default()
728        };
729
730        let mailet = OxiFYMailet::with_client(mock, config);
731        let mut mail = create_test_mail();
732
733        let result = mailet.service(&mut mail).await;
734        assert!(result.is_ok());
735
736        assert_eq!(
737            mail.get_attribute("header.X-OxiFY-Sentiment")
738                .and_then(|v| v.as_str()),
739            Some("positive")
740        );
741        assert_eq!(
742            mail.get_attribute("oxify.sentiment")
743                .and_then(|v| v.as_str()),
744            Some("positive")
745        );
746    }
747
748    #[tokio::test]
749    async fn test_oxify_sentiment_negative() {
750        let mock = MockHttpClient::with_success(AnalysisResponse {
751            sentiment: Sentiment::Negative,
752            sentiment_score: 0.85,
753            categories: vec![MailCategory::Personal],
754            priority: 3,
755            tags: vec![],
756            folder: None,
757        });
758
759        let config = OxiFYConfig {
760            enabled: true,
761            api_key: "test_key".to_string(),
762            ..Default::default()
763        };
764
765        let mailet = OxiFYMailet::with_client(mock, config);
766        let mut mail = create_test_mail();
767
768        mailet.service(&mut mail).await.unwrap();
769
770        assert_eq!(
771            mail.get_attribute("header.X-OxiFY-Sentiment")
772                .and_then(|v| v.as_str()),
773            Some("negative")
774        );
775    }
776
777    #[tokio::test]
778    async fn test_oxify_sentiment_neutral() {
779        let mock = MockHttpClient::with_success(AnalysisResponse {
780            sentiment: Sentiment::Neutral,
781            sentiment_score: 0.5,
782            categories: vec![MailCategory::Work],
783            priority: 5,
784            tags: vec![],
785            folder: None,
786        });
787
788        let config = OxiFYConfig {
789            enabled: true,
790            api_key: "test_key".to_string(),
791            ..Default::default()
792        };
793
794        let mailet = OxiFYMailet::with_client(mock, config);
795        let mut mail = create_test_mail();
796
797        mailet.service(&mut mail).await.unwrap();
798
799        assert_eq!(
800            mail.get_attribute("header.X-OxiFY-Sentiment")
801                .and_then(|v| v.as_str()),
802            Some("neutral")
803        );
804    }
805
806    #[tokio::test]
807    async fn test_oxify_category_work() {
808        let mock = MockHttpClient::with_success(AnalysisResponse {
809            sentiment: Sentiment::Neutral,
810            sentiment_score: 0.5,
811            categories: vec![MailCategory::Work],
812            priority: 7,
813            tags: vec!["meeting".to_string()],
814            folder: None,
815        });
816
817        let config = OxiFYConfig {
818            enabled: true,
819            api_key: "test_key".to_string(),
820            ..Default::default()
821        };
822
823        let mailet = OxiFYMailet::with_client(mock, config);
824        let mut mail = create_test_mail();
825
826        mailet.service(&mut mail).await.unwrap();
827
828        assert_eq!(
829            mail.get_attribute("header.X-OxiFY-Categories")
830                .and_then(|v| v.as_str()),
831            Some("work")
832        );
833    }
834
835    #[tokio::test]
836    async fn test_oxify_category_spam() {
837        let mock = MockHttpClient::with_success(AnalysisResponse {
838            sentiment: Sentiment::Negative,
839            sentiment_score: 0.2,
840            categories: vec![MailCategory::Spam],
841            priority: 1,
842            tags: vec![],
843            folder: Some("Spam".to_string()),
844        });
845
846        let config = OxiFYConfig {
847            enabled: true,
848            api_key: "test_key".to_string(),
849            ..Default::default()
850        };
851
852        let mailet = OxiFYMailet::with_client(mock, config);
853        let mut mail = create_test_mail();
854
855        mailet.service(&mut mail).await.unwrap();
856
857        assert_eq!(
858            mail.get_attribute("header.X-OxiFY-Categories")
859                .and_then(|v| v.as_str()),
860            Some("spam")
861        );
862        assert_eq!(
863            mail.get_attribute("oxify.is_spam")
864                .and_then(|v| v.as_bool()),
865            Some(true)
866        );
867    }
868
869    #[tokio::test]
870    async fn test_oxify_category_urgent() {
871        let mock = MockHttpClient::with_success(AnalysisResponse {
872            sentiment: Sentiment::Neutral,
873            sentiment_score: 0.5,
874            categories: vec![MailCategory::Urgent, MailCategory::Work],
875            priority: 9,
876            tags: vec!["urgent".to_string()],
877            folder: None,
878        });
879
880        let config = OxiFYConfig {
881            enabled: true,
882            api_key: "test_key".to_string(),
883            ..Default::default()
884        };
885
886        let mailet = OxiFYMailet::with_client(mock, config);
887        let mut mail = create_test_mail();
888
889        mailet.service(&mut mail).await.unwrap();
890
891        assert!(mail
892            .get_attribute("header.X-OxiFY-Categories")
893            .and_then(|v| v.as_str())
894            .unwrap()
895            .contains("urgent"));
896        assert_eq!(
897            mail.get_attribute("oxify.is_urgent")
898                .and_then(|v| v.as_bool()),
899            Some(true)
900        );
901    }
902
903    #[tokio::test]
904    async fn test_oxify_category_newsletter() {
905        let mock = MockHttpClient::with_success(AnalysisResponse {
906            sentiment: Sentiment::Neutral,
907            sentiment_score: 0.5,
908            categories: vec![MailCategory::Newsletter],
909            priority: 3,
910            tags: vec!["newsletter".to_string()],
911            folder: None,
912        });
913
914        let config = OxiFYConfig {
915            enabled: true,
916            api_key: "test_key".to_string(),
917            ..Default::default()
918        };
919
920        let mailet = OxiFYMailet::with_client(mock, config);
921        let mut mail = create_test_mail();
922
923        mailet.service(&mut mail).await.unwrap();
924
925        assert_eq!(
926            mail.get_attribute("header.X-OxiFY-Categories")
927                .and_then(|v| v.as_str()),
928            Some("newsletter")
929        );
930    }
931
932    #[tokio::test]
933    async fn test_oxify_category_promotional() {
934        let mock = MockHttpClient::with_success(AnalysisResponse {
935            sentiment: Sentiment::Positive,
936            sentiment_score: 0.6,
937            categories: vec![MailCategory::Promotional],
938            priority: 2,
939            tags: vec!["sale".to_string()],
940            folder: None,
941        });
942
943        let config = OxiFYConfig {
944            enabled: true,
945            api_key: "test_key".to_string(),
946            ..Default::default()
947        };
948
949        let mailet = OxiFYMailet::with_client(mock, config);
950        let mut mail = create_test_mail();
951
952        mailet.service(&mut mail).await.unwrap();
953
954        assert_eq!(
955            mail.get_attribute("header.X-OxiFY-Categories")
956                .and_then(|v| v.as_str()),
957            Some("promotional")
958        );
959    }
960
961    #[tokio::test]
962    async fn test_oxify_multi_category() {
963        let mock = MockHttpClient::with_success(AnalysisResponse {
964            sentiment: Sentiment::Neutral,
965            sentiment_score: 0.5,
966            categories: vec![MailCategory::Work, MailCategory::Urgent],
967            priority: 8,
968            tags: vec!["meeting".to_string(), "urgent".to_string()],
969            folder: None,
970        });
971
972        let config = OxiFYConfig {
973            enabled: true,
974            api_key: "test_key".to_string(),
975            ..Default::default()
976        };
977
978        let mailet = OxiFYMailet::with_client(mock, config);
979        let mut mail = create_test_mail();
980
981        mailet.service(&mut mail).await.unwrap();
982
983        let categories = mail
984            .get_attribute("header.X-OxiFY-Categories")
985            .and_then(|v| v.as_str())
986            .unwrap();
987        assert!(categories.contains("work"));
988        assert!(categories.contains("urgent"));
989    }
990
991    #[tokio::test]
992    async fn test_oxify_priority_scoring() {
993        let mock = MockHttpClient::with_success(AnalysisResponse {
994            sentiment: Sentiment::Neutral,
995            sentiment_score: 0.5,
996            categories: vec![MailCategory::Urgent],
997            priority: 10,
998            tags: vec![],
999            folder: None,
1000        });
1001
1002        let config = OxiFYConfig {
1003            enabled: true,
1004            api_key: "test_key".to_string(),
1005            ..Default::default()
1006        };
1007
1008        let mailet = OxiFYMailet::with_client(mock, config);
1009        let mut mail = create_test_mail();
1010
1011        mailet.service(&mut mail).await.unwrap();
1012
1013        assert_eq!(
1014            mail.get_attribute("header.X-OxiFY-Priority")
1015                .and_then(|v| v.as_str()),
1016            Some("10")
1017        );
1018        assert_eq!(
1019            mail.get_attribute("oxify.priority")
1020                .and_then(|v| v.as_i64()),
1021            Some(10)
1022        );
1023    }
1024
1025    #[tokio::test]
1026    async fn test_oxify_priority_high_urgent() {
1027        let mock = MockHttpClient::with_success(AnalysisResponse {
1028            sentiment: Sentiment::Neutral,
1029            sentiment_score: 0.5,
1030            categories: vec![MailCategory::Work],
1031            priority: 9,
1032            tags: vec![],
1033            folder: None,
1034        });
1035
1036        let config = OxiFYConfig {
1037            enabled: true,
1038            api_key: "test_key".to_string(),
1039            ..Default::default()
1040        };
1041
1042        let mailet = OxiFYMailet::with_client(mock, config);
1043        let mut mail = create_test_mail();
1044
1045        mailet.service(&mut mail).await.unwrap();
1046
1047        assert_eq!(
1048            mail.get_attribute("oxify.is_urgent")
1049                .and_then(|v| v.as_bool()),
1050            Some(true)
1051        );
1052    }
1053
1054    #[tokio::test]
1055    async fn test_oxify_auto_tagging() {
1056        let mock = MockHttpClient::with_success(AnalysisResponse {
1057            sentiment: Sentiment::Neutral,
1058            sentiment_score: 0.5,
1059            categories: vec![MailCategory::Work],
1060            priority: 5,
1061            tags: vec![
1062                "meeting".to_string(),
1063                "invoice".to_string(),
1064                "action-required".to_string(),
1065            ],
1066            folder: None,
1067        });
1068
1069        let config = OxiFYConfig {
1070            enabled: true,
1071            api_key: "test_key".to_string(),
1072            ..Default::default()
1073        };
1074
1075        let mailet = OxiFYMailet::with_client(mock, config);
1076        let mut mail = create_test_mail();
1077
1078        mailet.service(&mut mail).await.unwrap();
1079
1080        let tags = mail
1081            .get_attribute("header.X-OxiFY-Tags")
1082            .and_then(|v| v.as_str())
1083            .unwrap();
1084        assert!(tags.contains("meeting"));
1085        assert!(tags.contains("invoice"));
1086        assert!(tags.contains("action-required"));
1087    }
1088
1089    #[tokio::test]
1090    async fn test_oxify_folder_routing() {
1091        let mock = MockHttpClient::with_success(AnalysisResponse {
1092            sentiment: Sentiment::Neutral,
1093            sentiment_score: 0.5,
1094            categories: vec![MailCategory::Newsletter],
1095            priority: 3,
1096            tags: vec![],
1097            folder: Some("Newsletters".to_string()),
1098        });
1099
1100        let config = OxiFYConfig {
1101            enabled: true,
1102            api_key: "test_key".to_string(),
1103            ..Default::default()
1104        };
1105
1106        let mailet = OxiFYMailet::with_client(mock, config);
1107        let mut mail = create_test_mail();
1108
1109        mailet.service(&mut mail).await.unwrap();
1110
1111        assert_eq!(
1112            mail.get_attribute("oxify.folder").and_then(|v| v.as_str()),
1113            Some("Newsletters")
1114        );
1115    }
1116
1117    #[tokio::test]
1118    async fn test_oxify_folder_mapping() {
1119        let mock = MockHttpClient::with_success(AnalysisResponse {
1120            sentiment: Sentiment::Neutral,
1121            sentiment_score: 0.5,
1122            categories: vec![MailCategory::Work],
1123            priority: 5,
1124            tags: vec![],
1125            folder: None,
1126        });
1127
1128        let mut folder_mapping = HashMap::new();
1129        folder_mapping.insert("work".to_string(), "Work".to_string());
1130
1131        let config = OxiFYConfig {
1132            enabled: true,
1133            api_key: "test_key".to_string(),
1134            folder_mapping,
1135            ..Default::default()
1136        };
1137
1138        let mailet = OxiFYMailet::with_client(mock, config);
1139        let mut mail = create_test_mail();
1140
1141        mailet.service(&mut mail).await.unwrap();
1142
1143        assert_eq!(
1144            mail.get_attribute("oxify.folder").and_then(|v| v.as_str()),
1145            Some("Work")
1146        );
1147    }
1148
1149    #[tokio::test]
1150    async fn test_oxify_network_error() {
1151        let mock = MockHttpClient::with_error(MockError::Network("DNS failed".to_string()));
1152
1153        let config = OxiFYConfig {
1154            enabled: true,
1155            api_key: "test_key".to_string(),
1156            ..Default::default()
1157        };
1158
1159        let mailet = OxiFYMailet::with_client(mock, config);
1160        let mut mail = create_test_mail();
1161
1162        // Should continue on network error
1163        let result = mailet.service(&mut mail).await;
1164        assert!(result.is_ok());
1165        assert!(matches!(result.unwrap(), MailetAction::Continue));
1166    }
1167
1168    #[tokio::test]
1169    async fn test_oxify_timeout_error() {
1170        let mock = MockHttpClient::with_error(MockError::Timeout);
1171
1172        let config = OxiFYConfig {
1173            enabled: true,
1174            api_key: "test_key".to_string(),
1175            ..Default::default()
1176        };
1177
1178        let mailet = OxiFYMailet::with_client(mock, config);
1179        let mut mail = create_test_mail();
1180
1181        // Should continue on timeout
1182        let result = mailet.service(&mut mail).await;
1183        assert!(result.is_ok());
1184        assert!(matches!(result.unwrap(), MailetAction::Continue));
1185    }
1186
1187    #[tokio::test]
1188    async fn test_oxify_rate_limited() {
1189        let mock = MockHttpClient::with_error(MockError::RateLimited);
1190
1191        let config = OxiFYConfig {
1192            enabled: true,
1193            api_key: "test_key".to_string(),
1194            ..Default::default()
1195        };
1196
1197        let mailet = OxiFYMailet::with_client(mock, config);
1198        let mut mail = create_test_mail();
1199
1200        // Should continue on rate limit
1201        let result = mailet.service(&mut mail).await;
1202        assert!(result.is_ok());
1203        assert!(matches!(result.unwrap(), MailetAction::Continue));
1204    }
1205
1206    #[tokio::test]
1207    async fn test_oxify_api_error() {
1208        let mock =
1209            MockHttpClient::with_error(MockError::Api(500, "Internal Server Error".to_string()));
1210
1211        let config = OxiFYConfig {
1212            enabled: true,
1213            api_key: "test_key".to_string(),
1214            ..Default::default()
1215        };
1216
1217        let mailet = OxiFYMailet::with_client(mock, config);
1218        let mut mail = create_test_mail();
1219
1220        // Should continue on API error
1221        let result = mailet.service(&mut mail).await;
1222        assert!(result.is_ok());
1223        assert!(matches!(result.unwrap(), MailetAction::Continue));
1224    }
1225
1226    #[tokio::test]
1227    async fn test_oxify_parse_error() {
1228        let mock = MockHttpClient::with_error(MockError::Parse("Invalid JSON".to_string()));
1229
1230        let config = OxiFYConfig {
1231            enabled: true,
1232            api_key: "test_key".to_string(),
1233            ..Default::default()
1234        };
1235
1236        let mailet = OxiFYMailet::with_client(mock, config);
1237        let mut mail = create_test_mail();
1238
1239        // Should continue on parse error
1240        let result = mailet.service(&mut mail).await;
1241        assert!(result.is_ok());
1242        assert!(matches!(result.unwrap(), MailetAction::Continue));
1243    }
1244
1245    #[tokio::test]
1246    async fn test_oxify_disabled() {
1247        let mock = MockHttpClient::with_success(create_success_response());
1248
1249        let config = OxiFYConfig {
1250            enabled: false,
1251            api_key: "test_key".to_string(),
1252            ..Default::default()
1253        };
1254
1255        let mailet = OxiFYMailet::with_client(mock, config);
1256        let mut mail = create_test_mail();
1257
1258        // Should continue when disabled
1259        let result = mailet.service(&mut mail).await;
1260        assert!(result.is_ok());
1261        assert!(matches!(result.unwrap(), MailetAction::Continue));
1262
1263        // Should not add any headers
1264        assert!(mail.get_attribute("header.X-OxiFY-Sentiment").is_none());
1265    }
1266
1267    #[tokio::test]
1268    async fn test_oxify_config_timeout() {
1269        let mut mailet = OxiFYMailet::<ReqwestClient>::new();
1270        let config = MailetConfig::new("OxiFY")
1271            .with_param("enabled", "false")
1272            .with_param("api_url", "http://localhost:8080/api/v1/analyze")
1273            .with_param("api_key", "test_key")
1274            .with_param("timeout_ms", "10000");
1275
1276        mailet.init(config).await.unwrap();
1277    }
1278
1279    #[tokio::test]
1280    async fn test_oxify_config_cache_ttl() {
1281        let mut mailet = OxiFYMailet::<ReqwestClient>::new();
1282        let config = MailetConfig::new("OxiFY")
1283            .with_param("enabled", "false")
1284            .with_param("api_url", "http://localhost:8080/api/v1/analyze")
1285            .with_param("api_key", "test_key")
1286            .with_param("cache_ttl", "7200");
1287
1288        mailet.init(config).await.unwrap();
1289    }
1290
1291    #[tokio::test]
1292    async fn test_oxify_config_max_body_size() {
1293        let mut mailet = OxiFYMailet::<ReqwestClient>::new();
1294        let config = MailetConfig::new("OxiFY")
1295            .with_param("enabled", "false")
1296            .with_param("api_url", "http://localhost:8080/api/v1/analyze")
1297            .with_param("api_key", "test_key")
1298            .with_param("max_body_size", "102400");
1299
1300        mailet.init(config).await.unwrap();
1301    }
1302
1303    #[tokio::test]
1304    async fn test_mail_category_from_str() {
1305        assert_eq!(MailCategory::parse("work"), Some(MailCategory::Work));
1306        assert_eq!(MailCategory::parse("SPAM"), Some(MailCategory::Spam));
1307        assert_eq!(MailCategory::parse("urgent"), Some(MailCategory::Urgent));
1308        assert_eq!(MailCategory::parse("invalid"), None);
1309    }
1310
1311    #[tokio::test]
1312    async fn test_mail_category_as_str() {
1313        assert_eq!(MailCategory::Work.as_str(), "work");
1314        assert_eq!(MailCategory::Spam.as_str(), "spam");
1315        assert_eq!(MailCategory::Urgent.as_str(), "urgent");
1316    }
1317
1318    #[tokio::test]
1319    async fn test_sentiment_from_str() {
1320        assert_eq!(Sentiment::parse("positive"), Some(Sentiment::Positive));
1321        assert_eq!(Sentiment::parse("NEGATIVE"), Some(Sentiment::Negative));
1322        assert_eq!(Sentiment::parse("neutral"), Some(Sentiment::Neutral));
1323        assert_eq!(Sentiment::parse("invalid"), None);
1324    }
1325
1326    #[tokio::test]
1327    async fn test_sentiment_as_str() {
1328        assert_eq!(Sentiment::Positive.as_str(), "positive");
1329        assert_eq!(Sentiment::Negative.as_str(), "negative");
1330        assert_eq!(Sentiment::Neutral.as_str(), "neutral");
1331    }
1332
1333    #[tokio::test]
1334    async fn test_oxify_sentiment_score_validation() {
1335        let mock = MockHttpClient::with_success(AnalysisResponse {
1336            sentiment: Sentiment::Positive,
1337            sentiment_score: 1.5, // Out of range, should be clamped
1338            categories: vec![MailCategory::Personal],
1339            priority: 5,
1340            tags: vec![],
1341            folder: None,
1342        });
1343
1344        let config = OxiFYConfig {
1345            enabled: true,
1346            api_key: "test_key".to_string(),
1347            ..Default::default()
1348        };
1349
1350        let service = OxiFYService::with_client(config, mock);
1351        let mail = create_test_mail();
1352
1353        let result = service.analyze(&mail).await.unwrap();
1354        assert_eq!(result.sentiment_score, 1.0); // Clamped to 1.0
1355    }
1356
1357    #[tokio::test]
1358    async fn test_oxify_priority_validation() {
1359        let mock = MockHttpClient::with_success(AnalysisResponse {
1360            sentiment: Sentiment::Neutral,
1361            sentiment_score: 0.5,
1362            categories: vec![MailCategory::Work],
1363            priority: 15, // Out of range, should be clamped
1364            tags: vec![],
1365            folder: None,
1366        });
1367
1368        let config = OxiFYConfig {
1369            enabled: true,
1370            api_key: "test_key".to_string(),
1371            ..Default::default()
1372        };
1373
1374        let service = OxiFYService::with_client(config, mock);
1375        let mail = create_test_mail();
1376
1377        let result = service.analyze(&mail).await.unwrap();
1378        assert_eq!(result.priority, 10); // Clamped to 10
1379    }
1380
1381    #[tokio::test]
1382    async fn test_oxify_empty_mail() {
1383        let mock = MockHttpClient::with_success(AnalysisResponse {
1384            sentiment: Sentiment::Neutral,
1385            sentiment_score: 0.5,
1386            categories: vec![MailCategory::Personal],
1387            priority: 5,
1388            tags: vec![],
1389            folder: None,
1390        });
1391
1392        let config = OxiFYConfig {
1393            enabled: true,
1394            api_key: "test_key".to_string(),
1395            ..Default::default()
1396        };
1397
1398        let mailet = OxiFYMailet::with_client(mock, config);
1399        let mut mail = create_test_mail();
1400
1401        // Should process without errors
1402        let result = mailet.service(&mut mail).await;
1403        assert!(result.is_ok());
1404    }
1405
1406    #[tokio::test]
1407    async fn test_oxify_default() {
1408        let mailet = OxiFYMailet::<ReqwestClient>::default();
1409        assert_eq!(mailet.name(), "OxiFY");
1410    }
1411}