1use crate::corpus::{HEADER_BYTES, HTML_BYTES, Leaf, Node, Text};
48use crate::labels::Label;
49use crate::{Dirs, LearnError, io_error, now};
50use serde::{Deserialize, Serialize};
51use std::collections::{BTreeMap, HashSet};
52use std::io::{BufRead, BufReader, Write};
53use std::path::{Path, PathBuf};
54
55const TEXT_CHARS: usize = 6000;
57const SUBJECT_CHARS: usize = 1000;
59const UNNAMED: &str = "outside";
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Message {
66 pub label: Label,
67 pub date: i64,
69 pub subject: String,
70 pub text: String,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub html: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub from: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub headers: Option<String>,
77}
78
79impl Message {
80 pub fn message_id(&self) -> Option<String> {
82 crate::labels::message_id(self.headers.as_deref()?.as_bytes())
83 }
84
85 pub fn words(&self, lexicon: &crate::spamcore::Lexicon) -> Vec<String> {
89 let excerpt = self.rebuilt(b"").map(|card| card.excerpt).unwrap_or_else(|| self.text.clone());
90 crate::spamcore::tokens_with(lexicon, &self.subject, &excerpt).words.into_iter().filter(|w| !w.is_empty() && !w.contains(char::is_whitespace)).collect()
91 }
92
93 pub fn card(&self) -> Option<sioul_core::card::Card> {
97 let line = |text: &str| text.replace(['\r', '\n'], " ");
98 let mut header = String::new();
99 if let Some(from) = self.from.as_deref() {
100 header.push_str(&format!("From: {}\r\n", line(from)));
101 }
102 header.push_str(&format!("Subject: {}\r\n", line(&self.subject)));
103 if let Some(id) = self.message_id() {
104 header.push_str(&format!("Message-ID: <{}>\r\n", line(&id)));
105 }
106 self.rebuilt(header.as_bytes())
107 }
108
109 fn rebuilt(&self, header: &[u8]) -> Option<sioul_core::card::Card> {
111 let plain = (!self.text.is_empty()).then(|| Text { at: vec![1], text: self.text.clone() });
112 let html = self.html.as_ref().filter(|h| !h.is_empty()).map(|h| Text { at: vec![if plain.is_some() { 2 } else { 1 }], text: h.clone() });
113 let leaf = |mime: &str| Node::Leaf(Leaf { mime: mime.into(), charset: Some("utf-8".into()), ..Leaf::default() });
114 let structure = match (&plain, &html) {
115 (Some(_), Some(_)) => Node::Multipart { subtype: "alternative".into(), parts: vec![leaf("text/plain"), leaf("text/html")] },
116 (None, Some(_)) => leaf("text/html"),
117 _ => leaf("text/plain"),
118 };
119 let raw = crate::spamcore::rebuild(header, Some(&structure), plain.as_ref(), html.as_ref());
120 sioul_core::card::Card::from_bytes(&raw)
121 }
122}
123
124#[derive(Debug, Deserialize)]
126struct Line {
127 label: Option<String>,
128 date: Option<serde_json::Value>,
129 subject: Option<String>,
130 text: Option<String>,
131 #[serde(default)]
132 html: Option<String>,
133 #[serde(default)]
134 from: Option<String>,
135 #[serde(default)]
136 headers: Option<String>,
137 #[serde(default)]
138 source: Option<String>,
139}
140
141pub const REFUSED: [&str; 6] = ["not-json", "label", "date", "fields", "empty", "copy"];
143
144#[derive(Debug, Clone, Default, PartialEq, Eq)]
146pub struct Imported {
147 pub lines: u64,
149 pub sources: BTreeMap<String, Counts>,
151 pub refused: BTreeMap<&'static str, u64>,
153}
154
155#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub struct Counts {
158 pub ham: u64,
159 pub spam: u64,
160 pub first: i64,
163 pub last: i64,
164 #[serde(default)]
166 pub imported: i64,
167}
168
169impl Counts {
170 fn add(&mut self, message: &Message) {
171 match message.label {
172 Label::Ham => self.ham += 1,
173 Label::Spam => self.spam += 1,
174 }
175 if message.date > 0 {
177 self.first = if self.first > 0 { self.first.min(message.date) } else { message.date };
178 self.last = self.last.max(message.date);
179 }
180 }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct Source {
186 pub name: String,
187 pub counts: Counts,
188 pub bytes: u64,
190}
191
192pub fn source_name(text: &str) -> String {
194 let name = sioul_core::config::slug(text);
195 if name.is_empty() { UNNAMED.to_string() } else { name }
196}
197
198fn file_of(dirs: &Dirs, source: &str) -> PathBuf {
199 dirs.external().join(format!("{source}.jsonl.gz"))
200}
201
202fn counts_of(dirs: &Dirs, source: &str) -> PathBuf {
203 dirs.external().join(format!("{source}.toml"))
204}
205
206fn date_of(value: &serde_json::Value) -> Option<i64> {
208 match value {
209 serde_json::Value::Number(n) => n.as_i64().or_else(|| n.as_f64().filter(|f| f.is_finite()).map(|f| f as i64)),
210 serde_json::Value::String(text) => {
211 let text = text.trim();
212 text.parse::<i64>().ok().or_else(|| text.parse::<jiff::Timestamp>().ok().map(|t| t.as_second()))
213 }
214 _ => None,
215 }
216}
217
218fn cut_chars(text: &str, chars: usize) -> String {
220 text.chars().take(chars).collect()
221}
222
223fn cut_bytes(text: &str, bytes: usize) -> String {
225 let mut end = text.len().min(bytes);
226 while !text.is_char_boundary(end) {
227 end -= 1;
228 }
229 text[..end].to_string()
230}
231
232fn check(line: &str, default_source: &str) -> Result<(String, Message), &'static str> {
234 let line: Line = serde_json::from_str(line).map_err(|_| "not-json")?;
235 let label = match line.label.as_deref().map(|l| l.trim().to_ascii_lowercase()).as_deref() {
236 Some("spam") => Label::Spam,
237 Some("ham") => Label::Ham,
238 _ => return Err("label"),
239 };
240 let date = line.date.as_ref().and_then(date_of).ok_or("date")?;
241 let (Some(subject), Some(text)) = (line.subject, line.text) else { return Err("fields") };
242 let html = line.html.filter(|h| !h.trim().is_empty());
243 if subject.trim().is_empty() && text.trim().is_empty() && html.is_none() {
244 return Err("empty");
245 }
246 let source = line.source.as_deref().map(source_name).unwrap_or_else(|| default_source.to_string());
247 Ok((
248 source,
249 Message {
250 label,
251 date,
252 subject: cut_chars(&subject, SUBJECT_CHARS),
253 text: cut_chars(&text, TEXT_CHARS),
254 html: html.map(|h| cut_bytes(&h, HTML_BYTES as usize)),
255 from: line.from.filter(|f| !f.trim().is_empty()),
256 headers: line.headers.filter(|h| !h.trim().is_empty()).map(|h| cut_bytes(&h, HEADER_BYTES as usize)),
257 },
258 ))
259}
260
261fn key_of(message: &Message) -> u64 {
263 match message.message_id() {
264 Some(id) => crate::labels::fnv64(id.as_bytes()),
265 None => crate::labels::fnv64(format!("{}\u{1f}{}\u{1f}{}", message.date, message.subject, message.text).as_bytes()),
266 }
267}
268
269pub fn import(dirs: &Dirs, path: &Path, source: Option<&str>) -> Result<Imported, LearnError> {
273 let file = std::fs::File::open(path).map_err(|e| io_error(path, e))?;
274 let mut reader = BufReader::new(file);
275 let gzipped = reader.fill_buf().map_err(|e| io_error(path, e))?.starts_with(&[0x1f, 0x8b]);
276 let stem = path.file_name().and_then(|n| n.to_str()).map(|n| n.split('.').next().unwrap_or(n)).unwrap_or(UNNAMED);
277 let default_source = source_name(source.unwrap_or(stem));
278 if gzipped {
279 import_from(dirs, BufReader::new(flate2::read::MultiGzDecoder::new(reader)), &default_source)
280 } else {
281 import_from(dirs, reader, &default_source)
282 }
283}
284
285struct Writing {
287 temporary: PathBuf,
288 out: flate2::write::GzEncoder<std::fs::File>,
289 counts: Counts,
290 seen: HashSet<u64>,
291}
292
293pub fn import_from(dirs: &Dirs, reader: impl BufRead, default_source: &str) -> Result<Imported, LearnError> {
295 let folder = dirs.external();
296 make_private(&folder)?;
297 let mut imported = Imported::default();
298 let mut writing: BTreeMap<String, Writing> = BTreeMap::new();
299 for line in reader.split(b'\n') {
300 let line = line.map_err(|e| io_error(&folder, e))?;
301 let line = String::from_utf8_lossy(&line);
302 if line.trim().is_empty() {
303 continue;
304 }
305 imported.lines += 1;
306 let (source, message) = match check(&line, default_source) {
307 Ok(checked) => checked,
308 Err(why) => {
309 *imported.refused.entry(why).or_default() += 1;
310 continue;
311 }
312 };
313 if !writing.contains_key(&source) {
314 let temporary = folder.join(format!(".{source}.jsonl.gz.{}", std::process::id()));
315 let file = private_file(&temporary)?;
316 writing.insert(source.clone(), Writing { temporary, out: flate2::write::GzEncoder::new(file, flate2::Compression::default()), counts: Counts::default(), seen: HashSet::new() });
317 }
318 let Some(w) = writing.get_mut(&source) else { continue };
319 if !w.seen.insert(key_of(&message)) {
320 *imported.refused.entry("copy").or_default() += 1;
321 continue;
322 }
323 let mut text = serde_json::to_vec(&message).map_err(|e| io_error(&w.temporary, e))?;
324 text.push(b'\n');
325 w.out.write_all(&text).map_err(|e| io_error(&w.temporary, e))?;
326 w.counts.add(&message);
327 }
328 for (source, w) in writing {
329 let Writing { temporary, out, mut counts, .. } = w;
330 let file = out.finish().map_err(|e| io_error(&temporary, e))?;
331 file.sync_all().map_err(|e| io_error(&temporary, e))?;
332 drop(file);
333 counts.imported = now();
334 std::fs::rename(&temporary, file_of(dirs, &source)).map_err(|e| io_error(&temporary, e))?;
335 let text = toml::to_string(&counts).map_err(|e| io_error(&folder, e))?;
337 let beside = folder.join(format!(".{source}.toml.{}", std::process::id()));
338 private_file(&beside)?.write_all(text.as_bytes()).map_err(|e| io_error(&beside, e))?;
339 std::fs::rename(&beside, counts_of(dirs, &source)).map_err(|e| io_error(&beside, e))?;
340 imported.sources.insert(source, counts);
341 }
342 Ok(imported)
343}
344
345fn make_private(folder: &Path) -> Result<(), LearnError> {
347 let mut builder = std::fs::DirBuilder::new();
348 builder.recursive(true);
349 #[cfg(unix)]
350 std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
351 builder.create(folder).map_err(|e| io_error(folder, e))
352}
353
354fn private_file(path: &Path) -> Result<std::fs::File, LearnError> {
356 let mut options = std::fs::OpenOptions::new();
357 options.write(true).create(true).truncate(true);
358 #[cfg(unix)]
359 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
360 options.open(path).map_err(|e| io_error(path, e))
361}
362
363pub fn remove(dirs: &Dirs, source: &str) -> Result<bool, LearnError> {
365 let source = source_name(source);
366 let file = file_of(dirs, &source);
367 let found = file.exists();
368 for path in [file, counts_of(dirs, &source)] {
369 match std::fs::remove_file(&path) {
370 Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(io_error(&path, e)),
371 _ => {}
372 }
373 }
374 Ok(found)
375}
376
377pub fn sources(dirs: &Dirs) -> Vec<Source> {
379 let mut found: Vec<Source> = std::fs::read_dir(dirs.external())
380 .into_iter()
381 .flatten()
382 .filter_map(Result::ok)
383 .filter_map(|entry| {
384 let name = entry.file_name().to_str()?.strip_suffix(".jsonl.gz")?.to_string();
385 (!name.starts_with('.')).then_some(name)
386 })
387 .map(|name| {
388 let counts = std::fs::read_to_string(counts_of(dirs, &name)).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
389 let bytes = std::fs::metadata(file_of(dirs, &name)).map_or(0, |m| m.len());
390 Source { name, counts, bytes }
391 })
392 .collect();
393 found.sort_by(|a, b| a.name.cmp(&b.name));
394 found
395}
396
397pub fn read_all(dirs: &Dirs, mut each: impl FnMut(&str, Message)) -> Result<(), LearnError> {
399 for source in sources(dirs) {
400 let path = file_of(dirs, &source.name);
401 let file = std::fs::File::open(&path).map_err(|e| io_error(&path, e))?;
402 let reader = BufReader::new(flate2::read::MultiGzDecoder::new(BufReader::new(file)));
403 for line in reader.split(b'\n') {
404 let Ok(line) = line else { break };
405 if let Ok(message) = serde_json::from_slice::<Message>(&line) {
406 each(&source.name, message);
407 }
408 }
409 }
410 Ok(())
411}
412
413#[cfg(test)]
415fn read_source(dirs: &Dirs, source: &str) -> Vec<Message> {
416 use std::io::Read;
417 let mut text = String::new();
418 let file = std::fs::File::open(file_of(dirs, source)).unwrap();
419 flate2::read::MultiGzDecoder::new(file).read_to_string(&mut text).unwrap();
420 text.lines().map(|l| serde_json::from_str(l).unwrap()).collect()
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 fn scratch(name: &str) -> PathBuf {
428 let dir = std::env::temp_dir().join(format!("sioul-learn-external-{name}-{}", std::process::id()));
429 let _ = std::fs::remove_dir_all(&dir);
430 std::fs::create_dir_all(&dir).unwrap();
431 dir
432 }
433
434 #[test]
436 fn lines_are_checked_and_kept_apart() {
437 let root = scratch("lines");
438 let dirs = Dirs::under(&root);
439 let file = root.join("old-filter.jsonl");
440 let long = "word ".repeat(3000);
441 let lines = [
442 r#"{"label":"spam","date":"2023-06-20T16:23:00+02:00","subject":"You won","text":"Claim your prize","source":"Old Filter"}"#.to_string(),
443 r#"{"label":"HAM","date":1687271000,"subject":"Lunch","text":"Tomorrow?","from":"Jane <jane@example.org>"}"#.to_string(),
444 r#"{"label":"ham","date":"1687271060","subject":"Notes","text":"","html":"<p>The notes</p>","headers":"Message-ID: <n1@example.org>\r\nFrom: a@example.org\r\n"}"#.to_string(),
445 format!(r#"{{"label":"ham","date":1687271120,"subject":"Long","text":"{long}"}}"#),
446 r#"{"label":"maybe","date":1,"subject":"x","text":"y"}"#.to_string(),
447 r#"{"label":"spam","date":"yesterday","subject":"x","text":"y"}"#.to_string(),
448 r#"{"label":"spam","date":1,"text":"no subject field"}"#.to_string(),
449 r#"{"label":"spam","date":1,"subject":" ","text":""}"#.to_string(),
450 "not json".to_string(),
451 r#"{"label":"ham","date":1687271000,"subject":"Lunch","text":"Tomorrow?"}"#.to_string(),
452 String::new(),
453 ];
454 std::fs::write(&file, lines.join("\n")).unwrap();
455 let imported = import(&dirs, &file, None).unwrap();
456 assert_eq!(imported.lines, 10);
457 assert_eq!(imported.refused, BTreeMap::from([("label", 1), ("date", 1), ("fields", 1), ("empty", 1), ("not-json", 1), ("copy", 1)]));
458 assert_eq!(imported.sources.keys().collect::<Vec<_>>(), ["old-filter"], "a line's own name, made safe; the file's name else");
459 let counts = imported.sources["old-filter"];
460 assert_eq!((counts.ham, counts.spam, counts.first, counts.last), (3, 1, 1_687_270_980, 1_687_271_120));
461 let kept = read_source(&dirs, "old-filter");
462 assert_eq!(kept[0].date, 1_687_270_980);
463 assert_eq!(kept[3].text.chars().count(), TEXT_CHARS, "cut to what the Porch reads");
464 assert_eq!(kept[2].message_id().as_deref(), Some("n1@example.org"));
465 let words = kept[2].words(&crate::spamcore::Lexicon::builtin());
467 assert!(words.iter().any(|w| w.starts_with("note")), "{words:?}");
468 assert_eq!(sources(&dirs).iter().map(|s| (s.name.as_str(), s.counts.ham, s.counts.spam)).collect::<Vec<_>>(), [("old-filter", 3, 1)]);
469 #[cfg(unix)]
470 {
471 use std::os::unix::fs::PermissionsExt;
472 assert_eq!(std::fs::metadata(dirs.external()).unwrap().permissions().mode() & 0o777, 0o700, "yours alone");
473 assert_eq!(std::fs::metadata(file_of(&dirs, "old-filter")).unwrap().permissions().mode() & 0o777, 0o600);
474 assert_eq!(std::fs::metadata(counts_of(&dirs, "old-filter")).unwrap().permissions().mode() & 0o777, 0o600);
475 }
476 let gz = root.join("again.jsonl.gz");
478 let mut encoder = flate2::write::GzEncoder::new(std::fs::File::create(&gz).unwrap(), flate2::Compression::default());
479 encoder.write_all(lines[1].as_bytes()).unwrap();
480 encoder.finish().unwrap();
481 assert_eq!(import(&dirs, &gz, Some("old-filter")).unwrap().sources["old-filter"].ham, 1);
482 assert_eq!(read_source(&dirs, "old-filter").len(), 1, "replaced");
483 let mut all = Vec::new();
484 read_all(&dirs, |source, m| all.push((source.to_string(), m.label))).unwrap();
485 assert_eq!(all, [("old-filter".to_string(), Label::Ham)]);
486 assert!(remove(&dirs, "Old Filter").unwrap());
488 assert!(sources(&dirs).is_empty() && !remove(&dirs, "old-filter").unwrap());
489 assert!(std::fs::read_dir(dirs.external()).unwrap().next().is_none());
490 let _ = std::fs::remove_dir_all(root);
491 }
492
493 #[test]
496 fn undated_messages_leave_the_dates_alone() {
497 let root = scratch("undated");
498 let dirs = Dirs::under(&root);
499 let file = root.join("export.jsonl");
500 let lines = [
501 r#"{"label":"ham","date":0,"subject":"No date","text":"Kept all the same"}"#,
502 r#"{"label":"spam","date":1687271000,"subject":"You won","text":"Claim it"}"#,
503 r#"{"label":"ham","date":"0","subject":"No date either","text":"Kept too"}"#,
504 r#"{"label":"ham","date":1687270000,"subject":"Lunch","text":"Tomorrow?"}"#,
505 ];
506 std::fs::write(&file, lines.join("\n")).unwrap();
507 let counts = import(&dirs, &file, None).unwrap().sources["export"];
508 assert_eq!((counts.ham, counts.spam, counts.first, counts.last), (3, 1, 1_687_270_000, 1_687_271_000));
509 assert_eq!(sources(&dirs)[0].counts.first, 1_687_270_000, "as `sioul spam status` reads it");
510 std::fs::write(&file, lines[0]).unwrap();
512 let counts = import(&dirs, &file, None).unwrap().sources["export"];
513 assert_eq!((counts.ham, counts.first, counts.last), (1, 0, 0));
514 let _ = std::fs::remove_dir_all(root);
515 }
516}