Skip to main content

sioul_learn/
external.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Outside training material: mail labelled elsewhere (an older filter's
5//! dump, another mail client's archive, a public corpus), imported once to
6//! give the training more to learn from, and a baseline to measure it
7//! against (docs/spam-filter.md, "Outside material"). `sioul spam import
8//! <file>` reads it; `sioul spam import --remove <source>` takes it away.
9//!
10//! The format: JSON lines, one message each, a plain file or gzipped:
11//!
12//! ```text
13//! {"label":"spam","date":"2023-06-20T16:23:00+02:00","subject":"You won","text":"Claim your prize…","source":"old-filter"}
14//! {"label":"ham","date":1687271000,"subject":"Lunch","text":"Tomorrow?","from":"Jane <jane@example.org>"}
15//! ```
16//!
17//! - `label`: "spam" or "ham" (required);
18//! - `date`: when it arrived, RFC 3339 or Unix seconds (required): the
19//!   training's split is by time;
20//! - `subject`, `text`: its subject and its text part (required; one of them,
21//!   or `html`, not empty);
22//! - `html`: its HTML, read as text as the Porch reads it when there is no
23//!   text, or only a stand-in ("view it in your browser");
24//! - `from`: its sender; `headers`: its raw header block, of which only the
25//!   Message-ID is read now (a message also in your own mail is learned once,
26//!   from yours); both are kept for a later version;
27//! - `source`: a name for where it comes from; else the one given to the
28//!   import, else the file's name.
29//!
30//! Kept apart from your own corpus, never shared; an AI agent sees of it
31//! only the sender and the subject, masked, of a test's worst errors, when
32//! you let it use the spam filter's tools (`[mcp] spam`, docs/mcp.md):
33//! `$XDG_DATA_HOME/sioul/spam/external/<source>.jsonl.gz`, each
34//! message checked and cut to what the Porch reads (6 000 characters of
35//! text, 64 KB of HTML, 32 KB of headers), with `<source>.toml` beside it
36//! (counts only). An import replaces what was imported before under the same
37//! name.
38//!
39//! In training (`train::train`): its words join the language model's
40//! corpus, and its messages the classifier's learning set with their header
41//! features at the training's mean (0 once standardized): they pull no
42//! header weight, only the words learn from them. Each source is split in
43//! time as your own mail is (its oldest 80 % learned from, its newest 20 %
44//! held out); the held-out part gives "the baseline", the same numbers as
45//! for your own mail, said apart.
46
47use 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
55/// The text kept of a message, in characters: what the Porch reads.
56const TEXT_CHARS: usize = 6000;
57/// The subject kept, in characters.
58const SUBJECT_CHARS: usize = 1000;
59/// A source's name when none is given anywhere.
60const UNNAMED: &str = "outside";
61
62/// One message as imported: checked, its date in Unix seconds, its texts cut
63/// to what the Porch reads. Its source is its file's name.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Message {
66    pub label: Label,
67    /// When it arrived, Unix seconds.
68    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    /// Its Message-ID, bare, from its headers when it came with them.
81    pub fn message_id(&self) -> Option<String> {
82        crate::labels::message_id(self.headers.as_deref()?.as_bytes())
83    }
84
85    /// Its words, as the Porch reads a message: the subject, the text, the
86    /// HTML read as text when there is no text or only a stand-in; read with
87    /// `lexicon` (a training's, or the table's own).
88    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    /// The message as the Porch would read it: its sender, its subject, its
94    /// Message-ID and the start of its text, so that a test's worst errors
95    /// can say which message each is (`detail`). Each field on one line.
96    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    /// The message made again from its texts, as a corpus record is (`spamcore::rebuild`), under `header`.
110    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/// One line as written by whoever made the file, before it is checked.
125#[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
141/// Why a line was left out: said by these words, counted.
142pub const REFUSED: [&str; 6] = ["not-json", "label", "date", "fields", "empty", "copy"];
143
144/// What one import did: counts only, never a message.
145#[derive(Debug, Clone, Default, PartialEq, Eq)]
146pub struct Imported {
147    /// Lines read.
148    pub lines: u64,
149    /// Each source written, with what it now holds.
150    pub sources: BTreeMap<String, Counts>,
151    /// Lines left out, by why (`REFUSED`).
152    pub refused: BTreeMap<&'static str, u64>,
153}
154
155/// What a source holds: counts and dates only.
156#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub struct Counts {
158    pub ham: u64,
159    pub spam: u64,
160    /// Its oldest and newest message's dates (Unix seconds), of those dated
161    /// after 1970: a date of 0 is an export's "no date"; 0 when none is.
162    pub first: i64,
163    pub last: i64,
164    /// When it was imported (Unix seconds).
165    #[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        // A message without a real date (0, or before 1970) says nothing of the source's dates.
176        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/// A source as `sioul spam status` and the settings say it.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct Source {
186    pub name: String,
187    pub counts: Counts,
188    /// The bytes its file takes.
189    pub bytes: u64,
190}
191
192/// A source's name, safe as a file's: lowercase letters, digits and dashes.
193pub 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
206/// A date as the format allows it: Unix seconds (a number, or digits), or RFC 3339.
207fn 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
218/// The first `chars` characters of a text.
219fn cut_chars(text: &str, chars: usize) -> String {
220    text.chars().take(chars).collect()
221}
222
223/// The first `bytes` bytes of a text, cut on a character's edge.
224fn 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
232/// A line checked: its source's name and the message; or why it is left out.
233fn 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
261/// What tells one message from another within a source: its Message-ID, else its date, subject and text.
262fn 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
269/// Imports a file of outside material (JSON lines, gzipped or not): each
270/// source it names replaces what was kept before under that name. A line's
271/// own `source` first, else `source`, else the file's name.
272pub 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
285/// A source being written: its file beside its place, its counts, what it holds already.
286struct Writing {
287    temporary: PathBuf,
288    out: flate2::write::GzEncoder<std::fs::File>,
289    counts: Counts,
290    seen: HashSet<u64>,
291}
292
293/// Imports lines from `reader` (see `import`).
294pub 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        // Its counts beside it, written whole, yours alone as it is.
336        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
345/// The folder made, yours alone (Unix).
346fn 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
354/// A new file, yours alone (Unix).
355fn 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
363/// Takes a source away: its file and its counts. False when there was none.
364pub 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
377/// The sources kept, by name: what each holds, from the counts written at its import.
378pub 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
397/// Every message of every source, source after source (by name), in the order imported.
398pub 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/// The lines of a file of outside material, read whole (tests).
414#[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    /// The format as docs/spam-filter.md says it: what is kept, what is left out and why.
435    #[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        // Its words, as the Porch reads them: the HTML read as text when there is no text.
466        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        // Imported again, gzipped, under another name given: a source of its own; the first one replaced when named alike.
477        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        // Removed: nothing left of it.
487        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    /// A source's dates are its dated messages' only: an export that writes 0
494    /// for "no date" leaves its first date as the oldest real one, never 1970.
495    #[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        // None dated: no dates, said as unknown.
511        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}