use crate::libs::{base_config::BaseConfig, config::Config, filter::Filter, filters::Filters};
use derive_more::Display;
use exn::{bail, OptionExt as _, Result, ResultExt as _};
use imap::{types::Uid, ImapConnection, Session};
use imap_proto::NameAttribute;
use serde::Serialize;
use std::{
collections::{BTreeMap, HashMap, HashSet},
fmt::Debug,
};
#[derive(Debug, Display)]
pub struct ImapError(String);
impl std::error::Error for ImapError {}
#[derive(Clone, Debug)]
pub struct ListResult<T>
where
T: Clone + Debug,
{
pub extra: Option<T>,
}
#[derive(Debug)]
pub struct Imap<T>
where
T: Clone + Debug + Serialize,
{
pub session: Session<Box<dyn ImapConnection>>,
extra: Option<T>,
filters: Option<Filters<T>>,
cached_capabilities: HashMap<String, bool>,
}
impl<T> Drop for Imap<T>
where
T: Clone + Debug + Serialize,
{
#[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip(self)))]
#[expect(clippy::print_stderr, reason = "ok")]
fn drop(&mut self) {
if let Err(e) = self.session.logout() {
eprintln!("error disconnecting: {e}");
}
}
}
impl<T> Imap<T>
where
T: Clone + Debug + Serialize,
{
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", skip(base), ret, err(level = "info"))
)]
pub fn connect_base(base: &BaseConfig) -> Result<Self, ImapError> {
#[cfg(feature = "tracing")]
tracing::trace!(?base);
let server = base
.server
.as_ref()
.ok_or_raise(|| ImapError("Missing server".to_owned()))?;
let mut builder = imap::ClientBuilder::new(server.as_str(), 143);
if let Some(ref mode) = base.mode {
builder = builder.mode(mode.clone().into());
}
let mut client = builder
.connect()
.or_raise(|| ImapError(format!("failed to connect to {server} on port 143")))?;
if base.debug {
client.debug = true;
}
let session = client
.login(
base.username
.as_ref()
.ok_or_raise(|| ImapError("Missing username".to_owned()))?,
base.password()
.or_raise(|| ImapError("Password error".to_owned()))?,
)
.map_err(|err| err.0)
.or_raise(|| ImapError("imap login failed".to_owned()))?;
let mut ret = Self {
session,
extra: None,
filters: None,
cached_capabilities: HashMap::new(),
};
if !ret.has_capability("UIDPLUS")? {
bail!(ImapError("The server does not support the UIDPLUS capability, and all our operations need UIDs for safety".to_owned()));
}
Ok(ret)
}
#[cfg(test)]
pub fn connect_base_on_port(base: &BaseConfig, port: u16) -> Result<Self, ImapError> {
let server = base
.server
.as_ref()
.ok_or_raise(|| ImapError("Missing server".to_owned()))?;
let client = imap::ClientBuilder::new(server.as_str(), port)
.mode(imap::ConnectionMode::Plaintext)
.connect()
.or_raise(|| ImapError(format!("failed to connect to {server} on port {port}")))?;
let session = client
.login(
base.username
.as_ref()
.ok_or_raise(|| ImapError("Missing username".to_owned()))?,
base.password()
.or_raise(|| ImapError("Password error".to_owned()))?,
)
.map_err(|err| err.0)
.or_raise(|| ImapError("imap login failed".to_owned()))?;
let mut ret = Self {
session,
extra: None,
filters: None,
cached_capabilities: HashMap::new(),
};
if !ret.has_capability("UIDPLUS")? {
bail!(ImapError("The server does not support UIDPLUS".to_owned()));
}
Ok(ret)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", skip(config), ret, err(level = "info"))
)]
pub fn connect(config: &Config<T>) -> Result<Self, ImapError> {
let mut ret = Self::connect_base(&config.base)?;
ret.extra.clone_from(&config.extra);
ret.filters.clone_from(&config.filters);
Ok(ret)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", skip(self), ret, err(level = "info"))
)]
pub fn has_capability<S: AsRef<str> + Debug>(&mut self, cap: S) -> Result<bool, ImapError> {
if let Some(&cached_result) = self.cached_capabilities.get(cap.as_ref()) {
return Ok(cached_result);
}
let has_capability = self
.session
.capabilities()
.or_raise(|| ImapError("imap capabilities failed".to_owned()))?
.has_str(cap.as_ref());
self.cached_capabilities
.insert(cap.as_ref().to_owned(), has_capability);
Ok(has_capability)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", skip(self), ret, err(level = "info"))
)]
pub fn list(&mut self) -> Result<BTreeMap<String, ListResult<T>>, ImapError> {
let mut mailboxes: BTreeMap<String, ListResult<T>> = BTreeMap::new();
for filter in self.filters.clone().unwrap_or_else(||
vec![Filter::default()])
{
let mut found = false;
for mailbox in self
.session
.list(filter.reference.as_deref(), filter.pattern.as_deref())
.or_raise(|| ImapError(format!("imap list failed with {filter:?}")))?
.iter()
.filter(|mbx| !mbx.attributes().contains(&NameAttribute::NoSelect))
.filter(|mbx| {
filter
.include_re
.as_ref()
.is_none_or(|re| re.is_match(mbx.name()))
})
.filter(|mbx| {
filter
.exclude_re
.as_ref()
.is_none_or(|re| !re.is_match(mbx.name()))
})
{
found = true;
mailboxes.insert(
mailbox.name().to_owned(),
ListResult {
extra: filter.extra.clone().or_else(|| self.extra.clone()),
},
);
}
if !found {
bail!(ImapError(format!(
"This filter did not return anything {filter:?}"
)));
}
}
Ok(mailboxes)
}
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", skip(ids), ret)
)]
pub fn ids_list_to_collapsed_sequence(ids: &HashSet<Uid>) -> String {
#[cfg(feature = "tracing")]
tracing::trace!(?ids);
debug_assert!(!ids.is_empty(), "ids must not be empty");
let mut sorted_ids: Vec<_> = ids.iter().copied().collect();
sorted_ids.sort_unstable();
let mut result = Vec::new();
let mut start = sorted_ids.first().copied();
let mut end = start;
for &id in sorted_ids.get(1..).unwrap_or_default() {
match (end, start) {
(Some(e), Some(_s)) if id == e + 1 => end = Some(id),
_ => {
if let (Some(s), Some(e)) = (start, end) {
result.push(if s == e {
s.to_string()
} else {
format!("{s}:{e}")
});
}
start = Some(id);
end = start;
}
}
}
if let (Some(s), Some(e)) = (start, end) {
result.push(if s == e {
s.to_string()
} else {
format!("{s}:{e}")
});
}
result.join(",")
}
#[cfg(test)]
mod tests {
use super::ids_list_to_collapsed_sequence;
use imap::types::Uid;
use std::collections::HashSet;
#[test]
#[should_panic(expected = "ids must not be empty")]
fn empty_set() {
let ids: HashSet<Uid> = HashSet::new();
ids_list_to_collapsed_sequence(&ids);
}
#[test]
fn single_id() {
let mut ids = HashSet::new();
ids.insert(5);
assert_eq!(ids_list_to_collapsed_sequence(&ids), "5");
}
#[test]
fn continuous_range() {
let ids: HashSet<_> = [1, 2, 3, 4, 5].iter().copied().collect();
assert_eq!(ids_list_to_collapsed_sequence(&ids), "1:5");
}
#[test]
fn multiple_disjoint_ranges() {
let ids: HashSet<_> = [1, 2, 3, 7, 8, 10, 11].iter().copied().collect();
assert_eq!(ids_list_to_collapsed_sequence(&ids), "1:3,7:8,10:11");
}
#[test]
fn mixed_ranges_and_single_ids() {
let ids: HashSet<_> = [1, 3, 4, 6, 7, 10, 12].iter().copied().collect();
assert_eq!(ids_list_to_collapsed_sequence(&ids), "1,3:4,6:7,10,12");
}
#[test]
fn unsorted_input() {
let ids: HashSet<_> = [10, 1, 4, 5, 12, 6, 22, 23, 24, 31]
.iter()
.copied()
.collect();
assert_eq!(ids_list_to_collapsed_sequence(&ids), "1,4:6,10,12,22:24,31");
}
}