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        // Reachable lifts the Porch's rest after a pause: new mail told (part 3).
108        let may = config.reminders.mail && (!moment.holds.porch_rests || moment.reachable);
109        Seen { attention: Attention::of(&config), always: People::load(&People::default_path()), config, senders: std::cell::OnceCell::new(), now: moment, may }
110    }
111
112    fn senders(&self) -> &porch::Senders {
113        self.senders.get_or_init(|| porch::Senders::load(&self.config))
114    }
115
116    /// What the matrix says of this message now (`Attention::mail`): its
117    /// sender's row as the Porch weighs it, Always through, what the address
118    /// it came to is for (unsaid: work's).
119    fn decided(&self, t: &Triaged) -> Output {
120        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);
121        self.attention.mail(t, self.senders(), &self.always, area, &self.now)
122    }
123
124    /// Told now, through the system's do-not-disturb, later, or never.
125    fn fate(&self, t: &Triaged) -> Fate {
126        let out = self.decided(t);
127        match out {
128            _ if !self.may => Fate::Later,
129            out if out.told && out.pierce => Fate::Through,
130            out if out.told => Fate::Now,
131            out if out.level == Level::Never => Fate::Never,
132            _ => Fate::Later,
133        }
134    }
135
136    /// Whether this message may be told now.
137    fn tells(&self, t: &Triaged) -> bool {
138        self.may && self.decided(t).told
139    }
140
141    fn moment(&self) -> String {
142        mailnote::moment(&self.now, self.may)
143    }
144
145    /// Messages judged as the Porch judges them: with the projects and their
146    /// ties, the senders you let in, who is safe or blocked.
147    fn judge(&self, files: &[PathBuf], stamp: i64) -> Vec<Triaged> {
148        let (store, known) = self.world();
149        porch::judge(files, &self.config.mail_sources(), store.as_ref(), &known, self.senders(), stamp)
150    }
151
152    /// The Porch as it is: every message not closed on yet.
153    fn porch(&self, stamp: i64) -> Vec<Triaged> {
154        let (store, known) = self.world();
155        let state = sioul_core::state::PorchState::load(&sioul_core::state::PorchState::default_path());
156        porch::gather(&self.config.mail_sources(), store.as_ref(), &known, self.senders(), &state, stamp)
157    }
158
159    fn world(&self) -> (Option<sioul_core::projects::ProjectStore>, porch::SenderList) {
160        let ties = sioul_core::links::LocalLinks::load(&sioul_core::links::LocalLinks::default_path());
161        let store = self.config.notes_root_path().and_then(|root| sioul_core::projects::ProjectStore::load(&root).ok()).map(|s| s.with_ties(&ties));
162        (store, porch::KnownSenders::load(&self.config.known_senders_path()))
163    }
164}
165
166/// After a fetch of an account (`new`: its inbox's arrivals; nothing on its
167/// first fetch, which brings two weeks of mail): from a process without the
168/// window (a phone's service).
169#[cfg_attr(not(target_os = "android"), allow(dead_code))]
170pub(crate) fn arrived(account: &str, new: &[PathBuf], first: bool) {
171    arrived_from(account, new, first, None);
172}
173
174/// The same, from the window's watchers.
175pub(crate) fn arrived_in_window(window: Window, account: &str, new: &[PathBuf], first: bool) {
176    arrived_from(account, new, first, Some(window));
177}
178
179/// What your mail filters could not act on (a folder missing, the server
180/// refusing), once this device's filters ran: told as any new mail is, by the
181/// same rules, the arrivals having been held back for them
182/// (`filters::untold`). Told once only: the ledger knows what was.
183#[cfg_attr(not(target_os = "android"), allow(dead_code))]
184pub(crate) fn unfiltered(account: &str, files: &[PathBuf]) {
185    told(account, files, None, false);
186}
187
188/// The same, from the window's watchers.
189pub(crate) fn unfiltered_in_window(window: Window, account: &str, files: &[PathBuf]) {
190    told(account, files, Some(window), false);
191}
192
193fn arrived_from(account: &str, new: &[PathBuf], first: bool, window: Option<Window>) {
194    if !first {
195        told(account, new, window, true);
196    }
197}
198
199/// `files` judged and told by the rules of what reaches you; `hold`: what a
200/// mail filter would move out of the inbox or mark read held back, for this
201/// device's filters to act on first (`unfiltered` tells it if they cannot).
202fn told(_account: &str, files: &[PathBuf], window: Option<Window>, hold: bool) {
203    if files.is_empty() {
204        return;
205    }
206    let config = load_config();
207    if !config.reminders.mail {
208        return;
209    }
210    let now = Zoned::now();
211    let stamp = now.timestamp().as_second();
212    let newsletters = config.reminders.mail_newsletters;
213    let seen = Seen::now(config, &now);
214    let judged = seen.judge(files, stamp);
215    // What your mail filters would move out of the inbox or mark read waits for them: told after, if they could not.
216    let arrivals = if hold { crate::filters::untold(&seen.config, judged, seen.senders()) } else { judged };
217    let path = Ledger::default_path();
218    // Marked at once: those never told (its sender's row says "not at all"
219    // now: on the Porch only), those waiting for their time, and those of the
220    // batch, waiting until it is told (Sioul stopped before, they come with
221    // "The Porch opens").
222    let letters = sioul_core::filelock::with_lock(&path, || {
223        let mut ledger = Ledger::load(&path);
224        let sorted = mailnote::sort(&arrivals, &ledger, newsletters, |t| seen.fate(t));
225        for key in &sorted.never {
226            ledger.tell(key, stamp);
227        }
228        for key in sorted.later.iter().chain(sorted.now.iter().map(|l| &l.key)) {
229            ledger.wait(key, stamp);
230        }
231        ledger.forget_old(stamp);
232        if let Err(e) = ledger.save(&path) {
233            eprintln!("{e}");
234        }
235        sorted.now
236    });
237    if !letters.is_empty() {
238        add_to_batch(letters, window, false);
239    }
240}
241
242/// Every account fetched at once, in a process without the window (a phone's
243/// background step, `steps::fetch_mail`): every arrival is known, so the batch
244/// is told now, in one notification, not ten seconds on, which kept the phone
245/// awake for nothing. The thread waiting for it finds it empty, and ends.
246#[cfg_attr(not(target_os = "android"), allow(dead_code))]
247pub(crate) fn close_now() {
248    let (letters, window, opens) = {
249        let Ok(mut batch) = BATCH.lock() else { return };
250        batch.first = None;
251        batch.last = None;
252        (std::mem::take(&mut batch.letters), batch.window.take(), std::mem::take(&mut batch.opens))
253    };
254    if !letters.is_empty() {
255        tell_batch(letters, window, opens);
256    }
257}
258
259/// Waits for the batch to close (ten seconds without another arrival, a
260/// minute at most), then tells it.
261fn close_batch() {
262    loop {
263        std::thread::sleep(Duration::from_secs(1));
264        let ready = {
265            let Ok(mut batch) = BATCH.lock() else { return };
266            let done = batch.last.is_none_or(|last| last.elapsed() >= GATHERED) || batch.first.is_some_and(|first| first.elapsed() >= AT_MOST);
267            done.then(|| {
268                batch.waiting = false;
269                batch.first = None;
270                batch.last = None;
271                (std::mem::take(&mut batch.letters), batch.window.take(), std::mem::take(&mut batch.opens))
272            })
273        };
274        if let Some((letters, window, opens)) = ready {
275            // Told already (`close_now`): nothing left.
276            if !letters.is_empty() {
277                tell_batch(letters, window, opens);
278            }
279            return;
280        }
281    }
282}
283
284/// A batch told, its letters claimed first: those still waiting (told by
285/// nobody else meanwhile). Nothing may be told now (a pause pressed in the
286/// meantime): they keep waiting for their time. Some waited for it
287/// (`opens`): "The Porch opens: …", for them all.
288fn tell_batch(letters: Vec<Letter>, window: Option<Window>, opens: bool) {
289    let now = Zoned::now();
290    let stamp = now.timestamp().as_second();
291    if !Seen::now(load_config(), &now).may {
292        return;
293    }
294    let mine = claim(letters, &[], stamp);
295    if mine.is_empty() || !ours(window.as_ref()) {
296        return;
297    }
298    let (title, body) = if opens { mailnote::opens(tr(), &mine) } else { mailnote::batch(tr(), &mine) };
299    show(&title, &body, mine.iter().any(|l| l.through), window.as_ref());
300}
301
302/// Each minute: when the moment changed (a time began, waking, a pause or a
303/// do-not-disturb over, Sioul starting), the mail that waited and may come
304/// now, told in one notification. From a process without the window.
305#[cfg_attr(not(target_os = "android"), allow(dead_code))]
306pub(crate) fn tick() {
307    tick_from(None);
308}
309
310/// The same, from the window's minute: off its thread.
311pub(crate) fn tick_in_window(window: Window) {
312    std::thread::spawn(move || tick_from(Some(window)));
313}
314
315fn tick_from(window: Option<Window>) {
316    static BUSY: Mutex<()> = Mutex::new(());
317    let Some(_busy) = crate::backend::one_at_a_time(&BUSY) else { return };
318    let config = load_config();
319    if !config.reminders.mail {
320        return;
321    }
322    let now = Zoned::now();
323    let stamp = now.timestamp().as_second();
324    let newsletters = config.reminders.mail_newsletters;
325    let seen = Seen::now(config, &now);
326    let moment = seen.moment();
327    let changed = MOMENT.lock().is_ok_and(|mut last| last.replace(moment.clone()).as_ref() != Some(&moment));
328    if !changed || !seen.may {
329        return;
330    }
331    let path = Ledger::default_path();
332    let ledger = Ledger::load(&path);
333    if ledger.waiting.is_empty() {
334        return;
335    }
336    let items = seen.porch(stamp);
337    let letters = mailnote::waited(&items, &ledger, newsletters, |t| seen.tells(t));
338    // Read meanwhile, blocked, set aside: waiting no more.
339    let gone = mailnote::gone_by(&items, &ledger, newsletters);
340    // With the window, gathered with what its fetches bring meanwhile (Sioul
341    // starting): one notification. Without it, told now: the process may end.
342    if window.is_some() {
343        claim(Vec::new(), &gone, stamp);
344        if !letters.is_empty() {
345            add_to_batch(letters, window, true);
346        }
347        return;
348    }
349    let mine = claim(letters, &gone, stamp);
350    if mine.is_empty() || !ours(window.as_ref()) {
351        return;
352    }
353    let (title, body) = mailnote::opens(tr(), &mine);
354    show(&title, &body, mine.iter().any(|l| l.through), window.as_ref());
355}
356
357/// The letters still waiting in the ledger, marked told by this process
358/// (the others are another's to tell, or told); `gone` marked told too.
359fn claim(letters: Vec<Letter>, gone: &[String], stamp: i64) -> Vec<Letter> {
360    let path = Ledger::default_path();
361    sioul_core::filelock::with_lock(&path, || {
362        let mut ledger = Ledger::load(&path);
363        let mut seen = std::collections::BTreeSet::new();
364        let mine: Vec<Letter> = letters.into_iter().filter(|l| ledger.waiting.contains_key(&l.key) && seen.insert(l.key.clone())).collect();
365        for key in mine.iter().map(|l| &l.key).chain(gone) {
366            ledger.tell(key, stamp);
367        }
368        if mine.is_empty() && gone.is_empty() {
369            return mine;
370        }
371        match ledger.save(&path) {
372            Ok(()) => mine,
373            // Not written: told by none, rather than twice.
374            Err(e) => {
375                eprintln!("{e}");
376                Vec::new()
377            }
378        }
379    })
380}
381
382/// Whether this device tells: the one you used last (the "notices" lease,
383/// renewed by the window; only read without it). Sharing off: this one.
384fn ours(window: Option<&Window>) -> bool {
385    match window {
386        Some(w) => crate::share::keeper("notices", sioul_sync::lease::Rule::FollowsYou, w.active, false).0.mine,
387        None => crate::share::looked("notices", sioul_sync::lease::Rule::FollowsYou).mine,
388    }
389}
390
391/// The notification: the desktop's, with "Open" (the Porch) where it takes
392/// buttons, at critical urgency when someone Always through is in it
393/// (`through`: it passes the desktop's do-not-disturb); on a phone,
394/// Android's, in the quiet "New mail" channel, a tap opening the Porch.
395fn show(title: &str, body: &str, through: bool, window: Option<&Window>) {
396    #[cfg(target_os = "android")]
397    {
398        let _ = window;
399        crate::eventalarms::mail_note(title, body, through);
400    }
401    #[cfg(not(target_os = "android"))]
402    {
403        use cxx_qt_lib::QString;
404        let open = window.map(|w| {
405            let qt = w.qt.clone();
406            let action: Box<dyn FnOnce() + Send> = Box::new(move || {
407                let _ = qt.queue(|mut sioul| sioul.as_mut().reminder_opened(QString::from("porch"), QString::default(), QString::default()));
408            });
409            (tr().text("reminder-open", None), action)
410        });
411        if let Err(e) = sioul_sync::notify::mail(title, body, through, open) {
412            eprintln!("{e}");
413        }
414    }
415}