/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: Apache-2.0 OR MIT */ use std::collections::{BTreeMap, HashMap, HashSet}; use regex::Regex; use crate::imap::automap; use crate::imap::name::{canonicalise_inbox, decode_mailbox_name_with}; use crate::imap::response::Untagged; #[derive(Debug, Clone, Default)] pub struct FolderStatus { pub uidvalidity: Option, pub uidnext: Option, pub messages: Option, } #[derive(Debug, Clone)] pub struct DiscoveredFolder { pub name: String, pub delimiter: Option, pub attributes: Vec, pub subscribed: bool, pub selectable: bool, pub status: Option, } #[derive(Debug, Clone)] pub struct ResolvedFolder { pub name: String, pub leaf: String, pub parent_path: Option, pub delimiter: Option, pub role: Option<&'static str>, pub subscribed: bool, pub status: Option, } pub struct FolderFilters { pub include: Vec, pub exclude: Vec, pub exclude_special: Vec, pub explicit: Vec, pub subscribed_only: bool, pub automap_enabled: bool, pub namespace_prefix: String, } pub fn collect_from_list( untagged: &[Untagged], utf8_accept: bool, ) -> Result, String> { let mut subscribed = HashSet::new(); let mut statuses: BTreeMap = BTreeMap::new(); let mut found = BTreeMap::new(); for u in untagged { match u { Untagged::Lsub { name, .. } => { let decoded = decode_mailbox_name_with(name, utf8_accept).map_err(|e| e.to_string())?; subscribed.insert(canonicalise_inbox(&decoded)); } Untagged::List { attributes, delimiter, name, } => { let decoded = decode_mailbox_name_with(name, utf8_accept).map_err(|e| e.to_string())?; let canonical = canonicalise_inbox(&decoded); let attrs_lower: Vec = attributes.iter().map(|a| a.to_ascii_lowercase()).collect(); let selectable = !attrs_lower .iter() .any(|a| a == "\\noselect" || a == "\\nonexistent"); let extended_subscribed = attrs_lower.iter().any(|a| a == "\\subscribed"); if extended_subscribed { subscribed.insert(canonical.clone()); } found.insert( canonical.clone(), DiscoveredFolder { name: canonical, delimiter: *delimiter, attributes: attributes.clone(), subscribed: false, selectable, status: None, }, ); } Untagged::Status { mailbox, items } => { let decoded = decode_mailbox_name_with(mailbox, utf8_accept).map_err(|e| e.to_string())?; let canonical = canonicalise_inbox(&decoded); let mut st = FolderStatus::default(); if let Some(v) = items.get("UIDVALIDITY") { st.uidvalidity = Some(*v); } if let Some(v) = items.get("UIDNEXT") { st.uidnext = Some(*v); } if let Some(v) = items.get("MESSAGES") { st.messages = Some(*v); } statuses.insert(canonical, st); } _ => {} } } let mut out: Vec = found.into_values().collect(); for f in &mut out { if subscribed.contains(&f.name) { f.subscribed = true; } if let Some(s) = statuses.remove(&f.name) { f.status = Some(s); } } Ok(out) } pub fn apply_filters( folders: Vec, filters: &FolderFilters, ) -> Vec { let mut keep: Vec = folders .into_iter() .filter(|f| f.selectable) .filter(|f| match_filters(&f.name, filters)) .collect(); if filters.subscribed_only { keep.retain(|f| f.subscribed); } let mut resolved: Vec = Vec::with_capacity(keep.len()); for f in keep { let role = automap::role_for_folder( &f.name, &f.attributes, &filters.namespace_prefix, filters.automap_enabled, ); if let Some(r) = role && filters .exclude_special .iter() .any(|s| s.eq_ignore_ascii_case(r)) { continue; } let delim = f.delimiter; let (leaf, parent_path) = split_parent(&f.name, delim); resolved.push(ResolvedFolder { name: f.name, leaf, parent_path, delimiter: delim, role, subscribed: f.subscribed, status: f.status, }); } resolved } fn match_filters(name: &str, filters: &FolderFilters) -> bool { if !filters.explicit.is_empty() { return filters.explicit.iter().any(|e| e == name); } if !filters.include.is_empty() && !filters.include.iter().any(|r| r.is_match(name)) { return false; } if filters.exclude.iter().any(|r| r.is_match(name)) { return false; } true } fn split_parent(name: &str, delim: Option) -> (String, Option) { let Some(d) = delim else { return (name.to_owned(), None); }; if let Some(idx) = name.rfind(d) { let parent = &name[..idx]; let leaf = &name[idx + d.len_utf8()..]; (leaf.to_owned(), Some(parent.to_owned())) } else { (name.to_owned(), None) } } pub fn sort_by_depth(resolved: &mut [ResolvedFolder]) { resolved.sort_by(|a, b| { let da = a.parent_path.as_deref().map_or(0, |s| { a.delimiter.map(|d| s.matches(d).count() + 1).unwrap_or(0) }); let db = b.parent_path.as_deref().map_or(0, |s| { b.delimiter.map(|d| s.matches(d).count() + 1).unwrap_or(0) }); da.cmp(&db).then_with(|| a.name.cmp(&b.name)) }); } pub fn vanished_folders(local: &HashMap, server: &HashSet) -> Vec { local .keys() .filter(|k| !server.contains(*k)) .cloned() .collect() } pub fn vanished_depth_sort(names: &mut [String], delimiter: char) { names.sort_by(|a, b| { let depth_a = a.matches(delimiter).count(); let depth_b = b.matches(delimiter).count(); depth_b.cmp(&depth_a).then_with(|| a.cmp(b)) }); } #[cfg(test)] mod tests { use super::*; fn lst(name: &str, delim: &str, attrs: &[&str]) -> Untagged { Untagged::List { attributes: attrs.iter().map(|s| (*s).to_owned()).collect(), delimiter: delim.chars().next(), name: name.to_owned(), } } #[test] fn collect_filters_noselect_and_canonicalises_inbox() { let resps = vec![ lst("Inbox", "/", &[]), lst("Sent", "/", &["\\Sent"]), lst("Hidden", "/", &["\\Noselect"]), ]; let folders = collect_from_list(&resps, false).unwrap(); assert_eq!(folders.len(), 3); let inbox = folders.iter().find(|f| f.name == "INBOX").unwrap(); assert!(inbox.selectable); let hidden = folders.iter().find(|f| f.name == "Hidden").unwrap(); assert!(!hidden.selectable); } #[test] fn collect_marks_subscribed_via_lsub_or_extended() { let resps = vec![ lst("Inbox", "/", &[]), lst("Sent", "/", &["\\Subscribed"]), Untagged::Lsub { attributes: vec![], delimiter: Some('/'), name: "Inbox".to_owned(), }, ]; let folders = collect_from_list(&resps, false).unwrap(); let inbox = folders.iter().find(|f| f.name == "INBOX").unwrap(); assert!(inbox.subscribed); let sent = folders.iter().find(|f| f.name == "Sent").unwrap(); assert!(sent.subscribed); } #[test] fn collect_attaches_list_status_data_per_mailbox() { let mut inbox_status: std::collections::BTreeMap = std::collections::BTreeMap::new(); inbox_status.insert("UIDVALIDITY".into(), 12345); inbox_status.insert("UIDNEXT".into(), 42); inbox_status.insert("MESSAGES".into(), 7); let resps = vec![ lst("INBOX", "/", &[]), lst("Sent", "/", &["\\Sent"]), Untagged::Status { mailbox: "INBOX".to_owned(), items: inbox_status, }, ]; let folders = collect_from_list(&resps, false).unwrap(); let inbox = folders.iter().find(|f| f.name == "INBOX").unwrap(); let st = inbox.status.as_ref().expect("INBOX has status"); assert_eq!(st.uidvalidity, Some(12345)); assert_eq!(st.uidnext, Some(42)); assert_eq!(st.messages, Some(7)); let sent = folders.iter().find(|f| f.name == "Sent").unwrap(); assert!(sent.status.is_none()); } #[test] fn collect_decodes_modified_utf7_names() { let resps = vec![lst("&ZeVnLIqe-", "/", &[])]; let folders = collect_from_list(&resps, false).unwrap(); assert_eq!(folders[0].name, "日本語"); } fn filters_default() -> FolderFilters { FolderFilters { include: Vec::new(), exclude: Vec::new(), exclude_special: Vec::new(), explicit: Vec::new(), subscribed_only: false, automap_enabled: true, namespace_prefix: String::new(), } } #[test] fn filters_default_keep_everything() { let folders = collect_from_list( &[ lst("INBOX", "/", &[]), lst("Sent", "/", &["\\Sent"]), lst("Drafts", "/", &[]), ], false, ) .unwrap(); let res = apply_filters(folders, &filters_default()); assert_eq!(res.len(), 3); let sent = res.iter().find(|f| f.name == "Sent").unwrap(); assert_eq!(sent.role, Some("sent")); } #[test] fn include_excludes_non_matching() { let folders = collect_from_list( &[ lst("INBOX", "/", &[]), lst("Trash", "/", &[]), lst("Sent", "/", &["\\Sent"]), ], false, ) .unwrap(); let mut f = filters_default(); f.include = vec![Regex::new(r"^(INBOX|Sent)$").unwrap()]; let res = apply_filters(folders, &f); assert_eq!(res.len(), 2); assert!(res.iter().any(|x| x.name == "INBOX")); assert!(res.iter().any(|x| x.name == "Sent")); } #[test] fn exclude_drops_matching() { let folders = collect_from_list(&[lst("INBOX", "/", &[]), lst("Trash", "/", &[])], false).unwrap(); let mut f = filters_default(); f.exclude = vec![Regex::new(r"^Trash$").unwrap()]; let res = apply_filters(folders, &f); assert_eq!(res.len(), 1); assert_eq!(res[0].name, "INBOX"); } #[test] fn exclude_special_drops_by_role() { let folders = collect_from_list( &[lst("INBOX", "/", &[]), lst("Trash", "/", &["\\Trash"])], false, ) .unwrap(); let mut f = filters_default(); f.exclude_special = vec!["trash".to_owned()]; let res = apply_filters(folders, &f); assert_eq!(res.len(), 1); assert_eq!(res[0].name, "INBOX"); } #[test] fn explicit_folder_list_overrides_include_exclude() { let folders = collect_from_list( &[ lst("INBOX", "/", &[]), lst("Sent", "/", &[]), lst("Trash", "/", &[]), ], false, ) .unwrap(); let mut f = filters_default(); f.explicit = vec!["Sent".to_owned()]; let res = apply_filters(folders, &f); assert_eq!(res.len(), 1); assert_eq!(res[0].name, "Sent"); } #[test] fn subscribed_only_filters_unsubscribed() { let mut folders = collect_from_list( &[lst("INBOX", "/", &["\\Subscribed"]), lst("Other", "/", &[])], false, ) .unwrap(); let mut f = filters_default(); f.subscribed_only = true; for x in &mut folders { if x.name == "INBOX" { x.subscribed = true; } } let res = apply_filters(folders, &f); assert_eq!(res.len(), 1); assert_eq!(res[0].name, "INBOX"); } #[test] fn split_parent_handles_root_and_nested() { assert_eq!(split_parent("INBOX", Some('/')), ("INBOX".to_owned(), None)); assert_eq!( split_parent("Projects/Alpha", Some('/')), ("Alpha".to_owned(), Some("Projects".to_owned())) ); assert_eq!( split_parent("Projects/Alpha/Beta", Some('/')), ("Beta".to_owned(), Some("Projects/Alpha".to_owned())) ); } #[test] fn split_parent_with_no_delimiter_returns_root() { assert_eq!( split_parent("Anything", None), ("Anything".to_owned(), None) ); } #[test] fn sort_by_depth_places_roots_first() { let mut r = vec![ ResolvedFolder { name: "Projects/Alpha".into(), leaf: "Alpha".into(), parent_path: Some("Projects".into()), delimiter: Some('/'), role: None, subscribed: false, status: None, }, ResolvedFolder { name: "INBOX".into(), leaf: "INBOX".into(), parent_path: None, delimiter: Some('/'), role: Some("inbox"), subscribed: true, status: None, }, ResolvedFolder { name: "Projects".into(), leaf: "Projects".into(), parent_path: None, delimiter: Some('/'), role: None, subscribed: false, status: None, }, ]; sort_by_depth(&mut r); assert_eq!(r[0].name, "INBOX"); assert_eq!(r[1].name, "Projects"); assert_eq!(r[2].name, "Projects/Alpha"); } #[test] fn vanished_folders_is_set_minus() { let mut local = HashMap::new(); local.insert("INBOX".to_owned(), 1); local.insert("Old".to_owned(), 2); local.insert("Sent".to_owned(), 3); let mut server = HashSet::new(); server.insert("INBOX".to_owned()); server.insert("Sent".to_owned()); let v = vanished_folders(&local, &server); assert_eq!(v, vec!["Old".to_owned()]); } #[test] fn vanished_depth_sort_deepest_first() { let mut names = vec!["A".to_owned(), "A/B/C".to_owned(), "A/B".to_owned()]; vanished_depth_sort(&mut names, '/'); assert_eq!(names, vec!["A/B/C", "A/B", "A"]); } #[test] fn untagged_status_does_not_become_a_folder() { let resps = vec![ lst("INBOX", "/", &[]), Untagged::Status { mailbox: "INBOX".to_owned(), items: BTreeMap::new(), }, ]; let folders = collect_from_list(&resps, false).unwrap(); assert_eq!(folders.len(), 1); } }