Files
inbuxa-migrate/src/sync/import_imap/folders.rs
T
2026-05-29 18:02:15 +02:00

505 lines
16 KiB
Rust

/*
* 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);
}
}