use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use futures::TryStreamExt;
use indicatif::MultiProgress;
use crate::commands::job::progress::new_progress_bar;
use crate::commands::keyring::email::identity::sanitize_segment;
use crate::commands::keyring::email::imap_client::ImapSession;
const UIDVALIDITY_FILE_NAME: &str = ".uidvalidity";
pub(crate) async fn fetch_uids(
session: &mut ImapSession,
mailbox_name: &str,
mailbox_dir: &Path,
missing: &[u32],
multi_progress: &MultiProgress,
) -> Result<usize, String> {
if missing.is_empty() {
return Ok(0);
}
let bar = new_progress_bar(
format!("{mailbox_name} fetch"),
missing.len() as u64,
multi_progress,
);
let uid_set = missing
.iter()
.map(|uid| uid.to_string())
.collect::<Vec<_>>()
.join(",");
let mut fetches = session
.uid_fetch(&uid_set, "(UID BODY.PEEK[])")
.await
.map_err(|err| format!("failed to fetch messages in '{mailbox_name}': {err}"))?;
let mut written = 0;
while let Some(fetch) = fetches
.try_next()
.await
.map_err(|err| format!("failed to fetch messages in '{mailbox_name}': {err}"))?
{
let (Some(uid), Some(body)) = (fetch.uid, fetch.body()) else {
tracing::warn!(
mailbox = mailbox_name,
uid = fetch.uid,
"dropped FETCH response missing uid or body"
);
continue;
};
let path = mailbox_dir.join(format!("{uid}.eml"));
fs::write(&path, body)
.map_err(|err| format!("failed to write {}: {err}", path.display()))?;
written += 1;
bar.inc(1);
}
bar.finish();
Ok(written)
}
pub(crate) fn sanitize_mailbox_path(name: &str, delimiter: Option<&str>) -> PathBuf {
let segments: Vec<&str> = match delimiter {
Some(delimiter) if !delimiter.is_empty() => name.split(delimiter).collect(),
_ => vec![name],
};
segments
.into_iter()
.map(sanitize_segment)
.filter(|segment| !segment.is_empty())
.collect()
}
pub(crate) fn missing_uids(server: &HashSet<u32>, local: &HashSet<u32>) -> Vec<u32> {
let mut missing: Vec<u32> = server.difference(local).copied().collect();
missing.sort_unstable();
missing
}
pub(crate) fn is_stale(on_disk_validity: Option<u32>, current_validity: u32) -> bool {
on_disk_validity.is_some_and(|value| value != current_validity)
}
pub(crate) fn read_uidvalidity(mailbox_dir: &Path) -> Option<u32> {
fs::read_to_string(mailbox_dir.join(UIDVALIDITY_FILE_NAME))
.ok()?
.trim()
.parse()
.ok()
}
pub(crate) fn write_uidvalidity(mailbox_dir: &Path, value: u32) -> Result<(), String> {
let path = mailbox_dir.join(UIDVALIDITY_FILE_NAME);
fs::write(&path, value.to_string())
.map_err(|err| format!("failed to write {}: {err}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitizes_single_segment_mailbox() {
assert_eq!(sanitize_mailbox_path("INBOX", None), PathBuf::from("inbox"));
}
#[test]
fn sanitizes_multi_segment_mailbox() {
assert_eq!(
sanitize_mailbox_path("Archive/2020", Some("/")),
PathBuf::from("archive").join("2020")
);
}
#[test]
fn sanitizes_spaces_in_mailbox_name() {
assert_eq!(
sanitize_mailbox_path("Sent Items", None),
PathBuf::from("sent-items")
);
}
#[test]
fn missing_uids_is_server_minus_local() {
let server: HashSet<u32> = [1, 2, 3, 4, 5].into_iter().collect();
let local: HashSet<u32> = [1, 2, 3].into_iter().collect();
assert_eq!(missing_uids(&server, &local), vec![4, 5]);
}
#[test]
fn missing_uids_empty_when_up_to_date() {
let server: HashSet<u32> = [1, 2].into_iter().collect();
let local: HashSet<u32> = [1, 2].into_iter().collect();
assert!(missing_uids(&server, &local).is_empty());
}
#[test]
fn first_run_is_never_stale() {
assert!(!is_stale(None, 100));
}
#[test]
fn matching_validity_is_not_stale() {
assert!(!is_stale(Some(100), 100));
}
#[test]
fn mismatched_validity_is_stale() {
assert!(is_stale(Some(100), 200));
}
#[test]
fn uidvalidity_round_trips() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(read_uidvalidity(dir.path()), None);
write_uidvalidity(dir.path(), 42).unwrap();
assert_eq!(read_uidvalidity(dir.path()), Some(42));
}
}