Merge pull request #249 from bitfehler/bitfehler/list-status

Add support for RFC 5819: LIST-STATUS extension
This commit is contained in:
Jon Gjengset 2023-01-21 14:21:22 -08:00 committed by GitHub
commit 4e7933d422
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 212 additions and 1 deletions

View file

@ -1113,6 +1113,7 @@ impl<T: Read + Write> Session<T> {
/// - `UIDNEXT`: The next [`Uid`] of the mailbox.
/// - `UIDVALIDITY`: The unique identifier validity value of the mailbox (see [`Uid`]).
/// - `UNSEEN`: The number of messages which do not have [`Flag::Seen`] set.
/// - `HIGHESTMODSEQ`: The highest mod sequence of the mailbox, counting modifications made to it.
///
/// `data_items` is a space-separated list enclosed in parentheses.
pub fn status(

View file

@ -0,0 +1,197 @@
//! Adds support for the IMAP LIST-STATUS extension specificed in [RFC
//! 5819](https://tools.ietf.org/html/rfc5819).
use crate::client::{validate_str, Session};
use crate::error::{Error, ParseError, Result};
use crate::parse::try_handle_unilateral;
use crate::types::{Mailbox, Name, UnsolicitedResponse};
use imap_proto::types::{MailboxDatum, Response, StatusAttribute};
use ouroboros::self_referencing;
use std::io::{Read, Write};
use std::slice::Iter;
use std::sync::mpsc;
/// A wrapper for one or more [`Name`] responses paired with optional [`Mailbox`] responses.
///
/// This structure represents responses to a LIST-STATUS command, as implemented in
/// [`Session::list_status`]. See [RFC 5819, section 2](https://tools.ietf.org/html/rfc5819.html#section-2).
#[self_referencing]
pub struct ExtendedNames {
data: Vec<u8>,
#[borrows(data)]
#[covariant]
pub(crate) extended_names: Vec<(Name<'this>, Option<Mailbox>)>,
}
impl ExtendedNames {
/// Parse one or more LIST-STATUS responses from a response buffer
pub(crate) fn parse(
owned: Vec<u8>,
unsolicited: &mut mpsc::Sender<UnsolicitedResponse>,
) -> core::result::Result<Self, Error> {
ExtendedNamesTryBuilder {
data: owned,
extended_names_builder: |input| {
let mut lines: &[u8] = &input;
let mut names = Vec::new();
let mut current_name: Option<Name<'_>> = None;
let mut current_mailbox: Option<Mailbox> = None;
loop {
if lines.is_empty() {
if let Some(cur_name) = current_name {
names.push((cur_name, current_mailbox));
}
break;
}
match imap_proto::parser::parse_response(lines) {
Ok((
rest,
Response::MailboxData(MailboxDatum::List {
name_attributes,
delimiter,
name,
}),
)) => {
lines = rest;
if let Some(cur_name) = current_name {
names.push((cur_name, current_mailbox));
current_mailbox = None;
}
current_name = Some(Name {
attributes: name_attributes,
delimiter,
name,
});
}
Ok((
rest,
Response::MailboxData(MailboxDatum::Status { mailbox: _, status }),
)) => {
lines = rest;
let mut mb = Mailbox::default();
for attr in status {
match attr {
StatusAttribute::HighestModSeq(v) => {
mb.highest_mod_seq = Some(v)
}
StatusAttribute::Messages(v) => mb.exists = v,
StatusAttribute::Recent(v) => mb.recent = v,
StatusAttribute::UidNext(v) => mb.uid_next = Some(v),
StatusAttribute::UidValidity(v) => mb.uid_validity = Some(v),
StatusAttribute::Unseen(v) => mb.unseen = Some(v),
_ => {} // needed because StatusAttribute is #[non_exhaustive]
}
}
current_mailbox = Some(mb);
}
Ok((rest, resp)) => {
lines = rest;
if let Some(unhandled) = try_handle_unilateral(resp, unsolicited) {
return Err(unhandled.into());
}
}
Err(_) => {
return Err(Error::Parse(ParseError::Invalid(lines.to_vec())));
}
}
}
Ok(names)
},
}
.try_build()
}
/// Iterate over the contained elements
pub fn iter(&self) -> Iter<'_, (Name<'_>, Option<Mailbox>)> {
self.borrow_extended_names().iter()
}
/// Get the number of elements in this container.
pub fn len(&self) -> usize {
self.borrow_extended_names().len()
}
/// Return true of there are no elements in the container.
pub fn is_empty(&self) -> bool {
self.borrow_extended_names().is_empty()
}
/// Get the element at the given index
pub fn get(&self, index: usize) -> Option<&(Name<'_>, Option<Mailbox>)> {
self.borrow_extended_names().get(index)
}
}
impl<T: Read + Write> Session<T> {
/// The [extended `LIST` command](https://tools.ietf.org/html/rfc5819.html#section-2) returns
/// a subset of names from the complete set of all names available to the client. Each name
/// _should_ be paired with a STATUS response, though the server _may_ drop it if it encounters
/// problems looking up the required information.
///
/// This version of the command is also often referred to as `LIST-STATUS` command, as that is
/// the name of the extension and it is a combination of the two.
///
/// The `reference_name` and `mailbox_pattern` arguments have the same semantics as they do in
/// [`Session::list`].
///
/// The `data_items` argument has the same semantics as it does in [`Session::status`].
pub fn list_status(
&mut self,
reference_name: Option<&str>,
mailbox_pattern: Option<&str>,
data_items: &str,
) -> Result<ExtendedNames> {
let reference = validate_str("LIST-STATUS", "reference", reference_name.unwrap_or(""))?;
self.run_command_and_read_response(&format!(
"LIST {} {} RETURN (STATUS {})",
&reference,
mailbox_pattern.unwrap_or("\"\""),
data_items
))
.and_then(|lines| ExtendedNames::parse(lines, &mut self.unsolicited_responses_tx))
}
}
#[cfg(test)]
mod tests {
use imap_proto::NameAttribute;
use super::*;
#[test]
fn parse_list_status_test() {
let lines = b"\
* LIST () \".\" foo\r\n\
* STATUS foo (HIGHESTMODSEQ 122)\r\n\
* LIST () \".\" foo.bar\r\n\
* STATUS foo.bar (HIGHESTMODSEQ 132)\r\n\
* LIST (\\UnMarked) \".\" feeds\r\n\
* LIST () \".\" feeds.test\r\n\
* STATUS feeds.test (HIGHESTMODSEQ 757)\r\n";
let (mut send, recv) = mpsc::channel();
let fetches = ExtendedNames::parse(lines.to_vec(), &mut send).unwrap();
assert!(recv.try_recv().is_err());
assert!(!fetches.is_empty());
assert_eq!(fetches.len(), 4);
let (name, status) = fetches.get(0).unwrap();
assert_eq!(&name.name, "foo");
assert!(status.is_some());
assert_eq!(status.as_ref().unwrap().highest_mod_seq, Some(122));
let (name, status) = fetches.get(1).unwrap();
assert_eq!(&name.name, "foo.bar");
assert!(status.is_some());
assert_eq!(status.as_ref().unwrap().highest_mod_seq, Some(132));
let (name, status) = fetches.get(2).unwrap();
assert_eq!(&name.name, "feeds");
assert_eq!(name.attributes.len(), 1);
assert_eq!(name.attributes.get(0).unwrap(), &NameAttribute::Unmarked);
assert!(status.is_none());
let (name, status) = fetches.get(3).unwrap();
assert_eq!(&name.name, "feeds.test");
assert!(status.is_some());
assert_eq!(status.as_ref().unwrap().highest_mod_seq, Some(757));
}
}

View file

@ -1,4 +1,5 @@
//! Implementations of various IMAP extensions.
pub mod idle;
pub mod list_status;
pub mod metadata;
pub mod sort;

View file

@ -383,6 +383,18 @@ fn list() {
// TODO: make a subdir
}
#[test]
#[cfg(feature = "test-full-imap")]
fn list_status() {
let mut s = session("readonly-test@localhost");
let extnames = s.list_status(None, Some("*"), "(HIGHESTMODSEQ)").unwrap();
assert_eq!(extnames.len(), 1);
let (name, status) = extnames.get(0).unwrap();
assert_eq!(name.name(), "INBOX");
assert!(status.is_some());
assert!(status.as_ref().unwrap().highest_mod_seq.is_some());
}
#[test]
fn append() {
let to = "inbox-append1@localhost";
@ -659,7 +671,7 @@ fn status() {
}
#[test]
#[ignore]
#[cfg(feature = "test-full-imap")]
fn qresync() {
// Ignored because Greenmail does not support QRESYNC. Does work with Cyrus, though.
let to = "inbox-qresync@localhost";