Merge pull request #149 from open-xchange/metadata-merge

Adding METADATA support
This commit is contained in:
Jon Gjengset 2021-03-06 12:44:08 -05:00 committed by GitHub
commit 076d7ae274
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 228 additions and 11 deletions

View file

@ -43,7 +43,7 @@ impl<E> OptionExt<E> for Option<E> {
/// grammar](https://tools.ietf.org/html/rfc3501#section-9)
/// calls "quoted", which is reachable from "string" et al.
/// Also ensure it doesn't contain a colliding command-delimiter (newline).
fn validate_str(value: &str) -> Result<String> {
pub(crate) fn validate_str(value: &str) -> Result<String> {
validate_str_noquote(value)?;
Ok(quote!(value))
}
@ -108,7 +108,7 @@ fn validate_sequence_set(value: &str) -> Result<&str> {
#[derive(Debug)]
pub struct Session<T: Read + Write> {
conn: Connection<T>,
unsolicited_responses_tx: mpsc::Sender<UnsolicitedResponse>,
pub(crate) unsolicited_responses_tx: mpsc::Sender<UnsolicitedResponse>,
/// Server responses that are not related to the current command. See also the note on
/// [unilateral server responses in RFC 3501](https://tools.ietf.org/html/rfc3501#section-7).
@ -1462,6 +1462,7 @@ mod tests {
use super::super::error::Result;
use super::super::mock_stream::MockStream;
use super::*;
use imap_proto::types::*;
macro_rules! mock_session {
($s:expr) => {
@ -1819,7 +1820,12 @@ mod tests {
let response = b"* CAPABILITY IMAP4rev1 STARTTLS AUTH=GSSAPI LOGINDISABLED\r\n\
a1 OK CAPABILITY completed\r\n"
.to_vec();
let expected_capabilities = vec!["IMAP4rev1", "STARTTLS", "AUTH=GSSAPI", "LOGINDISABLED"];
let expected_capabilities = vec![
Capability::Imap4rev1,
Capability::Atom("STARTTLS"),
Capability::Auth("GSSAPI"),
Capability::Atom("LOGINDISABLED"),
];
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
let capabilities = session.capabilities().unwrap();
@ -1829,7 +1835,7 @@ mod tests {
);
assert_eq!(capabilities.len(), 4);
for e in expected_capabilities {
assert!(capabilities.has_str(e));
assert!(capabilities.has(&e));
}
}

179
src/extensions/metadata.rs Normal file
View file

@ -0,0 +1,179 @@
//! Adds support for the IMAP METADATA extension specificed in [RFC
//! 5464](https://tools.ietf.org/html/rfc5464).
use crate::client::*;
use crate::error::{Error, ParseError, Result};
use crate::parse::handle_unilateral;
use crate::types::*;
use imap_proto::types::{MailboxDatum, Metadata, Response};
use std::io::{Read, Write};
use std::sync::mpsc;
trait CmdListItemFormat {
fn format_as_cmd_list_item(&self) -> String;
}
impl CmdListItemFormat for Metadata {
fn format_as_cmd_list_item(&self) -> String {
format!(
"{} {}",
validate_str(self.entry.as_str()).unwrap(),
self.value
.as_ref()
.map(|v| validate_str(v.as_str()).unwrap())
.unwrap_or("NIL".to_string())
)
}
}
/// Represents variants of DEPTH parameters for GETMETADATA command.
/// "0" - no entries below the specified entry are returned
/// "1" - only entries immediately below the specified entry are returned
/// "infinity" - all entries below the specified entry are returned
/// See [RFC 5464, section 4.2.2](https://tools.ietf.org/html/rfc5464#section-4.2.2)
#[derive(Debug, Copy, Clone)]
pub enum MetadataDepth {
/// Depth 0 for get metadata
Zero,
/// Depth 1 for get metadata
One,
/// Depth infinity for get metadata
Inf,
}
impl Default for MetadataDepth {
fn default() -> Self {
Self::Zero
}
}
impl MetadataDepth {
fn depth_str<'a>(self) -> &'a str {
match self {
MetadataDepth::Zero => return "0",
MetadataDepth::One => return "1",
MetadataDepth::Inf => return "infinity",
}
}
}
fn parse_metadata<'a>(
mut lines: &'a [u8],
unsolicited: &'a mut mpsc::Sender<UnsolicitedResponse>,
) -> Result<Vec<Metadata>> {
let mut res: Vec<Metadata> = Vec::new();
loop {
if lines.is_empty() {
break Ok(res);
}
match imap_proto::parse_response(lines) {
Ok((rest, resp)) => {
lines = rest;
match resp {
Response::MailboxData(MailboxDatum::MetadataSolicited {
mailbox: _,
mut values,
}) => {
res.append(&mut values);
}
_ => {
if let Some(unhandled) = handle_unilateral(resp, unsolicited) {
break Err(unhandled.into());
}
}
}
}
Err(_) => {
return Err(Error::Parse(ParseError::Invalid(lines.to_vec())));
}
}
}
}
impl<T: Read + Write> Session<T> {
/// Sends GETMETADATA command of the METADATA extension to IMAP protocol
/// to the server and returns the list of entries and their values.
/// Server support for the extension is indicated by METADATA capability.
/// @param mbox mailbox name. When the mailbox name is the empty string, this command retrieves server annotations. When the mailbox name is not empty, this command retrieves annotations on the specified mailbox.
/// @param entries list of metadata entries to be retrieved.
/// @param depth GETMETADATA DEPTH option, specifies if children entries are to be retrieved as well.
/// @param maxside GETMETADATA MAXSIZE option. When the MAXSIZE option is specified with the GETMETADATA command, it restricts which entry values are returned by the server. Only entry values that are less than or equal in octet size to the specified MAXSIZE limit are returned.
/// See [RFC 5464, section 4.2](https://tools.ietf.org/html/rfc5464#section-4.2) for more details.
pub fn get_metadata(
&mut self,
mbox: impl AsRef<str>,
entries: &[impl AsRef<str>],
depth: MetadataDepth,
maxsize: Option<usize>,
) -> Result<Vec<Metadata>> {
let v: Vec<String> = entries
.iter()
.map(|e| validate_str(e.as_ref()).unwrap())
.collect();
let s = v.as_slice().join(" ");
let mut command = format!("GETMETADATA (DEPTH {}", depth.depth_str());
match maxsize {
Some(size) => {
command.push_str(format!(" MAXSIZE {}", size).as_str());
}
_ => {}
}
command.push_str(format!(") {} ({})", validate_str(mbox.as_ref()).unwrap(), s).as_str());
self.run_command_and_read_response(command)
.and_then(|lines| parse_metadata(&lines[..], &mut self.unsolicited_responses_tx))
}
/// Sends SETMETADATA command of the METADATA extension to IMAP protocol
/// to the server and checks if it was executed successfully.
/// Server support for the extension is indicated by METADATA capability.
/// @param mbox mailbox name. When the mailbox name is the empty string, this command sets server annotations. When the mailbox name is not empty, this command sets annotations on the specified mailbox.
/// @param keyvl list of entry value pairs to be set.
/// See [RFC 5464, section 4.3](https://tools.ietf.org/html/rfc5464#section-4.3)
pub fn set_metadata(&mut self, mbox: impl AsRef<str>, keyval: &[Metadata]) -> Result<()> {
let v: Vec<String> = keyval
.iter()
.map(|metadata| metadata.format_as_cmd_list_item())
.collect();
let s = v.as_slice().join(" ");
let command = format!("SETMETADATA {} ({})", validate_str(mbox.as_ref())?, s);
self.run_command_and_check_ok(command)
}
}
#[cfg(test)]
mod tests {
use crate::extensions::metadata::*;
use crate::mock_stream::MockStream;
use crate::*;
#[test]
fn test_getmetadata() {
let response = "a1 OK Logged in.\r\n* METADATA \"\" (/shared/vendor/vendor.coi/a {3}\r\nAAA /shared/vendor/vendor.coi/b {3}\r\nBBB /shared/vendor/vendor.coi/c {3}\r\nCCC)\r\na2 OK GETMETADATA Completed\r\n";
let mock_stream = MockStream::new(response.as_bytes().to_vec());
let client = Client::new(mock_stream);
let mut session = client.login("testuser", "pass").unwrap();
let r = get_metadata(
&mut session,
"",
&["/shared/vendor/vendor.coi", "/shared/comment"],
MetadataDepth::Inf,
Option::None,
);
match r {
Ok(v) => {
assert_eq!(v.len(), 3);
assert_eq!(v[0].entry, "/shared/vendor/vendor.coi/a");
assert_eq!(v[0].value.as_ref().expect("None is not expected"), "AAA");
assert_eq!(v[1].entry, "/shared/vendor/vendor.coi/b");
assert_eq!(v[1].value.as_ref().expect("None is not expected"), "BBB");
assert_eq!(v[2].entry, "/shared/vendor/vendor.coi/c");
assert_eq!(v[2].value.as_ref().expect("None is not expected"), "CCC");
}
Err(e) => panic!("Unexpected error: {:?}", e),
}
}
}

View file

@ -1,2 +1,3 @@
//! Implementations of various IMAP extensions.
pub mod idle;
pub mod metadata;

View file

@ -343,7 +343,7 @@ pub fn parse_ids(
// check if this is simply a unilateral server response
// (see Section 7 of RFC 3501):
fn handle_unilateral<'a>(
pub(crate) fn handle_unilateral<'a>(
res: Response<'a>,
unsolicited: &mut mpsc::Sender<UnsolicitedResponse>,
) -> Option<Response<'a>> {
@ -375,6 +375,17 @@ fn handle_unilateral<'a>(
Response::Expunge(n) => {
unsolicited.send(UnsolicitedResponse::Expunge(n)).unwrap();
}
Response::MailboxData(MailboxDatum::MetadataUnsolicited {
mailbox,
values,
}) => {
unsolicited
.send(UnsolicitedResponse::Metadata {
mailbox: mailbox.to_string(),
metadata_entries: values.iter().map(|s| s.to_string()).collect(),
})
.unwrap();
}
Response::Vanished { earlier, uids } => {
unsolicited
.send(UnsolicitedResponse::Vanished { earlier, uids })
@ -390,10 +401,16 @@ fn handle_unilateral<'a>(
#[cfg(test)]
mod tests {
use super::*;
use imap_proto::types::*;
#[test]
fn parse_capability_test() {
let expected_capabilities = vec!["IMAP4rev1", "STARTTLS", "AUTH=GSSAPI", "LOGINDISABLED"];
let expected_capabilities = vec![
Capability::Imap4rev1,
Capability::Atom("STARTTLS"),
Capability::Auth("GSSAPI"),
Capability::Atom("LOGINDISABLED"),
];
let lines = b"* CAPABILITY IMAP4rev1 STARTTLS AUTH=GSSAPI LOGINDISABLED\r\n";
let (mut send, recv) = mpsc::channel();
let capabilities = parse_capabilities(lines.to_vec(), &mut send).unwrap();
@ -401,14 +418,14 @@ mod tests {
assert!(recv.try_recv().is_err());
assert_eq!(capabilities.len(), 4);
for e in expected_capabilities {
assert!(capabilities.has_str(e));
assert!(capabilities.has(&e));
}
}
#[test]
fn parse_capability_case_insensitive_test() {
// Test that "IMAP4REV1" (instead of "IMAP4rev1") is accepted
let expected_capabilities = vec!["IMAP4rev1", "STARTTLS"];
let expected_capabilities = vec![Capability::Imap4rev1, Capability::Atom("STARTTLS")];
let lines = b"* CAPABILITY IMAP4REV1 STARTTLS\r\n";
let (mut send, recv) = mpsc::channel();
let capabilities = parse_capabilities(lines.to_vec(), &mut send).unwrap();
@ -416,7 +433,7 @@ mod tests {
assert!(recv.try_recv().is_err());
assert_eq!(capabilities.len(), 2);
for e in expected_capabilities {
assert!(capabilities.has_str(e));
assert!(capabilities.has(&e));
}
}
@ -509,7 +526,12 @@ mod tests {
#[test]
fn parse_capabilities_w_unilateral() {
let expected_capabilities = vec!["IMAP4rev1", "STARTTLS", "AUTH=GSSAPI", "LOGINDISABLED"];
let expected_capabilities = vec![
Capability::Imap4rev1,
Capability::Atom("STARTTLS"),
Capability::Auth("GSSAPI"),
Capability::Atom("LOGINDISABLED"),
];
let lines = b"\
* CAPABILITY IMAP4rev1 STARTTLS AUTH=GSSAPI LOGINDISABLED\r\n\
* STATUS dev.github (MESSAGES 10 UIDNEXT 11 UIDVALIDITY 1408806928 UNSEEN 0)\r\n\
@ -519,7 +541,7 @@ mod tests {
assert_eq!(capabilities.len(), 4);
for e in expected_capabilities {
assert!(capabilities.has_str(e));
assert!(capabilities.has(&e));
}
assert_eq!(

View file

@ -282,6 +282,15 @@ pub enum UnsolicitedResponse {
// TODO: the spec doesn't seem to say anything about when these may be received as unsolicited?
Expunge(Seq),
/// An unsolicited METADATA response (https://tools.ietf.org/html/rfc5464#section-4.4.2)
/// that reports a change in a server or mailbox annotation.
Metadata {
/// Mailbox name for which annotations were changed.
mailbox: String,
/// List of annotations that were changed.
metadata_entries: Vec<String>,
},
/// An unsolicited [`VANISHED` response](https://tools.ietf.org/html/rfc7162#section-3.2.10)
/// that reports a sequence-set of `UID`s that have been expunged from the mailbox.
///