Skip to main content

sioul_core/
mailnote.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! New mail as a notification, at the times it may come (docs/porch.md,
5//! "Notifications"). The Porch keeps mail until you look; what your lists let
6//! through now is also told, once per batch, never once per message: the
7//! count in words and the first senders with their subjects. Batched
8//! notifications helped attention and mood where none at all made people more
9//! anxious (Fitz et al. 2019): what helps is predictability, not silence.
10//!
11//! Mail that comes while it may not be told (its sender's row of the matrix
12//! of what reaches you says later or shown, not told, now: as usual, sleep,
13//! a pause, Free time, a slot of time for you; `attention`) waits; when a
14//! time begins in which some of it may come, one notification says the
15//! Porch opens. A row that says "not at all" now: never told, on the Porch only.
16//!
17//! Never told: what is set aside (forged, spam, a borrowed name, blocked),
18//! what your own spam filter flagged or moved (the review queue: it waits
19//! for your word, on every device), hostile mail, codes (they have their own
20//! notification), what you send yourself, the less important accounts,
21//! newsletters unless asked, and mail read elsewhere before it came here. Each message once, by its account and
22//! Message-ID, told or waiting (`Ledger`), on this device.
23
24use crate::i18n::Translator;
25use crate::porch::{Lane, Reason, Triaged};
26use serde::{Deserialize, Serialize};
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::{Path, PathBuf};
29
30/// How many letters a notification names: the count says the rest.
31pub const NAMED: usize = 3;
32
33/// A message's mark is kept this long: the Porch shows its last two weeks.
34const KEPT: i64 = 30 * 86_400;
35
36/// A letter as a notification names it.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Letter {
39    /// Its account and Message-ID, hashed (`key`).
40    pub key: String,
41    pub sender: String,
42    pub subject: String,
43    /// From someone Always through, told through the system's do-not-disturb
44    /// (critical urgency on a computer).
45    pub through: bool,
46}
47
48impl Letter {
49    pub fn of(t: &Triaged) -> Letter {
50        Letter { key: key(t), sender: one_line(t.card.sender()), subject: one_line(&t.card.subject), through: false }
51    }
52}
53
54/// A sender's name or a subject on one line: a mail's words never break the notification's.
55fn one_line(text: &str) -> String {
56    text.split_whitespace().collect::<Vec<_>>().join(" ")
57}
58
59fn fnv(text: &str) -> u64 {
60    text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |hash, b| (hash ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3))
61}
62
63/// A message's key: its account and Message-ID, else its file's unique name
64/// (which a change of flags keeps), hashed: the same message fetched again
65/// keeps it, the same one in another account is another; and the ledger
66/// holds no address and no Message-ID.
67pub fn key(t: &Triaged) -> String {
68    let account = t.card.account.as_deref().unwrap_or("");
69    let id = t
70        .card
71        .message_id
72        .as_deref()
73        .map(str::trim)
74        .filter(|m| !m.is_empty())
75        .map(str::to_string)
76        .or_else(|| t.card.path.as_ref().and_then(|p| p.file_name()).and_then(|n| n.to_str()).map(|n| format!("file:{}", crate::maildir::unique_part(n))))
77        .unwrap_or_default();
78    format!("{:016x}", fnv(&format!("{account}\u{1f}{id}")))
79}
80
81/// Read before it came here (the server said \Seen: read on a phone, in a
82/// webmail): nothing to tell.
83fn read_already(t: &Triaged) -> bool {
84    t.card.path.as_deref().is_some_and(|p| crate::maildir::flags_of(p).contains('S'))
85}
86
87/// Whether a message is never told, whatever the time: set aside (forged,
88/// spam, a borrowed name, blocked), in the review queue (your own filter
89/// flagged it, or moved it into a Junk folder), hostile, a code (it has its
90/// own notification), from yourself, in the less important accounts' lane, a
91/// newsletter in the Filed lane unless `newsletters` (an automatic sender
92/// there, a bill from no-reply, is told), or read already.
93pub fn never(t: &Triaged, newsletters: bool) -> bool {
94    match &t.lane {
95        Lane::SetAside | Lane::Review | Lane::Hostile | Lane::RightNow | Lane::Low => return true,
96        Lane::Filed if !newsletters && t.reasons.contains(&Reason::Newsletter) => return true,
97        _ => {}
98    }
99    t.reasons.iter().any(|r| matches!(r, Reason::FromYourself | Reason::Blocked)) || read_already(t)
100}
101
102/// The moment as new mail sees it: what now is for, its columns and layers
103/// in the matrix (a slot of time for you, do-not-disturb from its switch or
104/// a focus session), Free time's "Nothing at all", and whether mail may be
105/// told at all. When it changes, a time began: what waited and may come now
106/// is told once (`waited`).
107pub fn moment(now: &crate::attention::Now, may: bool) -> String {
108    let columns: Vec<&str> = now.times.iter().map(|c| c.id()).collect();
109    format!("{}:{}:{}:{}:{}:{may}", now.time.id(), columns.join("+"), now.slot, now.dnd, now.nothing)
110}
111
112/// What becomes of a new message now, as the matrix says of its sender.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum Fate {
115    /// Told now.
116    Now,
117    /// Told now, through the system's do-not-disturb: someone Always through.
118    Through,
119    /// It waits for a time in which it is told.
120    Later,
121    /// Never told: its row says "not at all" now; it stays on the Porch.
122    Never,
123}
124
125/// What was done with each new message, by key: told (or never to tell), or
126/// waiting for a time it may come in; with when. On this device only, in
127/// `$XDG_STATE_HOME/sioul/mail-notified.toml`, forgotten after 30 days.
128#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
129pub struct Ledger {
130    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
131    pub told: BTreeMap<String, i64>,
132    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
133    pub waiting: BTreeMap<String, i64>,
134}
135
136impl Ledger {
137    pub fn default_path() -> PathBuf {
138        crate::config::state_dir().join("mail-notified.toml")
139    }
140
141    /// As kept; empty when missing or unreadable (nothing then is told twice
142    /// but what was waiting, which waits no more).
143    pub fn load(path: &Path) -> Ledger {
144        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
145    }
146
147    /// Written whole under another name, then renamed: never half a ledger.
148    pub fn save(&self, path: &Path) -> Result<(), String> {
149        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
150        if let Some(parent) = path.parent() {
151            std::fs::create_dir_all(parent).map_err(fail)?;
152        }
153        let text = toml::to_string(self).map_err(|e| e.to_string())?;
154        let temporary = path.with_extension(format!("toml.{}", std::process::id()));
155        std::fs::write(&temporary, text).map_err(fail)?;
156        std::fs::rename(&temporary, path).map_err(fail)
157    }
158
159    /// Whether a message was dealt with already: told, or waiting.
160    pub fn known(&self, key: &str) -> bool {
161        self.told.contains_key(key) || self.waiting.contains_key(key)
162    }
163
164    /// Told (or never to tell): never again.
165    pub fn tell(&mut self, key: &str, at: i64) {
166        self.waiting.remove(key);
167        self.told.insert(key.to_string(), at);
168    }
169
170    /// Waiting for a time it may come in.
171    pub fn wait(&mut self, key: &str, at: i64) {
172        if !self.told.contains_key(key) {
173            self.waiting.entry(key.to_string()).or_insert(at);
174        }
175    }
176
177    /// Marks older than 30 days forgotten: their mail left the Porch's view long ago.
178    pub fn forget_old(&mut self, now: i64) {
179        self.told.retain(|_, at| now - *at < KEPT);
180        self.waiting.retain(|_, at| now - *at < KEPT);
181    }
182}
183
184/// A fetch's arrivals, sorted.
185#[derive(Debug, Default, PartialEq, Eq)]
186pub struct Sorted {
187    /// To tell now, in the order they came.
188    pub now: Vec<Letter>,
189    /// Waiting for a time they may come in: their keys.
190    pub later: Vec<String>,
191    /// Never to tell: their keys.
192    pub never: Vec<String>,
193}
194
195/// Sorts the inbox's arrivals of a fetch, as the Porch judged them: those
196/// told now, those that wait, those never told (`fate`: what the matrix of
197/// what reaches you says of each now, `attention::Attention::decide`; and
198/// the lanes never told). A message the ledger knows is left out: told once.
199pub fn sort(arrivals: &[Triaged], ledger: &Ledger, newsletters: bool, fate: impl Fn(&Triaged) -> Fate) -> Sorted {
200    let mut out = Sorted::default();
201    let mut seen = BTreeSet::new();
202    for t in arrivals {
203        let key = key(t);
204        if ledger.known(&key) || !seen.insert(key.clone()) {
205            continue;
206        }
207        if never(t, newsletters) {
208            out.never.push(key);
209            continue;
210        }
211        match fate(t) {
212            Fate::Now => out.now.push(Letter::of(t)),
213            Fate::Through => out.now.push(Letter { through: true, ..Letter::of(t) }),
214            Fate::Later => out.later.push(key),
215            Fate::Never => out.never.push(key),
216        }
217    }
218    out
219}
220
221/// The mail that waited and may come now (`now`, as for `sort`): among the
222/// Porch's messages (`items`), those waiting in the ledger, still unread and
223/// not set aside since (a sender blocked meanwhile).
224pub fn waited(items: &[Triaged], ledger: &Ledger, newsletters: bool, now: impl Fn(&Triaged) -> bool) -> Vec<Letter> {
225    let mut seen = BTreeSet::new();
226    items.iter().filter(|t| ledger.waiting.contains_key(&key(t)) && !never(t, newsletters) && now(t)).map(Letter::of).filter(|l| seen.insert(l.key.clone())).collect()
227}
228
229/// Among the Porch's messages, those waiting that will never be told now:
230/// read meanwhile, blocked, set aside. Their marks become told.
231pub fn gone_by(items: &[Triaged], ledger: &Ledger, newsletters: bool) -> Vec<String> {
232    items.iter().filter(|t| never(t, newsletters)).map(key).filter(|k| ledger.waiting.contains_key(k)).collect()
233}
234
235/// The notification of new mail: "Two letters", and the first senders with
236/// their subjects, "Murena, Your invoice · Alice, Dinner on Friday".
237pub fn batch(tr: &Translator, letters: &[Letter]) -> (String, String) {
238    (tr.text("mail-note-title", Some(&tr.counted(letters.len()))), named(tr, letters))
239}
240
241/// When a time begins in which mail that waited may come: "The Porch opens:
242/// three letters wait for you.", the first senders below.
243pub fn opens(tr: &Translator, letters: &[Letter]) -> (String, String) {
244    (tr.text("mail-note-opens", Some(&tr.counted(letters.len()))), named(tr, letters))
245}
246
247/// The first letters, "Sender, Subject", joined by " · ".
248fn named(tr: &Translator, letters: &[Letter]) -> String {
249    letters
250        .iter()
251        .take(NAMED)
252        .map(|l| {
253            let mut args = crate::i18n::args();
254            args.set("sender", l.sender.clone());
255            args.set("subject", if l.subject.is_empty() { tr.text("mail-no-subject", None) } else { l.subject.clone() });
256            tr.text("mail-note-letter", Some(&args))
257        })
258        .collect::<Vec<_>>()
259        .join(" · ")
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::areas::{Area, Time, Week};
266    use crate::porch::{SenderList, Senders};
267    use crate::quiet::{Mode, Reason as Why};
268
269    /// Told now or later, as a test's rule says.
270    fn fate(now: bool) -> Fate {
271        if now { Fate::Now } else { Fate::Later }
272    }
273
274    /// A message as the Porch judges it, fetched into `account`; `headers` before the subject.
275    fn message(account: &str, from: &str, headers: &str, subject: &str, id: &str, senders: &Senders, priority: crate::config::Priority) -> Triaged {
276        let raw = format!("From: {from}\r\nMessage-ID: <{id}>\r\n{headers}Subject: {subject}\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\n\r\n{subject}.\r\n");
277        let known = SenderList::parse("alice@example.org");
278        let trusted = ["mx.example.net".to_string()];
279        let own = ["me@example.net".to_string()];
280        let ctx = crate::porch::Context { projects: None, known: &known, senders, trusted_ids: &trusted, now: None, priority, own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &own, spam: None };
281        let mut card = crate::card::Card::from_bytes(raw.as_bytes()).unwrap();
282        card.account = Some(account.to_string());
283        card.path = Some(PathBuf::from(format!("/mail/{account}/new/1759400000.U1-{}.sioul", fnv(id) % 10_000)));
284        crate::porch::triage(card, &ctx)
285    }
286
287    fn mode(time: Time, reason: Why) -> Mode {
288        Mode { quiet: false, time, week: Week { work_hours: true, admin_hours: false, meals: true, sleep: true }, reason, until: None, back: None, label: String::new() }
289    }
290
291    #[test]
292    fn a_batch_once_at_its_time() {
293        let senders = Senders { safe: SenderList::parse("alice@example.org"), blocked: SenderList::parse("spam@example.com"), ..Senders::default() };
294        let usual = crate::config::Priority::default();
295        let alice = message("home", "Alice <alice@example.org>", "", "Dinner on Friday", "1@example.org", &senders, usual);
296        let bill = message("home", "Murena <no-reply@murena.example>", "", "Your invoice", "2@murena.example", &senders, usual);
297        let bob = message("home", "Bob <bob@example.org>", "", "Photos", "3@example.org", &senders, usual);
298        let news = message("home", "Shop <news@shop.example>", "List-Id: <news.shop.example>\r\nList-Unsubscribe: <mailto:u@shop.example>\r\n", "Sale", "4@shop.example", &senders, usual);
299        let code = message("home", "Bank <codes@bank.example>", "", "Your verification code: 482913", "5@bank.example", &senders, usual);
300        let forged = message("home", "Alice <alice@example.org>", "Authentication-Results: mx.example.net; dmarc=fail (p=reject) header.from=example.org\r\n", "Urgent", "6@example.org", &senders, usual);
301        let own = message("home", "Me <me@example.net>", "Authentication-Results: mx.example.net; dmarc=pass header.from=example.net\r\n", "A file", "7@example.net", &senders, usual);
302        let low = message("social", "Network <hello@social.example>", "", "Someone liked", "8@social.example", &senders, crate::config::Priority::Below);
303        let mut read = message("home", "Carol <carol@example.org>", "", "Read on the phone", "9@example.org", &senders, usual);
304        read.card.path = Some(PathBuf::from("/mail/home/cur/1759400000.U1-9.sioul:2,S"));
305        assert_eq!(bill.lane, Lane::Filed, "an automatic sender is filed");
306        assert_eq!((news.lane.clone(), code.lane.clone(), forged.lane.clone(), low.lane.clone()), (Lane::Filed, Lane::RightNow, Lane::SetAside, Lane::Low));
307        let arrivals = vec![bill.clone(), alice.clone(), bob.clone(), news.clone(), code, forged, own, low, read];
308
309        // In view: the safe sender always; the others as the matrix says (here: the bill and Bob too).
310        let ledger = Ledger::default();
311        let sorted = sort(&arrivals, &ledger, false, |t| fate(t.card.from_address.as_deref() != Some("bob@example.org")));
312        assert_eq!(sorted.now.iter().map(|l| l.sender.as_str()).collect::<Vec<_>>(), ["Murena", "Alice"], "{sorted:?}");
313        assert_eq!(sorted.later, [key(&bob)], "Bob waits for his list's times");
314        assert_eq!(sorted.never.len(), 6, "the newsletter, the code, the forged one, your own, the less important account, the one read already");
315        // Newsletters when asked.
316        assert!(sort(&[news.clone()], &ledger, true, |_| Fate::Now).now.len() == 1);
317
318        // The words: the count in words, the first senders with their subjects.
319        let en = Translator::new("en");
320        assert_eq!(batch(&en, &sorted.now), ("Two letters".to_string(), "Murena, Your invoice · Alice, Dinner on Friday".to_string()));
321        let fr = Translator::new("fr");
322        assert_eq!(batch(&fr, &sorted.now), ("Deux lettres".to_string(), "Murena, Your invoice · Alice, Dinner on Friday".to_string()));
323        assert_eq!(batch(&fr, &sorted.now[..1]).0, "Une lettre");
324        assert_eq!(batch(&en, &sorted.now[..1]).0, "One letter");
325        // Past twelve, digits; three named at most; a message without a subject says so.
326        let many: Vec<Letter> = (0..13).map(|i| Letter { key: i.to_string(), sender: format!("S{i}"), subject: if i == 0 { String::new() } else { format!("T{i}") }, through: false }).collect();
327        assert_eq!(batch(&en, &many), ("13 letters".to_string(), "S0, (no subject) · S1, T1 · S2, T2".to_string()));
328
329        // Told once, by account and Message-ID: fetched again, nothing; the same message in another account is another.
330        let mut ledger = Ledger::default();
331        for letter in &sorted.now {
332            ledger.tell(&letter.key, 100);
333        }
334        for k in &sorted.never {
335            ledger.tell(k, 100);
336        }
337        for k in &sorted.later {
338            ledger.wait(k, 100);
339        }
340        assert!(sort(&arrivals, &ledger, false, |_| Fate::Now).now.is_empty(), "never twice");
341        let elsewhere = message("work", "Alice <alice@example.org>", "", "Dinner on Friday", "1@example.org", &senders, usual);
342        assert_eq!(sort(&[elsewhere], &ledger, false, |_| Fate::Now).now.len(), 1);
343        // A message without a Message-ID: its file's unique name, whatever its flags.
344        let mut nameless = alice.clone();
345        nameless.card.message_id = None;
346        let flagged = Triaged { card: crate::card::Card { path: Some(PathBuf::from("/mail/home/cur/1759400000.U1-42.sioul:2,F")), ..nameless.card.clone() }, ..nameless.clone() };
347        nameless.card.path = Some(PathBuf::from("/mail/home/new/1759400000.U1-42.sioul"));
348        assert_eq!(key(&nameless), key(&flagged));
349
350        // Asleep (or in a pause): nothing now, everything in view waits.
351        let asleep = sort(&[bob.clone()], &Ledger::default(), false, |_| Fate::Later);
352        // Its row saying "not at all" now: never told, on the Porch only.
353        assert_eq!(sort(&[bob.clone()], &Ledger::default(), false, |_| Fate::Never).never.len(), 1);
354        assert!(asleep.now.is_empty() && asleep.later.len() == 1);
355        // A do-not-disturb you switched on: the people it lets through are told, the others wait.
356        let gate = |t: &Triaged| t.card.from_address.as_deref() == Some("alice@example.org");
357        let held = sort(&[alice.clone(), bob.clone()], &Ledger::default(), false, |t| fate(gate(t)));
358        assert_eq!((held.now.len(), held.now[0].sender.as_str(), held.later.len()), (1, "Alice", 1));
359        // Someone Always through: told through the system's do-not-disturb.
360        let through = sort(&[alice.clone()], &Ledger::default(), false, |_| Fate::Through);
361        assert!(through.now[0].through && !held.now[0].through);
362
363        // A time begins: what waited and may come now, told once.
364        let waiting = waited(&arrivals, &ledger, false, |_| true);
365        assert_eq!(waiting.iter().map(|l| l.sender.as_str()).collect::<Vec<_>>(), ["Bob"]);
366        assert_eq!(opens(&en, &waiting).0, "The Porch opens: one letter waits for you.");
367        assert_eq!(opens(&fr, &[waiting[0].clone(), waiting[0].clone(), waiting[0].clone()]).0, "Le Porche ouvre\u{202f}: trois lettres vous attendent.");
368        assert!(waited(&arrivals, &ledger, false, |_| false).is_empty(), "not while its list may not come");
369        for letter in &waiting {
370            ledger.tell(&letter.key, 200);
371        }
372        assert!(waited(&arrivals, &ledger, false, |_| true).is_empty(), "once per window");
373        // Read meanwhile: no longer waiting.
374        let mut ledger = Ledger::default();
375        ledger.wait(&key(&bob), 100);
376        let mut seen = bob.clone();
377        seen.card.path = Some(PathBuf::from("/mail/home/cur/1759400000.U1-3.sioul:2,S"));
378        assert!(waited(&[seen.clone()], &ledger, false, |_| true).is_empty());
379        assert_eq!(gone_by(&[seen], &ledger, false), [key(&bob)]);
380    }
381
382    #[test]
383    fn the_matrix_and_the_moment() {
384        use crate::attention::{Attention, Event, Lane as MailLane, Level, Now, Source};
385        let senders = Senders::default();
386        let usual = crate::config::Priority::default();
387        let stranger = message("work", "Someone <someone@elsewhere.example>", "", "A question", "10@elsewhere.example", &senders, usual);
388        let attention = Attention::usual();
389        let decided = |t: &Triaged, m: &Mode| attention.decide(&Event::of(Source::Mail { who: senders.who_of(&t.card), always: false, lane: MailLane::Usual }).for_area(Some(Area::WORK)), &Now::of(m));
390        let fate_at = |m: Mode| {
391            let decided = &decided;
392            move |t: &Triaged| match decided(t, &m) {
393                out if out.told => Fate::Now,
394                out if out.level == Level::Never => Fate::Never,
395                _ => Fate::Later,
396            }
397        };
398        // A stranger writing to a work address: told in work time, waiting in the evening.
399        assert_eq!(sort(&[stranger.clone()], &Ledger::default(), false, fate_at(mode(Time::Work, Why::Working))).now.len(), 1);
400        assert_eq!(sort(&[stranger.clone()], &Ledger::default(), false, fate_at(mode(Time::Leisure, Why::Evening))).later.len(), 1);
401        // As usual, sleep, the pause and Free time hold the telling of a safe sender's mail; work, the evening and meals do not.
402        let safe_told = |m: &Mode| attention.decide(&Event::of(Source::Mail { who: crate::reach::Who::Safe, always: false, lane: MailLane::Usual }), &Now::of(m)).told;
403        assert!(!safe_told(&mode(Time::Sleep, Why::Sleep)) && !safe_told(&mode(Time::Sleep, Why::Paused)) && !safe_told(&mode(Time::Leisure, Why::FreeTime)));
404        assert!(safe_told(&mode(Time::Work, Why::Working)) && safe_told(&mode(Time::Leisure, Why::Evening)) && safe_told(&mode(Time::Meals, Why::Meal)));
405        // A time beginning is a change of moment; the same moment twice is none.
406        let at = |time: Time, why: Why| Now::of(&mode(time, why));
407        let evening = moment(&at(Time::Leisure, Why::Evening), true);
408        assert_eq!(evening, moment(&at(Time::Leisure, Why::Evening), true));
409        assert_ne!(evening, moment(&at(Time::Work, Why::Working), true));
410        assert_ne!(moment(&at(Time::Sleep, Why::Sleep), false), moment(&at(Time::Leisure, Why::Evening), true), "waking");
411        assert_ne!(moment(&at(Time::Work, Why::Working).layers(false, true), true), moment(&at(Time::Work, Why::Working), true), "a do-not-disturb ends");
412        assert_ne!(moment(&at(Time::Work, Why::Working).layers(true, false), true), moment(&at(Time::Work, Why::Working), true), "a slot of time for you ends");
413        // Kept and read back; old marks forgotten.
414        let path = std::env::temp_dir().join(format!("sioul-mail-notified-{}.toml", std::process::id()));
415        let mut ledger = Ledger::default();
416        ledger.tell("a", 1_000);
417        ledger.wait("b", 5_000_000);
418        ledger.save(&path).unwrap();
419        let mut back = Ledger::load(&path);
420        assert_eq!(back, ledger);
421        back.forget_old(1_000 + KEPT);
422        assert!(!back.known("a") && back.known("b"));
423        let _ = std::fs::remove_file(&path);
424    }
425}