1use 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#[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 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 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#[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 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 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#[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#[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#[derive(Debug, Clone)]
119pub struct AnalysisResult {
120 pub sentiment: Sentiment,
122 pub sentiment_score: f64,
124 pub categories: Vec<MailCategory>,
126 pub priority: u8,
128 pub tags: Vec<String>,
130 pub folder: Option<String>,
132}
133
134#[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#[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#[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#[derive(Debug, Clone)]
229pub struct OxiFYConfig {
230 pub api_url: String,
232 pub api_key: String,
234 pub enabled: bool,
236 pub timeout_ms: u64,
238 pub cache_ttl: u64,
240 pub max_body_size: usize,
242 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, folder_mapping: HashMap::new(),
256 }
257 }
258}
259
260pub struct OxiFYService<C: HttpClient = ReqwestClient> {
262 config: OxiFYConfig,
263 client: Arc<C>,
264}
265
266impl OxiFYService<ReqwestClient> {
267 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 pub fn with_client(config: OxiFYConfig, client: C) -> Self {
279 Self {
280 config,
281 client: Arc::new(client),
282 }
283 }
284
285 pub async fn analyze(&self, mail: &Mail) -> Result<AnalysisResult, OxiFYError> {
287 if !self.config.enabled {
288 return Err(OxiFYError::Disabled);
289 }
290
291 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 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 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 let priority = response.priority.clamp(1, 10);
342
343 let sentiment_score = response.sentiment_score.clamp(0.0, 1.0);
345
346 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
367pub struct OxiFYMailet<C: HttpClient = ReqwestClient> {
369 name: String,
370 service: Option<OxiFYService<C>>,
371}
372
373impl OxiFYMailet<ReqwestClient> {
374 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 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 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 fn apply_analysis(&self, mail: &mut Mail, result: AnalysisResult) {
402 mail.set_attribute(
404 "header.X-OxiFY-Sentiment",
405 result.sentiment.as_str().to_string(),
406 );
407
408 mail.set_attribute(
410 "header.X-OxiFY-Sentiment-Score",
411 format!("{:.3}", result.sentiment_score),
412 );
413
414 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 mail.set_attribute("header.X-OxiFY-Priority", result.priority.to_string());
425
426 let tags_str = result.tags.join(",");
428 mail.set_attribute("header.X-OxiFY-Tags", tags_str.clone());
429
430 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 if let Some(folder) = result.folder {
439 mail.set_attribute("oxify.folder", folder);
440 }
441
442 if result.categories.contains(&MailCategory::Spam) {
444 mail.set_attribute("oxify.is_spam", true);
445 }
446
447 if result.categories.contains(&MailCategory::Urgent) || result.priority >= 8 {
449 mail.set_attribute("oxify.is_urgent", true);
450 }
451 }
452
453 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 let mut oxify_config = OxiFYConfig::default();
518
519 if let Some(url) = config.get_param("api_url") {
521 oxify_config.api_url = url.to_string();
522 }
523
524 if let Some(key) = config.get_param("api_key") {
526 oxify_config.api_key = key.to_string();
527 }
528
529 if let Some(enabled) = config.get_param("enabled") {
531 oxify_config.enabled = enabled.parse().unwrap_or(false);
532 }
533
534 if let Some(timeout) = config.get_param("timeout_ms") {
536 oxify_config.timeout_ms = timeout.parse().unwrap_or(5000);
537 }
538
539 if let Some(ttl) = config.get_param("cache_ttl") {
541 oxify_config.cache_ttl = ttl.parse().unwrap_or(3600);
542 }
543
544 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 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 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 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 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 #[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 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 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 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 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 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 let result = mailet.service(&mut mail).await;
1260 assert!(result.is_ok());
1261 assert!(matches!(result.unwrap(), MailetAction::Continue));
1262
1263 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, 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); }
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, 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); }
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 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}