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 std::time::Duration;
117
118    use async_trait::async_trait;
119    use kevy_embedded::Store;
120
121    use super::SpamCache;
122
123    /// Kevy-backed [`SpamCache`] using an in-process [`kevy_embedded::Store`].
124    ///
125    /// The cache silently ignores all store errors — a missing/failed
126    /// lookup always falls through to the provider, and a failed `set`
127    /// just loses one cache entry. Both situations are recoverable
128    /// without breaking classification.
129    #[derive(Clone)]
130    pub struct KevySpamCache {
131        store: Store,
132    }
133
134    impl std::fmt::Debug for KevySpamCache {
135        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136            f.debug_struct("KevySpamCache").finish_non_exhaustive()
137        }
138    }
139
140    impl KevySpamCache {
141        /// Construct a Kevy-backed spam-classification cache from an
142        /// in-process [`Store`] handle (callers typically pass a clone of
143        /// the shared cement-owned store).
144        pub fn new(store: Store) -> Self {
145            Self { store }
146        }
147    }
148
149    #[async_trait]
150    impl SpamCache for KevySpamCache {
151        async fn get(&self, key: &str) -> Option<String> {
152            self.store
153                .get(key.as_bytes())
154                .ok()
155                .flatten()
156                .and_then(|bytes| String::from_utf8(bytes).ok())
157        }
158
159        async fn set(&self, key: &str, value: &str, ttl_secs: u64) {
160            let _ = self.store.set_with_ttl(
161                key.as_bytes(),
162                value.as_bytes(),
163                Duration::from_secs(ttl_secs),
164            );
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn parse_ai_response_valid() {
175        let r = parse_ai_response(r#"{"score": 7.5, "reason": "phishing attempt"}"#).unwrap();
176        assert!((r.score - 7.5).abs() < 0.01);
177        assert_eq!(r.reason, "phishing attempt");
178    }
179
180    #[test]
181    fn parse_ai_response_with_surrounding_text() {
182        let r = parse_ai_response(
183            r#"Here is my analysis: {"score": 2.0, "reason": "legitimate newsletter"} hope that helps"#,
184        )
185        .unwrap();
186        assert!((r.score - 2.0).abs() < 0.01);
187    }
188
189    #[test]
190    fn parse_ai_response_invalid() {
191        assert!(parse_ai_response("no json here").is_none());
192        assert!(parse_ai_response(r#"{"no_score": true}"#).is_none());
193    }
194
195    #[test]
196    fn parse_ai_response_clamps_score() {
197        let r = parse_ai_response(r#"{"score": 15.0, "reason": "very spam"}"#).unwrap();
198        assert!((r.score - 10.0).abs() < 0.01);
199    }
200
201    #[test]
202    fn cache_key_format() {
203        let key = make_cache_key("user@example.com", "Hello World", "body");
204        assert!(key.starts_with("ai:"));
205        let key2 = make_cache_key("other@example.com", "Hello World", "body");
206        assert_ne!(key, key2);
207    }
208
209    #[test]
210    fn parse_cached_roundtrip() {
211        let cached = r#"{"s":7.5,"r":"phishing attempt"}"#;
212        let r = parse_cached(cached).unwrap();
213        assert!((r.score - 7.5).abs() < 0.01);
214        assert_eq!(r.reason, "phishing attempt");
215    }
216
217    #[test]
218    fn parse_cached_with_pipe_in_reason() {
219        let cached = r#"{"s":3.0,"r":"too many links | phishing indicators"}"#;
220        let r = parse_cached(cached).unwrap();
221        assert!((r.score - 3.0).abs() < 0.01);
222        assert_eq!(r.reason, "too many links | phishing indicators");
223    }
224}
225
226#[cfg(test)]
227mod integration_tests {
228    use super::*;
229    use crate::provider::LlmProvider;
230    use std::sync::Mutex;
231
232    /// LlmProvider returning a canned response, useful for asserting
233    /// downstream behavior without touching a real LLM endpoint.
234    struct MockProvider {
235        canned: String,
236        calls: Mutex<u32>,
237    }
238
239    #[async_trait]
240    impl LlmProvider for MockProvider {
241        async fn complete(&self, _system: &str, _user: &str, _temp: f32) -> Option<String> {
242            *self.calls.lock().unwrap() += 1;
243            Some(self.canned.clone())
244        }
245        async fn embed(&self, _text: &str) -> Option<Vec<f32>> {
246            None
247        }
248        fn model_id(&self) -> &str {
249            "mock/1"
250        }
251    }
252
253    /// LlmProvider that always errors.
254    struct DeadProvider;
255
256    #[async_trait]
257    impl LlmProvider for DeadProvider {
258        async fn complete(&self, _system: &str, _user: &str, _temp: f32) -> Option<String> {
259            None
260        }
261        async fn embed(&self, _text: &str) -> Option<Vec<f32>> {
262            None
263        }
264        fn model_id(&self) -> &str {
265            "dead/0"
266        }
267    }
268
269    /// In-memory SpamCache for testing the cache pathway end-to-end.
270    struct MemCache {
271        inner: Mutex<std::collections::HashMap<String, String>>,
272    }
273
274    #[async_trait]
275    impl SpamCache for MemCache {
276        async fn get(&self, key: &str) -> Option<String> {
277            self.inner.lock().unwrap().get(key).cloned()
278        }
279        async fn set(&self, key: &str, value: &str, _ttl: u64) {
280            self.inner.lock().unwrap().insert(key.into(), value.into());
281        }
282    }
283
284    #[tokio::test]
285    async fn classify_returns_score_from_provider() {
286        let provider = MockProvider {
287            canned: r#"{"score": 7.5, "reason": "phishing pattern"}"#.into(),
288            calls: Mutex::new(0),
289        };
290        let result = classify(&provider, None, "evil@x", "Win now!", "click here")
291            .await
292            .expect("classify must succeed");
293        assert!((result.score - 7.5).abs() < 0.01);
294        assert_eq!(result.reason, "phishing pattern");
295    }
296
297    #[tokio::test]
298    async fn classify_returns_none_on_dead_provider() {
299        let result = classify(&DeadProvider, None, "any@x", "subj", "body").await;
300        assert!(result.is_none());
301    }
302
303    #[tokio::test]
304    async fn classify_unparseable_response_returns_none() {
305        let provider = MockProvider {
306            canned: "I don't speak JSON".into(),
307            calls: Mutex::new(0),
308        };
309        assert!(classify(&provider, None, "x", "y", "z").await.is_none());
310    }
311
312    #[tokio::test]
313    async fn classify_writes_to_cache_on_miss() {
314        let provider = MockProvider {
315            canned: r#"{"score": 2.0, "reason": "legit"}"#.into(),
316            calls: Mutex::new(0),
317        };
318        let cache = MemCache {
319            inner: Mutex::new(Default::default()),
320        };
321        let _ = classify(&provider, Some(&cache), "a", "b", "c").await;
322        assert_eq!(
323            cache.inner.lock().unwrap().len(),
324            1,
325            "cache must hold one entry"
326        );
327    }
328
329    #[tokio::test]
330    async fn classify_hits_cache_on_second_call() {
331        let provider = MockProvider {
332            canned: r#"{"score": 5.0, "reason": "borderline"}"#.into(),
333            calls: Mutex::new(0),
334        };
335        let cache = MemCache {
336            inner: Mutex::new(Default::default()),
337        };
338
339        let r1 = classify(&provider, Some(&cache), "a", "b", "c")
340            .await
341            .unwrap();
342        let r2 = classify(&provider, Some(&cache), "a", "b", "c")
343            .await
344            .unwrap();
345
346        assert!((r1.score - 5.0).abs() < 0.01);
347        assert!((r2.score - 5.0).abs() < 0.01);
348        assert_eq!(
349            *provider.calls.lock().unwrap(),
350            1,
351            "second call should hit cache, not provider"
352        );
353    }
354
355    #[tokio::test]
356    async fn classify_different_inputs_use_different_cache_keys() {
357        let provider = MockProvider {
358            canned: r#"{"score": 3.0, "reason": "ok"}"#.into(),
359            calls: Mutex::new(0),
360        };
361        let cache = MemCache {
362            inner: Mutex::new(Default::default()),
363        };
364
365        classify(&provider, Some(&cache), "a", "subject1", "body").await;
366        classify(&provider, Some(&cache), "a", "subject2", "body").await;
367
368        assert_eq!(cache.inner.lock().unwrap().len(), 2);
369        assert_eq!(*provider.calls.lock().unwrap(), 2);
370    }
371
372    #[tokio::test]
373    async fn classify_caches_negative_decisions_too() {
374        let provider = MockProvider {
375            canned: r#"{"score": 0.1, "reason": "totally legit"}"#.into(),
376            calls: Mutex::new(0),
377        };
378        let cache = MemCache {
379            inner: Mutex::new(Default::default()),
380        };
381        classify(&provider, Some(&cache), "a", "b", "c").await;
382        assert_eq!(
383            cache.inner.lock().unwrap().len(),
384            1,
385            "low-score result still cached"
386        );
387    }
388
389    // ===== Additional integration tests =====
390
391    #[tokio::test]
392    async fn classify_handles_empty_strings() {
393        // Edge case: empty sender/subject/body should still produce a cache key
394        // (the hasher tolerates empty input) and round-trip through the provider.
395        let provider = MockProvider {
396            canned: r#"{"score": 0.0, "reason": "empty"}"#.into(),
397            calls: Mutex::new(0),
398        };
399        let cache = MemCache {
400            inner: Mutex::new(Default::default()),
401        };
402        let r = classify(&provider, Some(&cache), "", "", "").await.unwrap();
403        assert!((r.score - 0.0).abs() < 0.01);
404        assert_eq!(cache.inner.lock().unwrap().len(), 1);
405    }
406
407    #[tokio::test]
408    async fn classify_cache_corrupted_value_falls_through_to_provider() {
409        // If the cached value is malformed, classify should fall through and
410        // ask the provider, then overwrite the cached value.
411        let provider = MockProvider {
412            canned: r#"{"score": 4.0, "reason": "fresh"}"#.into(),
413            calls: Mutex::new(0),
414        };
415        let cache = MemCache {
416            inner: Mutex::new(Default::default()),
417        };
418        // pre-seed cache with garbage that has the correct key
419        let key = make_cache_key("a", "b", "c");
420        cache
421            .inner
422            .lock()
423            .unwrap()
424            .insert(key.clone(), "not-json".to_string());
425        let r = classify(&provider, Some(&cache), "a", "b", "c")
426            .await
427            .unwrap();
428        assert!((r.score - 4.0).abs() < 0.01, "fell through to provider");
429        // and the cache was overwritten with a valid entry
430        let cached = cache.inner.lock().unwrap().get(&key).cloned().unwrap();
431        assert!(cached.contains("\"s\":4.0") || cached.contains("\"s\":4"));
432    }
433
434    #[tokio::test]
435    async fn classify_subject_change_busts_cache() {
436        let provider = MockProvider {
437            canned: r#"{"score": 1.0, "reason": "ok"}"#.into(),
438            calls: Mutex::new(0),
439        };
440        let cache = MemCache {
441            inner: Mutex::new(Default::default()),
442        };
443        classify(&provider, Some(&cache), "a", "subj1", "body").await;
444        classify(&provider, Some(&cache), "a", "subj2", "body").await;
445        // Subject change must produce a distinct cache key -> provider called twice
446        assert_eq!(*provider.calls.lock().unwrap(), 2);
447        assert_eq!(cache.inner.lock().unwrap().len(), 2);
448    }
449
450    #[tokio::test]
451    async fn classify_returns_none_when_score_field_missing() {
452        // Response with a "reason" but no "score" must fail parsing.
453        let provider = MockProvider {
454            canned: r#"{"reason": "I forgot the score"}"#.into(),
455            calls: Mutex::new(0),
456        };
457        let r = classify(&provider, None, "a", "b", "c").await;
458        assert!(r.is_none());
459    }
460
461    #[tokio::test]
462    async fn classify_score_clamped_after_provider() {
463        // If provider returns out-of-range score (e.g. 15.0), it must be clamped to 10.0.
464        let provider = MockProvider {
465            canned: r#"{"score": 99.9, "reason": "off the scale"}"#.into(),
466            calls: Mutex::new(0),
467        };
468        let r = classify(&provider, None, "a", "b", "c").await.unwrap();
469        assert!((r.score - 10.0).abs() < 0.01);
470    }
471
472    #[tokio::test]
473    async fn classify_negative_score_clamped() {
474        let provider = MockProvider {
475            canned: r#"{"score": -5.0, "reason": "negative"}"#.into(),
476            calls: Mutex::new(0),
477        };
478        let r = classify(&provider, None, "a", "b", "c").await.unwrap();
479        assert!((r.score - 0.0).abs() < 0.01);
480    }
481}