1pub mod features;
18pub mod labels;
19pub mod table;
20pub mod tokenize;
21
22use crate::card::Card;
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::path::PathBuf;
26use std::sync::{Arc, Mutex};
27use table::Table;
28
29pub const THRESHOLD_SPAM: f32 = 0.95;
31pub const THRESHOLD_UNSURE: f32 = 0.5;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum Class {
39 Spam,
40 Unsure,
41 Ham,
42}
43
44impl Class {
45 pub const ALL: [Class; 3] = [Class::Spam, Class::Unsure, Class::Ham];
47
48 pub fn as_str(self) -> &'static str {
50 match self {
51 Class::Spam => "spam",
52 Class::Unsure => "unsure",
53 Class::Ham => "ham",
54 }
55 }
56
57 pub fn read(text: &str) -> Option<Class> {
58 Class::ALL.into_iter().find(|c| c.as_str() == text.trim())
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub enum Action {
66 Move,
70 Flag,
72 Nothing,
74}
75
76impl Action {
77 pub const ALL: [Action; 3] = [Action::Move, Action::Flag, Action::Nothing];
78
79 pub fn as_str(self) -> &'static str {
81 match self {
82 Action::Move => "move",
83 Action::Flag => "flag",
84 Action::Nothing => "nothing",
85 }
86 }
87
88 pub fn read(text: &str) -> Option<Action> {
89 Action::ALL.into_iter().find(|a| a.as_str() == text.trim())
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub struct Actions {
97 pub spam: Action,
98 pub unsure: Action,
99 pub ham: Action,
100}
101
102impl Default for Actions {
103 fn default() -> Actions {
105 Actions { spam: Action::Flag, unsure: Action::Flag, ham: Action::Nothing }
106 }
107}
108
109impl Actions {
110 pub fn of_mode(mode: &str) -> Option<Actions> {
113 let (spam, unsure) = match mode.trim() {
114 "off" => (Action::Nothing, Action::Nothing),
115 "say" => (Action::Flag, Action::Flag),
116 "act" => (Action::Move, Action::Flag),
117 _ => return None,
118 };
119 Some(Actions { spam, unsure, ham: Action::Nothing })
120 }
121
122 pub fn of(&self, class: Class) -> Action {
124 match class {
125 Class::Spam => self.spam,
126 Class::Unsure => self.unsure,
127 Class::Ham => self.ham,
128 }
129 }
130
131 pub fn with(mut self, class: Class, action: Action) -> Actions {
133 match class {
134 Class::Spam => self.spam = action,
135 Class::Unsure => self.unsure = action,
136 Class::Ham => self.ham = action,
137 }
138 self
139 }
140
141 pub fn idle(&self) -> bool {
143 Class::ALL.iter().all(|c| self.of(*c) == Action::Nothing)
144 }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq)]
151pub struct Verdict {
152 pub p: f32,
153}
154
155pub fn table_path() -> PathBuf {
157 crate::config::data_dir().join("spam").join("table.bin")
158}
159
160#[derive(Debug, Clone, PartialEq)]
162pub struct Filter {
163 pub actions: Actions,
165 pub threshold_spam: f32,
167 pub threshold_unsure: f32,
168 pub table: PathBuf,
170}
171
172impl Filter {
173 pub fn of(config: &crate::config::Config) -> Filter {
175 let (threshold_spam, threshold_unsure) = config.spam.thresholds();
176 Filter { actions: config.spam.actions(), threshold_spam, threshold_unsure, table: table_path() }
177 }
178
179 pub fn class(&self, p: f32) -> Class {
181 if p >= self.threshold_spam {
182 Class::Spam
183 } else if p >= self.threshold_unsure {
184 Class::Unsure
185 } else {
186 Class::Ham
187 }
188 }
189
190 pub fn action(&self, verdict: Verdict) -> Action {
192 self.actions.of(self.class(verdict.p))
193 }
194
195 pub fn judge(&self, card: &Card, trusted_ids: &[String]) -> Option<Verdict> {
200 if self.actions.idle() {
201 return None;
202 }
203 let table = Table::cached(&self.table)?;
204 let key = card.path.as_ref().map(|p| p.to_string_lossy().into_owned());
205 if let Some(verdict) = key.as_deref().and_then(|key| remembered(&table, key)) {
206 return Some(verdict);
207 }
208 let verdict = score(&table, card, trusted_ids);
209 if let Some(key) = key {
210 remember(&table, key, verdict);
211 }
212 Some(verdict)
213 }
214}
215
216pub fn score(table: &Table, card: &Card, trusted_ids: &[String]) -> Verdict {
224 let tokens = tokenize::tokens_with(&table.lexicon(), &card.subject, &card.excerpt);
225 let received = card
226 .path
227 .as_deref()
228 .and_then(|p| std::fs::metadata(p).ok()?.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok())
229 .and_then(|d| i64::try_from(d.as_secs()).ok());
230 let auth = features::auth_results(&card.headers, trusted_ids);
231 let x = features::features(card, auth.as_ref(), received, &tokens.links);
232 Verdict { p: table.score(&tokens.words, &x).p }
233}
234
235type Remembered = Option<(Arc<Table>, HashMap<String, Verdict>)>;
237static REMEMBERED: Mutex<Remembered> = Mutex::new(None);
238
239const REMEMBERED_MOST: usize = 20_000;
241
242fn remembered(table: &Arc<Table>, key: &str) -> Option<Verdict> {
243 let kept = REMEMBERED.lock().unwrap_or_else(|e| e.into_inner());
244 kept.as_ref().filter(|(t, _)| Arc::ptr_eq(t, table)).and_then(|(_, verdicts)| verdicts.get(key).copied())
245}
246
247fn remember(table: &Arc<Table>, key: String, verdict: Verdict) {
248 let mut kept = REMEMBERED.lock().unwrap_or_else(|e| e.into_inner());
249 if !kept.as_ref().is_some_and(|(t, verdicts)| Arc::ptr_eq(t, table) && verdicts.len() < REMEMBERED_MOST) {
250 *kept = Some((Arc::clone(table), HashMap::new()));
251 }
252 if let Some((_, verdicts)) = kept.as_mut() {
253 verdicts.insert(key, verdict);
254 }
255}