Skip to main content

sioul_app/
mail.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The mail client behind the window: folders and their messages, what you do
5//! to them, drafts and sending.
6//!
7//! Moving, archiving, deleting, junking, sending and discarding a draft wait
8//! ten seconds with "Undo" before anything reaches the server or the disk;
9//! meanwhile the message or the draft is hidden from the lists
10//! (docs/client.md, "Calm first", rules 3 and 4). Reading and flagging happen
11//! at once and show at once: undoing them is doing them again.
12
13use crate::backend::{QtThread, Shared, config_path, json, load_config, say, set_status, show, tell, tr};
14use cxx_qt_lib::QString;
15use jiff::Zoned;
16use sioul_core::card::Card;
17use sioul_core::compose::{self, Draft, DraftKind};
18use sioul_core::config::{Account, Config};
19use sioul_core::folders::{Folder, Role};
20use sioul_core::mailsearch::ServerRef;
21use sioul_core::{maildir, unsubscribe, view};
22use sioul_sync::mailbox::{self, Action};
23use sioul_sync::{SyncError, secret};
24use std::collections::{BTreeMap, BTreeSet};
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::time::{Duration, UNIX_EPOCH};
29
30/// Seconds to change your mind (docs/client.md, rules 3 and 4).
31const UNDO_SECONDS: u64 = 10;
32
33/// The folder the mail page shows.
34#[derive(Clone, Default)]
35pub(crate) struct OpenFolder {
36    pub account: String,
37    pub folder: String,
38    /// Past the last two weeks.
39    pub all: bool,
40    pub query: String,
41}
42
43/// What waits for its ten seconds.
44enum Work {
45    Act { account: Account, file: PathBuf, action: Action },
46    /// Into a folder of another account: copied there, then taken off here.
47    Across { from: Account, file: PathBuf, to: Account, folder: String },
48    /// Several messages of one account, kept here or seen on its server only
49    /// (a search's), in one connection: moves, archiving, the trash.
50    Batch { account: Account, files: Vec<PathBuf>, remote: Vec<ServerRef>, action: Action },
51    /// A message seen on its server only, into a folder of another account.
52    AcrossRemote { from: Account, place: ServerRef, to: Account, folder: String },
53    /// Several messages at once, under one "Undo".
54    Many(Vec<Work>),
55    Send { draft: String },
56    /// A message built at Send, signed with your security key: sent as built
57    /// once its ten seconds are gone; "Undo" throws the signed copy away.
58    SendSigned { draft: String, account: Account, outgoing: compose::Outgoing },
59    Discard { draft: String },
60    /// A contact or an event deleted here; the account's sync deletes it there.
61    Remove { account: String, file: PathBuf },
62    /// One occurrence of a repeating event left out (EXDATE).
63    Skip { account: String, file: PathBuf, start: i64 },
64    /// The day closed, already in effect: "Undo" puts the time back as it was.
65    /// `previous` as before the act, `after` as the act left it: only what it changed is put back.
66    Rest { previous: sioul_core::quiet::Overrides, after: sioul_core::quiet::Overrides },
67    /// A note in the vault's trash already: "Undo" puts it back.
68    Untrash { trashed: String, path: String },
69    /// A change in effect already (a meal, an event or a step moved by a
70    /// drag): "Undo" runs `back`, which puts back what was there and says
71    /// what went wrong, else "".
72    Back { back: Box<dyn Fn(&QtThread, &Arc<Shared>) -> String + Send + Sync> },
73    /// Leaving a list, in one click or by a message from `account`; the message stays where it is.
74    Unsubscribe { list: unsubscribe::List, sender: String, account: String, way: unsubscribe::Way },
75    /// Your mail filters run on the inboxes, as you asked after their preview:
76    /// each account's messages in one connection (`filters::perform`).
77    Filters { planned: Vec<(Account, Vec<sioul_sync::filters::Job>)> },
78}
79
80pub(crate) struct Pending {
81    work: Work,
82    /// "Moved to the trash.", shown with "Undo".
83    line: String,
84    /// Taken once, by whoever comes first: its timer, "Undo", or the window closing.
85    taken: AtomicBool,
86}
87
88/// What the mail page shows, computed off Qt's thread.
89pub(crate) struct MailViews {
90    pub accounts: String,
91    pub folder: String,
92    pub drafts: String,
93}
94
95/// Your addresses: never answered to, never counted among the others.
96pub(crate) fn own_addresses(config: &Config) -> Vec<String> {
97    config.accounts.iter().filter_map(|a| a.address.clone()).collect()
98}
99
100fn moves(action: &Action) -> bool {
101    !matches!(action, Action::Read(_) | Action::Flag(_))
102}
103
104/// Messages moved and drafts sent or discarded, while "Undo" is offered.
105fn hidden(shared: &Shared) -> (BTreeSet<PathBuf>, BTreeSet<String>) {
106    let (mut files, mut drafts) = (BTreeSet::new(), BTreeSet::new());
107    fn add(work: &Work, files: &mut BTreeSet<PathBuf>, drafts: &mut BTreeSet<String>) {
108        match work {
109            Work::Act { file, action, .. } if moves(action) => {
110                files.insert(file.clone());
111            }
112            Work::Across { file, .. } | Work::Remove { file, .. } => {
113                files.insert(file.clone());
114            }
115            Work::Batch { files: batch, .. } => files.extend(batch.iter().cloned()),
116            Work::Filters { planned } => files.extend(planned.iter().flat_map(|(_, jobs)| jobs.iter().filter(|j| j.plan.leaves()).map(|j| j.file.clone()))),
117            Work::Send { draft } | Work::Discard { draft } | Work::SendSigned { draft, .. } => {
118                drafts.insert(draft.clone());
119            }
120            Work::Many(works) => works.iter().for_each(|w| add(w, files, drafts)),
121            // Leaving its list, a message stays in view where it is.
122            Work::Act { .. } | Work::AcrossRemote { .. } | Work::Skip { .. } | Work::Rest { .. } | Work::Untrash { .. } | Work::Back { .. } | Work::Unsubscribe { .. } => {}
123        }
124    }
125    for pending in shared.pending.lock().map(|p| p.clone()).unwrap_or_default() {
126        add(&pending.work, &mut files, &mut drafts);
127    }
128    (files, drafts)
129}
130
131/// Contacts and events deleted, and occurrences left out, while "Undo" is offered.
132pub(crate) fn hidden_pim(shared: &Shared) -> (BTreeSet<PathBuf>, BTreeSet<(PathBuf, i64)>) {
133    fn add(work: &Work, files: &mut BTreeSet<PathBuf>, skipped: &mut BTreeSet<(PathBuf, i64)>) {
134        match work {
135            Work::Remove { file, .. } => {
136                files.insert(file.clone());
137            }
138            Work::Skip { file, start, .. } => {
139                skipped.insert((file.clone(), *start));
140            }
141            // A task and its time blocks, under one "Undo".
142            Work::Many(works) => works.iter().for_each(|w| add(w, files, skipped)),
143            _ => {}
144        }
145    }
146    let (mut files, mut skipped) = (BTreeSet::new(), BTreeSet::new());
147    for pending in shared.pending.lock().map(|p| p.clone()).unwrap_or_default() {
148        add(&pending.work, &mut files, &mut skipped);
149    }
150    (files, skipped)
151}
152
153/// Whether a work deletes a contact, an event or a task, or leaves an occurrence out: the pages follow at once.
154fn touches_pim(work: &Work) -> bool {
155    match work {
156        Work::Remove { .. } | Work::Skip { .. } => true,
157        Work::Many(works) => works.iter().any(touches_pim),
158        _ => false,
159    }
160}
161
162/// Deletes several files at once after ten seconds to undo, (account, file)
163/// each: a task and its time blocks to come, or a task's blocks left to the
164/// plan. `back` runs on "Undo", for what was changed at once (a task's link to
165/// its block taken out): it says what went wrong, else "".
166pub(crate) fn schedule_removals(qt: &QtThread, shared: &Arc<Shared>, files: Vec<(String, PathBuf)>, back: Option<Box<dyn Fn(&QtThread, &Arc<Shared>) -> String + Send + Sync>>, line: String) {
167    let mut works: Vec<Work> = files.into_iter().map(|(account, file)| Work::Remove { account, file }).collect();
168    works.extend(back.map(|back| Work::Back { back }));
169    schedule(qt, shared, Work::Many(works), line);
170}
171
172/// Deletes a contact or an event after ten seconds to undo.
173pub(crate) fn schedule_removal(qt: &QtThread, shared: &Arc<Shared>, account: &str, file: PathBuf, line: String) {
174    schedule(qt, shared, Work::Remove { account: account.to_string(), file }, line);
175}
176
177/// Runs your mail filters on the inboxes as their preview said, after ten
178/// seconds to undo; what they move leaves the lists meanwhile.
179pub(crate) fn schedule_filters(qt: &QtThread, shared: &Arc<Shared>, planned: Vec<(Account, Vec<sioul_sync::filters::Job>)>, line: String) {
180    schedule(qt, shared, Work::Filters { planned }, line);
181}
182
183/// Leaves one occurrence of a repeating event out, after ten seconds to undo.
184pub(crate) fn schedule_skip(qt: &QtThread, shared: &Arc<Shared>, account: &str, file: PathBuf, start: i64, line: String) {
185    schedule(qt, shared, Work::Skip { account: account.to_string(), file, start }, line);
186}
187
188/// The messages the Porch should not show either.
189pub(crate) fn hidden_files(shared: &Shared) -> BTreeSet<PathBuf> {
190    hidden(shared).0
191}
192
193/// Messages seen on their server only (a search's), moved or deleted while "Undo" is offered: their keys.
194pub(crate) fn hidden_keys(shared: &Shared) -> BTreeSet<String> {
195    fn add(work: &Work, keys: &mut BTreeSet<String>) {
196        match work {
197            Work::Batch { remote, action, .. } if moves(action) => keys.extend(remote.iter().map(ServerRef::key)),
198            Work::AcrossRemote { place, .. } => {
199                keys.insert(place.key());
200            }
201            Work::Many(works) => works.iter().for_each(|w| add(w, keys)),
202            _ => {}
203        }
204    }
205    let mut keys = BTreeSet::new();
206    for pending in shared.pending.lock().map(|p| p.clone()).unwrap_or_default() {
207        add(&pending.work, &mut keys);
208    }
209    keys
210}
211
212#[derive(serde::Serialize)]
213struct DraftRow {
214    id: String,
215    subject: String,
216    to: String,
217    date: String,
218}
219
220fn draft_row(draft: &Draft) -> DraftRow {
221    let saved = std::fs::metadata(draft.path())
222        .and_then(|m| m.modified())
223        .ok()
224        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
225        .and_then(|d| i64::try_from(d.as_secs()).ok());
226    DraftRow {
227        id: draft.id.clone(),
228        subject: if draft.subject.trim().is_empty() { tr().text("mail-no-subject", None) } else { draft.subject.clone() },
229        to: say("mail-to-whom", &[("names", names(&draft.to))]),
230        date: view::date(tr(), saved),
231    }
232}
233
234/// "Jane, paul@example.org", else "nobody yet".
235fn names(entries: &[String]) -> String {
236    let names: Vec<String> = entries
237        .iter()
238        .map(|e| match e.rfind('<') {
239            Some(open) if open > 0 => e[..open].trim().trim_matches('"').to_string(),
240            _ => e.trim().to_string(),
241        })
242        .collect();
243    if names.is_empty() { tr().text("mail-nobody", None) } else { names.join(", ") }
244}
245
246pub(crate) fn views(shared: &Shared) -> MailViews {
247    let config = load_config();
248    let (files, drafts) = hidden(shared);
249    let accounts: Vec<(Account, Vec<Folder>)> = config.accounts.iter().filter(|a| a.syncs()).map(|a| (a.clone(), mailbox::folders(&a.id))).collect();
250    let open = shared.open_folder.lock().ok().and_then(|o| o.clone());
251    let folder = open.and_then(|open| {
252        let (account, folders) = accounts.iter().find(|(a, _)| a.id == open.account)?;
253        let folder = folders.iter().find(|f| f.name == open.folder)?;
254        // Gmail's "All Mail", shown as the archive: what the inbox and Sent show is left out.
255        let shown_elsewhere = if folder.role == Role::All {
256            let mut ids = maildir::message_ids(&account.maildir_path());
257            for sent in folders.iter().filter(|f| f.role == Role::Sent) {
258                ids.extend(maildir::message_ids(&account.maildir_path().join(&sent.local)));
259            }
260            ids
261        } else {
262            Default::default()
263        };
264        let cards: Vec<Card> = maildir::read_messages(&account.maildir_path().join(&folder.local))
265            .into_iter()
266            .filter(|c| c.path.as_ref().is_none_or(|p| !files.contains(p)))
267            .filter(|c| c.message_id.as_ref().is_none_or(|id| !shown_elsewhere.contains(id)))
268            .collect();
269        let trusted = config.mail_sources().into_iter().find(|s| s.account.as_deref() == Some(account.id.as_str())).map(|s| s.trusted_ids).unwrap_or_default();
270        let now = Zoned::now().timestamp().as_second();
271        // By conversation, when chosen: your answers come from Sent.
272        let sent = (config.mail.threads && !matches!(folder.role, Role::Sent | Role::Drafts)).then(|| {
273            folders.iter().filter(|f| f.role == Role::Sent).flat_map(|f| maildir::read_messages(&account.maildir_path().join(&f.local))).collect::<Vec<Card>>()
274        });
275        Some(json(&view::folder(account, folder, cards, &trusted, &sioul_core::words::Words::of(&config), open.all, &open.query, tr(), now, sent)))
276    });
277    let rows: Vec<DraftRow> = Draft::all().iter().filter(|d| !drafts.contains(&d.id)).map(draft_row).collect();
278    let mode = crate::backend::mode_now();
279    let hours = (mode.time != sioul_core::areas::Time::Any).then_some((mode.time, mode.week));
280    MailViews { accounts: json(&view::mail_accounts(&accounts, tr(), hours)), folder: folder.unwrap_or_default(), drafts: json(&rows) }
281}
282
283/// Computes the mail page on a thread and shows it, unless a newer one came first.
284pub(crate) fn show_mail(qt: &QtThread, shared: &Arc<Shared>) {
285    crate::mailsearch::changed(qt, shared);
286    let qt = qt.clone();
287    crate::backend::coalesced(shared, |s| &s.mail_job, move |shared| show_mail_here(&qt, shared));
288}
289
290/// The mail page computed on this thread, and shown unless a newer one came
291/// first. `backend::show` runs it in the same look at the mail as the Porch,
292/// so that the inbox the page shows is parsed once for both (`maildir::CardsKept`).
293pub(crate) fn show_mail_here(qt: &QtThread, shared: &Shared) {
294    let generation = shared.mail_generation.fetch_add(1, Ordering::Relaxed) + 1;
295    let views = views(shared);
296    let _ = qt.queue(move |mut sioul| {
297        // Only the newest (see `backend::show`).
298        if sioul.shared().mail_generation.load(Ordering::Relaxed) != generation || sioul.shared().mail_shown_generation.fetch_max(generation, Ordering::Relaxed) > generation {
299            return;
300        }
301        sioul.as_mut().set_mail_accounts(QString::from(&views.accounts));
302        sioul.as_mut().set_mail_folder(QString::from(&views.folder));
303        sioul.as_mut().set_drafts(QString::from(&views.drafts));
304    });
305}
306
307/// The account a message belongs to, and where its file is now.
308pub(crate) fn locate(key: &str) -> Option<(Account, PathBuf)> {
309    let file = maildir::locate(Path::new(key))?;
310    let account = load_config().account_of(&file)?.clone();
311    Some((account, file))
312}
313
314/// "read", "unread", "flag", "unflag", "archive", "trash", "junk",
315/// "not-junk", "spam", "not-spam", "move". "Spam" and "Not spam", from the
316/// review queue or the Reader, as the message's folder asks: in a Junk
317/// folder (your filter moved it there), "Spam" keeps it there marked, "Not
318/// spam" brings it back to the inbox; elsewhere, "Spam" moves it into the
319/// Junk folder, "Not spam" leaves it where it is, marked.
320pub(crate) fn act(qt: &QtThread, shared: &Arc<Shared>, key: &str, action: &str, target: &str) {
321    if let Some(place) = ServerRef::parse(key) {
322        return act_on_server(qt, shared, place, action, target);
323    }
324    let Some((account, file)) = locate(key) else {
325        set_status(qt, tr().text("mail-message-gone", None));
326        return;
327    };
328    let folder = mailbox::folder_of(&account, &file);
329    let in_junk = folder.as_ref().is_some_and(|f| f.role == Role::Junk);
330    let Some(action) = action_of(action, target, in_junk) else { return };
331    let line = match &action {
332        Action::Read(_) | Action::Flag(_) => return act_now(qt, shared, account, file, action),
333        Action::Archive => tr().text("undo-archived", None),
334        Action::Trash if folder.as_ref().is_some_and(|f| f.role == Role::Trash) => tr().text("undo-deleted", None),
335        Action::Trash => tr().text("undo-trashed", None),
336        Action::Junk if in_junk => tr().text("undo-spam-kept", None),
337        Action::Junk => tr().text("undo-junked", None),
338        Action::NotJunk => tr().text("undo-not-junk", None),
339        Action::NotSpam => tr().text("undo-not-spam", None),
340        Action::Move(name) => {
341            let title = mailbox::folders(&account.id).into_iter().find(|f| f.name == *name).map_or_else(|| name.clone(), |f| folder_title(&f));
342            say("undo-moved", &[("folder", title)])
343        }
344        // Your filter's own move is never asked from the window.
345        Action::Filtered(_) => return,
346    };
347    schedule(qt, shared, Work::Act { account, file, action }, line);
348}
349
350/// An act on a message seen on its server only (a search's): reading and
351/// flags at once, moves, archiving and the trash with "Undo"; what teaches
352/// the spam filter needs the message here first.
353fn act_on_server(qt: &QtThread, shared: &Arc<Shared>, place: ServerRef, action: &str, target: &str) {
354    let Some(account) = load_config().account(&place.account).filter(|a| a.syncs()).cloned() else {
355        set_status(qt, say("account-unknown", &[("id", place.account.clone())]));
356        return;
357    };
358    let Some(action) = action_of(action, target, false) else { return };
359    let line = match &action {
360        Action::Read(_) | Action::Flag(_) => return act_now_many(qt, shared, Vec::new(), vec![(account, place)], action),
361        Action::Archive => tr().text("undo-archived", None),
362        Action::Trash => tr().text("undo-trashed", None),
363        Action::Move(name) => {
364            let title = mailbox::folders(&account.id).into_iter().find(|f| f.name == *name).map_or_else(|| name.clone(), |f| folder_title(&f));
365            say("undo-moved", &[("folder", title)])
366        }
367        _ => {
368            set_status(qt, tr().text("search-open-first", None));
369            return;
370        }
371    };
372    schedule(qt, shared, Work::Batch { account, files: Vec::new(), remote: vec![place], action }, line);
373}
374
375/// A sender blocked from one of their messages: the message goes into the
376/// spam filter's label log (`spam::labels`). Blocking does not wait on it.
377pub(crate) fn label_blocked(key: &str) {
378    if let Some((account, file)) = locate(key) {
379        let _ = mailbox::label(&account, &file, sioul_core::spam::labels::Source::Block);
380    }
381}
382
383fn folder_title(folder: &Folder) -> String {
384    if folder.role == Role::Other { folder.display.clone() } else { tr().text(folder.role.message_id(), None) }
385}
386
387/// What a key names, as an act on its message (`in_junk`: it is in a Junk
388/// folder); None for what is not a message here.
389fn action_of(action: &str, target: &str, in_junk: bool) -> Option<Action> {
390    Some(match action {
391        "read" => Action::Read(true),
392        "unread" => Action::Read(false),
393        "flag" => Action::Flag(true),
394        "unflag" => Action::Flag(false),
395        "archive" => Action::Archive,
396        "trash" => Action::Trash,
397        "junk" | "spam" => Action::Junk,
398        "not-junk" => Action::NotJunk,
399        // Your filter moved it into the Junk folder: back to the inbox.
400        "not-spam" if in_junk => Action::NotJunk,
401        "not-spam" => Action::NotSpam,
402        "move" if !target.is_empty() => Action::Move(target.to_string()),
403        _ => return None,
404    })
405}
406
407/// Whether a stored message is in a Junk folder of its account.
408fn in_junk(account: &Account, file: &Path) -> bool {
409    mailbox::folder_of(account, file).is_some_and(|f| f.role == Role::Junk)
410}
411
412/// The same act on several messages (a selection, the review queue's "for
413/// all"): reading and flagging at once, the rest under one "Undo"; "spam"
414/// and "not-spam" as each message's folder asks (`act`).
415pub(crate) fn act_many(qt: &QtThread, shared: &Arc<Shared>, keys: &[String], action: &str) {
416    let Some(first) = action_of(action, "", false) else { return };
417    let config = load_config();
418    let found: Vec<(Account, PathBuf)> = keys.iter().filter(|k| !ServerRef::is_key(k)).filter_map(|k| locate(k)).collect();
419    // Seen on their server only (a search's).
420    let remote: Vec<(Account, ServerRef)> = keys.iter().filter_map(|k| ServerRef::parse(k)).filter_map(|p| Some((config.account(&p.account).filter(|a| a.syncs())?.clone(), p))).collect();
421    if found.is_empty() && remote.is_empty() {
422        set_status(qt, tr().text("mail-message-gone", None));
423        return;
424    }
425    if !moves(&first) {
426        act_now_many(qt, shared, found, remote, first);
427        return;
428    }
429    // Archiving and the trash: one connection per account, the messages of a folder as one set.
430    if matches!(first, Action::Archive | Action::Trash) {
431        let in_trash = !found.is_empty() && remote.is_empty() && found.iter().all(|(account, file)| mailbox::folder_of(account, file).is_some_and(|f| f.role == Role::Trash));
432        let n = found.len() + remote.len();
433        let id = match (&first, in_trash) {
434            (Action::Archive, _) => "undo-many-archived",
435            (_, true) => "undo-many-deleted",
436            _ => "undo-many-trashed",
437        };
438        let works = batches(found, remote, &first);
439        schedule(qt, shared, Work::Many(works), say(id, &[("n", n.to_string())]));
440        return;
441    }
442    if found.is_empty() {
443        set_status(qt, tr().text("search-open-first", None));
444        return;
445    }
446    let in_trash = found.iter().all(|(account, file)| mailbox::folder_of(account, file).is_some_and(|f| f.role == Role::Trash));
447    let id = match (action, &first) {
448        ("spam", _) => "undo-review-all-spam",
449        ("not-spam", _) => "undo-review-all-not-spam",
450        (_, Action::Archive) => "undo-many-archived",
451        (_, Action::Junk) => "undo-many-junked",
452        (_, Action::NotJunk) => "undo-many-not-junk",
453        (_, Action::NotSpam) => "undo-many-not-spam",
454        _ if in_trash => "undo-many-deleted",
455        _ => "undo-many-trashed",
456    };
457    let line = say(id, &[("n", found.len().to_string())]);
458    let works = found
459        .into_iter()
460        .filter_map(|(account, file)| {
461            let action = action_of(action, "", in_junk(&account, &file))?;
462            Some(Work::Act { account, file, action })
463        })
464        .collect();
465    schedule(qt, shared, Work::Many(works), line);
466}
467
468/// Messages of several accounts, kept here or seen on their server only, as
469/// one batch per account (`Work::Batch`).
470fn batches(found: Vec<(Account, PathBuf)>, remote: Vec<(Account, ServerRef)>, action: &Action) -> Vec<Work> {
471    let mut by_account: BTreeMap<String, (Account, Vec<PathBuf>, Vec<ServerRef>)> = BTreeMap::new();
472    for (account, file) in found {
473        by_account.entry(account.id.clone()).or_insert_with(|| (account, Vec::new(), Vec::new())).1.push(file);
474    }
475    for (account, place) in remote {
476        by_account.entry(account.id.clone()).or_insert_with(|| (account, Vec::new(), Vec::new())).2.push(place);
477    }
478    by_account.into_values().map(|(account, files, remote)| Work::Batch { account, files, remote, action: action.clone() }).collect()
479}
480
481/// Messages dropped on a folder, of their account or of another one: those
482/// of its account in one connection, a folder's as one set (`UID MOVE`);
483/// those of another one copied there first, then taken off theirs.
484pub(crate) fn move_many(qt: &QtThread, shared: &Arc<Shared>, keys: &[String], account: &str, folder: &str) {
485    let config = load_config();
486    let Some(to) = config.account(account).filter(|a| a.syncs()).cloned() else {
487        set_status(qt, say("account-unknown", &[("id", account.to_string())]));
488        return;
489    };
490    let (mut files, mut remote, mut across) = (Vec::new(), Vec::new(), Vec::new());
491    for key in keys {
492        if let Some(place) = ServerRef::parse(key) {
493            // Already there: nothing to do.
494            if place.account == to.id {
495                if place.folder != folder {
496                    remote.push(place);
497                }
498            } else if let Some(from) = config.account(&place.account).filter(|a| a.syncs()) {
499                across.push(Work::AcrossRemote { from: from.clone(), place, to: to.clone(), folder: folder.to_string() });
500            }
501        } else if let Some((from, file)) = locate(key) {
502            if from.id != to.id {
503                across.push(Work::Across { from, file, to: to.clone(), folder: folder.to_string() });
504            } else if mailbox::folder_of(&from, &file).is_none_or(|f| f.name != folder) {
505                files.push(file);
506            }
507        }
508    }
509    let count = files.len() + remote.len() + across.len();
510    if count == 0 {
511        return;
512    }
513    let crossing = !across.is_empty();
514    let mut works = across;
515    if !files.is_empty() || !remote.is_empty() {
516        works.insert(0, Work::Batch { account: to.clone(), files, remote, action: Action::Move(folder.to_string()) });
517    }
518    let title = mailbox::folders(&to.id).into_iter().find(|f| f.name == folder).map_or_else(|| folder.to_string(), |f| folder_title(&f));
519    let n = count.to_string();
520    let line = if crossing {
521        say("undo-moved-across", &[("n", n), ("folder", title), ("account", to.address.clone().unwrap_or_else(|| to.id.clone()))])
522    } else if count > 1 {
523        say("undo-many-moved", &[("n", n), ("folder", title)])
524    } else {
525        say("undo-moved", &[("folder", title)])
526    };
527    schedule(qt, shared, Work::Many(works), line);
528}
529
530/// Reading and flagging: shown at once, then told to the server; put back if it refuses.
531fn act_now(qt: &QtThread, shared: &Arc<Shared>, account: Account, file: PathBuf, action: Action) {
532    let before = maildir::flags_of(&file);
533    let after = match &action {
534        Action::Read(true) => format!("{before}S"),
535        Action::Read(false) => before.replace('S', ""),
536        Action::Flag(true) => format!("{before}F"),
537        Action::Flag(false) => before.replace('F', ""),
538        _ => return,
539    };
540    let file = maildir::set_flags(&file, &after).unwrap_or(file);
541    show_mail(qt, shared);
542    let (qt, shared) = (qt.clone(), Arc::clone(shared));
543    std::thread::spawn(move || {
544        if let Err(e) = secret::password(&account).and_then(|p| mailbox::act(&account, &p, &file, &action)) {
545            if let Some(now) = maildir::locate(&file) {
546                let _ = maildir::set_flags(&now, &before);
547            }
548            tell(&qt, &shared, e.sentence(tr(), &account.id));
549            show_mail(&qt, &shared);
550        }
551    });
552}
553
554/// Reading and flagging several messages: shown at once, then each
555/// account's server told in one connection; put back where it refuses.
556fn act_now_many(qt: &QtThread, shared: &Arc<Shared>, found: Vec<(Account, PathBuf)>, remote: Vec<(Account, ServerRef)>, action: Action) {
557    let letters = |before: &str| match &action {
558        Action::Read(true) => format!("{before}S"),
559        Action::Read(false) => before.replace('S', ""),
560        Action::Flag(true) => format!("{before}F"),
561        Action::Flag(false) => before.replace('F', ""),
562        _ => before.to_string(),
563    };
564    type Marks = (Account, Vec<(PathBuf, String)>, Vec<ServerRef>);
565    let mut by_account: BTreeMap<String, Marks> = BTreeMap::new();
566    for (account, file) in found {
567        let before = maildir::flags_of(&file);
568        let file = maildir::set_flags(&file, &letters(&before)).unwrap_or(file);
569        by_account.entry(account.id.clone()).or_insert_with(|| (account, Vec::new(), Vec::new())).1.push((file, before));
570    }
571    for (account, place) in remote {
572        by_account.entry(account.id.clone()).or_insert_with(|| (account, Vec::new(), Vec::new())).2.push(place);
573    }
574    let keys: Vec<String> = by_account.values().flat_map(|(_, _, remote)| remote.iter().map(ServerRef::key)).collect();
575    crate::mailsearch::marked(&keys, letters);
576    show_mail(qt, shared);
577    for (account, files, remote) in by_account.into_values() {
578        let (qt, shared, action) = (qt.clone(), Arc::clone(shared), action.clone());
579        std::thread::spawn(move || {
580            let paths: Vec<PathBuf> = files.iter().map(|(file, _)| file.clone()).collect();
581            let pairs: Vec<(String, sioul_core::card::ImapOrigin)> = remote.iter().map(|p| (p.folder.clone(), p.origin)).collect();
582            if let Err(e) = secret::password(&account).and_then(|p| mailbox::act_many(&account, &p, &paths, &pairs, &action)) {
583                for (file, before) in &files {
584                    if let Some(now) = maildir::locate(file) {
585                        let _ = maildir::set_flags(&now, before);
586                    }
587                }
588                tell(&qt, &shared, e.sentence(tr(), &account.id));
589                show_mail(&qt, &shared);
590            }
591        });
592    }
593}
594
595/// Opening a message marks it read, except while the window takes its own pictures.
596pub(crate) fn opened(qt: &QtThread, shared: &Arc<Shared>, key: &str) {
597    if std::env::var_os("SIOUL_GRAB").is_some() {
598        return;
599    }
600    if let Some((account, file)) = locate(key)
601        && !maildir::flags_of(&file).contains('S')
602    {
603        act_now(qt, shared, account, file, Action::Read(true));
604    }
605}
606
607fn schedule(qt: &QtThread, shared: &Arc<Shared>, work: Work, line: String) {
608    let pending = Arc::new(Pending { work, line: line.clone(), taken: AtomicBool::new(false) });
609    if let Ok(mut list) = shared.pending.lock() {
610        list.push(Arc::clone(&pending));
611    }
612    let _ = qt.queue(move |mut sioul| sioul.as_mut().set_undo_line(QString::from(&line)));
613    show(qt, shared);
614    // An event deleted or left out: gone from the day and the agenda at once.
615    let events = touches_pim(&pending.work);
616    if events {
617        crate::work::show_work(qt, shared);
618        crate::pim::show_pim(qt, shared);
619    }
620    let (qt, shared) = (qt.clone(), Arc::clone(shared));
621    std::thread::spawn(move || {
622        std::thread::sleep(Duration::from_secs(UNDO_SECONDS));
623        finish(&qt, &shared, &pending);
624    });
625}
626
627/// What waited, done now if nobody did it yet: its timer ran out, or the app is put away.
628fn finish(qt: &QtThread, shared: &Arc<Shared>, pending: &Arc<Pending>) {
629    if pending.taken.swap(true, Ordering::Relaxed) {
630        return;
631    }
632    // Done: the line says it, without "Undo" now.
633    let line = perform(&pending.work, shared).or_else(|| Some(pending.line.clone()));
634    forget(qt, shared, pending);
635    if let Some(line) = line {
636        tell(qt, shared, line);
637    }
638    show(qt, shared);
639    if touches_pim(&pending.work) {
640        crate::work::show_work(qt, shared);
641    }
642}
643
644/// Put away on a phone, Sioul may be stopped any moment: what waits for its
645/// ten seconds is done now rather than lost (an event deleted would come back).
646pub(crate) fn finish_all(qt: &QtThread, shared: &Arc<Shared>) {
647    for pending in shared.pending.lock().map(|p| p.clone()).unwrap_or_default() {
648        finish(qt, shared, &pending);
649    }
650}
651
652/// Every watcher: mail, calendars and contacts, GitHub.
653fn every_watcher(shared: &Shared) -> Vec<Arc<sioul_sync::Control>> {
654    let mut all: Vec<Arc<sioul_sync::Control>> = shared.watchers.lock().map(|w| w.values().cloned().collect()).unwrap_or_default();
655    all.extend(shared.dav_watchers.lock().map(|w| w.values().cloned().collect::<Vec<_>>()).unwrap_or_default());
656    all.extend(shared.github.lock().ok().and_then(|g| g.clone()));
657    all
658}
659
660/// Put away on a phone: the watchers keep only what notices new mail (the
661/// inbox's IDLE); the other folders, calendars and GitHub wait for its return.
662pub(crate) fn quiet_watchers(shared: &Shared) {
663    for control in every_watcher(shared) {
664        control.set_quiet(true);
665    }
666}
667
668/// Back on a phone's screen, where pauses may have slept through hours: every
669/// watcher fetches now (leaving quiet wakes it).
670pub(crate) fn wake_watchers(shared: &Shared) {
671    for control in every_watcher(shared) {
672        control.set_quiet(false);
673        control.nudge();
674    }
675}
676
677/// A note in the trash: "Undo" stays offered for a moment, as for mail.
678pub(crate) fn offer_untrash(qt: &QtThread, shared: &Arc<Shared>, trashed: String, path: String, line: String) {
679    schedule(qt, shared, Work::Untrash { trashed, path }, line);
680}
681
682/// The day closed: "Undo" stays offered for a moment, as for mail.
683pub(crate) fn rest(qt: &QtThread, shared: &Arc<Shared>, previous: sioul_core::quiet::Overrides, after: sioul_core::quiet::Overrides, line: String) {
684    schedule(qt, shared, Work::Rest { previous, after }, line);
685}
686
687/// A change made already (a drag): "Undo" stays offered for a moment, as for
688/// mail, and `back` puts back what was there.
689pub(crate) fn offer_back(qt: &QtThread, shared: &Arc<Shared>, line: String, back: impl Fn(&QtThread, &Arc<Shared>) -> String + Send + Sync + 'static) {
690    schedule(qt, shared, Work::Back { back: Box::new(back) }, line);
691}
692
693/// Takes a pending act off the list; the one before it, if any, can still be undone.
694fn forget(qt: &QtThread, shared: &Shared, pending: &Arc<Pending>) {
695    let next = shared
696        .pending
697        .lock()
698        .map(|mut list| {
699            list.retain(|p| !Arc::ptr_eq(p, pending));
700            list.iter().rev().find(|p| !p.taken.load(Ordering::Relaxed)).map(|p| p.line.clone()).unwrap_or_default()
701        })
702        .unwrap_or_default();
703    let _ = qt.queue(move |mut sioul| sioul.as_mut().set_undo_line(QString::from(&next)));
704}
705
706/// Does what waited; returns what the status line says about it, if anything.
707fn perform(work: &Work, shared: &Shared) -> Option<String> {
708    match work {
709        Work::Act { account, file, action } => match secret::password(account).and_then(|p| mailbox::act(account, &p, file, action)) {
710            Ok(()) => {
711                nudge(shared, &account.id);
712                None
713            }
714            Err(e) => Some(e.sentence(tr(), &account.id)),
715        },
716        Work::Across { from, file, to, folder } => {
717            let moved = secret::password(from).and_then(|p| secret::password(to).and_then(|q| mailbox::move_across(from, &p, file, to, &q, folder)));
718            match moved {
719                Ok(()) => {
720                    nudge(shared, &from.id);
721                    nudge(shared, &to.id);
722                    None
723                }
724                Err(e) => Some(e.sentence(tr(), &from.id)),
725            }
726        }
727        Work::Batch { account, files, remote, action } => {
728            let pairs: Vec<(String, sioul_core::card::ImapOrigin)> = remote.iter().map(|p| (p.folder.clone(), p.origin)).collect();
729            match secret::password(account).and_then(|p| mailbox::act_many(account, &p, files, &pairs, action)) {
730                Ok(()) => {
731                    nudge(shared, &account.id);
732                    crate::mailsearch::forget(&remote.iter().map(ServerRef::key).collect::<Vec<_>>());
733                    None
734                }
735                Err(e) => Some(e.sentence(tr(), &account.id)),
736            }
737        }
738        Work::AcrossRemote { from, place, to, folder } => {
739            let moved = secret::password(from).and_then(|p| secret::password(to).and_then(|q| mailbox::move_across_from_server(from, &p, &place.folder, place.origin, to, &q, folder)));
740            match moved {
741                Ok(()) => {
742                    nudge(shared, &from.id);
743                    nudge(shared, &to.id);
744                    crate::mailsearch::forget(&[place.key()]);
745                    None
746                }
747                Err(e) => Some(e.sentence(tr(), &from.id)),
748            }
749        }
750        // Each in turn; the first problem is said, the others are still done.
751        Work::Many(works) => works.iter().filter_map(|w| perform(w, shared)).collect::<Vec<_>>().into_iter().next(),
752        Work::Send { draft } => Some(send_now(draft, shared)),
753        Work::SendSigned { draft, account, outgoing } => Some(send_signed_now(draft, account, outgoing, shared)),
754        Work::Discard { draft } => {
755            if let Some(draft) = Draft::by_id(draft) {
756                draft.discard();
757            }
758            None
759        }
760        Work::Remove { account, file } => match std::fs::remove_file(file) {
761            Ok(()) => {
762                crate::pim::nudge(shared, account);
763                None
764            }
765            Err(e) => Some(format!("{}: {e}", file.display())),
766        },
767        Work::Rest { .. } | Work::Untrash { .. } | Work::Back { .. } => None,
768        Work::Unsubscribe { list, sender, account, way } => Some(unsubscribe_now(list, sender, account, way, shared)),
769        Work::Filters { planned } => {
770            let said = crate::filters::perform(planned);
771            for (account, _) in planned {
772                nudge(shared, &account.id);
773            }
774            said
775        }
776        Work::Skip { account, file, start } => {
777            let skipped = std::fs::read_to_string(file).ok().and_then(|text| sioul_core::agenda::skip_occurrence(&text, *start));
778            match skipped.map(|text| sioul_core::vdir::write_item(file, &text)) {
779                Some(Ok(())) => {
780                    crate::pim::nudge(shared, account);
781                    None
782                }
783                Some(Err(e)) => Some(e),
784                None => Some(tr().text("agenda-gone", None)),
785            }
786        }
787    }
788}
789
790/// The account's watcher fetches now: a moved message shows in its new folder,
791/// a sent one in Sent.
792fn nudge(shared: &Shared, account: &str) {
793    if let Some(control) = shared.watchers.lock().ok().and_then(|w| w.get(account).cloned()) {
794        control.nudge();
795    }
796}
797
798fn send_now(id: &str, shared: &Shared) -> String {
799    let Some(draft) = Draft::by_id(id) else { return tr().text("mail-draft-gone", None) };
800    let config = load_config();
801    let Some(account) = config.account(&draft.account).filter(|a| a.syncs()).cloned() else {
802        return say("account-unknown", &[("id", draft.account.clone())]);
803    };
804    let sent = secret::password(&account).and_then(|p| sioul_sync::send::send_draft(&config_path(), &account, &p, &draft, tr()));
805    match sent {
806        Ok(()) => {
807            draft.discard();
808            nudge(shared, &account.id);
809            tr().text("mail-sent", None)
810        }
811        Err(e @ SyncError::NotFiled(_)) => {
812            draft.discard();
813            e.sentence(tr(), &account.id)
814        }
815        Err(e) => say("mail-not-sent", &[("detail", e.sentence(tr(), &account.id))]),
816    }
817}
818
819/// Sends what was built and signed at Send, as built; then the draft goes.
820fn send_signed_now(id: &str, account: &Account, outgoing: &compose::Outgoing, shared: &Shared) -> String {
821    let sent = secret::password(account).and_then(|p| sioul_sync::send::send_built(&config_path(), account, &p, outgoing));
822    let discard = || {
823        if let Some(draft) = Draft::by_id(id) {
824            draft.discard();
825        }
826    };
827    match sent {
828        Ok(()) => {
829            discard();
830            nudge(shared, &account.id);
831            tr().text("mail-sent", None)
832        }
833        Err(e @ SyncError::NotFiled(_)) => {
834            discard();
835            e.sentence(tr(), &account.id)
836        }
837        Err(e) => say("mail-not-sent", &[("detail", e.sentence(tr(), &account.id))]),
838    }
839}
840
841/// What a message offers to leave its list (docs/client.md, "Unsubscribing"),
842/// judged as the Reader judges it, and the account a request by mail goes
843/// out from: the one the message came to, when it sends.
844fn unsubscribe_offer(config: &Config, triaged: &sioul_core::porch::Triaged) -> Option<(unsubscribe::Offer, Option<Account>)> {
845    let account = triaged.card.account.as_deref();
846    let trusted = config.mail_sources().into_iter().find(|s| s.account.as_deref() == account).map(|s| s.trusted_ids).unwrap_or_default();
847    let mut offer = unsubscribe::offer(&triaged.card, &trusted, triaged.trust, &triaged.lane, &triaged.reasons)?;
848    // In the junk, as spam: answering it tells its sender your address is read.
849    let file = triaged.card.path.as_deref();
850    if file.and_then(|f| mailbox::folder_of(config.account_of(f)?, f)).is_some_and(|f| f.role == Role::Junk) {
851        offer.way = Err(unsubscribe::Refusal::Spam);
852    }
853    let sender = account.and_then(|id| config.account(id)).filter(|a| a.syncs() && a.address.is_some()).cloned();
854    Some((offer, sender))
855}
856
857/// The Reader's button for a message: none when it names no way out of a list.
858pub(crate) fn unsubscribe_view(config: &Config, triaged: &sioul_core::porch::Triaged) -> Option<unsubscribe::UnsubscribeView> {
859    let (offer, sender) = unsubscribe_offer(config, triaged)?;
860    let record = unsubscribe::Record::load(&unsubscribe::Record::default_path());
861    let from = sender.as_ref().and_then(|a| a.address.as_deref());
862    Some(unsubscribe::view(&offer, &record, from, tr(), Zoned::now().date()))
863}
864
865/// One click on "Unsubscribe": one click (RFC 8058) or a message to the list
866/// waits ten seconds with "Undo", as sending does; a list that takes it only
867/// on its web page has the page returned, to open in the browser, at once.
868/// What cannot be done is said in the status line, the button's tip.
869pub(crate) fn unsubscribe(qt: &QtThread, shared: &Arc<Shared>, config: &Config, triaged: Option<sioul_core::porch::Triaged>) -> String {
870    let Some(triaged) = triaged else {
871        set_status(qt, tr().text("mail-message-gone", None));
872        return String::new();
873    };
874    let Some((offer, sender)) = unsubscribe_offer(config, &triaged) else { return String::new() };
875    let record = unsubscribe::Record::default_path();
876    let shown = unsubscribe::view(&offer, &unsubscribe::Record::load(&record), sender.as_ref().and_then(|a| a.address.as_deref()), tr(), Zoned::now().date());
877    if !shown.offered {
878        tell(qt, shared, shown.tip);
879        return String::new();
880    }
881    // A demo profile stays off the network.
882    if crate::backend::offline() {
883        tell(qt, shared, tr().text("unsubscribe-demo", None));
884        return String::new();
885    }
886    let list = offer.list.clone();
887    let address = triaged.card.from_address.clone().unwrap_or_default();
888    match offer.way {
889        Ok(unsubscribe::Way::Page { url }) => {
890            let left = unsubscribe::Left { key: list.key.clone(), name: list.name.clone(), address, way: "page".into(), at: Zoned::now().timestamp().as_second() };
891            if let Err(e) = unsubscribe::Record::add(&record, left) {
892                set_status(qt, e);
893            }
894            tell(qt, shared, say("unsubscribe-page-open", &[("list", list.name)]));
895            url
896        }
897        Ok(way) => {
898            let line = say("unsubscribe-pending", &[("list", list.name.clone())]);
899            // Asked already, still waiting: its line again, not a second request.
900            let waiting = shared.pending.lock().is_ok_and(|p| p.iter().any(|p| !p.taken.load(Ordering::Relaxed) && matches!(&p.work, Work::Unsubscribe { list: l, .. } if l.key == list.key)));
901            if waiting {
902                let _ = qt.queue(move |mut sioul| sioul.as_mut().set_undo_line(QString::from(&line)));
903                return String::new();
904            }
905            let account = sender.map(|a| a.id).unwrap_or_default();
906            schedule(qt, shared, Work::Unsubscribe { list, sender: address, account, way }, line);
907            String::new()
908        }
909        Err(_) => String::new(),
910    }
911}
912
913/// Leaves a list, its ten seconds gone: posts the one click, or sends the
914/// message from `account` as any message is sent (a copy in Sent, which
915/// Gmail files itself). Done, it is kept in the record. Returns what the
916/// status line says.
917fn unsubscribe_now(list: &unsubscribe::List, sender: &str, account: &str, way: &unsubscribe::Way, shared: &Shared) -> String {
918    let failed = |why: String| say("unsubscribe-failed", &[("list", list.name.clone()), ("why", why)]);
919    let done = |way: &str| {
920        let left = unsubscribe::Left { key: list.key.clone(), name: list.name.clone(), address: sender.to_string(), way: way.to_string(), at: Zoned::now().timestamp().as_second() };
921        unsubscribe::Record::add(&unsubscribe::Record::default_path(), left).err()
922    };
923    match way {
924        unsubscribe::Way::OneClick { url } => match sioul_sync::unsubscribe::one_click(url) {
925            Ok(sioul_sync::unsubscribe::Outcome::Done) => done("one-click").unwrap_or_else(|| say("unsubscribe-done-one-click", &[("list", list.name.clone())])),
926            Ok(sioul_sync::unsubscribe::Outcome::Refused(code)) => failed(say("unsubscribe-why-status", &[("code", code.to_string())])),
927            Ok(sioul_sync::unsubscribe::Outcome::Elsewhere(host)) => failed(say("unsubscribe-why-redirect", &[("host", host)])),
928            Ok(sioul_sync::unsubscribe::Outcome::TimedOut) => failed(say("unsubscribe-why-timeout", &[("seconds", sioul_sync::unsubscribe::WAIT.as_secs().to_string())])),
929            Err(SyncError::Tls(detail)) => failed(say("unsubscribe-why-tls", &[("detail", detail)])),
930            Err(e) => failed(say("unsubscribe-why-network", &[("detail", e.detail().to_string())])),
931        },
932        unsubscribe::Way::Mail { to, subject, body } => {
933            let config = load_config();
934            let Some(account) = config.account(account).filter(|a| a.syncs()).cloned() else {
935                return failed(say("account-unknown", &[("id", account.to_string())]));
936            };
937            let me = account.address.clone().unwrap_or_default();
938            let name = account.name.clone().unwrap_or_default();
939            let sent = unsubscribe::request((&name, &me), to, subject, body, Zoned::now().timestamp().as_second()).and_then(|outgoing| {
940                let password = secret::password(&account).map_err(|e| e.sentence(tr(), &account.id))?;
941                let account = crate::pim::with_smtp(&account)?;
942                match sioul_sync::send::send(&account, &password, &outgoing) {
943                    // Sent; a copy not filed in Sent changes nothing for the list.
944                    Ok(()) | Err(SyncError::NotFiled(_)) => Ok(()),
945                    Err(e) => Err(e.sentence(tr(), &account.id)),
946                }
947            });
948            match sent {
949                Ok(()) => {
950                    nudge(shared, &account.id);
951                    done("mail").unwrap_or_else(|| say("unsubscribe-done-mail", &[("list", list.name.clone()), ("to", to.clone())]))
952                }
953                Err(why) => failed(why),
954            }
955        }
956        unsubscribe::Way::Page { .. } => String::new(),
957    }
958}
959
960/// Cancels the newest act still waiting; a send or a discard gives back its
961/// draft, whose window opens again.
962pub(crate) fn undo(qt: &QtThread, shared: &Arc<Shared>) -> Option<String> {
963    let pending = shared.pending.lock().ok()?.iter().rev().find(|p| !p.taken.load(Ordering::Relaxed)).cloned()?;
964    if pending.taken.swap(true, Ordering::Relaxed) {
965        set_status(qt, tr().text("undo-too-late", None));
966        return None;
967    }
968    forget(qt, shared, &pending);
969    show(qt, shared);
970    let (line, draft) = match &pending.work {
971        Work::Untrash { trashed, path } => {
972            let root = load_config().notes_root_path();
973            let back = root.ok_or_else(|| tr().text("error-no-store", None)).and_then(|root| sioul_core::notes::untrash(&root, trashed, path));
974            crate::work::show_work(qt, shared);
975            (back.err().unwrap_or_else(|| tr().text("undo-done", None)), None)
976        }
977        Work::Rest { previous, after } => {
978            // What the act changed put back as it was, over the file as it is now
979            // (`Overrides::put_back`: `save_merged` then writes what differs from this read).
980            let path = sioul_core::quiet::Overrides::default_path();
981            let _ = sioul_core::quiet::Overrides::load(&path).put_back(previous, after).save(&path);
982            let _ = qt.queue(|mut sioul| sioul.as_mut().set_mode(QString::from(&crate::backend::mode_json())));
983            show(qt, shared);
984            crate::work::show_work(qt, shared);
985            (tr().text("undo-done", None), None)
986        }
987        Work::Back { back } => {
988            let problem = back(qt, shared);
989            (if problem.is_empty() { tr().text("undo-done", None) } else { problem }, None)
990        }
991        Work::Send { draft } | Work::SendSigned { draft, .. } => (tr().text("undo-send-undone", None), Some(draft.clone())),
992        Work::Discard { draft } => (tr().text("undo-done", None), Some(draft.clone())),
993        // A task or an event deleted, an occurrence left out: back in the plan and the agenda at once.
994        Work::Remove { .. } | Work::Skip { .. } => {
995            if let Ok(mut cache) = shared.loaded.lock() {
996                *cache = None;
997            }
998            crate::work::show_work(qt, shared);
999            crate::pim::show_pim(qt, shared);
1000            (tr().text("undo-done", None), None)
1001        }
1002        // A task and its time blocks: what was changed at once put back, what waited kept.
1003        Work::Many(works) if works.iter().any(|w| matches!(w, Work::Back { .. })) || touches_pim(&pending.work) => {
1004            // Each put back, the first problem said.
1005            let problems: Vec<String> = works.iter().filter_map(|w| if let Work::Back { back } = w { Some(back(qt, shared)) } else { None }).collect();
1006            let problem = problems.into_iter().find(|p| !p.is_empty());
1007            if let Ok(mut cache) = shared.loaded.lock() {
1008                *cache = None;
1009            }
1010            crate::work::show_work(qt, shared);
1011            crate::pim::show_pim(qt, shared);
1012            (problem.unwrap_or_else(|| tr().text("undo-done", None)), None)
1013        }
1014        Work::Act { .. } | Work::Across { .. } | Work::Batch { .. } | Work::AcrossRemote { .. } | Work::Many(_) | Work::Unsubscribe { .. } | Work::Filters { .. } => (tr().text("undo-done", None), None),
1015    };
1016    tell(qt, shared, line);
1017    draft
1018}
1019
1020/// The window closes: what waits is done now rather than lost.
1021pub(crate) fn flush(shared: &Arc<Shared>) {
1022    let waiting: Vec<Arc<Pending>> = shared.pending.lock().map(|p| p.clone()).unwrap_or_default();
1023    for pending in waiting {
1024        if !pending.taken.swap(true, Ordering::Relaxed) {
1025            let _ = perform(&pending.work, shared);
1026        }
1027    }
1028}
1029
1030/// The account a new message goes out from: the one asked, else the first by priority.
1031fn sender<'a>(config: &'a Config, asked: &str) -> Option<&'a Account> {
1032    config.account(asked).filter(|a| a.syncs()).or_else(|| config.accounts.iter().filter(|a| a.syncs()).min_by_key(|a| a.priority))
1033}
1034
1035/// A new draft: "new", "reply", "reply-all", "forward" (the last three from the message `key`).
1036pub(crate) fn compose(kind: &str, key: &str, account: &str) -> Result<String, String> {
1037    let config = load_config();
1038    let mut draft = match kind {
1039        "new" => {
1040            let account = sender(&config, account).ok_or_else(|| tr().text("mail-no-account", None))?;
1041            Draft::new(&account.id)
1042        }
1043        _ => {
1044            let kind = match kind {
1045                "reply" => DraftKind::Reply,
1046                "reply-all" => DraftKind::ReplyAll,
1047                "forward" => DraftKind::Forward,
1048                _ => return Err(String::new()),
1049            };
1050            let file = maildir::locate(Path::new(key)).ok_or_else(|| tr().text("mail-message-gone", None))?;
1051            let account = config.account_of(&file).or_else(|| sender(&config, account)).ok_or_else(|| tr().text("mail-no-account", None))?;
1052            let mut draft = compose::answer(&file, kind, &account.id, &own_addresses(&config)).ok_or_else(|| tr().text("mail-message-gone", None))?;
1053            draft.keep_original()?;
1054            crate::crypto::protect_answer(&mut draft);
1055            draft
1056        }
1057    };
1058    draft.add_signature(config.account(&draft.account).and_then(|a| a.signature.as_deref()));
1059    draft.save()?;
1060    Ok(draft.id)
1061}
1062
1063#[derive(serde::Serialize)]
1064struct SenderView {
1065    id: String,
1066    label: String,
1067}
1068
1069#[derive(serde::Serialize)]
1070struct AttachedView {
1071    index: usize,
1072    name: String,
1073    size: String,
1074}
1075
1076#[derive(serde::Serialize)]
1077struct DraftView {
1078    id: String,
1079    account: String,
1080    accounts: Vec<SenderView>,
1081    to: String,
1082    cc: String,
1083    bcc: String,
1084    subject: String,
1085    body: String,
1086    attachments: Vec<AttachedView>,
1087    /// A forward's attachments, by their index in the original.
1088    forwarded: Vec<AttachedView>,
1089    /// "Reply to Jane", "New message"…
1090    title: String,
1091    /// What goes below your text when it is sent: "Jane's message of …, quoted."
1092    below: String,
1093    sign: bool,
1094    encrypt: bool,
1095    protection: crate::crypto::DraftProtection,
1096    /// "Attaching 2 files…" while files shared from another application are copied (`outside`), else "".
1097    attaching: String,
1098}
1099
1100/// A draft as the writing window shows it.
1101pub(crate) fn draft(id: &str) -> Option<String> {
1102    let draft = Draft::by_id(id)?;
1103    let config = load_config();
1104    let size = |bytes: u64| view::size(tr(), usize::try_from(bytes).unwrap_or(usize::MAX));
1105    let title = match draft.kind {
1106        DraftKind::New => tr().text("compose-title-new", None),
1107        DraftKind::Reply | DraftKind::ReplyAll => say("compose-title-reply", &[("names", names(&draft.to))]),
1108        DraftKind::Forward => tr().text("compose-title-forward", None),
1109    };
1110    let original = draft.original.as_deref().and_then(maildir::read_one);
1111    let below = match (draft.kind, original) {
1112        (DraftKind::New, _) | (_, None) => String::new(),
1113        (DraftKind::Forward, Some(card)) => say("compose-below-forward", &[("name", card.sender().to_string()), ("date", view::date(tr(), card.date))]),
1114        (_, Some(card)) => say("compose-below-quote", &[("name", card.sender().to_string()), ("date", view::date(tr(), card.date))]),
1115    };
1116    Some(json(&DraftView {
1117        id: draft.id.clone(),
1118        account: draft.account.clone(),
1119        accounts: config
1120            .accounts
1121            .iter()
1122            .filter(|a| a.syncs())
1123            .map(|a| SenderView { id: a.id.clone(), label: a.address.clone().unwrap_or_else(|| a.id.clone()) })
1124            .collect(),
1125        to: draft.to.join(", "),
1126        cc: draft.cc.join(", "),
1127        bcc: draft.bcc.join(", "),
1128        subject: draft.subject.clone(),
1129        body: draft.body.clone(),
1130        attachments: draft
1131            .attachments
1132            .iter()
1133            .enumerate()
1134            .map(|(index, path)| AttachedView {
1135                index,
1136                name: path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().to_string()),
1137                size: std::fs::metadata(path).map(|m| size(m.len())).unwrap_or_default(),
1138            })
1139            .collect(),
1140        forwarded: draft.forwarded().into_iter().map(|(index, name)| AttachedView { index: index as usize, name, size: String::new() }).collect(),
1141        title,
1142        below,
1143        sign: draft.sign,
1144        encrypt: draft.encrypt,
1145        protection: crate::crypto::draft_protection(&draft),
1146        attaching: crate::outside::attaching(&draft.id),
1147    }))
1148}
1149
1150#[derive(serde::Deserialize)]
1151struct DraftEdit {
1152    id: String,
1153    account: String,
1154    to: String,
1155    cc: String,
1156    bcc: String,
1157    subject: String,
1158    body: String,
1159    #[serde(default)]
1160    sign: bool,
1161    #[serde(default)]
1162    encrypt: bool,
1163}
1164
1165/// Saves what the writing window holds; returns "Saved at 14:02".
1166pub(crate) fn save_draft(text: &str) -> Result<String, String> {
1167    let edit: DraftEdit = serde_json::from_str(text).map_err(|e| e.to_string())?;
1168    let mut draft = Draft::by_id(&edit.id).ok_or_else(|| tr().text("mail-draft-gone", None))?;
1169    if load_config().account(&edit.account).is_some_and(Account::syncs) {
1170        draft.account = edit.account;
1171    }
1172    draft.to = compose::split_addresses(&edit.to);
1173    draft.cc = compose::split_addresses(&edit.cc);
1174    draft.bcc = compose::split_addresses(&edit.bcc);
1175    draft.subject = edit.subject;
1176    draft.body = edit.body;
1177    draft.sign = edit.sign;
1178    draft.encrypt = edit.encrypt;
1179    draft.save()?;
1180    Ok(say("compose-saved", &[("time", Zoned::now().strftime("%H:%M").to_string())]))
1181}
1182
1183/// Adds a file, from the file dialog or dropped on the window (`file://` URLs).
1184pub(crate) fn attach(id: &str, url: &str) -> Result<(), String> {
1185    let mut draft = Draft::by_id(id).ok_or_else(|| tr().text("mail-draft-gone", None))?;
1186    let path = crate::backend::local_path(url);
1187    if !path.is_file() {
1188        return Err(say("compose-not-a-file", &[("path", path.display().to_string())]));
1189    }
1190    if !draft.attachments.contains(&path) {
1191        draft.attachments.push(path);
1192    }
1193    draft.save().map(|_| ())
1194}
1195
1196/// Takes an attachment off: one you added, by position, or one a forward carries, by index.
1197pub(crate) fn detach(id: &str, index: usize, forwarded: bool) -> Result<(), String> {
1198    let mut draft = Draft::by_id(id).ok_or_else(|| tr().text("mail-draft-gone", None))?;
1199    if forwarded {
1200        draft.dropped.push(u32::try_from(index).unwrap_or(u32::MAX));
1201    } else if index < draft.attachments.len() {
1202        let taken = draft.attachments.remove(index);
1203        draft.save()?;
1204        // A copy Sioul made of a file another application shared goes with it.
1205        compose::remove_own_copy(&taken);
1206        return Ok(());
1207    }
1208    draft.save().map(|_| ())
1209}
1210
1211/// Sends after ten seconds; the window closes, "Undo" opens it again.
1212/// Returns what stops it now (no recipient), else nothing.
1213pub(crate) fn send(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> Option<String> {
1214    let Some(draft) = Draft::by_id(id) else { return Some(tr().text("mail-draft-gone", None)) };
1215    // Files shared from another application still being copied: not without them.
1216    if crate::outside::coming(id) {
1217        return Some(tr().text("compose-wait-files", None));
1218    }
1219    if draft.recipients().is_empty() {
1220        return Some(tr().text("compose-no-recipient", None));
1221    }
1222    let unknown: Vec<&String> = draft.to.iter().chain(&draft.cc).chain(&draft.bcc).filter(|e| compose::address_of(e).is_none()).collect();
1223    if let Some(entry) = unknown.first() {
1224        return Some(say("compose-bad-address", &[("entry", entry.to_string())]));
1225    }
1226    schedule(qt, shared, Work::Send { draft: id.to_string() }, tr().text("undo-sending", None));
1227    None
1228}
1229
1230/// Deletes a draft after ten seconds; "Undo" opens it again.
1231pub(crate) fn discard(qt: &QtThread, shared: &Arc<Shared>, id: &str) {
1232    schedule(qt, shared, Work::Discard { draft: id.to_string() }, tr().text("undo-discarded", None));
1233}
1234
1235/// Sends after ten seconds a message built and signed at Send with your
1236/// security key; "Undo" throws it away and opens the draft again.
1237pub(crate) fn send_signed(qt: &QtThread, shared: &Arc<Shared>, id: &str, account: Account, outgoing: compose::Outgoing) {
1238    schedule(qt, shared, Work::SendSigned { draft: id.to_string(), account, outgoing }, tr().text("undo-sending", None));
1239}
1240
1241/// The writing window closed: an untouched draft goes, a written one stays in Drafts.
1242pub(crate) fn closed(qt: &QtThread, shared: &Arc<Shared>, id: &str) {
1243    let Some(draft) = Draft::by_id(id) else { return };
1244    let signature = load_config().account(&draft.account).and_then(|a| a.signature.clone());
1245    let pending = hidden(shared).1.contains(id);
1246    if draft.is_empty(signature.as_deref()) && !crate::outside::coming(id) {
1247        draft.discard();
1248    } else if !pending {
1249        set_status(qt, tr().text("compose-kept", None));
1250    }
1251    crate::mail::show_mail(qt, shared);
1252}
1253
1254#[derive(serde::Serialize)]
1255struct Place {
1256    account: String,
1257    folder: String,
1258}
1259
1260/// Where a message is: its account and its folder's server name.
1261pub(crate) fn place(key: &str) -> String {
1262    let Some((account, file)) = locate(key) else { return "{}".into() };
1263    let folder = mailbox::folder_of(&account, &file).map(|f| f.name).unwrap_or_default();
1264    json(&Place { account: account.id, folder })
1265}
1266
1267/// The message as it came, headers and all, for "Show the source"; cut after 512 KB.
1268pub(crate) fn source(key: &str) -> String {
1269    let Some((_, file)) = locate(key) else { return String::new() };
1270    let raw = std::fs::read(&file).unwrap_or_default();
1271    let cut = raw.len().min(512 * 1024);
1272    String::from_utf8_lossy(&raw[..cut]).into_owned()
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277    use super::*;
1278
1279    #[test]
1280    fn a_task_and_its_blocks_go_together_or_stay_together() {
1281        // A task deleted with its time block to come (`work::delete`, `blocks::with_task`): one "Undo" for both.
1282        let dir = std::env::temp_dir().join(format!("sioul-removals-{}", std::process::id()));
1283        std::fs::create_dir_all(&dir).unwrap();
1284        let (task, block, gone) = (dir.join("task.ics"), dir.join("block.ics"), dir.join("gone.ics"));
1285        std::fs::write(&task, "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:t\r\nEND:VTODO\r\nEND:VCALENDAR\r\n").unwrap();
1286        std::fs::write(&block, "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:b\r\nX-SIOUL-TASK:t\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n").unwrap();
1287        let shared = Shared::default();
1288        let work = Work::Many(vec![Work::Remove { account: String::new(), file: task.clone() }, Work::Remove { account: String::new(), file: block.clone() }]);
1289        let pending = Arc::new(Pending { work, line: String::new(), taken: AtomicBool::new(false) });
1290        shared.pending.lock().unwrap().push(Arc::clone(&pending));
1291        // While "Undo" is offered: both out of view, both still there.
1292        let (hidden, _) = hidden_pim(&shared);
1293        assert!(hidden.contains(&task) && hidden.contains(&block), "{hidden:?}");
1294        assert!(touches_pim(&pending.work) && task.exists() && block.exists());
1295        // Undone (taken off the list, never done): both back, untouched.
1296        assert!(!pending.taken.swap(true, Ordering::Relaxed));
1297        shared.pending.lock().unwrap().clear();
1298        assert!(hidden_pim(&shared).0.is_empty() && task.exists() && block.exists());
1299        // Left to its ten seconds: both deleted; one gone already is said, the other still deleted.
1300        let late = Work::Many(vec![Work::Remove { account: String::new(), file: gone.clone() }, Work::Remove { account: String::new(), file: task.clone() }, Work::Remove { account: String::new(), file: block.clone() }]);
1301        let said = perform(&late, &shared);
1302        assert!(said.is_some_and(|line| line.contains("gone.ics")));
1303        assert!(!task.exists() && !block.exists());
1304        let _ = std::fs::remove_dir_all(&dir);
1305    }
1306
1307    #[test]
1308    fn dropped_files_keep_their_names() {
1309        use crate::backend::local_path;
1310        assert_eq!(local_path("file:///home/me/Re%C3%A7u%20final.pdf"), PathBuf::from("/home/me/Reçu final.pdf"));
1311        assert_eq!(local_path("file:///tmp/100%"), PathBuf::from("/tmp/100%"));
1312    }
1313}