Initial commit
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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<u64>,
|
||||
pub uidnext: Option<u64>,
|
||||
pub messages: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredFolder {
|
||||
pub name: String,
|
||||
pub delimiter: Option<char>,
|
||||
pub attributes: Vec<String>,
|
||||
pub subscribed: bool,
|
||||
pub selectable: bool,
|
||||
pub status: Option<FolderStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedFolder {
|
||||
pub name: String,
|
||||
pub leaf: String,
|
||||
pub parent_path: Option<String>,
|
||||
pub delimiter: Option<char>,
|
||||
pub role: Option<&'static str>,
|
||||
pub subscribed: bool,
|
||||
pub status: Option<FolderStatus>,
|
||||
}
|
||||
|
||||
pub struct FolderFilters {
|
||||
pub include: Vec<Regex>,
|
||||
pub exclude: Vec<Regex>,
|
||||
pub exclude_special: Vec<String>,
|
||||
pub explicit: Vec<String>,
|
||||
pub subscribed_only: bool,
|
||||
pub automap_enabled: bool,
|
||||
pub namespace_prefix: String,
|
||||
}
|
||||
|
||||
pub fn collect_from_list(
|
||||
untagged: &[Untagged],
|
||||
utf8_accept: bool,
|
||||
) -> Result<Vec<DiscoveredFolder>, String> {
|
||||
let mut subscribed = HashSet::new();
|
||||
let mut statuses: BTreeMap<String, FolderStatus> = 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<String> =
|
||||
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<DiscoveredFolder> = 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<DiscoveredFolder>,
|
||||
filters: &FolderFilters,
|
||||
) -> Vec<ResolvedFolder> {
|
||||
let mut keep: Vec<DiscoveredFolder> = 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<ResolvedFolder> = 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<char>) -> (String, Option<String>) {
|
||||
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<String, i64>, server: &HashSet<String>) -> Vec<String> {
|
||||
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<String, u64> =
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user