Skip to main content

sioul_app/
mailnote.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! New mail told as a notification, at the times it may come (docs/porch.md,
5//! "Notifications"; the rules are `sioul_core::mailnote`). After each fetch
6//! (`arrived`), the inbox's arrivals are judged as the Porch judges them and
7//! gathered for ten seconds (a minute at most), every account together:
8//! then one quiet notification for the batch. What may not come now waits,
9//! and comes in one notification when a time begins in which it may
10//! (`tick`, each minute): "The Porch opens: …".
11//!
12//! Only the device you used last tells (the "notices" lease, as for the
13//! sites' gathered notification): your phone and your computer fetch the
14//! same mail, and the sharing does not carry what each one told. The other
15//! marks it told without a word.
16//!
17//! Without the window too: on a phone, a service of its own fetches the
18//! inboxes in another process, without Qt, and calls `arrived` and `tick`
19//! here (docs/android.md); the ledger is written under a lock, and a letter
20//! is claimed before it is told, so that two processes never tell it twice.
21
22use crate::backend::{QtThread, load_config, tr};
23use jiff::Zoned;
24use sioul_core::areas::Area;
25use sioul_core::attention::{Attention, Level, Now, Output};
26use sioul_core::config::Config;
27use sioul_core::everywhere::People;
28use sioul_core::mailnote::{self, Fate, Ledger, Letter};
29use sioul_core::porch::{self, Triaged};
30use std::path::PathBuf;
31use std::sync::Mutex;
32use std::time::{Duration, Instant};
33
34/// Arrivals this close make one batch: the accounts fetched as Sioul starts.
35const GATHERED: Duration = Duration::from_secs(10);
36/// A batch waits this long at most for others to join it.
37const AT_MOST: Duration = Duration::from_secs(60);
38
39/// Sioul's window, when it runs in this process: you at it (for the lease)
40/// and "Open" on a computer's notification.
41#[derive(Clone)]
42pub(crate) struct Window {
43    /// "Open" on a computer's notification; a phone's opens through Java.
44    #[cfg_attr(target_os = "android", allow(dead_code))]
45    pub(crate) qt: QtThread,
46    /// When you were last at it (Unix seconds).
47    pub(crate) active: i64,
48}
49
50/// The letters waiting for their batch to close, in this process.
51struct Batch {
52    letters: Vec<Letter>,
53    first: Option<Instant>,
54    last: Option<Instant>,
55    window: Option<Window>,
56    /// Some waited for their time: "The Porch opens", for the batch whole.
57    opens: bool,
58    /// A thread waits to tell them.
59    waiting: bool,
60}
61
62static BATCH: Mutex<Batch> = Mutex::new(Batch { letters: Vec::new(), first: None, last: None, window: None, opens: false, waiting: false });
63
64/// Letters added to this process's batch (`opens`: they waited for their
65/// time), told ten seconds after the last added, a minute at most after the first.
66fn add_to_batch(letters: Vec<Letter>, window: Option<Window>, opens: bool) {
67    let start = {
68        let Ok(mut batch) = BATCH.lock() else { return };
69        batch.letters.extend(letters);
70        batch.first.get_or_insert_with(Instant::now);
71        batch.last = Some(Instant::now());
72        batch.opens |= opens;
73        if window.is_some() {
74            batch.window = window;
75        }
76        !std::mem::replace(&mut batch.waiting, true)
77    };
78    if start {
79        std::thread::spawn(close_batch);
80    }
81}
82
83/// The moment last seen by `tick`, in this process: a change is a time beginning.
84static MOMENT: Mutex<Option<String>> = Mutex::new(None);
85
86/// What decides now whether a message is told: the matrix of what reaches
87/// you (`attention`), the moment as it reads it (the time, a slot of time for
88/// you, do-not-disturb, Free time's "Nothing at all", the Porch resting
89/// after a pause), the Always through list, and whether new mail is told at all.
90struct Seen {
91    config: Config,
92    /// Read when a message is judged, not for the minute's moment: the
93    /// contacts' categories are looked at for it.
94    senders: std::cell::OnceCell<porch::Senders>,
95    attention: Attention,
96    now: Now,
97    /// Always through: the people on that list, by their address.
98    always: People,
99    /// Nothing is told with the setting off, nor while the Porch rests after a pause.
100    may: bool,
101}
102
103impl Seen {
104    fn now(config: Config, now: &Zoned) -> Seen {
105        let mode = crate::hours::mode_at(now);
106        let moment = crate::hours::attention_at(&mode, now);
107        let may = config.reminders.mail && !moment.holds.porch_rests;
108        Seen { attention: Attention::of(&config), always: People::load(&People::default_path()), config, senders: std::cell::OnceCell::new(), now: moment, may }
109    }
110
111    fn senders(&self) -> &porch::Senders {
112        self.senders.get_or_init(|| porch::Senders::load(&self.config))
113    }
114
115    /// What the matrix says of this message now (`Attention::mail`): its
116    /// sender's row as the Porch weighs it, Always through, what the address
117    /// it came to is for (unsaid: work's).
118    fn decided(&self, t: &Triaged) -> Output {
119        let area = t.card.account.as_deref().and_then(|id| self.config.account(id)).and_then(|a| a.area.as_deref()).and_then(Area::parse).unwrap_or(Area::WORK);
120        self.attention.mail(t, self.senders(), &self.always, area, &self.now)
121    }
122
123    /// Told now, through the system's do-not-disturb, later, or never.
124    fn fate(&self, t: &Triaged) -> Fate {
125        let out = self.decided(t);
126        match out {
127            _ if !self.may => Fate::Later,
128            out if out.told && out.pierce => Fate::Through,
129            out if out.told => Fate::Now,
130            out if out.level == Level::Never => Fate::Never,
131            _ => Fate::Later,
132        }
133    }
134
135    /// Whether this message may be told now.
136    fn tells(&self, t: &Triaged) -> bool {
137        self.may && self.decided(t).told
138    }
139
140    fn moment(&self) -> String {
141        mailnote::moment(&self.now, self.may)
142    }
143
144    /// Messages judged as the Porch judges them: with the projects and their
145    /// ties, the senders you let in, who is safe or blocked.
146    fn judge(&self, files: &[PathBuf], stamp: i64) -> Vec<Triaged> {
147        let (store, known) = self.world();
148        porch::judge(files, &self.config.mail_sources(), store.as_ref(), &known, self.senders(), stamp)
149    }
150
151    /// The Porch as it is: every message not closed on yet.
152    fn porch(&self, stamp: i64) -> Vec<Triaged> {
153        let (store, known) = self.world();
154        let state = sioul_core::state::PorchState::load(&sioul_core::state::PorchState::default_path());
155        porch::gather(&self.config.mail_sources(), store.as_ref(), &known, self.senders(), &state, stamp)
156    }
157
158    fn world(&self) -> (Option<sioul_core::projects::ProjectStore>, porch::SenderList) {
159        let ties = sioul_core::links::LocalLinks::load(&sioul_core::links::LocalLinks::default_path());
160        let store = self.config.notes_root_path().and_then(|root| sioul_core::projects::ProjectStore::load(&root).ok()).map(|s| s.with_ties(&ties));
161        (store, porch::KnownSenders::load(&self.config.known_senders_path()))
162    }
163}
164
165/// After a fetch of an account (`new`: its inbox's arrivals; nothing on its
166/// first fetch, which brings two weeks of mail): from a process without the
167/// window (a phone's service).
168#[cfg_attr(not(target_os = "android"), allow(dead_code))]
169pub(crate) fn arrived(account: &str, new: &[PathBuf], first: bool) {
170    arrived_from(account, new, first, None);
171}
172
173/// The same, from the window's watchers.
174pub(crate) fn arrived_in_window(window: Window, account: &str, new: &[PathBuf], first: bool) {
175    arrived_from(account, new, first, Some(window));
176}
177
178/// What your mail filters could not act on (a folder missing, the server
179/// refusing), once this device's filters ran: told as any new mail is, by the
180/// same rules, the arrivals having been held back for them
181/// (`filters::untold`). Told once only: the ledger knows what was.
182#[cfg_attr(not(target_os = "android"), allow(dead_code))]
183pub(crate) fn unfiltered(account: &str, files: &[PathBuf]) {
184    told(account, files, None, false);
185}
186
187/// The same, from the window's watchers.
188pub(crate) fn unfiltered_in_window(window: Window, account: &str, files: &[PathBuf]) {
189    told(account, files, Some(window), false);
190}
191
192fn arrived_from(account: &str, new: &[PathBuf], first: bool, window: Option<Window>) {
193    if !first {
194        told(account, new, window, true);
195    }
196}
197
198/// `files` judged and told by the rules of what reaches you; `hold`: what a
199/// mail filter would move out of the inbox or mark read held back, for this
200/// device's filters to act on first (`unfiltered` tells it if they cannot).
201fn told(_account: &str, files: &[PathBuf], window: Option<Window>, hold: bool) {
202    if files.is_empty() {
203        return;
204    }
205    let config = load_config();
206    if !config.reminders.mail {
207        return;
208    }
209    let now = Zoned::now();
210    let stamp = now.timestamp().as_second();
211    let newsletters = config.reminders.mail_newsletters;
212    let seen = Seen::now(config, &now);
213    let judged = seen.judge(files, stamp);
214    // What your mail filters would move out of the inbox or mark read waits for them: told after, if they could not.
215    let arrivals = if hold { crate::filters::untold(&seen.config, judged, seen.senders()) } else { judged };
216    let path = Ledger::default_path();
217    // Marked at once: those never told (its sender's row says "not at all"
218    // now: on the Porch only), those waiting for their time, and those of the
219    // batch, waiting until it is told (Sioul stopped before, they come with
220    // "The Porch opens").
221    let letters = sioul_core::filelock::with_lock(&path, || {
222        let mut ledger = Ledger::load(&path);
223        let sorted = mailnote::sort(&arrivals, &ledger, newsletters, |t| seen.fate(t));
224        for key in &sorted.never {
225            ledger.tell(key, stamp);
226        }
227        for key in sorted.later.iter().chain(sorted.now.iter().map(|l| &l.key)) {
228            ledger.wait(key, stamp);
229        }
230        ledger.forget_old(stamp);
231        if let Err(e) = ledger.save(&path) {
232            eprintln!("{e}");
233        }
234        sorted.now
235    });
236    if !letters.is_empty() {
237        add_to_batch(letters, window, false);
238    }
239}
240
241/// Every account fetched at once, in a process without the window (a phone's
242/// background step, `steps::fetch_mail`): every arrival is known, so the batch
243/// is told now, in one notification, not ten seconds on, which kept the phone
244/// awake for nothing. The thread waiting for it finds it empty, and ends.
245#[cfg_attr(not(target_os = "android"), allow(dead_code))]
246pub(crate) fn close_now() {
247    let (letters, window, opens) = {
248        let Ok(mut batch) = BATCH.lock() else { return };
249        batch.first = None;
250        batch.last = None;
251        (std::mem::take(&mut batch.letters), batch.window.take(), std::mem::take(&mut batch.opens))
252    };
253    if !letters.is_empty() {
254        tell_batch(letters, window, opens);
255    }
256}
257
258/// Waits for the batch to close (ten seconds without another arrival, a
259/// minute at most), then tells it.
260fn close_batch() {
261    loop {
262        std::thread::sleep(Duration::from_secs(1));
263        let ready = {
264            let Ok(mut batch) = BATCH.lock() else { return };
265            let done = batch.last.is_none_or(|last| last.elapsed() >= GATHERED) || batch.first.is_some_and(|first| first.elapsed() >= AT_MOST);
266            done.then(|| {
267                batch.waiting = false;
268                batch.first = None;
269                batch.last = None;
270                (std::mem::take(&mut batch.letters), batch.window.take(), std::mem::take(&mut batch.opens))
271            })
272        };
273        if let Some((letters, window, opens)) = ready {
274            // Told already (`close_now`): nothing left.
275            if !letters.is_empty() {
276                tell_batch(letters, window, opens);
277            }
278            return;
279        }
280    }
281}
282
283/// A batch told, its letters claimed first: those still waiting (told by
284/// nobody else meanwhile). Nothing may be told now (a pause pressed in the
285/// meantime): they keep waiting for their time. Some waited for it
286/// (`opens`): "The Porch opens: …", for them all.
287fn tell_batch(letters: Vec<Letter>, window: Option<Window>, opens: bool) {
288    let now = Zoned::now();
289    let stamp = now.timestamp().as_second();
290    if !Seen::now(load_config(), &now).may {
291        return;
292    }
293    let mine = claim(letters, &[], stamp);
294    if mine.is_empty() || !ours(window.as_ref()) {
295        return;
296    }
297    let (title, body) = if opens { mailnote::opens(tr(), &mine) } else { mailnote::batch(tr(), &mine) };
298    show(&title, &body, mine.iter().any(|l| l.through), window.as_ref());
299}
300
301/// Each minute: when the moment changed (a time began, waking, a pause or a
302/// do-not-disturb over, Sioul starting), the mail that waited and may come
303/// now, told in one notification. From a process without the window.
304#[cfg_attr(not(target_os = "android"), allow(dead_code))]
305pub(crate) fn tick() {
306    tick_from(None);
307}
308
309/// The same, from the window's minute: off its thread.
310pub(crate) fn tick_in_window(window: Window) {
311    std::thread::spawn(move || tick_from(Some(window)));
312}
313
314fn tick_from(window: Option<Window>) {
315    static BUSY: Mutex<()> = Mutex::new(());
316    let Some(_busy) = crate::backend::one_at_a_time(&BUSY) else { return };
317    let config = load_config();
318    if !config.reminders.mail {
319        return;
320    }
321    let now = Zoned::now();
322    let stamp = now.timestamp().as_second();
323    let newsletters = config.reminders.mail_newsletters;
324    let seen = Seen::now(config, &now);
325    let moment = seen.moment();
326    let changed = MOMENT.lock().is_ok_and(|mut last| last.replace(moment.clone()).as_ref() != Some(&moment));
327    if !changed || !seen.may {
328        return;
329    }
330    let path = Ledger::default_path();
331    let ledger = Ledger::load(&path);
332    if ledger.waiting.is_empty() {
333        return;
334    }
335    let items = seen.porch(stamp);
336    let letters = mailnote::waited(&items, &ledger, newsletters, |t| seen.tells(t));
337    // Read meanwhile, blocked, set aside: waiting no more.
338    let gone = mailnote::gone_by(&items, &ledger, newsletters);
339    // With the window, gathered with what its fetches bring meanwhile (Sioul
340    // starting): one notification. Without it, told now: the process may end.
341    if window.is_some() {
342        claim(Vec::new(), &gone, stamp);
343        if !letters.is_empty() {
344            add_to_batch(letters, window, true);
345        }
346        return;
347    }
348    let mine = claim(letters, &gone, stamp);
349    if mine.is_empty() || !ours(window.as_ref()) {
350        return;
351    }
352    let (title, body) = mailnote::opens(tr(), &mine);
353    show(&title, &body, mine.iter().any(|l| l.through), window.as_ref());
354}
355
356/// The letters still waiting in the ledger, marked told by this process
357/// (the others are another's to tell, or told); `gone` marked told too.
358fn claim(letters: Vec<Letter>, gone: &[String], stamp: i64) -> Vec<Letter> {
359    let path = Ledger::default_path();
360    sioul_core::filelock::with_lock(&path, || {
361        let mut ledger = Ledger::load(&path);
362        let mut seen = std::collections::BTreeSet::new();
363        let mine: Vec<Letter> = letters.into_iter().filter(|l| ledger.waiting.contains_key(&l.key) && seen.insert(l.key.clone())).collect();
364        for key in mine.iter().map(|l| &l.key).chain(gone) {
365            ledger.tell(key, stamp);
366        }
367        if mine.is_empty() && gone.is_empty() {
368            return mine;
369        }
370        match ledger.save(&path) {
371            Ok(()) => mine,
372            // Not written: told by none, rather than twice.
373            Err(e) => {
374                eprintln!("{e}");
375                Vec::new()
376            }
377        }
378    })
379}
380
381/// Whether this device tells: the one you used last (the "notices" lease,
382/// renewed by the window; only read without it). Sharing off: this one.
383fn ours(window: Option<&Window>) -> bool {
384    match window {
385        Some(w) => crate::share::keeper("notices", sioul_sync::lease::Rule::FollowsYou, w.active, false).0.mine,
386        None => crate::share::looked("notices", sioul_sync::lease::Rule::FollowsYou).mine,
387    }
388}
389
390/// The notification: the desktop's, with "Open" (the Porch) where it takes
391/// buttons, at critical urgency when someone Always through is in it
392/// (`through`: it passes the desktop's do-not-disturb); on a phone,
393/// Android's, in the quiet "New mail" channel, a tap opening the Porch.
394fn show(title: &str, body: &str, through: bool, window: Option<&Window>) {
395    #[cfg(target_os = "android")]
396    {
397        let _ = window;
398        crate::eventalarms::mail_note(title, body, through);
399    }
400    #[cfg(not(target_os = "android"))]
401    {
402        use cxx_qt_lib::QString;
403        let open = window.map(|w| {
404            let qt = w.qt.clone();
405            let action: Box<dyn FnOnce() + Send> = Box::new(move || {
406                let _ = qt.queue(|mut sioul| sioul.as_mut().reminder_opened(QString::from("porch"), QString::default(), QString::default()));
407            });
408            (tr().text("reminder-open", None), action)
409        });
410        if let Err(e) = sioul_sync::notify::mail(title, body, through, open) {
411            eprintln!("{e}");
412        }
413    }
414}