Merge branch 'master' into mod_seq_vanished

This commit is contained in:
Jon Gjengset 2021-03-06 12:40:30 -05:00 committed by GitHub
commit 137f3de14f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 291 additions and 86 deletions

View file

@ -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

View file

@ -1,26 +1,18 @@
[package]
name = "imap"
version = "2.3.0"
authors = ["Matt McCoy <mattnenterprise@yahoo.com>",
"Jon Gjengset <jon@thesquareplanet.com>"]
version = "3.0.0-alpha.1"
authors = ["Jon Gjengset <jon@thesquareplanet.com>",
"Matt McCoy <mattnenterprise@yahoo.com>"]
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"

View file

@ -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.

View file

@ -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<E> {
fn err(self) -> std::result::Result<(), E>;
}
impl<E> OptionExt<E> for Option<E> {
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<String> {
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<T: Read + Write> {
pub greeting_read: bool,
}
/// A builder for the append command
#[must_use]
pub struct AppendCmd<'a, T: Read + Write> {
session: &'a mut Session<T>,
content: &'a [u8],
mailbox: &'a str,
flags: Vec<Flag<'a>>,
date: Option<DateTime<FixedOffset>>,
}
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<Item = Flag<'a>>) -> &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<FixedOffset>) -> &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::<Vec<String>>()
.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<T: Read + Write> Deref for Client<T> {
@ -540,13 +681,17 @@ impl<T: Read + Write> Session<T> {
S1: AsRef<str>,
S2: AsRef<str>,
{
if sequence_set.as_ref().is_empty() {
parse_fetches(vec![], &mut self.unsolicited_responses_tx)
} else {
self.run_command_and_read_response(&format!(
"FETCH {} {}",
sequence_set.as_ref(),
query.as_ref()
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
/// [`Uid`]s. See also the [`UID` command](https://tools.ietf.org/html/rfc3501#section-6.4.8).
@ -555,13 +700,17 @@ impl<T: Read + Write> Session<T> {
S1: AsRef<str>,
S2: AsRef<str>,
{
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 {} {}",
uid_set.as_ref(),
query.as_ref()
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.
pub fn noop(&mut self) -> Result<()> {
@ -1075,49 +1224,15 @@ impl<T: Read + Write> Session<T> {
/// 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<S: AsRef<str>, 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<S: AsRef<str>, 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::<Vec<String>>()
.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

View file

@ -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();

View file

@ -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<std::ops::RangeInclusive<u32>>,
},
/// 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<Flag<'static>>),
}
/// This type wraps an input stream and a type that was constructed by parsing that input stream,

View file

@ -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);
}