1use crate::i18n::Translator;
25use crate::porch::{Lane, Reason, Triaged};
26use serde::{Deserialize, Serialize};
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::{Path, PathBuf};
29
30pub const NAMED: usize = 3;
32
33const KEPT: i64 = 30 * 86_400;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Letter {
39 pub key: String,
41 pub sender: String,
42 pub subject: String,
43 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
54fn 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
63pub 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
81fn read_already(t: &Triaged) -> bool {
84 t.card.path.as_deref().is_some_and(|p| crate::maildir::flags_of(p).contains('S'))
85}
86
87pub 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
102pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum Fate {
115 Now,
117 Through,
119 Later,
121 Never,
123}
124
125#[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 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 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 pub fn known(&self, key: &str) -> bool {
161 self.told.contains_key(key) || self.waiting.contains_key(key)
162 }
163
164 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 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 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#[derive(Debug, Default, PartialEq, Eq)]
186pub struct Sorted {
187 pub now: Vec<Letter>,
189 pub later: Vec<String>,
191 pub never: Vec<String>,
193}
194
195pub 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
221pub 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
229pub 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
235pub fn batch(tr: &Translator, letters: &[Letter]) -> (String, String) {
238 (tr.text("mail-note-title", Some(&tr.counted(letters.len()))), named(tr, letters))
239}
240
241pub fn opens(tr: &Translator, letters: &[Letter]) -> (String, String) {
244 (tr.text("mail-note-opens", Some(&tr.counted(letters.len()))), named(tr, letters))
245}
246
247fn 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 fn fate(now: bool) -> Fate {
271 if now { Fate::Now } else { Fate::Later }
272 }
273
274 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 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 assert!(sort(&[news.clone()], &ledger, true, |_| Fate::Now).now.len() == 1);
317
318 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 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 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 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 let asleep = sort(&[bob.clone()], &Ledger::default(), false, |_| Fate::Later);
352 assert_eq!(sort(&[bob.clone()], &Ledger::default(), false, |_| Fate::Never).never.len(), 1);
354 assert!(asleep.now.is_empty() && asleep.later.len() == 1);
355 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 let through = sort(&[alice.clone()], &Ledger::default(), false, |_| Fate::Through);
361 assert!(through.now[0].through && !held.now[0].through);
362
363 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 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 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 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 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 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}