1use 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
30const UNDO_SECONDS: u64 = 10;
32
33#[derive(Clone, Default)]
35pub(crate) struct OpenFolder {
36 pub account: String,
37 pub folder: String,
38 pub all: bool,
40 pub query: String,
41}
42
43enum Work {
45 Act { account: Account, file: PathBuf, action: Action },
46 Across { from: Account, file: PathBuf, to: Account, folder: String },
48 Batch { account: Account, files: Vec<PathBuf>, remote: Vec<ServerRef>, action: Action },
51 AcrossRemote { from: Account, place: ServerRef, to: Account, folder: String },
53 Many(Vec<Work>),
55 Send { draft: String },
56 SendSigned { draft: String, account: Account, outgoing: compose::Outgoing },
59 Discard { draft: String },
60 Remove { account: String, file: PathBuf },
62 Skip { account: String, file: PathBuf, start: i64 },
64 Rest { previous: sioul_core::quiet::Overrides, after: sioul_core::quiet::Overrides },
67 Untrash { trashed: String, path: String },
69 Back { back: Box<dyn Fn(&QtThread, &Arc<Shared>) -> String + Send + Sync> },
73 Unsubscribe { list: unsubscribe::List, sender: String, account: String, way: unsubscribe::Way },
75 Filters { planned: Vec<(Account, Vec<sioul_sync::filters::Job>)> },
78}
79
80pub(crate) struct Pending {
81 work: Work,
82 line: String,
84 taken: AtomicBool,
86}
87
88pub(crate) struct MailViews {
90 pub accounts: String,
91 pub folder: String,
92 pub drafts: String,
93}
94
95pub(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
104fn 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 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
131pub(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 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
153fn 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
162pub(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
172pub(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
177pub(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
183pub(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
188pub(crate) fn hidden_files(shared: &Shared) -> BTreeSet<PathBuf> {
190 hidden(shared).0
191}
192
193pub(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
234fn 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 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 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
283pub(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
290pub(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 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
307pub(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
314pub(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 Action::Filtered(_) => return,
346 };
347 schedule(qt, shared, Work::Act { account, file, action }, line);
348}
349
350fn 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
375pub(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
387fn 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 "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
407fn in_junk(account: &Account, file: &Path) -> bool {
409 mailbox::folder_of(account, file).is_some_and(|f| f.role == Role::Junk)
410}
411
412pub(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 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 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
468fn 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
481pub(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 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
530fn 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
554fn 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
595pub(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 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
627fn finish(qt: &QtThread, shared: &Arc<Shared>, pending: &Arc<Pending>) {
629 if pending.taken.swap(true, Ordering::Relaxed) {
630 return;
631 }
632 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
644pub(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
652fn 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
660pub(crate) fn quiet_watchers(shared: &Shared) {
663 for control in every_watcher(shared) {
664 control.set_quiet(true);
665 }
666}
667
668pub(crate) fn wake_watchers(shared: &Shared) {
671 for control in every_watcher(shared) {
672 control.set_quiet(false);
673 control.nudge();
674 }
675}
676
677pub(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
682pub(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
687pub(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
693fn 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
706fn 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 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
790fn 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
819fn 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
841fn 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 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
857pub(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
865pub(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 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 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
913fn 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 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
960pub(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 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 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 Work::Many(works) if works.iter().any(|w| matches!(w, Work::Back { .. })) || touches_pim(&pending.work) => {
1004 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
1020pub(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
1030fn 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
1035pub(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 forwarded: Vec<AttachedView>,
1089 title: String,
1091 below: String,
1093 sign: bool,
1094 encrypt: bool,
1095 protection: crate::crypto::DraftProtection,
1096 attaching: String,
1098}
1099
1100pub(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
1165pub(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
1183pub(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
1196pub(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 compose::remove_own_copy(&taken);
1206 return Ok(());
1207 }
1208 draft.save().map(|_| ())
1209}
1210
1211pub(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 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
1230pub(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
1235pub(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
1241pub(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
1260pub(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
1267pub(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 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 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 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 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}