diff --git a/CHANGELOG.md b/CHANGELOG.md index ade21d0..050a900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +## [2.4.1] - 2021-01-12 +### Changed + + - Handle empty-set inputs to `fetch` and `uid_fetch` (#177) + +## [2.4.0] - 2020-12-15 +### Added + + - `append_with_flags_and_date` (#174) + +## [2.3.0] - 2020-08-23 +### Added + + - `append_with_flags` (#171) + ## [2.2.0] - 2020-07-27 ### Added @@ -27,5 +42,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - MSRV increased - Better documentation of server greeting handling (#168) -[Unreleased]: https://github.com/jonhoo/rust-imap/compare/v2.2.0...HEAD +[Unreleased]: https://github.com/jonhoo/rust-imap/compare/v2.4.1...HEAD +[2.4.1]: https://github.com/jonhoo/rust-imap/compare/v2.4.0...v2.4.1 +[2.4.0]: https://github.com/jonhoo/rust-imap/compare/v2.3.0...v2.4.0 +[2.3.0]: https://github.com/jonhoo/rust-imap/compare/v2.2.0...v2.3.0 [2.2.0]: https://github.com/jonhoo/rust-imap/compare/v2.1.2...v2.2.0 diff --git a/Cargo.toml b/Cargo.toml index 4d4be82..3b1a975 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,26 +1,18 @@ [package] name = "imap" -version = "2.3.0" -authors = ["Matt McCoy ", - "Jon Gjengset "] +version = "3.0.0-alpha.1" +authors = ["Jon Gjengset ", + "Matt McCoy "] documentation = "https://docs.rs/imap/" repository = "https://github.com/jonhoo/rust-imap" homepage = "https://github.com/jonhoo/rust-imap" description = "IMAP client for Rust" -readme = "README.md" -license = "Apache-2.0/MIT" +license = "Apache-2.0 OR MIT" edition = "2018" keywords = ["email", "imap"] categories = ["email", "network-programming"] -[badges] -azure-devops = { project = "jonhoo/jonhoo", pipeline = "imap", build = "11" } -codecov = { repository = "jonhoo/rust-imap", branch = "master", service = "github" } -maintenance = { status = "actively-developed" } -is-it-maintained-issue-resolution = { repository = "jonhoo/rust-imap" } -is-it-maintained-open-issues = { repository = "jonhoo/rust-imap" } - [features] tls = ["native-tls"] default = ["tls"] @@ -31,14 +23,14 @@ regex = "1.0" bufstream = "0.1" imap-proto = "0.12.0" nom = "6.0" -base64 = "0.12" +base64 = "0.13" chrono = "0.4" lazy_static = "1.4" [dev-dependencies] lettre = "0.9" lettre_email = "0.9" -rustls-connector = "0.12.0" +rustls-connector = "0.13.0" [[example]] name = "basic" diff --git a/azure-pipelines.yml b/azure-pipelines.yml index c9ac219..aa6917d 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -25,9 +25,6 @@ jobs: - template: install-rust.yml@templates parameters: rust: $(rust) - components: - - rustfmt - - clippy - script: cargo check --all-targets displayName: cargo check - script: cargo test --examples @@ -36,10 +33,16 @@ jobs: displayName: cargo test --doc - script: cargo test --lib displayName: cargo test --lib - - script: cargo fmt --all -- --check + - script: | + set -e + rustup component add rustfmt + cargo fmt --all -- --check displayName: cargo fmt --check condition: and(eq( variables['rust'], 'beta' ), eq( variables['Agent.OS'], 'Linux' )) - - script: cargo clippy -- -D warnings + - script: | + set -e + rustup component add clippy + cargo clippy -- -D warnings displayName: cargo clippy condition: and(eq( variables['rust'], 'beta' ), eq( variables['Agent.OS'], 'Linux' )) # This represents the minimum Rust version supported. diff --git a/src/client.rs b/src/client.rs index 149bc24..d303591 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,4 +1,5 @@ use bufstream::BufStream; +use chrono::{DateTime, FixedOffset}; #[cfg(feature = "tls")] use native_tls::{TlsConnector, TlsStream}; use std::collections::HashSet; @@ -25,15 +26,74 @@ macro_rules! quote { }; } +trait OptionExt { + fn err(self) -> std::result::Result<(), E>; +} + +impl OptionExt for Option { + fn err(self) -> std::result::Result<(), E> { + match self { + Some(e) => Err(e), + None => Ok(()), + } + } +} + +/// Convert the input into what [the IMAP +/// 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 { - let quoted = quote!(value); - if quoted.find('\n').is_some() { - return Err(Error::Validate(ValidateError('\n'))); - } - if quoted.find('\r').is_some() { - return Err(Error::Validate(ValidateError('\r'))); - } - Ok(quoted) + validate_str_noquote(value)?; + Ok(quote!(value)) +} + +/// Ensure the input doesn't contain a command-terminator (newline), but don't quote it like +/// `validate_str`. +/// This is helpful for things like the FETCH attributes, which, +/// per [the IMAP grammar](https://tools.ietf.org/html/rfc3501#section-9) may not be quoted: +/// +/// > fetch = "FETCH" SP sequence-set SP ("ALL" / "FULL" / "FAST" / +/// > fetch-att / "(" fetch-att *(SP fetch-att) ")") +/// > +/// > fetch-att = "ENVELOPE" / "FLAGS" / "INTERNALDATE" / +/// > "RFC822" [".HEADER" / ".SIZE" / ".TEXT"] / +/// > "BODY" ["STRUCTURE"] / "UID" / +/// > "BODY" section ["<" number "." nz-number ">"] / +/// > "BODY.PEEK" section ["<" number "." nz-number ">"] +/// +/// Note the lack of reference to any of the string-like rules or the quote characters themselves. +fn validate_str_noquote(value: &str) -> Result<&str> { + value + .matches(|c| c == '\n' || c == '\r') + .next() + .and_then(|s| s.chars().next()) + .map(|offender| Error::Validate(ValidateError(offender))) + .err()?; + Ok(value) +} + +/// This ensures the input doesn't contain a command-terminator or any other whitespace +/// while leaving it not-quoted. +/// This is needed because, per [the formal grammer given in RFC +/// 3501](https://tools.ietf.org/html/rfc3501#section-9), a sequence set consists of the following: +/// +/// > sequence-set = (seq-number / seq-range) *("," sequence-set) +/// > seq-range = seq-number ":" seq-number +/// > seq-number = nz-number / "*" +/// > nz-number = digit-nz *DIGIT +/// > digit-nz = %x31-39 +/// +/// Note the lack of reference to SP or any other such whitespace terminals. +/// Per this grammar, in theory we ought to be even more restrictive than "no whitespace". +fn validate_sequence_set(value: &str) -> Result<&str> { + value + .matches(|c: char| c.is_ascii_whitespace()) + .next() + .and_then(|s| s.chars().next()) + .map(|offender| Error::Validate(ValidateError(offender))) + .err()?; + Ok(value) } /// An authenticated IMAP session providing the usual IMAP commands. This type is what you get from @@ -81,6 +141,87 @@ pub struct Connection { pub greeting_read: bool, } +/// A builder for the append command +#[must_use] +pub struct AppendCmd<'a, T: Read + Write> { + session: &'a mut Session, + content: &'a [u8], + mailbox: &'a str, + flags: Vec>, + date: Option>, +} + +impl<'a, T: Read + Write> AppendCmd<'a, T> { + /// The [`APPEND` command](https://tools.ietf.org/html/rfc3501#section-6.3.11) can take + /// an optional FLAGS parameter to set the flags on the new message. + /// + /// > If a flag parenthesized list is specified, the flags SHOULD be set + /// > in the resulting message; otherwise, the flag list of the + /// > resulting message is set to empty by default. In either case, the + /// > Recent flag is also set. + /// + /// The [`\Recent` flag](https://tools.ietf.org/html/rfc3501#section-2.3.2) is not + /// allowed as an argument to `APPEND` and will be filtered out if present in `flags`. + pub fn flag(&mut self, flag: Flag<'a>) -> &mut Self { + self.flags.push(flag); + self + } + + /// Set multiple flags at once. + pub fn flags(&mut self, flags: impl IntoIterator>) -> &mut Self { + self.flags.extend(flags); + self + } + + /// Pass a date in order to set the date that the message was originally sent. + /// + /// > If a date-time is specified, the internal date SHOULD be set in + /// > the resulting message; otherwise, the internal date of the + /// > resulting message is set to the current date and time by default. + pub fn internal_date(&mut self, date: DateTime) -> &mut Self { + self.date = Some(date); + self + } + + /// Finishes up the command and executes it. + /// + /// Note: be sure to set flags and optional date before you + /// finish the command. + pub fn finish(&mut self) -> Result<()> { + let flagstr = self + .flags + .clone() + .into_iter() + .filter(|f| *f != Flag::Recent) + .map(|f| f.to_string()) + .collect::>() + .join(" "); + + let datestr = if let Some(date) = self.date { + format!(" \"{}\"", date.format("%d-%h-%Y %T %z")) + } else { + "".to_string() + }; + + self.session.run_command(&format!( + "APPEND \"{}\" ({}){} {{{}}}", + self.mailbox, + flagstr, + datestr, + self.content.len() + ))?; + let mut v = Vec::new(); + self.session.readline(&mut v)?; + if !v.starts_with(b"+") { + return Err(Error::Append); + } + self.session.stream.write_all(self.content)?; + self.session.stream.write_all(b"\r\n")?; + self.session.stream.flush()?; + self.session.read_response().map(|_| ()) + } +} + // `Deref` instances are so we can make use of the same underlying primitives in `Client` and // `Session` impl Deref for Client { @@ -540,12 +681,16 @@ impl Session { S1: AsRef, S2: AsRef, { - self.run_command_and_read_response(&format!( - "FETCH {} {}", - sequence_set.as_ref(), - query.as_ref() - )) - .and_then(|lines| parse_fetches(lines, &mut self.unsolicited_responses_tx)) + if sequence_set.as_ref().is_empty() { + parse_fetches(vec![], &mut self.unsolicited_responses_tx) + } else { + self.run_command_and_read_response(&format!( + "FETCH {} {}", + validate_sequence_set(sequence_set.as_ref())?, + validate_str_noquote(query.as_ref())? + )) + .and_then(|lines| parse_fetches(lines, &mut self.unsolicited_responses_tx)) + } } /// Equivalent to [`Session::fetch`], except that all identifiers in `uid_set` are @@ -555,12 +700,16 @@ impl Session { S1: AsRef, S2: AsRef, { - self.run_command_and_read_response(&format!( - "UID FETCH {} {}", - uid_set.as_ref(), - query.as_ref() - )) - .and_then(|lines| parse_fetches(lines, &mut self.unsolicited_responses_tx)) + if uid_set.as_ref().is_empty() { + parse_fetches(vec![], &mut self.unsolicited_responses_tx) + } else { + self.run_command_and_read_response(&format!( + "UID FETCH {} {}", + validate_sequence_set(uid_set.as_ref())?, + validate_str_noquote(query.as_ref())? + )) + .and_then(|lines| parse_fetches(lines, &mut self.unsolicited_responses_tx)) + } } /// Noop always succeeds, and it does nothing. @@ -1075,49 +1224,15 @@ impl Session { /// Specifically, the server will generally notify the client immediately via an untagged /// `EXISTS` response. If the server does not do so, the client MAY issue a `NOOP` command (or /// failing that, a `CHECK` command) after one or more `APPEND` commands. - pub fn append, B: AsRef<[u8]>>(&mut self, mailbox: S, content: B) -> Result<()> { - self.append_with_flags(mailbox, content, &[]) - } - - /// The [`APPEND` command](https://tools.ietf.org/html/rfc3501#section-6.3.11) can take - /// an optional FLAGS parameter to set the flags on the new message. /// - /// > If a flag parenthesized list is specified, the flags SHOULD be set - /// > in the resulting message; otherwise, the flag list of the - /// > resulting message is set to empty by default. In either case, the - /// > Recent flag is also set. - /// - /// The [`\Recent` flag](https://tools.ietf.org/html/rfc3501#section-2.3.2) is not - /// allowed as an argument to `APPEND` and will be filtered out if present in `flags`. - pub fn append_with_flags, B: AsRef<[u8]>>( - &mut self, - mailbox: S, - content: B, - flags: &[Flag<'_>], - ) -> Result<()> { - let content = content.as_ref(); - let flagstr = flags - .iter() - .filter(|f| **f != Flag::Recent) - .map(|f| f.to_string()) - .collect::>() - .join(" "); - - self.run_command(&format!( - "APPEND \"{}\" ({}) {{{}}}", - mailbox.as_ref(), - flagstr, - content.len() - ))?; - let mut v = Vec::new(); - self.readline(&mut v)?; - if !v.starts_with(b"+") { - return Err(Error::Append); + pub fn append<'a>(&'a mut self, mailbox: &'a str, content: &'a [u8]) -> AppendCmd<'a, T> { + AppendCmd { + session: self, + content, + mailbox, + flags: Vec::new(), + date: None, } - self.stream.write_all(content)?; - self.stream.write_all(b"\r\n")?; - self.stream.flush()?; - self.read_response().map(|_| ()) } /// The [`SEARCH` command](https://tools.ietf.org/html/rfc3501#section-6.4.4) searches the diff --git a/src/parse.rs b/src/parse.rs index 9bf61b9..187ee80 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -359,8 +359,15 @@ fn handle_unilateral<'a>( Response::MailboxData(MailboxDatum::Recent(n)) => { unsolicited.send(UnsolicitedResponse::Recent(n)).unwrap(); } - Response::MailboxData(MailboxDatum::Flags(_)) => { - // TODO: next breaking change: + Response::MailboxData(MailboxDatum::Flags(flags)) => { + unsolicited + .send(UnsolicitedResponse::Flags( + flags + .into_iter() + .map(|s| Flag::from(s.to_string())) + .collect(), + )) + .unwrap(); } Response::MailboxData(MailboxDatum::Exists(n)) => { unsolicited.send(UnsolicitedResponse::Exists(n)).unwrap(); diff --git a/src/types/mod.rs b/src/types/mod.rs index 5dbaec9..f9c6815 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -229,6 +229,7 @@ pub use imap_proto::StatusAttribute; /// Note that `Recent`, `Exists` and `Expunge` responses refer to the currently `SELECT`ed folder, /// so the user must take care when interpreting these. #[derive(Debug, PartialEq, Eq)] +#[non_exhaustive] pub enum UnsolicitedResponse { /// An unsolicited [`STATUS response`](https://tools.ietf.org/html/rfc3501#section-7.2.4). Status { @@ -306,6 +307,15 @@ pub enum UnsolicitedResponse { /// The list of `UID`s which have been removed uids: Vec>, }, + + /// An unsolicited [`FLAGS` response](https://tools.ietf.org/html/rfc3501#section-7.2.6) that + /// identifies the flags (at a minimum, the system-defined flags) that are applicable in the + /// mailbox. Flags other than the system flags can also exist, depending on server + /// implementation. + /// + /// See [`Flag`] for details. + // TODO: the spec doesn't seem to say anything about when these may be received as unsolicited? + Flags(Vec>), } /// This type wraps an input stream and a type that was constructed by parsing that input stream, diff --git a/tests/imap_integration.rs b/tests/imap_integration.rs index 0a8bf76..997b2f5 100644 --- a/tests/imap_integration.rs +++ b/tests/imap_integration.rs @@ -1,8 +1,10 @@ +extern crate chrono; extern crate imap; extern crate lettre; extern crate lettre_email; extern crate native_tls; +use chrono::{FixedOffset, TimeZone}; use lettre::Transport; use std::net::TcpStream; @@ -251,7 +253,9 @@ fn append() { let mbox = "INBOX"; c.select(mbox).unwrap(); //append - c.append(mbox, e.message_to_string().unwrap()).unwrap(); + c.append(mbox, e.message_to_string().unwrap().as_bytes()) + .finish() + .unwrap(); // now we should see the e-mail! let inbox = c.uid_search("ALL").unwrap(); @@ -298,8 +302,10 @@ fn append_with_flags() { let mbox = "INBOX"; c.select(mbox).unwrap(); //append - let flags: &[Flag] = &[Flag::Seen, Flag::Flagged]; - c.append_with_flags(mbox, e.message_to_string().unwrap(), flags) + let flags = vec![Flag::Seen, Flag::Flagged]; + c.append(mbox, e.message_to_string().unwrap().as_bytes()) + .flags(flags) + .finish() .unwrap(); // now we should see the e-mail! @@ -330,3 +336,57 @@ fn append_with_flags() { let inbox = c.search("ALL").unwrap(); assert_eq!(inbox.len(), 0); } + +#[test] +fn append_with_flags_and_date() { + use imap::types::Flag; + + let to = "inbox-append3@localhost"; + + // make a message to append + let e: lettre::SendableEmail = lettre_email::Email::builder() + .from("sender@localhost") + .to(to) + .subject("My third e-mail") + .text("Hello world") + .build() + .unwrap() + .into(); + + // connect + let mut c = session(to); + let mbox = "INBOX"; + c.select(mbox).unwrap(); + // append + let date = FixedOffset::east(8 * 3600) + .ymd(2020, 12, 13) + .and_hms(13, 36, 36); + c.append(mbox, e.message_to_string().unwrap().as_bytes()) + .flag(Flag::Seen) + .flag(Flag::Flagged) + .internal_date(date) + .finish() + .unwrap(); + + // now we should see the e-mail! + let inbox = c.uid_search("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(); + + // fetch the e-mail + let fetch = c.uid_fetch(format!("{}", uid), "(ALL UID)").unwrap(); + assert_eq!(fetch.len(), 1); + let fetch = &fetch[0]; + assert_eq!(fetch.uid, Some(uid)); + assert_eq!(fetch.internal_date(), Some(date)); + + // and let's delete it to clean up + c.uid_store(format!("{}", uid), "+FLAGS (\\Deleted)") + .unwrap(); + c.expunge().unwrap(); + + // the e-mail should be gone now + let inbox = c.search("ALL").unwrap(); + assert_eq!(inbox.len(), 0); +}