Skip to main content

sioul_learn/
consent.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Your consent to the filter learning from a Microsoft address's mail, as
5//! Microsoft's terms ask, address by address (docs/microsoft.md, "The spam
6//! filter").
7//!
8//! Under the window's Train now, a sentence names the addresses it learns
9//! from (`named`). Pressing it is your consent, for each, and the addresses
10//! named at that press are kept on this computer (`consent.toml` beside the
11//! corpus, never shared). From then on, every download and every training
12//! learns from a Microsoft address's mail only when that address is on the
13//! list, whoever starts them: the press itself, training again by itself,
14//! the command line, an AI agent's jobs. A Microsoft address added after the
15//! last press waits for the next one. A work or school Microsoft address is
16//! never learned from, named or not: its mail is its organisation's too.
17//! Other addresses are learned from as they always were.
18//!
19//! The corpus records, for each account, whether it is Microsoft's and its
20//! address when it is downloaded, and every configured Microsoft account's
21//! at the start of each run (`corpus.rs`), so that what was kept of an
22//! address no longer on the list is not read again, even once the account is
23//! gone. A folder that recorded nothing yet (kept before kinds were
24//! recorded) is read only when the configuration says its account is not
25//! Microsoft's (`MicrosoftIds`): a Microsoft one's counts as a work one's.
26
27use crate::{Dirs, LearnError, io_error, now};
28use serde::{Deserialize, Serialize};
29use sioul_core::config::{Account, Config};
30use std::collections::BTreeSet;
31
32/// The addresses named under Train now at its last press.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Consent {
35    /// When Train now was last pressed (Unix seconds); 0: never.
36    #[serde(default)]
37    pub pressed: i64,
38    /// The addresses it named then, lowercased.
39    #[serde(default)]
40    pub addresses: BTreeSet<String>,
41}
42
43impl Consent {
44    /// The consent kept on this computer; none (nothing consented) when
45    /// Train now was never pressed, or its file cannot be read.
46    pub fn load(dirs: &Dirs) -> Consent {
47        std::fs::read_to_string(dirs.consent()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
48    }
49
50    /// Train now pressed, these addresses named under it (`named`): kept in
51    /// place of the list before. Written beside, then moved: never half a list.
52    pub fn given(dirs: &Dirs, named: impl IntoIterator<Item = String>) -> Result<Consent, LearnError> {
53        let consent = Consent { pressed: now(), addresses: named.into_iter().map(|a| a.trim().to_lowercase()).filter(|a| !a.is_empty()).collect() };
54        let path = dirs.consent();
55        std::fs::create_dir_all(&dirs.data).map_err(|e| io_error(&dirs.data, e))?;
56        let text = toml::to_string(&consent).map_err(|e| io_error(&path, e))?;
57        let beside = path.with_extension("toml.new");
58        std::fs::write(&beside, text).map_err(|e| io_error(&beside, e))?;
59        std::fs::rename(&beside, &path).map_err(|e| io_error(&path, e))?;
60        Ok(consent)
61    }
62
63    /// Whether the filter may download and learn from an account's mail:
64    /// any account but Microsoft's; a personal Microsoft one only when its
65    /// address is on the list; a work or school one never.
66    pub fn allows(&self, account: &Account) -> bool {
67        if !account.is_microsoft() {
68            return true;
69        }
70        let kind = if account.is_microsoft_work() { "work" } else { "personal" };
71        self.allows_kept(Some(kind), account.address.as_deref())
72    }
73
74    /// The same for what the corpus keeps of an account, by what its
75    /// download recorded: `microsoft`, its kind ("personal", "work"; None
76    /// for any other account), and its address. Anything else than
77    /// "personal" counts as work: never read.
78    pub fn allows_kept(&self, microsoft: Option<&str>, address: Option<&str>) -> bool {
79        match microsoft {
80            None => true,
81            Some("personal") => address.is_some_and(|a| self.addresses.contains(&a.trim().to_lowercase())),
82            Some(_) => false,
83        }
84    }
85}
86
87/// The configuration's Microsoft accounts, by id, switched off or not: what
88/// reading the corpus needs to know of a folder whose download recorded
89/// nothing (kept before kinds were recorded): one of a Microsoft account
90/// counts as a work one's, never read, until a download, or the start of a
91/// run, records its kind (the re-check of 15 October 2026, M2-bis).
92#[derive(Debug, Clone, Default, PartialEq, Eq)]
93pub struct MicrosoftIds(BTreeSet<String>);
94
95impl MicrosoftIds {
96    pub fn of(config: &Config) -> MicrosoftIds {
97        MicrosoftIds(config.every_account().filter(|a| a.is_microsoft()).map(|a| a.id.clone()).collect())
98    }
99
100    pub fn contains(&self, id: &str) -> bool {
101        self.0.contains(id)
102    }
103}
104
105/// The addresses Train now learns from, as its sentence names them: every
106/// account that syncs, but a work or school Microsoft one.
107pub fn named(config: &Config) -> Vec<String> {
108    config.accounts.iter().filter(|a| a.syncs() && !(a.is_microsoft() && a.is_microsoft_work())).map(label).collect()
109}
110
111/// The work or school Microsoft addresses that sync: never learned from.
112pub fn work(config: &Config) -> Vec<String> {
113    config.accounts.iter().filter(|a| a.syncs() && a.is_microsoft() && a.is_microsoft_work()).map(label).collect()
114}
115
116/// An account's address, or its id when it has none.
117fn label(account: &Account) -> String {
118    account.address.clone().unwrap_or_else(|| account.id.clone())
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use sioul_core::config::Security;
125
126    fn microsoft(id: &str, address: &str, kind: Option<&str>) -> Account {
127        let mut account = Account::imap(id, address, "outlook.office365.com", 993, Security::Tls, None);
128        account.auth = Some("microsoft".into());
129        account.microsoft_kind = kind.map(str::to_string);
130        account
131    }
132
133    /// The review of 15 October 2026, M2: a Microsoft address is learned
134    /// from only once named at a press of Train now; a work one never; any
135    /// other address as before; the list kept, replaced at each press.
136    #[test]
137    fn a_microsoft_address_is_learned_from_once_named() {
138        let root = std::env::temp_dir().join(format!("sioul-learn-consent-{}", std::process::id()));
139        let _ = std::fs::remove_dir_all(&root);
140        let dirs = Dirs::under(&root);
141        let plain = Account::imap("home", "me@example.org", "imap.example.org", 993, Security::Tls, None);
142        let personal = microsoft("live", "Me@Outlook.example", Some("personal"));
143        let work = microsoft("office", "me@contoso.example", Some("work"));
144        let config = Config { accounts: vec![plain.clone(), personal.clone(), work.clone()], ..Config::default() };
145        // Never pressed: nothing consented; Microsoft's personal address waits, the work one is never named.
146        let none = Consent::load(&dirs);
147        assert_eq!(none, Consent::default());
148        assert!(none.allows(&plain) && !none.allows(&personal) && !none.allows(&work));
149        assert_eq!(named(&config), ["me@example.org", "Me@Outlook.example"]);
150        assert_eq!(super::work(&config), ["me@contoso.example"]);
151        // Pressed with both named (and the work address slipped in): the personal one allowed, the work one still not.
152        let given = Consent::given(&dirs, named(&config).into_iter().chain(["me@contoso.example".to_string()])).unwrap();
153        assert_eq!(Consent::load(&dirs), given);
154        assert!(given.allows(&personal) && given.allows(&plain) && !given.allows(&work));
155        // What the corpus recorded: read only while consented.
156        assert!(given.allows_kept(Some("personal"), Some("me@outlook.example")) && given.allows_kept(None, None));
157        assert!(!given.allows_kept(Some("work"), Some("me@contoso.example")) && !given.allows_kept(Some("personal"), Some("other@outlook.example")) && !given.allows_kept(Some("personal"), None));
158        // Pressed again without it (its account gone): no longer allowed, even what was kept of it.
159        let again = Consent::given(&dirs, ["me@example.org".to_string()]).unwrap();
160        assert!(!again.allows(&personal) && !again.allows_kept(Some("personal"), Some("me@outlook.example")));
161        // A Microsoft account without its kind recorded counts as work.
162        let unknown = microsoft("old", "me@outlook.example", None);
163        assert!(!Consent::given(&dirs, ["me@outlook.example".to_string()]).unwrap().allows(&unknown));
164        // A file that cannot be read: nothing consented.
165        std::fs::write(dirs.consent(), "addresses = 3").unwrap();
166        assert_eq!(Consent::load(&dirs), Consent::default());
167        std::fs::remove_dir_all(&root).unwrap();
168    }
169}