diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..27fbd5e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +### Added + +### Changed + +### 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 + + - Changelog + - STARTTLS example (#165) + - Timeout example (#168) + - Export `Result` and `Error` types (#170) + +### Changed + + - MSRV increased + - Better documentation of server greeting handling (#168) + +[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 d7172e4..05ad84b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,26 +1,18 @@ [package] name = "imap" -version = "2.1.2" -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.10.0" nom = "5.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.11.0" +rustls-connector = "0.13.0" [[example]] name = "basic" diff --git a/azure-pipelines.yml b/azure-pipelines.yml index f9e126c..c0b9b84 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. @@ -52,7 +55,7 @@ jobs: steps: - template: install-rust.yml@templates parameters: - rust: 1.40.0 + rust: 1.40.0 # static-assertions (1.37+) and base64 (1.40+) - script: cargo check displayName: cargo check - job: integration @@ -79,11 +82,11 @@ resources: - repository: templates type: github name: crate-ci/azure-pipelines - ref: refs/heads/v0.3 + ref: refs/heads/v0.4 endpoint: jonhoo containers: - container: greenmail - image: greenmail/standalone:1.5.11 + image: greenmail/standalone:1.5.13 ports: - 3025:3025 - 3110:3110 diff --git a/examples/README.md b/examples/README.md index b1bde9d..1ba24a0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,3 +7,4 @@ Examples: * basic - This is a very basic example of using the client. * gmail_oauth2 - This is an example using oauth2 for logging into gmail as a secure appplication. * rustls - This demonstrates how to use Rustls instead of Openssl for secure connections (helpful for cross compilation). + * timeout - This demonstrates how to use timeouts while connecting to an IMAP server. diff --git a/examples/starttls.rs b/examples/starttls.rs new file mode 100644 index 0000000..071021f --- /dev/null +++ b/examples/starttls.rs @@ -0,0 +1,63 @@ +/** + * Here's an example showing how to connect to the IMAP server with STARTTLS. + * The only difference with the `basic.rs` example is when using `imap::connect_starttls()` method + * instead of `imap::connect()` (l. 52), and so you can connect on port 143 instead of 993 + * as you have to when using TLS the entire way. + * + * The following env vars are expected to be set: + * - IMAP_HOST + * - IMAP_USERNAME + * - IMAP_PASSWORD + * - IMAP_PORT (supposed to be 143) + */ +extern crate imap; +extern crate native_tls; + +use native_tls::TlsConnector; +use std::env; +use std::error::Error; + +fn main() -> Result<(), Box> { + let imap_host = env::var("IMAP_HOST").expect("Missing or invalid env var: IMAP_HOST"); + let imap_username = + env::var("IMAP_USERNAME").expect("Missing or invalid env var: IMAP_USERNAME"); + let imap_password = + env::var("IMAP_PASSWORD").expect("Missing or invalid env var: IMAP_PASSWORD"); + let imap_port: u16 = env::var("IMAP_PORT") + .expect("Missing or invalid env var: IMAP_PORT") + .to_string() + .parse() + .unwrap(); + + if let Some(_email) = fetch_inbox_top(imap_host, imap_username, imap_password, imap_port)? { + eprintln!("OK :)"); + } + + Ok(()) +} + +fn fetch_inbox_top( + host: String, + username: String, + password: String, + port: u16, +) -> Result, Box> { + let domain: &str = host.as_str(); + + let tls = TlsConnector::builder().build().unwrap(); + + // we pass in the domain twice to check that the server's TLS + // certificate is valid for the domain we're connecting to. + let client = imap::connect_starttls((domain, port), domain, &tls).unwrap(); + + // the client we have here is unauthenticated. + // to do anything useful with the e-mails, we need to log in + let mut _imap_session = client + .login(username.as_str(), password.as_str()) + .map_err(|e| e.0)?; + + // TODO Here you can process as you want. eg. search/fetch messages according to your needs. + + // This returns `Ok(None)` for the need of the example + Ok(None) +} diff --git a/examples/timeout.rs b/examples/timeout.rs new file mode 100644 index 0000000..d2d717e --- /dev/null +++ b/examples/timeout.rs @@ -0,0 +1,92 @@ +extern crate imap; +extern crate native_tls; + +use imap::Client; +use native_tls::TlsConnector; +use native_tls::TlsStream; +use std::env; +use std::error::Error; +use std::fmt; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::time::Duration; + +fn main() -> Result<(), Box> { + let server = env::var("IMAP_SERVER")?; + let port = env::var("IMAP_PORT").unwrap_or_else(|_| String::from("993")); + let port = port.parse()?; + + let username = env::var("IMAP_USER")?; + let password = env::var("IMAP_PASSWORD")?; + + let timeout = env::var("IMAP_TIMEOUT").unwrap_or_else(|_| String::from("1")); + let timeout = timeout.parse()?; + let timeout = Duration::from_secs(timeout); + + let tls = TlsConnector::builder().build()?; + + let client = connect_all_timeout((server.as_str(), port), server.as_str(), &tls, timeout)?; + + let mut session = client.login(&username, &password).map_err(|e| e.0)?; + + // do something productive with session + + session.logout()?; + + Ok(()) +} + +// connect to an IMAP host with a `Duration` timeout; note that this accepts only a single +// `SocketAddr` while `connect_all_timeout` does resolve the DNS entry and try to connect to all; +// this is necessary due to the difference of the function signatures of `TcpStream::connect` and +// `TcpStream::connect_timeout` +fn connect_timeout>( + addr: &SocketAddr, + domain: S, + ssl_connector: &TlsConnector, + timeout: Duration, +) -> Result>, Box> { + // the timeout is actually used with the initial TcpStream + let tcp_stream = TcpStream::connect_timeout(addr, timeout)?; + + let tls_stream = TlsConnector::connect(ssl_connector, domain.as_ref(), tcp_stream)?; + + let mut client = Client::new(tls_stream); + + // don't forget to wait for the IMAP protocol server greeting ;) + client.read_greeting()?; + + Ok(client) +} + +// resolve address and try to connect to all in order; note that this function is required to fully +// mimic `imap::connect` with the usage of `ToSocketAddrs` +fn connect_all_timeout>( + addr: A, + domain: S, + ssl_connector: &TlsConnector, + timeout: Duration, +) -> Result>, Box> { + let addrs = addr.to_socket_addrs()?; + + for addr in addrs { + match connect_timeout(&addr, &domain, ssl_connector, timeout) { + Ok(client) => return Ok(client), + Err(error) => eprintln!("couldn't connect to {}: {}", addr, error), + } + } + + Err(Box::new(TimeoutError)) +} + +// very simple timeout error; instead of printing the errors immediately like in +// `connect_all_timeout`, you may want to collect and return them +#[derive(Debug)] +struct TimeoutError; + +impl fmt::Display for TimeoutError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "all addresses failed to connect") + } +} + +impl Error for TimeoutError {} diff --git a/src/client.rs b/src/client.rs index dd6365b..2438a86 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,5 +1,6 @@ use base64; use bufstream::BufStream; +use chrono::{DateTime, FixedOffset}; #[cfg(feature = "tls")] use native_tls::{TlsConnector, TlsStream}; use nom; @@ -27,15 +28,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 @@ -240,6 +300,29 @@ impl Client { /// /// This method primarily exists for writing tests that mock the underlying transport, but can /// also be used to support IMAP over custom tunnels. + /// + /// **Note:** In case you do need to use `Client::new` over `imap::connect`, you will need to + /// listen for the IMAP protocol server greeting before authenticating: + /// + /// ```rust,no_run + /// # extern crate imap; + /// # extern crate native_tls; + /// # use imap::Client; + /// # use native_tls::TlsConnector; + /// # use std::io; + /// # use std::net::TcpStream; + /// # fn main() { + /// # let server = "imap.example.com"; + /// # let username = ""; + /// # let password = ""; + /// # let tcp = TcpStream::connect((server, 993)).unwrap(); + /// # let ssl_connector = TlsConnector::builder().build().unwrap(); + /// # let tls = TlsConnector::connect(&ssl_connector, server.as_ref(), tcp).unwrap(); + /// let mut client = Client::new(tls); + /// client.read_greeting().unwrap(); + /// let session = client.login(username, password).unwrap(); + /// # } + /// ``` pub fn new(stream: T) -> Client { Client { conn: Connection { @@ -456,7 +539,7 @@ impl Session { .and_then(|lines| parse_mailbox(&lines[..], &mut self.unsolicited_responses_tx)) } - /// Fetch retreives data associated with a set of messages in the mailbox. + /// Fetch retrieves data associated with a set of messages in the mailbox. /// /// Note that the server *is* allowed to unilaterally include `FETCH` responses for other /// messages in the selected mailbox whose status has changed. See the note on [unilateral @@ -519,12 +602,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 @@ -534,12 +621,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. @@ -1055,10 +1146,68 @@ impl Session { /// `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<()> { + self.append_with_flags_and_date(mailbox, content, flags, None) + } + + /// 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`. + /// + /// 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 append_with_flags_and_date, B: AsRef<[u8]>>( + &mut self, + mailbox: S, + content: B, + flags: &[Flag<'_>], + date: impl Into>>, + ) -> Result<()> { let content = content.as_ref(); + let flagstr = flags + .iter() + .filter(|f| **f != Flag::Recent) + .map(|f| f.to_string()) + .collect::>() + .join(" "); + let datestr = match date.into() { + Some(date) => format!(" \"{}\"", date.format("%d-%h-%Y %T %z")), + None => "".to_string(), + }; + self.run_command(&format!( - "APPEND \"{}\" {{{}}}", + "APPEND \"{}\" ({}){} {{{}}}", mailbox.as_ref(), + flagstr, + datestr, content.len() ))?; let mut v = Vec::new(); diff --git a/src/lib.rs b/src/lib.rs index 48fc3d9..dcc6826 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -87,6 +87,7 @@ mod client; pub use crate::client::*; pub mod error; +pub use error::{Error, Result}; pub mod extensions; diff --git a/src/types/mod.rs b/src/types/mod.rs index 8a13903..909dd6a 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -169,6 +169,21 @@ impl Flag<'static> { } } +impl<'a> fmt::Display for Flag<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Flag::Seen => write!(f, "{}", "\\Seen"), + Flag::Answered => write!(f, "{}", "\\Answered"), + Flag::Flagged => write!(f, "{}", "\\Flagged"), + Flag::Deleted => write!(f, "{}", "\\Deleted"), + Flag::Draft => write!(f, "{}", "\\Draft"), + Flag::Recent => write!(f, "{}", "\\Recent"), + Flag::MayCreate => write!(f, "{}", "\\*"), + Flag::Custom(ref s) => write!(f, "{}", s), + } + } +} + impl<'a> From for Flag<'a> { fn from(s: String) -> Self { if let Some(f) = Flag::system(&s) { diff --git a/tests/imap_integration.rs b/tests/imap_integration.rs index f639684..b37f3f3 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; @@ -231,3 +233,153 @@ fn list() { // TODO: make a subdir } + +#[test] +fn append() { + let to = "inbox-append1@localhost"; + + // make a message to append + let e: lettre::SendableEmail = lettre_email::Email::builder() + .from("sender@localhost") + .to(to) + .subject("My second e-mail") + .text("Hello world") + .build() + .unwrap() + .into(); + + // connect + let mut c = session(to); + let mbox = "INBOX"; + c.select(mbox).unwrap(); + //append + c.append(mbox, e.message_to_string().unwrap()).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)); + let e = fetch.envelope().unwrap(); + assert_eq!(e.subject, Some(&b"My second e-mail"[..])); + + // 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); +} + +#[test] +fn append_with_flags() { + use imap::types::Flag; + + let to = "inbox-append2@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 flags: &[Flag] = &[Flag::Seen, Flag::Flagged]; + c.append_with_flags(mbox, e.message_to_string().unwrap(), flags) + .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)); + let e = fetch.envelope().unwrap(); + assert_eq!(e.subject, Some(&b"My third e-mail"[..])); + + // check the flags + let setflags = fetch.flags(); + assert!(setflags.contains(&Flag::Seen)); + assert!(setflags.contains(&Flag::Flagged)); + + // 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); +} + +#[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 flags: &[Flag] = &[Flag::Seen, Flag::Flagged]; + let date = FixedOffset::east(8 * 3600) + .ymd(2020, 12, 13) + .and_hms(13, 36, 36); + c.append_with_flags_and_date(mbox, e.message_to_string().unwrap(), flags, Some(date)) + .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); +}