diff --git a/src/client.rs b/src/client.rs index a5ddc5c2..0811f887 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1275,7 +1275,7 @@ impl Session { /// - `SINCE `: Messages whose internal date (disregarding time and timezone) is within or later than the specified date. pub fn search>(&mut self, query: S) -> Result> { self.run_command_and_read_response(&format!("SEARCH {}", query.as_ref())) - .and_then(|lines| parse_ids(&lines, &mut self.unsolicited_responses_tx)) + .and_then(|lines| parse_id_set(&lines, &mut self.unsolicited_responses_tx)) } /// Equivalent to [`Session::search`], except that the returned identifiers @@ -1283,7 +1283,45 @@ impl Session { /// command](https://tools.ietf.org/html/rfc3501#section-6.4.8). pub fn uid_search>(&mut self, query: S) -> Result> { self.run_command_and_read_response(&format!("UID SEARCH {}", query.as_ref())) - .and_then(|lines| parse_ids(&lines, &mut self.unsolicited_responses_tx)) + .and_then(|lines| parse_id_set(&lines, &mut self.unsolicited_responses_tx)) + } + + /// This issues the [SORT command](https://tools.ietf.org/html/rfc5256#section-3), + /// which returns sorted search results. + /// + /// This command is like [`Session::search`], except that + /// the results are also sorted according to the supplied criteria (subject to the given charset). + pub fn sort>( + &mut self, + criteria: &[extensions::sort::SortCriterion<'_>], + charset: extensions::sort::SortCharset<'_>, + query: S, + ) -> Result> { + self.run_command_and_read_response(&format!( + "SORT {} {} {}", + extensions::sort::SortCriteria(criteria), + charset, + query.as_ref() + )) + .and_then(|lines| parse_id_seq(&lines, &mut self.unsolicited_responses_tx)) + } + + /// Equivalent to [`Session::sort`], except that it returns [`Uid`]s. + /// + /// See also [`Session::uid_search`]. + pub fn uid_sort>( + &mut self, + criteria: &[extensions::sort::SortCriterion<'_>], + charset: extensions::sort::SortCharset<'_>, + query: S, + ) -> Result> { + self.run_command_and_read_response(&format!( + "UID SORT {} {} {}", + extensions::sort::SortCriteria(criteria), + charset, + query.as_ref() + )) + .and_then(|lines| parse_id_seq(&lines, &mut self.unsolicited_responses_tx)) } // these are only here because they are public interface, the rest is in `Connection` @@ -1843,6 +1881,51 @@ mod tests { assert_eq!(ids, [1, 2, 3, 4, 5].iter().cloned().collect()); } + #[test] + fn sort() { + use extensions::sort::{SortCharset, SortCriterion}; + + let response = b"* SORT 1 2 3 4 5\r\n\ + a1 OK Sort completed\r\n" + .to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let ids = session + .sort(&[SortCriterion::Arrival], SortCharset::Utf8, "ALL") + .unwrap(); + let ids: Vec = ids.iter().cloned().collect(); + assert!( + session.stream.get_ref().written_buf == b"a1 SORT (ARRIVAL) UTF-8 ALL\r\n".to_vec(), + "Invalid sort command" + ); + assert_eq!(ids, [1, 2, 3, 4, 5].iter().cloned().collect::>()); + } + + #[test] + fn uid_sort() { + use extensions::sort::{SortCharset, SortCriterion}; + + let response = b"* SORT 1 2 3 4 5\r\n\ + a1 OK Sort completed\r\n" + .to_vec(); + let mock_stream = MockStream::new(response); + let mut session = mock_session!(mock_stream); + let ids = session + .uid_sort( + &[SortCriterion::Reverse(&SortCriterion::Size)], + SortCharset::UsAscii, + "SUBJECT", + ) + .unwrap(); + let ids: Vec = ids.iter().cloned().collect(); + assert!( + session.stream.get_ref().written_buf + == b"a1 UID SORT (REVERSE SIZE) US-ASCII SUBJECT\r\n".to_vec(), + "Invalid sort command" + ); + assert_eq!(ids, [1, 2, 3, 4, 5].iter().cloned().collect::>()); + } + #[test] fn capability() { let response = b"* CAPABILITY IMAP4rev1 STARTTLS AUTH=GSSAPI LOGINDISABLED\r\n\ diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index 6ac2a8e1..d2f548ca 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -1,3 +1,4 @@ //! Implementations of various IMAP extensions. pub mod idle; pub mod metadata; +pub mod sort; diff --git a/src/extensions/sort.rs b/src/extensions/sort.rs new file mode 100644 index 00000000..4bc27530 --- /dev/null +++ b/src/extensions/sort.rs @@ -0,0 +1,153 @@ +//! Adds support for the IMAP SORT extension specificed in [RFC +//! 5464](https://tools.ietf.org/html/rfc5256#section-3). +//! +//! The SORT command is a variant of SEARCH with sorting semantics for +//! the results. There are two arguments before the searching +//! criteria argument: a parenthesized list of sort criteria, and the +//! searching charset. + +use std::{borrow::Cow, fmt}; + +pub(crate) struct SortCriteria<'c>(pub(crate) &'c [SortCriterion<'c>]); + +impl<'c> fmt::Display for SortCriteria<'c> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.0.is_empty() { + write!(f, "") + } else { + let criteria: Vec = self.0.iter().map(|c| c.to_string()).collect(); + write!(f, "({})", criteria.join(" ")) + } + } +} + +/// Message sorting preferences used for [`Session::sort`] and [`Session::uid_sort`]. +/// +/// Any sorting criterion that refers to an address (`From`, `To`, etc.) sorts according to the +/// "addr-mailbox" of the indicated address. You can find the formal syntax for addr-mailbox [in +/// the IMAP spec](https://tools.ietf.org/html/rfc3501#section-9), and a more detailed discussion +/// of the relevant semantics [in RFC 2822](https://tools.ietf.org/html/rfc2822#section-3.4.1). +/// Essentially, the address refers _either_ to the name of the contact _or_ to its local-part (the +/// left part of the email address, before the `@`). +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy)] +pub enum SortCriterion<'c> { + /// Internal date and time of the message. This differs from the + /// ON criteria in SEARCH, which uses just the internal date. + Arrival, + + /// IMAP addr-mailbox of the first "Cc" address. + Cc, + + /// Sent date and time, as described in + /// [section 2.2](https://tools.ietf.org/html/rfc5256#section-2.2). + Date, + + /// IMAP addr-mailbox of the first "From" address. + From, + + /// Followed by another sort criterion, has the effect of that + /// criterion but in reverse (descending) order. + Reverse(&'c SortCriterion<'c>), + + /// Size of the message in octets. + Size, + + /// Base subject text. + Subject, + + /// IMAP addr-mailbox of the first "To" address. + To, +} + +impl<'c> fmt::Display for SortCriterion<'c> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use SortCriterion::*; + + match self { + Arrival => write!(f, "ARRIVAL"), + Cc => write!(f, "CC"), + Date => write!(f, "DATE"), + From => write!(f, "FROM"), + Reverse(c) => write!(f, "REVERSE {}", c), + Size => write!(f, "SIZE"), + Subject => write!(f, "SUBJECT"), + To => write!(f, "TO"), + } + } +} + +/// The character encoding to use for strings that are subject to a [`SortCriterion`]. +/// +/// Servers are only required to implement [`SortCharset::UsAscii`] and [`SortCharset::Utf8`]. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SortCharset<'c> { + /// Strings are UTF-8 encoded. + Utf8, + + /// Strings are encoded with ASCII. + UsAscii, + + /// Strings are encoded using some other character set. + /// + /// Note that this option is subject to server support for the specified character set. + Custom(Cow<'c, str>), +} + +impl<'c> fmt::Display for SortCharset<'c> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use SortCharset::*; + + match self { + Utf8 => write!(f, "UTF-8"), + UsAscii => write!(f, "US-ASCII"), + Custom(c) => write!(f, "{}", c), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_criterion_to_string() { + use SortCriterion::*; + + assert_eq!("ARRIVAL", Arrival.to_string()); + assert_eq!("CC", Cc.to_string()); + assert_eq!("DATE", Date.to_string()); + assert_eq!("FROM", From.to_string()); + assert_eq!("SIZE", Size.to_string()); + assert_eq!("SUBJECT", Subject.to_string()); + assert_eq!("TO", To.to_string()); + assert_eq!("REVERSE TO", Reverse(&To).to_string()); + assert_eq!("REVERSE REVERSE TO", Reverse(&Reverse(&To)).to_string()); + } + + #[test] + fn test_criteria_to_string() { + use SortCriterion::*; + + assert_eq!("", SortCriteria(&[]).to_string()); + assert_eq!("(ARRIVAL)", SortCriteria(&[Arrival]).to_string()); + assert_eq!( + "(ARRIVAL REVERSE FROM)", + SortCriteria(&[Arrival, Reverse(&From)]).to_string() + ); + assert_eq!( + "(ARRIVAL REVERSE REVERSE REVERSE FROM)", + SortCriteria(&[Arrival, Reverse(&Reverse(&Reverse(&From)))]).to_string() + ); + } + + #[test] + fn test_charset_to_string() { + use SortCharset::*; + + assert_eq!("UTF-8", Utf8.to_string()); + assert_eq!("US-ASCII", UsAscii.to_string()); + assert_eq!("CHARSET", Custom("CHARSET".into()).to_string()); + } +} diff --git a/src/parse.rs b/src/parse.rs index b28acecb..7a026c2b 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -315,21 +315,25 @@ pub fn parse_mailbox( } } -pub fn parse_ids( +fn parse_ids_with>( lines: &[u8], unsolicited: &mut mpsc::Sender, -) -> Result> { + mut collection: T, +) -> Result { let mut lines = lines; - let mut ids = HashSet::new(); loop { if lines.is_empty() { - break Ok(ids); + break Ok(collection); } match imap_proto::parser::parse_response(lines) { Ok((rest, Response::MailboxData(MailboxDatum::Search(c)))) => { lines = rest; - ids.extend(c); + collection.extend(c); + } + Ok((rest, Response::MailboxData(MailboxDatum::Sort(c)))) => { + lines = rest; + collection.extend(c); } Ok((rest, data)) => { lines = rest; @@ -344,6 +348,20 @@ pub fn parse_ids( } } +pub fn parse_id_set( + lines: &[u8], + unsolicited: &mut mpsc::Sender, +) -> Result> { + parse_ids_with(lines, unsolicited, HashSet::new()) +} + +pub fn parse_id_seq( + lines: &[u8], + unsolicited: &mut mpsc::Sender, +) -> Result> { + parse_ids_with(lines, unsolicited, Vec::new()) +} + /// Parse a single unsolicited response from IDLE responses. pub fn parse_idle(lines: &[u8]) -> (&[u8], Option>) { match imap_proto::parser::parse_response(lines) { @@ -547,7 +565,7 @@ mod tests { * 1 RECENT\r\n\ * STATUS INBOX (MESSAGES 10 UIDNEXT 11 UIDVALIDITY 1408806928 UNSEEN 0)\r\n"; let (mut send, recv) = mpsc::channel(); - let ids = parse_ids(lines, &mut send).unwrap(); + let ids = parse_id_set(lines, &mut send).unwrap(); assert_eq!(ids, [23, 42, 4711].iter().cloned().collect()); @@ -571,7 +589,7 @@ mod tests { let lines = b"* SEARCH 1600 1698 1739 1781 1795 1885 1891 1892 1893 1898 1899 1901 1911 1926 1932 1933 1993 1994 2007 2032 2033 2041 2053 2062 2063 2065 2066 2072 2078 2079 2082 2084 2095 2100 2101 2102 2103 2104 2107 2116 2120 2135 2138 2154 2163 2168 2172 2189 2193 2198 2199 2205 2212 2213 2221 2227 2267 2275 2276 2295 2300 2328 2330 2332 2333 2334\r\n\ * SEARCH 2335 2336 2337 2338 2339 2341 2342 2347 2349 2350 2358 2359 2362 2369 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2390 2392 2397 2400 2401 2403 2405 2409 2411 2414 2417 2419 2420 2424 2426 2428 2439 2454 2456 2467 2468 2469 2490 2515 2519 2520 2521\r\n"; let (mut send, recv) = mpsc::channel(); - let ids = parse_ids(lines, &mut send).unwrap(); + let ids = parse_id_set(lines, &mut send).unwrap(); assert!(recv.try_recv().is_err()); let ids: HashSet = ids.iter().cloned().collect(); assert_eq!( @@ -594,10 +612,17 @@ mod tests { let lines = b"* SEARCH\r\n"; let (mut send, recv) = mpsc::channel(); - let ids = parse_ids(lines, &mut send).unwrap(); + let ids = parse_id_set(lines, &mut send).unwrap(); assert!(recv.try_recv().is_err()); let ids: HashSet = ids.iter().cloned().collect(); assert_eq!(ids, HashSet::::new()); + + let lines = b"* SORT\r\n"; + let (mut send, recv) = mpsc::channel(); + let ids = parse_id_seq(lines, &mut send).unwrap(); + assert!(recv.try_recv().is_err()); + let ids: Vec = ids.iter().cloned().collect(); + assert_eq!(ids, Vec::::new()); } #[test] diff --git a/tests/imap_integration.rs b/tests/imap_integration.rs index 25770aab..a611f2f4 100644 --- a/tests/imap_integration.rs +++ b/tests/imap_integration.rs @@ -8,6 +8,8 @@ use chrono::{FixedOffset, TimeZone}; use lettre::Transport; use std::net::TcpStream; +use crate::imap::extensions::sort::{SortCharset, SortCriterion}; + fn tls() -> native_tls::TlsConnector { native_tls::TlsConnector::builder() .danger_accept_invalid_certs(true) @@ -113,11 +115,21 @@ fn inbox() { .unwrap(); s.send(e.into()).unwrap(); - // now we should see the e-mail! + // send a second e-mail + let e = lettre_email::Email::builder() + .from("sender2@localhost") + .to(to) + .subject("My second e-mail") + .text("Hello world from SMTP") + .build() + .unwrap(); + s.send(e.into()).unwrap(); + + // now we should see the e-mails! let inbox = c.search("ALL").unwrap(); - // and the one message should have the first message sequence number - assert_eq!(inbox.len(), 1); + assert_eq!(inbox.len(), 2); assert!(inbox.contains(&1)); + assert!(inbox.contains(&2)); // we should also get two unsolicited responses: Exists and Recent c.noop().unwrap(); @@ -128,12 +140,12 @@ fn inbox() { assert_eq!(unsolicited.len(), 2); assert!(unsolicited .iter() - .any(|m| m == &imap::types::UnsolicitedResponse::Exists(1))); + .any(|m| m == &imap::types::UnsolicitedResponse::Exists(2))); assert!(unsolicited .iter() - .any(|m| m == &imap::types::UnsolicitedResponse::Recent(1))); + .any(|m| m == &imap::types::UnsolicitedResponse::Recent(2))); - // let's see that we can also fetch the e-mail + // let's see that we can also fetch the e-mails let fetch = c.fetch("1", "(ALL UID)").unwrap(); assert_eq!(fetch.len(), 1); let fetch = &fetch[0]; @@ -155,13 +167,59 @@ fn inbox() { let date_opt = fetch.internal_date(); assert!(date_opt.is_some()); - // and let's delete it to clean up - c.store("1", "+FLAGS (\\Deleted)").unwrap(); + let inbox = c.search("ALL").unwrap(); + assert_eq!(inbox.len(), 2); + + // e-mails should be sorted by subject + let inbox = c + .sort(&[SortCriterion::Subject], SortCharset::UsAscii, "ALL") + .unwrap(); + assert_eq!(inbox.len(), 2); + let mut sort = inbox.iter(); + assert_eq!(sort.next().unwrap(), &1); + assert_eq!(sort.next().unwrap(), &2); + + // e-mails should be reverse sorted by subject + let inbox = c + .sort( + &[SortCriterion::Reverse(&SortCriterion::Subject)], + SortCharset::Utf8, + "ALL", + ) + .unwrap(); + assert_eq!(inbox.len(), 2); + let mut sort = inbox.iter(); + assert_eq!(sort.next().unwrap(), &2); + assert_eq!(sort.next().unwrap(), &1); + + // the number of reverse does not change the order + // one or more Reverse implies a reversed result + let inbox = c + .sort( + &[SortCriterion::Reverse(&SortCriterion::Reverse( + &SortCriterion::Reverse(&SortCriterion::Subject), + ))], + SortCharset::Custom("UTF-8".into()), + "ALL", + ) + .unwrap(); + assert_eq!(inbox.len(), 2); + let mut sort = inbox.iter(); + assert_eq!(sort.next().unwrap(), &2); + assert_eq!(sort.next().unwrap(), &1); + + // let's delete them to clean up + c.store("1,2", "+FLAGS (\\Deleted)").unwrap(); c.expunge().unwrap(); - // the e-mail should be gone now + // e-mails should be gone now let inbox = c.search("ALL").unwrap(); assert_eq!(inbox.len(), 0); + + let inbox = c + .sort(&[SortCriterion::Subject], SortCharset::Utf8, "ALL") + .unwrap(); + assert_eq!(inbox.len(), 0); } #[test] @@ -184,7 +242,9 @@ fn inbox_uid() { s.send(e.into()).unwrap(); // now we should see the e-mail! - let inbox = c.uid_search("ALL").unwrap(); + let inbox = c + .uid_sort(&[SortCriterion::Subject], SortCharset::Utf8, "ALL") + .unwrap(); // and the one message should have the first message sequence number assert_eq!(inbox.len(), 1); let uid = inbox.into_iter().next().unwrap();