Skip to main content

mailrs_intelligence/
spam.rs

1//! Spam classification via [`LlmProvider`] with an optional cache.
2//!
3//! [`classify`] hashes `(sender, subject, body_preview)` into a cache key
4//! and consults the optional [`SpamCache`] before calling the provider.
5//! On cache miss, the result is written back with a 24-hour TTL.
6//!
7//! A Kevy-backed [`SpamCache`] implementation ([`KevySpamCache`]) is
8//! available under the default `kevy-cache` feature.
9
10use std::collections::hash_map::DefaultHasher;
11use std::hash::{Hash, Hasher};
12
13use async_trait::async_trait;
14
15use crate::provider::LlmProvider;
16
17/// AI spam classification result.
18#[derive(Debug, Clone)]
19pub struct AiSpamResult {
20    /// 0.0 (clearly legitimate) → 10.0 (obvious spam).
21    pub score: f64,
22    /// Short natural-language reason from the model.
23    pub reason: String,
24}
25
26/// Pluggable cache for spam classification results.
27///
28/// Implementations should ignore failures rather than propagate them: a
29/// cache miss is always recoverable by re-asking the provider.
30#[async_trait]
31pub trait SpamCache: Send + Sync {
32    /// Look up a cached result by key. Return `None` on miss or error.
33    async fn get(&self, key: &str) -> Option<String>;
34    /// Store a result with TTL (seconds). Errors are ignored.
35    async fn set(&self, key: &str, value: &str, ttl_secs: u64);
36}
37
38/// Classify a message using `provider`, consulting `cache` if supplied.
39///
40/// Designed for the "grey zone" between rule-based spam thresholds —
41/// callers typically only invoke this when their cheaper heuristics are
42/// undecided. Returns `None` on provider failure or unparseable response.
43pub async fn classify(
44    provider: &dyn LlmProvider,
45    cache: Option<&dyn SpamCache>,
46    sender: &str,
47    subject: &str,
48    body_preview: &str,
49) -> Option<AiSpamResult> {
50    let cache_key = make_cache_key(sender, subject, body_preview);
51
52    if let Some(cache) = cache
53        && let Some(cached) = cache.get(&cache_key).await
54        && let Some(result) = parse_cached(&cached)
55    {
56        tracing::debug!(event = "ai_spam_cache_hit", key = %cache_key);
57        return Some(result);
58    }
59
60    let system = "You are a spam classifier. Analyze emails and respond with ONLY a JSON object: {\"score\": <0.0-10.0>, \"reason\": \"<brief reason>\"}. Score guide: 0=clearly legitimate, 5=suspicious, 10=obvious spam";
61
62    let user_message =
63        format!("Sender: {sender}\nSubject: {subject}\nBody preview: {body_preview}");
64
65    let text = provider.complete(system, &user_message, 0.1).await?;
66    let result = parse_ai_response(&text)?;
67
68    if let Some(cache) = cache {
69        let cached = serde_json::json!({"s": result.score, "r": result.reason}).to_string();
70        cache.set(&cache_key, &cached, 86400).await;
71    }
72
73    tracing::info!(
74        event = "ai_spam_classified",
75        score = result.score,
76        reason = %result.reason,
77    );
78
79    Some(result)
80}
81
82fn make_cache_key(sender: &str, subject: &str, body_preview: &str) -> String {
83    let mut hasher = DefaultHasher::new();
84    sender.hash(&mut hasher);
85    subject.hash(&mut hasher);
86    body_preview.hash(&mut hasher);
87    let hash = hasher.finish();
88    format!("ai:{hash:x}")
89}
90
91fn parse_cached(s: &str) -> Option<AiSpamResult> {
92    let v: serde_json::Value = serde_json::from_str(s).ok()?;
93    let score = v["s"].as_f64()?;
94    let reason = v["r"].as_str().unwrap_or("").to_string();
95    Some(AiSpamResult { score, reason })
96}
97
98fn parse_ai_response(text: &str) -> Option<AiSpamResult> {
99    let start = text.find('{')?;
100    let end = text.rfind('}')? + 1;
101    let json_str = &text[start..end];
102    let v: serde_json::Value = serde_json::from_str(json_str).ok()?;
103    let score = v["score"].as_f64()?;
104    let reason = v["reason"].as_str().unwrap_or("").to_string();
105    Some(AiSpamResult {
106        score: score.clamp(0.0, 10.0),
107        reason,
108    })
109}
110
111#[cfg(feature = "kevy-cache")]
112pub use kevy_impl::KevySpamCache;
113
114#[cfg(feature = "kevy-cache")]
115mod kevy_impl {
116    use super::SpamCache;
117    use async_trait::async_trait;
118    use redis::AsyncCommands;
119
120    /// Kevy-backed [`SpamCache`] using a shared [`redis::aio::ConnectionManager`].
121    ///
122    /// The cache silently ignores all Redis errors — a missing/failed
123    /// cache lookup always falls through to the provider, and a failed
124    /// `set` just loses one cache entry. Both situations are recoverable
125    /// without breaking classification.
126    #[derive(Debug, Clone)]
127    pub struct KevySpamCache {
128        conn: redis::aio::ConnectionManager,
129    }
130
131    impl KevySpamCache {
132        /// Construct a Kevy-backed spam-classification cache.
133        pub fn new(conn: redis::aio::ConnectionManager) -> Self {
134            Self { conn }
135        }
136    }
137
138    #[async_trait]
139    impl SpamCache for KevySpamCache {
140        async fn get(&self, key: &str) -> Option<String> {
141            let mut conn = self.conn.clone();
142            conn.get::<_, Option<String>>(key).await.ok().flatten()
143        }
144
145        async fn set(&self, key: &str, value: &str, ttl_secs: u64) {
146            let mut conn = self.conn.clone();
147            let _: Result<(), _> = conn.set_ex(key, value, ttl_secs).await;
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn parse_ai_response_valid() {
158        let r = parse_ai_response(r#"{"score": 7.5, "reason": "phishing attempt"}"#).unwrap();
159        assert!((r.score - 7.5).abs() < 0.01);
160        assert_eq!(r.reason, "phishing attempt");
161    }
162
163    #[test]
164    fn parse_ai_response_with_surrounding_text() {
165        let r = parse_ai_response(
166            r#"Here is my analysis: {"score": 2.0, "reason": "legitimate newsletter"} hope that helps"#,
167        )
168        .unwrap();
169        assert!((r.score - 2.0).abs() < 0.01);
170    }
171
172    #[test]
173    fn parse_ai_response_invalid() {
174        assert!(parse_ai_response("no json here").is_none());
175        assert!(parse_ai_response(r#"{"no_score": true}"#).is_none());
176    }
177
178    #[test]
179    fn parse_ai_response_clamps_score() {
180        let r = parse_ai_response(r#"{"score": 15.0, "reason": "very spam"}"#).unwrap();
181        assert!((r.score - 10.0).abs() < 0.01);
182    }
183
184    #[test]
185    fn cache_key_format() {
186        let key = make_cache_key("user@example.com", "Hello World", "body");
187        assert!(key.starts_with("ai:"));
188        let key2 = make_cache_key("other@example.com", "Hello World", "body");
189        assert_ne!(key, key2);
190    }
191
192    #[test]
193    fn parse_cached_roundtrip() {
194        let cached = r#"{"s":7.5,"r":"phishing attempt"}"#;
195        let r = parse_cached(cached).unwrap();
196        assert!((r.score - 7.5).abs() < 0.01);
197        assert_eq!(r.reason, "phishing attempt");
198    }
199
200    #[test]
201    fn parse_cached_with_pipe_in_reason() {
202        let cached = r#"{"s":3.0,"r":"too many links | phishing indicators"}"#;
203        let r = parse_cached(cached).unwrap();
204        assert!((r.score - 3.0).abs() < 0.01);
205        assert_eq!(r.reason, "too many links | phishing indicators");
206    }
207}
208
209#[cfg(test)]
210mod integration_tests {
211    use super::*;
212    use crate::provider::LlmProvider;
213    use std::sync::Mutex;
214
215    /// LlmProvider returning a canned response, useful for asserting
216    /// downstream behavior without touching a real LLM endpoint.
217    struct MockProvider {
218        canned: String,
219        calls: Mutex<u32>,
220    }
221
222    #[async_trait]
223    impl LlmProvider for MockProvider {
224        async fn complete(&self, _system: &str, _user: &str, _temp: f32) -> Option<String> {
225            *self.calls.lock().unwrap() += 1;
226            Some(self.canned.clone())
227        }
228        async fn embed(&self, _text: &str) -> Option<Vec<f32>> {
229            None
230        }
231        fn model_id(&self) -> &str {
232            "mock/1"
233        }
234    }
235
236    /// LlmProvider that always errors.
237    struct DeadProvider;
238
239    #[async_trait]
240    impl LlmProvider for DeadProvider {
241        async fn complete(&self, _system: &str, _user: &str, _temp: f32) -> Option<String> {
242            None
243        }
244        async fn embed(&self, _text: &str) -> Option<Vec<f32>> {
245            None
246        }
247        fn model_id(&self) -> &str {
248            "dead/0"
249        }
250    }
251
252    /// In-memory SpamCache for testing the cache pathway end-to-end.
253    struct MemCache {
254        inner: Mutex<std::collections::HashMap<String, String>>,
255    }
256
257    #[async_trait]
258    impl SpamCache for MemCache {
259        async fn get(&self, key: &str) -> Option<String> {
260            self.inner.lock().unwrap().get(key).cloned()
261        }
262        async fn set(&self, key: &str, value: &str, _ttl: u64) {
263            self.inner.lock().unwrap().insert(key.into(), value.into());
264        }
265    }
266
267    #[tokio::test]
268    async fn classify_returns_score_from_provider() {
269        let provider = MockProvider {
270            canned: r#"{"score": 7.5, "reason": "phishing pattern"}"#.into(),
271            calls: Mutex::new(0),
272        };
273        let result = classify(&provider, None, "evil@x", "Win now!", "click here")
274            .await
275            .expect("classify must succeed");
276        assert!((result.score - 7.5).abs() < 0.01);
277        assert_eq!(result.reason, "phishing pattern");
278    }
279
280    #[tokio::test]
281    async fn classify_returns_none_on_dead_provider() {
282        let result = classify(&DeadProvider, None, "any@x", "subj", "body").await;
283        assert!(result.is_none());
284    }
285
286    #[tokio::test]
287    async fn classify_unparseable_response_returns_none() {
288        let provider = MockProvider {
289            canned: "I don't speak JSON".into(),
290            calls: Mutex::new(0),
291        };
292        assert!(classify(&provider, None, "x", "y", "z").await.is_none());
293    }
294
295    #[tokio::test]
296    async fn classify_writes_to_cache_on_miss() {
297        let provider = MockProvider {
298            canned: r#"{"score": 2.0, "reason": "legit"}"#.into(),
299            calls: Mutex::new(0),
300        };
301        let cache = MemCache {
302            inner: Mutex::new(Default::default()),
303        };
304        let _ = classify(&provider, Some(&cache), "a", "b", "c").await;
305        assert_eq!(
306            cache.inner.lock().unwrap().len(),
307            1,
308            "cache must hold one entry"
309        );
310    }
311
312    #[tokio::test]
313    async fn classify_hits_cache_on_second_call() {
314        let provider = MockProvider {
315            canned: r#"{"score": 5.0, "reason": "borderline"}"#.into(),
316            calls: Mutex::new(0),
317        };
318        let cache = MemCache {
319            inner: Mutex::new(Default::default()),
320        };
321
322        let r1 = classify(&provider, Some(&cache), "a", "b", "c")
323            .await
324            .unwrap();
325        let r2 = classify(&provider, Some(&cache), "a", "b", "c")
326            .await
327            .unwrap();
328
329        assert!((r1.score - 5.0).abs() < 0.01);
330        assert!((r2.score - 5.0).abs() < 0.01);
331        assert_eq!(
332            *provider.calls.lock().unwrap(),
333            1,
334            "second call should hit cache, not provider"
335        );
336    }
337
338    #[tokio::test]
339    async fn classify_different_inputs_use_different_cache_keys() {
340        let provider = MockProvider {
341            canned: r#"{"score": 3.0, "reason": "ok"}"#.into(),
342            calls: Mutex::new(0),
343        };
344        let cache = MemCache {
345            inner: Mutex::new(Default::default()),
346        };
347
348        classify(&provider, Some(&cache), "a", "subject1", "body").await;
349        classify(&provider, Some(&cache), "a", "subject2", "body").await;
350
351        assert_eq!(cache.inner.lock().unwrap().len(), 2);
352        assert_eq!(*provider.calls.lock().unwrap(), 2);
353    }
354
355    #[tokio::test]
356    async fn classify_caches_negative_decisions_too() {
357        let provider = MockProvider {
358            canned: r#"{"score": 0.1, "reason": "totally legit"}"#.into(),
359            calls: Mutex::new(0),
360        };
361        let cache = MemCache {
362            inner: Mutex::new(Default::default()),
363        };
364        classify(&provider, Some(&cache), "a", "b", "c").await;
365        assert_eq!(
366            cache.inner.lock().unwrap().len(),
367            1,
368            "low-score result still cached"
369        );
370    }
371
372    // ===== Additional integration tests =====
373
374    #[tokio::test]
375    async fn classify_handles_empty_strings() {
376        // Edge case: empty sender/subject/body should still produce a cache key
377        // (the hasher tolerates empty input) and round-trip through the provider.
378        let provider = MockProvider {
379            canned: r#"{"score": 0.0, "reason": "empty"}"#.into(),
380            calls: Mutex::new(0),
381        };
382        let cache = MemCache {
383            inner: Mutex::new(Default::default()),
384        };
385        let r = classify(&provider, Some(&cache), "", "", "").await.unwrap();
386        assert!((r.score - 0.0).abs() < 0.01);
387        assert_eq!(cache.inner.lock().unwrap().len(), 1);
388    }
389
390    #[tokio::test]
391    async fn classify_cache_corrupted_value_falls_through_to_provider() {
392        // If the cached value is malformed, classify should fall through and
393        // ask the provider, then overwrite the cached value.
394        let provider = MockProvider {
395            canned: r#"{"score": 4.0, "reason": "fresh"}"#.into(),
396            calls: Mutex::new(0),
397        };
398        let cache = MemCache {
399            inner: Mutex::new(Default::default()),
400        };
401        // pre-seed cache with garbage that has the correct key
402        let key = make_cache_key("a", "b", "c");
403        cache
404            .inner
405            .lock()
406            .unwrap()
407            .insert(key.clone(), "not-json".to_string());
408        let r = classify(&provider, Some(&cache), "a", "b", "c")
409            .await
410            .unwrap();
411        assert!((r.score - 4.0).abs() < 0.01, "fell through to provider");
412        // and the cache was overwritten with a valid entry
413        let cached = cache.inner.lock().unwrap().get(&key).cloned().unwrap();
414        assert!(cached.contains("\"s\":4.0") || cached.contains("\"s\":4"));
415    }
416
417    #[tokio::test]
418    async fn classify_subject_change_busts_cache() {
419        let provider = MockProvider {
420            canned: r#"{"score": 1.0, "reason": "ok"}"#.into(),
421            calls: Mutex::new(0),
422        };
423        let cache = MemCache {
424            inner: Mutex::new(Default::default()),
425        };
426        classify(&provider, Some(&cache), "a", "subj1", "body").await;
427        classify(&provider, Some(&cache), "a", "subj2", "body").await;
428        // Subject change must produce a distinct cache key -> provider called twice
429        assert_eq!(*provider.calls.lock().unwrap(), 2);
430        assert_eq!(cache.inner.lock().unwrap().len(), 2);
431    }
432
433    #[tokio::test]
434    async fn classify_returns_none_when_score_field_missing() {
435        // Response with a "reason" but no "score" must fail parsing.
436        let provider = MockProvider {
437            canned: r#"{"reason": "I forgot the score"}"#.into(),
438            calls: Mutex::new(0),
439        };
440        let r = classify(&provider, None, "a", "b", "c").await;
441        assert!(r.is_none());
442    }
443
444    #[tokio::test]
445    async fn classify_score_clamped_after_provider() {
446        // If provider returns out-of-range score (e.g. 15.0), it must be clamped to 10.0.
447        let provider = MockProvider {
448            canned: r#"{"score": 99.9, "reason": "off the scale"}"#.into(),
449            calls: Mutex::new(0),
450        };
451        let r = classify(&provider, None, "a", "b", "c").await.unwrap();
452        assert!((r.score - 10.0).abs() < 0.01);
453    }
454
455    #[tokio::test]
456    async fn classify_negative_score_clamped() {
457        let provider = MockProvider {
458            canned: r#"{"score": -5.0, "reason": "negative"}"#.into(),
459            calls: Mutex::new(0),
460        };
461        let r = classify(&provider, None, "a", "b", "c").await.unwrap();
462        assert!((r.score - 0.0).abs() < 0.01);
463    }
464}