Skip to main content

mailrs_intelligence/
importance.rs

1//! Heuristic importance scoring (no LLM).
2//!
3//! Computes an importance score in `[-0.5, 1.0]` from boolean / numeric
4//! signals about a message, then maps it to a five-level enum
5//! ([`ImportanceLevel`]) for display and filtering.
6
7/// Importance level for a message.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ImportanceLevel {
10    /// Score ≥ 0.8 — surface prominently.
11    Critical,
12    /// Score 0.5-0.8 — important.
13    Important,
14    /// Score 0.2-0.5 — normal inbox priority.
15    Normal,
16    /// Score 0.0-0.2 — low priority (newsletters, notifications).
17    Low,
18    /// Score < 0 — noise; safe to demote / archive.
19    Noise,
20}
21
22impl ImportanceLevel {
23    /// Lower-snake-case rendering for serialization.
24    pub fn as_str(&self) -> &'static str {
25        match self {
26            Self::Critical => "critical",
27            Self::Important => "important",
28            Self::Normal => "normal",
29            Self::Low => "low",
30            Self::Noise => "noise",
31        }
32    }
33
34    /// Bucket a numeric score into a level. Score is expected in `[-0.5, 1.0]`.
35    pub fn from_score(score: f32) -> Self {
36        if score >= 0.8 {
37            Self::Critical
38        } else if score >= 0.5 {
39            Self::Important
40        } else if score >= 0.2 {
41            Self::Normal
42        } else if score >= 0.0 {
43            Self::Low
44        } else {
45            Self::Noise
46        }
47    }
48}
49
50/// Signals contributing to the importance score.
51#[derive(Debug, Clone)]
52pub struct ImportanceSignals {
53    /// Sender has been emailed by the user before (mutual relationship).
54    pub is_mutual_contact: bool,
55    /// User is in `To:` (not `Cc:` or `Bcc:`).
56    pub is_direct_recipient: bool,
57    /// Message references one the user previously sent.
58    pub is_reply_to_my_email: bool,
59    /// LLM analysis surfaced one or more action items.
60    pub has_action_items: bool,
61    /// Sender is on the user's explicit VIP list.
62    pub is_vip_sender: bool,
63    /// `List-*` headers indicate mailing-list / bulk traffic.
64    pub is_bulk_sender: bool,
65    /// Specifically a mailing-list message (List-Id present).
66    pub is_mailing_list: bool,
67    /// Sender local-part matches `no-reply@` / `notification@` / etc.
68    pub is_automated: bool,
69    /// Tracking pixel was found in the body.
70    pub has_tracking_pixel: bool,
71    /// HTML is mostly chrome — table layout, lots of inline styles.
72    pub is_template_heavy: bool,
73    /// Ratio of plain-text bytes to total HTML bytes.
74    pub text_to_html_ratio: f32,
75    /// Count of `<a>` tags in the body.
76    pub link_count: usize,
77    /// Manual per-contact bias from the user's address book.
78    pub contact_importance_bias: f32,
79}
80
81impl Default for ImportanceSignals {
82    fn default() -> Self {
83        Self {
84            is_mutual_contact: false,
85            is_direct_recipient: false,
86            is_reply_to_my_email: false,
87            has_action_items: false,
88            is_vip_sender: false,
89            is_bulk_sender: false,
90            is_mailing_list: false,
91            is_automated: false,
92            has_tracking_pixel: false,
93            is_template_heavy: false,
94            text_to_html_ratio: 1.0,
95            link_count: 0,
96            contact_importance_bias: 0.0,
97        }
98    }
99}
100
101/// Calculate importance score from signals.
102pub fn calculate_importance(signals: &ImportanceSignals) -> (ImportanceLevel, f32) {
103    let mut score: f32 = 0.3; // baseline: normal
104
105    if signals.is_mutual_contact {
106        score += 0.3;
107    }
108    if signals.is_direct_recipient {
109        score += 0.1;
110    }
111    if signals.is_reply_to_my_email {
112        score += 0.3;
113    }
114    if signals.has_action_items {
115        score += 0.2;
116    }
117    if signals.is_vip_sender {
118        score += 0.4;
119    }
120
121    if signals.is_bulk_sender {
122        score -= 0.3;
123    }
124    if signals.is_mailing_list {
125        score -= 0.2;
126    }
127    if signals.is_automated {
128        score -= 0.3;
129    }
130    if signals.has_tracking_pixel {
131        score -= 0.1;
132    }
133    if signals.is_template_heavy {
134        score -= 0.2;
135    }
136    if signals.link_count > 20 {
137        score -= 0.1;
138    }
139
140    score += signals.contact_importance_bias;
141
142    score = score.clamp(-0.5, 1.0);
143
144    let level = ImportanceLevel::from_score(score);
145    (level, score)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn baseline_score_is_normal() {
154        let signals = ImportanceSignals::default();
155        let (level, score) = calculate_importance(&signals);
156        assert_eq!(level, ImportanceLevel::Normal);
157        assert!((score - 0.3).abs() < 0.001);
158    }
159
160    #[test]
161    fn mutual_contact_direct_is_important() {
162        let signals = ImportanceSignals {
163            is_mutual_contact: true,
164            is_direct_recipient: true,
165            ..Default::default()
166        };
167        let (level, _) = calculate_importance(&signals);
168        assert!(level == ImportanceLevel::Important || level == ImportanceLevel::Critical);
169    }
170
171    #[test]
172    fn vip_reply_is_critical() {
173        let signals = ImportanceSignals {
174            is_vip_sender: true,
175            is_reply_to_my_email: true,
176            is_mutual_contact: true,
177            ..Default::default()
178        };
179        let (level, score) = calculate_importance(&signals);
180        assert_eq!(level, ImportanceLevel::Critical);
181        assert!(score >= 0.8);
182    }
183
184    #[test]
185    fn bulk_automated_is_low_or_noise() {
186        let signals = ImportanceSignals {
187            is_bulk_sender: true,
188            is_automated: true,
189            is_mailing_list: true,
190            has_tracking_pixel: true,
191            ..Default::default()
192        };
193        let (level, _) = calculate_importance(&signals);
194        assert!(level == ImportanceLevel::Low || level == ImportanceLevel::Noise);
195    }
196
197    #[test]
198    fn template_heavy_marketing_is_low() {
199        let signals = ImportanceSignals {
200            is_bulk_sender: true,
201            has_tracking_pixel: true,
202            is_template_heavy: true,
203            link_count: 30,
204            ..Default::default()
205        };
206        let (level, _) = calculate_importance(&signals);
207        assert!(level == ImportanceLevel::Low || level == ImportanceLevel::Noise);
208    }
209
210    #[test]
211    fn user_bias_positive_boosts() {
212        let signals = ImportanceSignals {
213            contact_importance_bias: 0.5,
214            ..Default::default()
215        };
216        let (level, _) = calculate_importance(&signals);
217        assert_eq!(level, ImportanceLevel::Critical);
218    }
219
220    #[test]
221    fn user_bias_negative_demotes() {
222        let signals = ImportanceSignals {
223            is_mutual_contact: true,
224            contact_importance_bias: -0.5,
225            ..Default::default()
226        };
227        let (level, _) = calculate_importance(&signals);
228        assert!(level == ImportanceLevel::Normal || level == ImportanceLevel::Low);
229    }
230
231    #[test]
232    fn score_clamped_to_range() {
233        let signals = ImportanceSignals {
234            is_vip_sender: true,
235            is_mutual_contact: true,
236            is_reply_to_my_email: true,
237            is_direct_recipient: true,
238            has_action_items: true,
239            contact_importance_bias: 1.0,
240            ..Default::default()
241        };
242        let (_, score) = calculate_importance(&signals);
243        assert!(score <= 1.0);
244
245        let signals2 = ImportanceSignals {
246            is_bulk_sender: true,
247            is_automated: true,
248            is_mailing_list: true,
249            has_tracking_pixel: true,
250            is_template_heavy: true,
251            contact_importance_bias: -1.0,
252            ..Default::default()
253        };
254        let (_, score2) = calculate_importance(&signals2);
255        assert!(score2 >= -0.5);
256    }
257
258    #[test]
259    fn importance_level_as_str() {
260        assert_eq!(ImportanceLevel::Critical.as_str(), "critical");
261        assert_eq!(ImportanceLevel::Important.as_str(), "important");
262        assert_eq!(ImportanceLevel::Normal.as_str(), "normal");
263        assert_eq!(ImportanceLevel::Low.as_str(), "low");
264        assert_eq!(ImportanceLevel::Noise.as_str(), "noise");
265    }
266
267    #[test]
268    fn importance_level_from_score_boundaries() {
269        assert_eq!(ImportanceLevel::from_score(1.0), ImportanceLevel::Critical);
270        assert_eq!(ImportanceLevel::from_score(0.8), ImportanceLevel::Critical);
271        assert_eq!(
272            ImportanceLevel::from_score(0.79),
273            ImportanceLevel::Important
274        );
275        assert_eq!(ImportanceLevel::from_score(0.5), ImportanceLevel::Important);
276        assert_eq!(ImportanceLevel::from_score(0.49), ImportanceLevel::Normal);
277        assert_eq!(ImportanceLevel::from_score(0.2), ImportanceLevel::Normal);
278        assert_eq!(ImportanceLevel::from_score(0.19), ImportanceLevel::Low);
279        assert_eq!(ImportanceLevel::from_score(0.0), ImportanceLevel::Low);
280        assert_eq!(ImportanceLevel::from_score(-0.01), ImportanceLevel::Noise);
281    }
282
283    // ===== Additional corner-case tests =====
284
285    #[test]
286    fn default_signals_have_text_to_html_ratio_one() {
287        // Sanity check on default — should be 1.0 (all-text), not 0.0.
288        let d = ImportanceSignals::default();
289        assert!((d.text_to_html_ratio - 1.0).abs() < f32::EPSILON);
290        assert_eq!(d.link_count, 0);
291        assert!((d.contact_importance_bias).abs() < f32::EPSILON);
292    }
293
294    #[test]
295    fn link_count_threshold_exactly_20_does_not_penalize() {
296        // The penalty triggers only when link_count > 20 (strict >).
297        let s = ImportanceSignals {
298            link_count: 20,
299            ..Default::default()
300        };
301        let (_, score) = calculate_importance(&s);
302        // Score should be the baseline 0.3 (no penalty applied).
303        assert!((score - 0.3).abs() < 0.001);
304    }
305
306    #[test]
307    fn link_count_above_threshold_penalizes() {
308        let s = ImportanceSignals {
309            link_count: 21,
310            ..Default::default()
311        };
312        let (_, score) = calculate_importance(&s);
313        // baseline (0.3) - 0.1 = 0.2
314        assert!((score - 0.2).abs() < 0.001);
315    }
316
317    #[test]
318    fn from_score_negative_clamp_lower_bound() {
319        // anything strictly below 0.0 → Noise, even very negative numbers.
320        assert_eq!(ImportanceLevel::from_score(-0.5), ImportanceLevel::Noise);
321        assert_eq!(ImportanceLevel::from_score(-100.0), ImportanceLevel::Noise);
322        assert_eq!(
323            ImportanceLevel::from_score(f32::NEG_INFINITY),
324            ImportanceLevel::Noise
325        );
326    }
327
328    #[test]
329    fn from_score_above_one_still_critical() {
330        // even out-of-range high values map to Critical.
331        assert_eq!(ImportanceLevel::from_score(2.0), ImportanceLevel::Critical);
332        assert_eq!(
333            ImportanceLevel::from_score(f32::INFINITY),
334            ImportanceLevel::Critical
335        );
336    }
337
338    #[test]
339    fn from_score_nan_behavior_is_noise() {
340        // NaN comparisons are always false → falls through to Noise.
341        assert_eq!(
342            ImportanceLevel::from_score(f32::NAN),
343            ImportanceLevel::Noise
344        );
345    }
346
347    #[test]
348    fn signals_with_all_positives_capped_at_one() {
349        // every positive signal + max bias still clamps to 1.0.
350        let s = ImportanceSignals {
351            is_mutual_contact: true,
352            is_direct_recipient: true,
353            is_reply_to_my_email: true,
354            has_action_items: true,
355            is_vip_sender: true,
356            contact_importance_bias: 10.0, // intentionally huge
357            ..Default::default()
358        };
359        let (level, score) = calculate_importance(&s);
360        assert!((score - 1.0).abs() < f32::EPSILON);
361        assert_eq!(level, ImportanceLevel::Critical);
362    }
363
364    #[test]
365    fn signals_with_all_negatives_capped_at_minus_half() {
366        // every negative signal + max negative bias clamps to -0.5.
367        let s = ImportanceSignals {
368            is_bulk_sender: true,
369            is_mailing_list: true,
370            is_automated: true,
371            has_tracking_pixel: true,
372            is_template_heavy: true,
373            link_count: 100,
374            contact_importance_bias: -10.0,
375            ..Default::default()
376        };
377        let (level, score) = calculate_importance(&s);
378        assert!((score - (-0.5)).abs() < f32::EPSILON);
379        assert_eq!(level, ImportanceLevel::Noise);
380    }
381
382    #[test]
383    fn direct_recipient_alone_does_not_promote_to_important() {
384        // baseline 0.3 + 0.1 = 0.4 → still Normal.
385        let s = ImportanceSignals {
386            is_direct_recipient: true,
387            ..Default::default()
388        };
389        let (level, score) = calculate_importance(&s);
390        assert!((score - 0.4).abs() < 0.001);
391        assert_eq!(level, ImportanceLevel::Normal);
392    }
393
394    #[test]
395    fn signals_cloning_preserves_all_fields() {
396        let s = ImportanceSignals {
397            is_mutual_contact: true,
398            is_direct_recipient: true,
399            is_vip_sender: true,
400            text_to_html_ratio: 0.5,
401            link_count: 7,
402            contact_importance_bias: 0.25,
403            ..Default::default()
404        };
405        let c = s.clone();
406        assert_eq!(c.is_mutual_contact, s.is_mutual_contact);
407        assert_eq!(c.is_direct_recipient, s.is_direct_recipient);
408        assert_eq!(c.is_vip_sender, s.is_vip_sender);
409        assert!((c.text_to_html_ratio - s.text_to_html_ratio).abs() < f32::EPSILON);
410        assert_eq!(c.link_count, s.link_count);
411        assert!((c.contact_importance_bias - s.contact_importance_bias).abs() < f32::EPSILON);
412    }
413
414    #[test]
415    fn importance_level_copy_trait() {
416        // Ensure ImportanceLevel is Copy — important for caller ergonomics.
417        let l = ImportanceLevel::Critical;
418        let copy = l;
419        // both still usable
420        assert_eq!(l, copy);
421        assert_eq!(copy.as_str(), "critical");
422    }
423
424    #[test]
425    fn calculate_importance_returns_consistent_level_and_score() {
426        // For any signals, the returned level must be exactly from_score(returned_score).
427        let cases = [
428            ImportanceSignals::default(),
429            ImportanceSignals {
430                is_vip_sender: true,
431                ..Default::default()
432            },
433            ImportanceSignals {
434                is_bulk_sender: true,
435                contact_importance_bias: -0.1,
436                ..Default::default()
437            },
438        ];
439        for s in cases {
440            let (level, score) = calculate_importance(&s);
441            assert_eq!(
442                level,
443                ImportanceLevel::from_score(score),
444                "level and score must agree (score={score})",
445            );
446        }
447    }
448}