1use crate::{SyncError, said};
14use crate::imap::{self, COMMAND, Imap, Server};
15use async_imap::types::NameAttribute;
16use futures_util::StreamExt;
17use sioul_core::card::ImapOrigin;
18use sioul_core::config::{Account, state_dir};
19use sioul_core::folders::{self, Folder, Role};
20use sioul_core::maildir;
21use sioul_core::spam::labels;
22use std::collections::{BTreeMap, BTreeSet};
23use std::path::{Path, PathBuf};
24
25pub async fn list(session: &mut Imap) -> Result<Vec<Folder>, SyncError> {
29 let mut found = Vec::new();
30 let mut names = imap::within(COMMAND, session.list(Some(""), Some("*"))).await?.map_err(imap::server)?;
31 while let Some(name) = names.next().await {
32 let name = name.map_err(imap::server)?;
33 let attributes = name.attributes();
34 if attributes.iter().any(|a| matches!(a, NameAttribute::NoSelect)) {
35 continue;
36 }
37 let special = attributes.iter().find_map(|a| match a {
38 NameAttribute::Sent => Some(Role::Sent),
39 NameAttribute::Drafts => Some(Role::Drafts),
40 NameAttribute::Junk => Some(Role::Junk),
41 NameAttribute::Trash => Some(Role::Trash),
42 NameAttribute::Archive => Some(Role::Archive),
43 NameAttribute::All => Some(Role::All),
44 NameAttribute::Flagged => Some(Role::Other),
45 _ => None,
46 });
47 let view = attributes.iter().any(|a| match a {
48 NameAttribute::Flagged => true,
49 NameAttribute::Extension(name) => name.eq_ignore_ascii_case("\\Important"),
50 _ => false,
51 });
52 if view {
53 continue;
54 }
55 found.push(folders::folder(name.name(), name.delimiter(), special));
56 }
57 drop(names);
58 found.sort_by(|a, b| (a.role, a.display.to_lowercase()).cmp(&(b.role, b.display.to_lowercase())));
59 Ok(found)
60}
61
62pub(crate) fn fetched(folder: &Folder, all: &[Folder], account: &Account) -> bool {
69 (folder.role != Role::All || !all.iter().any(|f| f.role == Role::Archive))
70 && (folder.role == Role::Inbox || (!account.skip_folders.contains(&folder.name) && own_dir(account, folder).is_some()))
71}
72
73pub(crate) fn own_dir(account: &Account, folder: &Folder) -> Option<PathBuf> {
76 let mut parts = Path::new(&folder.local).components();
77 match (parts.next(), parts.next()) {
78 (Some(std::path::Component::Normal(_)), None) => Some(account.maildir_path().join(&folder.local)),
79 _ => None,
80 }
81}
82
83pub fn create_folder(account: &Account, password: &str, name: &str) -> Result<(), SyncError> {
87 let name = name.trim();
88 if name.is_empty() {
89 return Err(SyncError::Said(said::FOLDER_NAME, String::new()));
90 }
91 let name = folders::encode_utf7(name);
92 let server = Server::of(account)?;
93 crate::fetch::block_on(async {
94 let mut session = imap::open(&server, password).await?;
95 let made = imap::within(COMMAND, session.create(&name)).await?.map_err(imap::server);
96 let listed = list(&mut session).await;
97 let _ = session.logout().await;
98 made?;
99 save(&account.id, &listed?)
100 })
101}
102
103pub const NOT_EMPTY: &str = "not-empty";
105
106pub fn delete_folder(account: &Account, password: &str, name: &str) -> Result<(), SyncError> {
110 let folder = folders(&account.id).into_iter().find(|f| f.name == name).ok_or_else(|| SyncError::Said(said::NO_FOLDER, name.to_string()))?;
111 if folder.role != Role::Other {
112 return Err(SyncError::Said(said::PURPOSE, folder.display.clone()));
113 }
114 let server = Server::of(account)?;
115 crate::fetch::block_on(async {
116 let mut session = imap::open(&server, password).await?;
117 let status = imap::within(COMMAND, session.status(name, "(MESSAGES)")).await?.map_err(imap::server)?;
118 if status.exists > 0 {
119 let _ = session.logout().await;
120 return Err(SyncError::Message(NOT_EMPTY.into()));
121 }
122 let deleted = imap::within(COMMAND, session.delete(name)).await?.map_err(imap::server);
123 let listed = list(&mut session).await;
124 let _ = session.logout().await;
125 deleted?;
126 save(&account.id, &listed?)
127 })?;
128 if let Some(dir) = own_dir(account, &folder) {
130 let _ = std::fs::remove_dir_all(dir);
131 }
132 crate::fetch::forget_folder_state(account, name)
133}
134
135pub fn forget_folder(account: &Account, name: &str) -> Result<(), SyncError> {
138 if let Some(root) = folders(&account.id).into_iter().find(|f| f.name == name && f.role != Role::Inbox).and_then(|f| own_dir(account, &f)) {
139 for sub in ["cur", "new"] {
140 for entry in std::fs::read_dir(root.join(sub)).into_iter().flatten().filter_map(Result::ok) {
141 let _ = std::fs::remove_file(entry.path());
142 }
143 }
144 }
145 crate::fetch::forget_folder_state(account, name)
146}
147
148fn folders_path(account: &str) -> PathBuf {
149 folders::saved_path(account)
150}
151
152#[derive(serde::Serialize, serde::Deserialize, Default)]
153struct Saved {
154 #[serde(default, rename = "folder")]
155 folders: Vec<Folder>,
156}
157
158pub(crate) fn save(account: &str, list: &[Folder]) -> Result<(), SyncError> {
160 let path = folders_path(account);
161 let fail = |e: std::io::Error| SyncError::Disk(format!("{}: {e}", path.display()));
162 if let Some(parent) = path.parent() {
163 std::fs::create_dir_all(parent).map_err(fail)?;
164 }
165 let text = toml::to_string(&Saved { folders: list.to_vec() }).map_err(|e| SyncError::Disk(e.to_string()))?;
166 std::fs::write(&path, text).map_err(fail)
167}
168
169pub fn folders(account: &str) -> Vec<Folder> {
171 let saved = folders::saved(account);
172 if saved.is_empty() { vec![folders::folder("INBOX", None, None)] } else { saved }
173}
174
175pub fn folder_of(account: &Account, file: &Path) -> Option<Folder> {
177 let directory = file.parent()?.parent()?;
178 let root = account.maildir_path();
179 let all = folders(&account.id);
180 if let Some(folder) = all.iter().find(|f| root.join(&f.local) == directory) {
181 return Some(folder.clone());
182 }
183 let directory = std::fs::canonicalize(directory).ok()?;
185 all.into_iter().find(|f| std::fs::canonicalize(root.join(&f.local)).is_ok_and(|d| d == directory))
186}
187
188pub(crate) async fn reconcile(session: &mut Imap, root: &Path, validity: u32) -> Result<(), SyncError> {
192 let local = local_files(root);
193 let uids: BTreeSet<u32> = local.iter().filter(|(o, _)| o.validity == validity).map(|(o, _)| o.uid).collect();
194 if uids.is_empty() {
195 return Ok(());
196 }
197 let mut server: BTreeMap<u32, String> = BTreeMap::new();
198 {
199 let mut fetches = imap::within(COMMAND, session.uid_fetch(ranges(&uids), "(UID FLAGS)")).await?.map_err(imap::server)?;
200 while let Some(fetch) = fetches.next().await {
201 let fetch = fetch.map_err(imap::server)?;
202 if let Some(uid) = fetch.uid {
203 server.insert(uid, crate::fetch::maildir_flags(fetch.flags()));
204 }
205 }
206 }
207 for (origin, path) in local.into_iter().filter(|(o, _)| o.validity == validity) {
208 match server.get(&origin.uid) {
209 None => {
210 let _ = std::fs::remove_file(&path);
211 }
212 Some(flags) if sorted(flags) != sorted(&maildir::flags_of(&path)) => {
213 let _ = maildir::set_flags(&path, flags);
214 }
215 Some(_) => {}
216 }
217 }
218 Ok(())
219}
220
221fn sorted(flags: &str) -> String {
222 let mut letters: Vec<char> = flags.chars().collect();
223 letters.sort_unstable();
224 letters.dedup();
225 letters.into_iter().collect()
226}
227
228fn local_files(root: &Path) -> Vec<(ImapOrigin, PathBuf)> {
230 ["new", "cur"]
231 .iter()
232 .filter_map(|sub| std::fs::read_dir(root.join(sub)).ok())
233 .flatten()
234 .filter_map(Result::ok)
235 .map(|e| e.path())
236 .filter_map(|p| Some((maildir::origin_of(&p)?, p)))
237 .collect()
238}
239
240pub(crate) fn ranges(uids: &BTreeSet<u32>) -> String {
242 let mut parts: Vec<String> = Vec::new();
243 let mut iter = uids.iter().copied().peekable();
244 while let Some(start) = iter.next() {
245 let mut end = start;
246 while iter.peek() == Some(&(end + 1)) {
247 end += 1;
248 iter.next();
249 }
250 parts.push(if start == end { start.to_string() } else { format!("{start}:{end}") });
251 }
252 parts.join(",")
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum Action {
258 Read(bool),
260 Flag(bool),
262 Move(String),
264 Trash,
266 Junk,
268 Archive,
270 NotJunk,
272 NotSpam,
276 Filtered(sioul_core::spam::Class),
283}
284
285pub fn act(account: &Account, password: &str, file: &Path, action: &Action) -> Result<(), SyncError> {
290 let server = Server::of(account)?;
291 let origin = maildir::origin_of(file).ok_or_else(|| SyncError::Said(said::NOT_FETCHED, String::new()))?;
292 let folder = folder_of(account, file).ok_or_else(|| SyncError::Said(said::UNKNOWN_FOLDER, String::new()))?;
293 if *action == Action::Archive && matches!(folder.role, Role::Archive | Role::All) {
294 return Ok(());
295 }
296 if matches!(action, Action::Filtered(_)) && folder.role == Role::Junk {
298 return Ok(());
299 }
300 let label = said(action).and_then(|source| labels::Entry::of_file(&account.id, &folder.name, file, source));
302 let moved = match action {
303 Action::Filtered(class) => labels::Moved::of_file(&account.id, &folder.name, file, *class),
304 _ => None,
305 };
306 crate::fetch::block_on(async {
307 let mut session = imap::open(&server, password).await?;
308 let result = act_in(&mut session, account, &folder, origin, action).await;
309 let _ = session.logout().await;
310 result
311 })?;
312 let root = labels::state();
314 let logged = match (&label, &moved) {
315 (Some(label), _) => labels::append_to(&labels::own_log(&root, &device()), label),
316 (_, Some(moved)) => labels::append_to(&labels::own_moved_log(&root, &device()), moved),
317 _ => Ok(()),
318 }
319 .map_err(SyncError::Disk);
320 let Some(file) = maildir::locate(file) else { return logged };
322 match kept_with(action, &maildir::flags_of(&file), folder.role) {
323 Some(flags) => {
324 maildir::set_flags(&file, &flags).map_err(|e| SyncError::Disk(e.to_string()))?;
325 }
326 None => {
327 let _ = std::fs::remove_file(&file);
328 }
329 }
330 logged
331}
332
333fn kept_with(action: &Action, flags: &str, role: Role) -> Option<String> {
338 match action {
339 Action::Read(read) => Some(if *read { format!("{flags}S") } else { flags.replace('S', "") }),
340 Action::Flag(flag) => Some(if *flag { format!("{flags}F") } else { flags.replace('F', "") }),
341 Action::NotSpam => Some(format!("{}{}", flags.replace(maildir::JUNK, ""), maildir::NOT_JUNK)),
342 Action::Junk if role == Role::Junk => Some(format!("{}{}", flags.replace(maildir::NOT_JUNK, "").replace(maildir::JUNK, ""), maildir::JUNK)),
343 Action::Move(_) | Action::Trash | Action::Junk | Action::Archive | Action::NotJunk | Action::Filtered(_) => None,
344 }
345}
346
347fn device() -> String {
350 crate::share::Here::load(&state_dir()).id
351}
352
353pub fn label(account: &Account, file: &Path, source: labels::Source) -> Result<(), SyncError> {
356 let folder = folder_of(account, file).ok_or_else(|| SyncError::Said(said::UNKNOWN_FOLDER, String::new()))?;
357 let entry = labels::Entry::of_file(&account.id, &folder.name, file, source).ok_or_else(|| SyncError::Said(said::NOT_FETCHED, String::new()))?;
358 labels::append_to(&labels::own_log(&labels::state(), &device()), &entry).map_err(SyncError::Disk)
359}
360
361pub fn flagged(account: &Account, file: &Path, class: sioul_core::spam::Class) -> Result<(), SyncError> {
367 let folder = folder_of(account, file).ok_or_else(|| SyncError::Said(said::UNKNOWN_FOLDER, String::new()))?;
368 let flag = labels::Flagged::of_file(&account.id, &folder.name, file, class).ok_or_else(|| SyncError::Said(said::NOT_FETCHED, String::new()))?;
369 labels::append_to(&labels::own_flagged_log(&labels::state(), &device()), &flag).map_err(SyncError::Disk)
370}
371
372fn said(action: &Action) -> Option<labels::Source> {
375 match action {
376 Action::Junk => Some(labels::Source::Junk),
377 Action::NotJunk => Some(labels::Source::NotJunk),
378 Action::NotSpam => Some(labels::Source::NotSpam),
379 Action::Read(_) | Action::Flag(_) | Action::Move(_) | Action::Trash | Action::Archive | Action::Filtered(_) => None,
380 }
381}
382
383pub fn move_across(from: &Account, from_password: &str, file: &Path, to: &Account, to_password: &str, folder: &str) -> Result<(), SyncError> {
388 let raw = std::fs::read(file).map_err(|e| SyncError::Disk(format!("{}: {e}", file.display())))?;
389 let origin = maildir::origin_of(file).ok_or_else(|| SyncError::Said(said::NOT_FETCHED, String::new()))?;
390 let source = folder_of(from, file).ok_or_else(|| SyncError::Said(said::UNKNOWN_FOLDER, String::new()))?;
391 append_then_remove(from, from_password, &source, origin, &raw, &maildir::flags_of(file), to, to_password, folder)?;
392 if let Some(file) = maildir::locate(file) {
393 let _ = std::fs::remove_file(&file);
394 }
395 Ok(())
396}
397
398pub fn move_across_from_server(from: &Account, from_password: &str, source: &str, origin: ImapOrigin, to: &Account, to_password: &str, folder: &str) -> Result<(), SyncError> {
401 let source = folders(&from.id).into_iter().find(|f| f.name == source).unwrap_or_else(|| folders::folder(source, None, None));
402 let server = Server::of(from)?;
403 let (raw, flags) = crate::fetch::block_on(async {
404 let mut session = imap::open(&server, from_password).await?;
405 let result = read_whole(&mut session, &source, origin).await;
406 let _ = session.logout().await;
407 result
408 })?;
409 append_then_remove(from, from_password, &source, origin, &raw, &flags, to, to_password, folder)
410}
411
412async fn read_whole(session: &mut Imap, folder: &Folder, origin: ImapOrigin) -> Result<(Vec<u8>, String), SyncError> {
414 let examined = imap::within(COMMAND, session.examine(&folder.name)).await?.map_err(imap::server)?;
415 if examined.uid_validity.unwrap_or(0) != origin.validity {
416 return Err(SyncError::Said(said::RENUMBERED, String::new()));
417 }
418 let mut fetches = imap::within(COMMAND, session.uid_fetch(origin.uid.to_string(), "(UID FLAGS BODY.PEEK[])")).await?.map_err(imap::server)?;
419 let mut found = None;
420 while let Some(fetch) = fetches.next().await {
421 let fetch = fetch.map_err(imap::server)?;
422 if fetch.uid == Some(origin.uid)
423 && let Some(body) = fetch.body()
424 {
425 found = Some((body.to_vec(), crate::fetch::maildir_flags(fetch.flags())));
426 }
427 }
428 found.ok_or_else(|| SyncError::Said(said::GONE, String::new()))
429}
430
431#[allow(clippy::too_many_arguments)]
434fn append_then_remove(from: &Account, from_password: &str, source: &Folder, origin: ImapOrigin, raw: &[u8], letters: &str, to: &Account, to_password: &str, folder: &str) -> Result<(), SyncError> {
435 let flags: Vec<&str> = [('S', "\\Seen"), ('F', "\\Flagged"), ('R', "\\Answered")].into_iter().filter(|(c, _)| letters.contains(*c)).map(|(_, f)| f).collect();
436 let flags = format!("({})", flags.join(" "));
437 let target = Server::of(to)?;
438 crate::fetch::block_on(async {
439 let mut session = imap::open(&target, to_password).await?;
440 let result = imap::within(COMMAND, session.append(folder, Some(&flags), None, raw)).await?.map_err(imap::server);
441 let _ = session.logout().await;
442 result
443 })?;
444 let server = Server::of(from)?;
445 crate::fetch::block_on(async {
446 let mut session = imap::open(&server, from_password).await?;
447 let result = remove_in(&mut session, source, origin).await;
448 let _ = session.logout().await;
449 result
450 })
451}
452
453const SET: usize = 200;
455
456pub fn act_many(account: &Account, password: &str, files: &[PathBuf], remote: &[(String, ImapOrigin)], action: &Action) -> Result<(), SyncError> {
464 if said(action).is_some() || matches!(action, Action::Filtered(_)) {
465 let mut first = None;
466 for file in files {
467 if let Err(e) = act(account, password, file, action) {
468 first.get_or_insert(e);
469 }
470 }
471 if !remote.is_empty() {
472 first.get_or_insert(SyncError::Said(said::OPEN_FIRST, String::new()));
473 }
474 return first.map_or(Ok(()), Err);
475 }
476 let known = folders(&account.id);
477 let mut groups: BTreeMap<String, (Folder, BTreeMap<u32, (BTreeSet<u32>, Vec<PathBuf>)>)> = BTreeMap::new();
479 let mut add = |folder: Folder, origin: ImapOrigin, file: Option<&PathBuf>| {
480 let entry = groups.entry(folder.name.clone()).or_insert_with(|| (folder, BTreeMap::new()));
481 let (uids, files) = entry.1.entry(origin.validity).or_default();
482 uids.insert(origin.uid);
483 files.extend(file.cloned());
484 };
485 let mut first: Option<SyncError> = None;
486 for file in files {
487 match (maildir::origin_of(file), folder_of(account, file)) {
488 (Some(origin), Some(folder)) => add(folder, origin, Some(file)),
489 _ => {
490 first.get_or_insert(SyncError::Said(said::NOT_FETCHED, String::new()));
491 }
492 }
493 }
494 for (name, origin) in remote {
495 let folder = known.iter().find(|f| f.name == *name).cloned().unwrap_or_else(|| folders::folder(name, None, None));
496 add(folder, *origin, None);
497 }
498 groups.retain(|_, (folder, _)| !(*action == Action::Archive && matches!(folder.role, Role::Archive | Role::All)));
500 if groups.is_empty() {
501 return first.map_or(Ok(()), Err);
502 }
503 let server = Server::of(account)?;
504 let done: Vec<(Role, Vec<PathBuf>)> = crate::fetch::block_on(async {
505 let mut session = imap::open(&server, password).await?;
506 let mut done = Vec::new();
507 for (folder, by_validity) in groups.values() {
508 for (validity, (uids, files)) in by_validity {
509 let uids: Vec<u32> = uids.iter().copied().collect();
510 let mut all_done = true;
511 for chunk in uids.chunks(SET) {
512 let set = ranges(&chunk.iter().copied().collect::<BTreeSet<u32>>());
513 if let Err(e) = act_set(&mut session, account, folder, *validity, &set, action).await {
514 all_done = false;
515 first.get_or_insert(e);
516 break;
517 }
518 }
519 if all_done {
520 done.push((folder.role, files.clone()));
521 }
522 }
523 }
524 let _ = session.logout().await;
525 Ok(done)
526 })?;
527 for (role, files) in done {
529 for file in files.iter().filter_map(|f| maildir::locate(f)) {
530 match kept_with(action, &maildir::flags_of(&file), role) {
531 Some(flags) => {
532 let _ = maildir::set_flags(&file, &flags);
533 }
534 None => {
535 let _ = std::fs::remove_file(&file);
536 }
537 }
538 }
539 }
540 first.map_or(Ok(()), Err)
541}
542
543async fn remove_in(session: &mut Imap, folder: &Folder, origin: ImapOrigin) -> Result<(), SyncError> {
545 let selected = imap::within(COMMAND, session.select(&folder.name)).await?.map_err(imap::server)?;
546 if selected.uid_validity.unwrap_or(0) != origin.validity {
547 return Err(SyncError::Said(said::RENUMBERED, String::new()));
548 }
549 let uid = origin.uid.to_string();
550 let capabilities = imap::within(COMMAND, session.capabilities()).await?.map_err(imap::server)?;
551 store(session, &uid, "+FLAGS.SILENT (\\Deleted)").await?;
552 expunge(session, &uid, capabilities.has_str("UIDPLUS")).await
553}
554
555async fn act_in(session: &mut Imap, account: &Account, folder: &Folder, origin: ImapOrigin, action: &Action) -> Result<(), SyncError> {
556 act_set(session, account, folder, origin.validity, &origin.uid.to_string(), action).await
557}
558
559async fn act_set(session: &mut Imap, account: &Account, folder: &Folder, validity: u32, uid: &str, action: &Action) -> Result<(), SyncError> {
562 let selected = imap::within(COMMAND, session.select(&folder.name)).await?.map_err(imap::server)?;
563 if selected.uid_validity.unwrap_or(0) != validity {
564 return Err(SyncError::Said(said::RENUMBERED, String::new()));
565 }
566 let capabilities = imap::within(COMMAND, session.capabilities()).await?.map_err(imap::server)?;
567 let (can_move, uidplus) = (capabilities.has_str("MOVE"), capabilities.has_str("UIDPLUS"));
568 match action {
569 Action::Read(read) => store(session, uid, if *read { "+FLAGS.SILENT (\\Seen)" } else { "-FLAGS.SILENT (\\Seen)" }).await,
570 Action::Flag(flag) => store(session, uid, if *flag { "+FLAGS.SILENT (\\Flagged)" } else { "-FLAGS.SILENT (\\Flagged)" }).await,
571 Action::Move(target) => move_to(session, uid, target, can_move, uidplus).await,
572 Action::Trash if folder.role == Role::Trash => {
573 store(session, uid, "+FLAGS.SILENT (\\Deleted)").await?;
574 expunge(session, uid, uidplus).await
575 }
576 Action::Trash => {
577 let target = role_folder(session, account, Role::Trash, "Trash").await?;
578 move_to(session, uid, &target, can_move, uidplus).await
579 }
580 Action::Junk => {
581 let _ = store(session, uid, "-FLAGS.SILENT ($NotJunk)").await;
583 let _ = store(session, uid, "+FLAGS.SILENT ($Junk)").await;
584 if folder.role == Role::Junk {
586 return Ok(());
587 }
588 let target = role_folder(session, account, Role::Junk, "Junk").await?;
589 move_to(session, uid, &target, can_move, uidplus).await
590 }
591 Action::Filtered(_) => {
593 let target = role_folder(session, account, Role::Junk, "Junk").await?;
594 move_to(session, uid, &target, can_move, uidplus).await
595 }
596 Action::Archive => {
597 let known = folders(&account.id);
598 let target = match folders::preferred(&known, Role::Archive).or(folders::preferred(&known, Role::All)) {
599 Some(f) => f.name.clone(),
600 None => role_folder(session, account, Role::Archive, "Archive").await?,
601 };
602 move_to(session, uid, &target, can_move, uidplus).await
603 }
604 Action::NotJunk => {
605 let _ = store(session, uid, "-FLAGS.SILENT ($Junk)").await;
606 let _ = store(session, uid, "+FLAGS.SILENT ($NotJunk)").await;
607 move_to(session, uid, "INBOX", can_move, uidplus).await
608 }
609 Action::NotSpam => {
611 let _ = store(session, uid, "-FLAGS.SILENT ($Junk)").await;
612 let _ = store(session, uid, "+FLAGS.SILENT ($NotJunk)").await;
613 Ok(())
614 }
615 }
616}
617
618pub(crate) async fn store(session: &mut Imap, uid: &str, query: &str) -> Result<(), SyncError> {
619 let mut answers = imap::within(COMMAND, session.uid_store(uid, query)).await?.map_err(imap::server)?;
620 while let Some(answer) = answers.next().await {
621 answer.map_err(imap::server)?;
622 }
623 Ok(())
624}
625
626pub(crate) async fn move_to(session: &mut Imap, uid: &str, target: &str, can_move: bool, uidplus: bool) -> Result<(), SyncError> {
627 if can_move {
628 return imap::within(COMMAND, session.uid_mv(uid, target)).await?.map_err(imap::server);
629 }
630 imap::within(COMMAND, session.uid_copy(uid, target)).await?.map_err(imap::server)?;
631 store(session, uid, "+FLAGS.SILENT (\\Deleted)").await?;
632 expunge(session, uid, uidplus).await
633}
634
635async fn expunge(session: &mut Imap, uid: &str, uidplus: bool) -> Result<(), SyncError> {
639 if !uidplus {
640 return Ok(());
641 }
642 let gone = imap::within(COMMAND, session.uid_expunge(uid)).await?.map_err(imap::server)?;
643 let mut gone = std::pin::pin!(gone);
644 while let Some(answer) = gone.next().await {
645 answer.map_err(imap::server)?;
646 }
647 Ok(())
648}
649
650pub(crate) async fn role_folder(session: &mut Imap, account: &Account, role: Role, name: &str) -> Result<String, SyncError> {
652 if let Some(folder) = folders::preferred(&folders(&account.id), role) {
653 return Ok(folder.name.clone());
654 }
655 let listed = list(session).await?;
656 if let Some(folder) = folders::preferred(&listed, role) {
657 let name = folder.name.clone();
658 save(&account.id, &listed)?;
659 return Ok(name);
660 }
661 let under_inbox = listed.iter().any(|f| f.name.to_ascii_uppercase().starts_with("INBOX."));
663 let name = if under_inbox { format!("INBOX.{name}") } else { name.to_string() };
664 imap::within(COMMAND, session.create(&name)).await?.map_err(imap::server)?;
665 let listed = list(session).await?;
666 save(&account.id, &listed)?;
667 Ok(name)
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 #[test]
675 fn what_an_act_leaves_here_and_says() {
676 assert_eq!(kept_with(&Action::NotSpam, &format!("S{}", maildir::JUNK), Role::Inbox), Some(format!("S{}", maildir::NOT_JUNK)));
678 assert_eq!(kept_with(&Action::NotSpam, "", Role::Inbox), Some(maildir::NOT_JUNK.to_string()));
679 assert_eq!(kept_with(&Action::Read(false), &format!("S{}", maildir::NOT_JUNK), Role::Inbox), Some(maildir::NOT_JUNK.to_string()));
681 assert_eq!(kept_with(&Action::Junk, "S", Role::Inbox), None);
683 assert_eq!(kept_with(&Action::NotJunk, "S", Role::Junk), None);
684 assert_eq!(kept_with(&Action::Filtered(sioul_core::spam::Class::Spam), "", Role::Inbox), None);
685 assert_eq!(kept_with(&Action::Junk, &format!("S{}", maildir::NOT_JUNK), Role::Junk), Some(format!("S{}", maildir::JUNK)));
687 assert_eq!([&Action::Junk, &Action::NotJunk, &Action::NotSpam].map(said), [Some(labels::Source::Junk), Some(labels::Source::NotJunk), Some(labels::Source::NotSpam)]);
689 assert_eq!([&Action::Read(true), &Action::Trash, &Action::Archive, &Action::Filtered(sioul_core::spam::Class::Spam)].map(said), [None, None, None, None]);
690 }
691
692 #[test]
693 fn uid_sets_are_short() {
694 let uids: BTreeSet<u32> = [1, 2, 3, 4, 5, 7, 9, 10, 11, 12].into_iter().collect();
695 assert_eq!(ranges(&uids), "1:5,7,9:12");
696 assert_eq!(ranges(&[42].into_iter().collect()), "42");
697 }
698}