diff --git a/.gitignore b/.gitignore index ea8c4bf..f9aa6ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +/.env +/output.log diff --git a/Cargo.lock b/Cargo.lock index f7d31a7..c370ff9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,7 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 3 + [[package]] name = "anyhow" version = "1.0.38" @@ -12,6 +14,12 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "async-trait" version = "0.1.42" @@ -50,6 +58,18 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +[[package]] +name = "bitvec" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ba35e9565969edb811639dbebfe34edc0368e472c5018474c8eb2543397f81" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "bufstream" version = "0.1.4" @@ -226,6 +246,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" +[[package]] +name = "funty" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" + [[package]] name = "futures" version = "0.3.12" @@ -351,6 +377,14 @@ dependencies = [ "winutil", ] +[[package]] +name = "imap" +version = "0.12.2" +dependencies = [ + "assert_matches", + "nom 6.1.0", +] + [[package]] name = "inotify" version = "0.7.1" @@ -438,7 +472,7 @@ dependencies = [ "hostname", "log", "native-tls", - "nom", + "nom 4.2.3", "serde", "serde_derive", "serde_json", @@ -576,7 +610,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ad2a91a8e869eeb30b9cb3119ae87773a8f4ae617f41b1eb9c154b2905f7bd6" dependencies = [ "memchr", - "version_check", + "version_check 0.1.5", +] + +[[package]] +name = "nom" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab6f70b46d6325aa300f1c7bb3d470127dfc27806d8ea6bf294ee0ce643ce2b1" +dependencies = [ + "bitvec", + "memchr", + "version_check 0.9.2", ] [[package]] @@ -685,10 +730,12 @@ dependencies = [ "crossterm", "fern", "futures", + "imap", "lettre", "log", "notify", "pin-project", + "rustls-connector", "tokio", "tokio-rustls", "tokio-util", @@ -795,6 +842,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "radium" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" + [[package]] name = "rand" version = "0.8.3" @@ -887,6 +940,30 @@ dependencies = [ "webpki", ] +[[package]] +name = "rustls-connector" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ffaf21b0bac725875490d079bb503cf88684618310c2dff167640fa006217cb" +dependencies = [ + "log", + "rustls", + "rustls-native-certs", + "webpki", +] + +[[package]] +name = "rustls-native-certs" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07b7c1885bd8ed3831c289b7870b13ef46fe0e856d288c30d9cc17d75a2092" +dependencies = [ + "openssl-probe", + "rustls", + "schannel", + "security-framework", +] + [[package]] name = "ryu" version = "1.0.5" @@ -1039,6 +1116,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "tap" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36474e732d1affd3a6ed582781b3683df3d0563714c59c39591e8ff707cf078e" + [[package]] name = "tempfile" version = "3.2.0" @@ -1066,9 +1149,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6714d663090b6b0acb0fa85841c6d66233d150cdb2602c8f9b8abb03370beb3f" +checksum = "e8190d04c665ea9e6b6a0dc45523ade572c088d2e6566244c1122671dbf4ae3a" dependencies = [ "autocfg", "bytes", @@ -1086,9 +1169,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42517d2975ca3114b22a16192634e8241dc5cc1f130be194645970cc1c371494" +checksum = "caf7b11a536f46a809a8a9f0bb4237020f70ecbf115b842360afb127ea2fda57" dependencies = [ "proc-macro2", "quote", @@ -1146,6 +1229,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "914b1a6776c4c929a602fafd8bc742e06365d4bcbe48c30f9cca5824f70dc9dd" +[[package]] +name = "version_check" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" + [[package]] name = "walkdir" version = "2.3.1" @@ -1308,6 +1397,12 @@ dependencies = [ "winapi-build", ] +[[package]] +name = "wyz" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" + [[package]] name = "xdg" version = "2.2.0" diff --git a/Cargo.toml b/Cargo.toml index 1bea666..4cb1e07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,9 @@ version = "0.1.0" authors = ["Michael Zhang "] edition = "2018" +[workspace] +members = ["imap"] + [dependencies] anyhow = "1.0.38" async-trait = "0.1.42" @@ -16,8 +19,11 @@ lettre = "0.9.5" log = "0.4.14" notify = "4.0.15" pin-project = "1.0.4" +rustls-connector = "0.13.1" tokio = { version = "1.1.1", features = ["full"] } tokio-rustls = "0.22.0" tokio-util = { version = "0.6.3", features = ["full"] } webpki-roots = "0.21.0" xdg = "2.2.0" + +imap = { path = "imap" } diff --git a/README.md b/README.md index 264bf48..f11380a 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,8 @@ Goals: - Hot-reload on-disk config. - Submit notifications to gotify-shaped notification servers. - Never have to actually close the application. + +Credits +------- + +IMAP library modified from [djc/tokio-imap](https://github.com/djc/tokio-imap), MIT licensed. diff --git a/imap/.ignore b/imap/.ignore new file mode 100644 index 0000000..72e8ffc --- /dev/null +++ b/imap/.ignore @@ -0,0 +1 @@ +* diff --git a/imap/Cargo.toml b/imap/Cargo.toml new file mode 100644 index 0000000..642d1b3 --- /dev/null +++ b/imap/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "imap" +version = "0.12.2" +authors = ["Dirkjan Ochtman "] +description = "IMAP protocol parser and data structures" +documentation = "https://docs.rs/imap-proto" +keywords = ["imap", "email"] +categories = ["email", "network-programming", "parser-implementations"] +homepage = "https://github.com/djc/tokio-imap" +repository = "https://github.com/djc/tokio-imap" +license = "MIT/Apache-2.0" +edition = "2018" + +[badges] +maintenance = { status = "passively-maintained" } + +[dependencies] +nom = { version = "6", default-features = false, features = ["std"] } + +[dev-dependencies] +assert_matches = "1.3" diff --git a/imap/examples/parse_response.rs b/imap/examples/parse_response.rs new file mode 100644 index 0000000..c506ff1 --- /dev/null +++ b/imap/examples/parse_response.rs @@ -0,0 +1,31 @@ +use imap_proto::Response; +use std::io::Write; + +fn main() -> std::io::Result<()> { + loop { + let line = { + print!("Enter IMAP4REV1 response: "); + std::io::stdout().flush().unwrap(); + + let mut line = String::new(); + std::io::stdin().read_line(&mut line)?; + line + }; + + match Response::from_bytes(line.replace("\n", "\r\n").as_bytes()) { + Ok((remaining, command)) => { + println!("{:#?}", command); + + if !remaining.is_empty() { + println!("Remaining data in buffer: {:?}", remaining); + } + } + Err(_) => { + println!("Error parsing the response. Is it correct? Exiting."); + break; + } + } + } + + Ok(()) +} diff --git a/imap/fuzz/.gitignore b/imap/fuzz/.gitignore new file mode 100644 index 0000000..572e03b --- /dev/null +++ b/imap/fuzz/.gitignore @@ -0,0 +1,4 @@ + +target +corpus +artifacts diff --git a/imap/fuzz/Cargo.toml b/imap/fuzz/Cargo.toml new file mode 100644 index 0000000..12a2143 --- /dev/null +++ b/imap/fuzz/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "imap-proto-fuzz" +version = "0.0.1" +authors = ["Automatically generated"] +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies.imap-proto] +path = ".." +[dependencies.libfuzzer-sys] +git = "https://github.com/rust-fuzz/libfuzzer-sys.git" + +[[bin]] +name = "utf8_parse_response" +path = "fuzz_targets/utf8_parse_response.rs" + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] \ No newline at end of file diff --git a/imap/fuzz/fuzz_targets/utf8_parse_response.rs b/imap/fuzz/fuzz_targets/utf8_parse_response.rs new file mode 100644 index 0000000..a7c0073 --- /dev/null +++ b/imap/fuzz/fuzz_targets/utf8_parse_response.rs @@ -0,0 +1,8 @@ +#![no_main] +#[macro_use] extern crate libfuzzer_sys; +extern crate imap_proto; + +// UTF-8 +fuzz_target!(|data: &[u8]| { + let _ = imap_proto::Response::from_bytes(data); +}); diff --git a/imap/src/builders/command.rs b/imap/src/builders/command.rs new file mode 100644 index 0000000..a2b1bb5 --- /dev/null +++ b/imap/src/builders/command.rs @@ -0,0 +1,397 @@ +use std::borrow::Cow; +use std::fmt; +use std::marker::PhantomData; +use std::ops::{RangeFrom, RangeInclusive}; +use std::str; + +use crate::types::{AttrMacro, Attribute, State}; + +pub struct CommandBuilder {} + +impl CommandBuilder { + pub fn check() -> Command { + let args = b"CHECK".to_vec(); + Command { + args, + next_state: None, + } + } + + pub fn close() -> Command { + let args = b"CLOSE".to_vec(); + Command { + args, + next_state: Some(State::Authenticated), + } + } + + pub fn starttls() -> Command { + let args = b"STARTTLS".to_vec(); + Command { + args, + next_state: None, + } + } + + pub fn examine(mailbox: &str) -> SelectCommand { + let args = format!("EXAMINE \"{}\"", quoted_string(mailbox).unwrap()).into_bytes(); + SelectCommand { + args, + state: PhantomData::default(), + } + } + + pub fn fetch() -> FetchCommand { + FetchCommand { + args: b"FETCH ".to_vec(), + state: PhantomData::default(), + } + } + + pub fn list(reference: &str, glob: &str) -> Command { + let args = format!( + "LIST \"{}\" \"{}\"", + quoted_string(reference).unwrap(), + quoted_string(glob).unwrap() + ) + .into_bytes(); + Command { + args, + next_state: None, + } + } + + pub fn login(user_name: &str, password: &str) -> Command { + let args = format!( + "LOGIN \"{}\" \"{}\"", + quoted_string(user_name).unwrap(), + quoted_string(password).unwrap() + ) + .into_bytes(); + Command { + args, + next_state: Some(State::Authenticated), + } + } + + pub fn select(mailbox: &str) -> SelectCommand { + let args = format!("SELECT \"{}\"", quoted_string(mailbox).unwrap()).into_bytes(); + SelectCommand { + args, + state: PhantomData::default(), + } + } + + pub fn uid_fetch() -> FetchCommand { + FetchCommand { + args: b"UID FETCH ".to_vec(), + state: PhantomData::default(), + } + } +} + +#[derive(Debug)] +pub struct Command { + pub args: Vec, + pub next_state: Option, +} + +pub struct SelectCommand { + args: Vec, + state: PhantomData, +} + +impl SelectCommand { + // RFC 4551 CONDSTORE parameter (based on RFC 4466 `select-param`) + pub fn cond_store(mut self) -> SelectCommand { + self.args.extend(b" (CONDSTORE"); + SelectCommand { + args: self.args, + state: PhantomData::default(), + } + } +} + +impl From> for Command { + fn from(cmd: SelectCommand) -> Command { + Command { + args: cmd.args, + next_state: Some(State::Selected), + } + } +} + +impl From> for Command { + fn from(mut cmd: SelectCommand) -> Command { + cmd.args.push(b')'); + Command { + args: cmd.args, + next_state: Some(State::Selected), + } + } +} + +pub mod select { + pub struct NoParams; + pub struct Params; +} + +pub mod fetch { + pub struct Empty; + pub struct Messages; + pub struct Attributes; + pub struct Modifiers; +} + +pub struct FetchCommand { + args: Vec, + state: PhantomData, +} + +impl FetchCommand { + pub fn num(mut self, num: u32) -> FetchCommand { + sequence_num(&mut self.args, num); + FetchCommand { + args: self.args, + state: PhantomData::default(), + } + } + + pub fn range(mut self, range: RangeInclusive) -> FetchCommand { + sequence_range(&mut self.args, range); + FetchCommand { + args: self.args, + state: PhantomData::default(), + } + } + + pub fn range_from(mut self, range: RangeFrom) -> FetchCommand { + range_from(&mut self.args, range); + FetchCommand { + args: self.args, + state: PhantomData::default(), + } + } +} + +impl FetchCommand { + pub fn num(mut self, num: u32) -> FetchCommand { + self.args.extend(b","); + sequence_num(&mut self.args, num); + self + } + + pub fn range(mut self, range: RangeInclusive) -> FetchCommand { + self.args.extend(b","); + sequence_range(&mut self.args, range); + self + } + + pub fn range_from(mut self, range: RangeFrom) -> FetchCommand { + self.args.extend(b","); + range_from(&mut self.args, range); + self + } + + pub fn attr_macro(mut self, named: AttrMacro) -> FetchCommand { + self.args.push(b' '); + self.args.extend( + match named { + AttrMacro::All => "ALL", + AttrMacro::Fast => "FAST", + AttrMacro::Full => "FULL", + } + .as_bytes(), + ); + FetchCommand { + args: self.args, + state: PhantomData::default(), + } + } + + pub fn attr(mut self, attr: Attribute) -> FetchCommand { + self.args.extend(b" ("); + push_attr(&mut self.args, attr); + FetchCommand { + args: self.args, + state: PhantomData::default(), + } + } +} + +fn sequence_num(cmd: &mut Vec, num: u32) { + cmd.extend(num.to_string().as_bytes()); +} + +fn sequence_range(cmd: &mut Vec, range: RangeInclusive) { + cmd.extend(range.start().to_string().as_bytes()); + cmd.push(b':'); + cmd.extend(range.end().to_string().as_bytes()); +} + +fn range_from(cmd: &mut Vec, range: RangeFrom) { + cmd.extend(range.start.to_string().as_bytes()); + cmd.extend(b":*"); +} + +impl FetchCommand { + pub fn attr(mut self, attr: Attribute) -> FetchCommand { + self.args.push(b' '); + push_attr(&mut self.args, attr); + self + } + + pub fn changed_since(mut self, seq: u64) -> FetchCommand { + self.args.push(b')'); + changed_since(&mut self.args, seq); + FetchCommand { + args: self.args, + state: PhantomData::default(), + } + } +} + +fn push_attr(cmd: &mut Vec, attr: Attribute) { + cmd.extend( + match attr { + Attribute::Body => "BODY", + Attribute::Envelope => "ENVELOPE", + Attribute::Flags => "FLAGS", + Attribute::InternalDate => "INTERNALDATE", + Attribute::ModSeq => "MODSEQ", + Attribute::Rfc822 => "RFC822", + Attribute::Rfc822Size => "RFC822.SIZE", + Attribute::Rfc822Text => "RFC822.TEXT", + Attribute::Uid => "UID", + } + .as_bytes(), + ); +} + +impl From> for Command { + fn from(mut cmd: FetchCommand) -> Command { + cmd.args.push(b')'); + Command { + args: cmd.args, + next_state: None, + } + } +} + +impl From> for Command { + fn from(cmd: FetchCommand) -> Command { + Command { + args: cmd.args, + next_state: None, + } + } +} + +impl FetchCommand { + pub fn changed_since(mut self, seq: u64) -> FetchCommand { + changed_since(&mut self.args, seq); + self + } +} + +fn changed_since(cmd: &mut Vec, seq: u64) { + cmd.extend(b" (CHANGEDSINCE "); + cmd.extend(seq.to_string().as_bytes()); + cmd.push(b')'); +} + +/// Returns an escaped string if necessary for use as a "quoted" string per +/// the IMAPv4 RFC. Return value does not include surrounding quote characters. +/// Will return Err if the argument contains illegal characters. +/// +/// Relevant definitions from RFC 3501 formal syntax: +/// +/// string = quoted / literal [literal elided here] +/// quoted = DQUOTE *QUOTED-CHAR DQUOTE +/// QUOTED-CHAR = / "\" quoted-specials +/// quoted-specials = DQUOTE / "\" +/// TEXT-CHAR = +fn quoted_string(s: &str) -> Result, &'static str> { + let bytes = s.as_bytes(); + let (mut start, mut new) = (0, Vec::::new()); + for (i, b) in bytes.iter().enumerate() { + match *b { + b'\r' | b'\n' => { + return Err("CR and LF not allowed in quoted strings"); + } + b'\\' | b'"' => { + if start < i { + new.extend(&bytes[start..i]); + } + new.push(b'\\'); + new.push(*b); + start = i + 1; + } + _ => {} + }; + } + if start == 0 { + Ok(Cow::Borrowed(s)) + } else { + if start < bytes.len() { + new.extend(&bytes[start..]); + } + // Since the argument is a str, it must contain valid UTF-8. Since + // this function's transformation preserves the UTF-8 validity, + // unwrapping here should be okay. + Ok(Cow::Owned(String::from_utf8(new).unwrap())) + } +} + +#[cfg(test)] +mod tests { + use super::{quoted_string, Attribute, Command, CommandBuilder}; + + #[test] + fn login() { + assert_eq!( + CommandBuilder::login("djc", "s3cr3t").args, + b"LOGIN \"djc\" \"s3cr3t\"" + ); + assert_eq!( + CommandBuilder::login("djc", "domain\\password").args, + b"LOGIN \"djc\" \"domain\\\\password\"" + ); + } + + #[test] + fn select() { + let cmd = Command::from(CommandBuilder::select("INBOX")); + assert_eq!(&cmd.args, br#"SELECT "INBOX""#); + let cmd = Command::from(CommandBuilder::examine("INBOX").cond_store()); + assert_eq!(&cmd.args, br#"EXAMINE "INBOX" (CONDSTORE)"#); + } + + #[test] + fn fetch() { + let cmd: Command = CommandBuilder::fetch() + .range_from(1..) + .attr(Attribute::Uid) + .attr(Attribute::ModSeq) + .changed_since(13) + .into(); + assert_eq!(cmd.args, &b"FETCH 1:* (UID MODSEQ) (CHANGEDSINCE 13)"[..]); + + let cmd: Command = CommandBuilder::fetch() + .num(1) + .num(2) + .attr(Attribute::Uid) + .attr(Attribute::ModSeq) + .into(); + assert_eq!(cmd.args, &b"FETCH 1,2 (UID MODSEQ)"[..]); + } + + #[test] + fn test_quoted_string() { + assert_eq!(quoted_string("a").unwrap(), "a"); + assert_eq!(quoted_string("").unwrap(), ""); + assert_eq!(quoted_string("a\"b\\c").unwrap(), "a\\\"b\\\\c"); + assert_eq!(quoted_string("\"foo\\").unwrap(), "\\\"foo\\\\"); + assert!(quoted_string("\n").is_err()); + } +} diff --git a/imap/src/builders/mod.rs b/imap/src/builders/mod.rs new file mode 100644 index 0000000..9fe7961 --- /dev/null +++ b/imap/src/builders/mod.rs @@ -0,0 +1 @@ +pub mod command; diff --git a/imap/src/lib.rs b/imap/src/lib.rs new file mode 100644 index 0000000..8363c84 --- /dev/null +++ b/imap/src/lib.rs @@ -0,0 +1,6 @@ +pub mod builders; +pub mod parser; +pub mod types; + +pub use crate::parser::ParseResult; +pub use crate::types::*; diff --git a/imap/src/parser/bodystructure.rs b/imap/src/parser/bodystructure.rs new file mode 100644 index 0000000..14653db --- /dev/null +++ b/imap/src/parser/bodystructure.rs @@ -0,0 +1,76 @@ +use std::collections::HashMap; + +use crate::types::BodyStructure; +/// An utility parser helping to find the appropriate +/// section part from a FETCH response. +pub struct BodyStructParser<'a> { + root: &'a BodyStructure<'a>, + prefix: Vec, + iter: u32, + map: HashMap, &'a BodyStructure<'a>>, +} + +impl<'a> BodyStructParser<'a> { + /// Returns a new parser + /// + /// # Arguments + /// + /// * `root` - The root of the `BodyStructure response. + pub fn new(root: &'a BodyStructure<'a>) -> Self { + let mut parser = BodyStructParser { + root, + prefix: vec![], + iter: 1, + map: HashMap::new(), + }; + + parser.parse(parser.root); + parser + } + + /// Search particular element within the bodystructure. + /// + /// # Arguments + /// + /// * `func` - The filter used to search elements within the bodystructure. + pub fn search(&self, func: F) -> Option> + where + F: Fn(&'a BodyStructure<'a>) -> bool, + { + let elem: Vec<_> = self + .map + .iter() + .filter_map(|(k, v)| { + if func(*v) { + let slice: &[u32] = k; + Some(slice) + } else { + None + } + }) + .collect(); + elem.first().map(|a| a.to_vec()) + } + + /// Reetr + fn parse(&mut self, node: &'a BodyStructure) { + match node { + BodyStructure::Multipart { bodies, .. } => { + let vec = self.prefix.clone(); + self.map.insert(vec, node); + + for (i, n) in bodies.iter().enumerate() { + self.iter += i as u32; + self.prefix.push(self.iter); + self.parse(n); + self.prefix.pop(); + } + self.iter = 1; + } + _ => { + let vec = self.prefix.clone(); + self.map.insert(vec, node); + } + }; + } +} diff --git a/imap/src/parser/core.rs b/imap/src/parser/core.rs new file mode 100644 index 0000000..5f5a4cd --- /dev/null +++ b/imap/src/parser/core.rs @@ -0,0 +1,331 @@ +use nom::{ + branch::alt, + bytes::streaming::{escaped, tag, tag_no_case, take, take_while, take_while1}, + character::streaming::{char, digit1, one_of}, + combinator::{map, map_res}, + multi::{separated_list0, separated_list1}, + sequence::{delimited, tuple}, + IResult, +}; + +use std::str::{from_utf8, FromStr}; + +// ----- number ----- + +// number = 1*DIGIT +// ; Unsigned 32-bit integer +// ; (0 <= n < 4,294,967,296) +pub fn number(i: &[u8]) -> IResult<&[u8], u32> { + let (i, bytes) = digit1(i)?; + match from_utf8(bytes).ok().and_then(|s| u32::from_str(s).ok()) { + Some(v) => Ok((i, v)), + None => Err(nom::Err::Error(nom::error::make_error( + i, + nom::error::ErrorKind::ParseTo, + ))), + } +} + +// same as `number` but 64-bit +pub fn number_64(i: &[u8]) -> IResult<&[u8], u64> { + let (i, bytes) = digit1(i)?; + match from_utf8(bytes).ok().and_then(|s| u64::from_str(s).ok()) { + Some(v) => Ok((i, v)), + None => Err(nom::Err::Error(nom::error::make_error( + i, + nom::error::ErrorKind::ParseTo, + ))), + } +} + +// seq-range = seq-number ":" seq-number +// ; two seq-number values and all values between +// ; these two regardless of order. +// ; seq-number is a nz-number +pub fn sequence_range(i: &[u8]) -> IResult<&[u8], std::ops::RangeInclusive> { + map(tuple((number, tag(":"), number)), |(s, _, e)| s..=e)(i) +} + +// sequence-set = (seq-number / seq-range) *("," sequence-set) +// ; set of seq-number values, regardless of order. +// ; Servers MAY coalesce overlaps and/or execute the +// ; sequence in any order. +pub fn sequence_set(i: &[u8]) -> IResult<&[u8], Vec>> { + separated_list1(tag(","), alt((sequence_range, map(number, |n| n..=n))))(i) +} + +// ----- string ----- + +// string = quoted / literal +pub fn string(i: &[u8]) -> IResult<&[u8], &[u8]> { + alt((quoted, literal))(i) +} + +// string bytes as utf8 +pub fn string_utf8(i: &[u8]) -> IResult<&[u8], &str> { + map_res(string, from_utf8)(i) +} + +// quoted = DQUOTE *QUOTED-CHAR DQUOTE +pub fn quoted(i: &[u8]) -> IResult<&[u8], &[u8]> { + delimited( + char('"'), + escaped( + take_while1(|byte| is_text_char(byte) && !is_quoted_specials(byte)), + '\\', + one_of("\\\""), + ), + char('"'), + )(i) +} + +// quoted bytes as utf8 +pub fn quoted_utf8(i: &[u8]) -> IResult<&[u8], &str> { + map_res(quoted, from_utf8)(i) +} + +// quoted-specials = DQUOTE / "\" +pub fn is_quoted_specials(c: u8) -> bool { + c == b'"' || c == b'\\' +} + +/// literal = "{" number "}" CRLF *CHAR8 +/// ; Number represents the number of CHAR8s +pub fn literal(input: &[u8]) -> IResult<&[u8], &[u8]> { + let mut parser = tuple((tag(b"{"), number, tag(b"}"), tag("\r\n"))); + + let (remaining, (_, count, _, _)) = parser(input)?; + + let (remaining, data) = take(count)(remaining)?; + + if !data.iter().all(|byte| is_char8(*byte)) { + // FIXME: what ErrorKind should this have? + return Err(nom::Err::Error(nom::error::Error::new( + remaining, + nom::error::ErrorKind::Verify, + ))); + } + + Ok((remaining, data)) +} + +/// CHAR8 = %x01-ff ; any OCTET except NUL, %x00 +pub fn is_char8(i: u8) -> bool { + i != 0 +} + +// ----- astring ----- atom (roughly) or string + +// astring = 1*ASTRING-CHAR / string +pub fn astring(i: &[u8]) -> IResult<&[u8], &[u8]> { + alt((take_while1(is_astring_char), string))(i) +} + +// astring bytes as utf8 +pub fn astring_utf8(i: &[u8]) -> IResult<&[u8], &str> { + map_res(astring, from_utf8)(i) +} + +// ASTRING-CHAR = ATOM-CHAR / resp-specials +pub fn is_astring_char(c: u8) -> bool { + is_atom_char(c) || is_resp_specials(c) +} + +// ATOM-CHAR = +pub fn is_atom_char(c: u8) -> bool { + is_char(c) && !is_atom_specials(c) +} + +// atom-specials = "(" / ")" / "{" / SP / CTL / list-wildcards / quoted-specials / resp-specials +pub fn is_atom_specials(c: u8) -> bool { + c == b'(' + || c == b')' + || c == b'{' + || c == b' ' + || c < 32 + || is_list_wildcards(c) + || is_quoted_specials(c) + || is_resp_specials(c) +} + +// resp-specials = "]" +pub fn is_resp_specials(c: u8) -> bool { + c == b']' +} + +// atom = 1*ATOM-CHAR +pub fn atom(i: &[u8]) -> IResult<&[u8], &str> { + map_res(take_while1(is_atom_char), from_utf8)(i) +} + +// ----- nstring ----- nil or string + +// nstring = string / nil +pub fn nstring(i: &[u8]) -> IResult<&[u8], Option<&[u8]>> { + alt((map(nil, |_| None), map(string, Some)))(i) +} + +// nstring bytes as utf8 +pub fn nstring_utf8(i: &[u8]) -> IResult<&[u8], Option<&str>> { + alt((map(nil, |_| None), map(string_utf8, Some)))(i) +} + +// nil = "NIL" +pub fn nil(i: &[u8]) -> IResult<&[u8], &[u8]> { + tag_no_case("NIL")(i) +} + +// ----- text ----- + +// text = 1*TEXT-CHAR +pub fn text(i: &[u8]) -> IResult<&[u8], &str> { + map_res(take_while(is_text_char), from_utf8)(i) +} + +// TEXT-CHAR = +pub fn is_text_char(c: u8) -> bool { + is_char(c) && c != b'\r' && c != b'\n' +} + +// CHAR = %x01-7F +// ; any 7-bit US-ASCII character, +// ; excluding NUL +// From RFC5234 +pub fn is_char(c: u8) -> bool { + matches!(c, 0x01..=0x7F) +} + +// ----- others ----- + +// list-wildcards = "%" / "*" +pub fn is_list_wildcards(c: u8) -> bool { + c == b'%' || c == b'*' +} + +pub fn paren_delimited<'a, F, O, E>(f: F) -> impl FnMut(&'a [u8]) -> IResult<&'a [u8], O, E> +where + F: FnMut(&'a [u8]) -> IResult<&'a [u8], O, E>, + E: nom::error::ParseError<&'a [u8]>, +{ + delimited(char('('), f, char(')')) +} + +pub fn parenthesized_nonempty_list<'a, F, O, E>( + f: F, +) -> impl FnMut(&'a [u8]) -> IResult<&'a [u8], Vec, E> +where + F: FnMut(&'a [u8]) -> IResult<&'a [u8], O, E>, + E: nom::error::ParseError<&'a [u8]>, +{ + delimited(char('('), separated_list1(char(' '), f), char(')')) +} + +pub fn parenthesized_list<'a, F, O, E>(f: F) -> impl FnMut(&'a [u8]) -> IResult<&'a [u8], Vec, E> +where + F: FnMut(&'a [u8]) -> IResult<&'a [u8], O, E>, + E: nom::error::ParseError<&'a [u8]>, +{ + delimited(char('('), separated_list0(char(' '), f), char(')')) +} + +pub fn opt_opt<'a, F, O, E>(mut f: F) -> impl FnMut(&'a [u8]) -> IResult<&'a [u8], Option, E> +where + F: FnMut(&'a [u8]) -> IResult<&'a [u8], Option, E>, +{ + move |i: &[u8]| match f(i) { + Ok((i, o)) => Ok((i, o)), + Err(nom::Err::Error(_)) => Ok((i, None)), + Err(e) => Err(e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + + #[test] + fn test_quoted() { + let (rem, val) = quoted(br#""Hello"???"#).unwrap(); + assert_eq!(rem, b"???"); + assert_eq!(val, b"Hello"); + + // Allowed escapes... + assert!(quoted(br#""Hello \" "???"#).is_ok()); + assert!(quoted(br#""Hello \\ "???"#).is_ok()); + + // Not allowed escapes... + assert!(quoted(br#""Hello \a "???"#).is_err()); + assert!(quoted(br#""Hello \z "???"#).is_err()); + assert!(quoted(br#""Hello \? "???"#).is_err()); + + let (rem, val) = quoted(br#""Hello \"World\""???"#).unwrap(); + assert_eq!(rem, br#"???"#); + // Should it be this (Hello \"World\") ... + assert_eq!(val, br#"Hello \"World\""#); + // ... or this (Hello "World")? + //assert_eq!(val, br#"Hello "World""#); // fails + + // Test Incomplete + assert_matches!(quoted(br#""#), Err(nom::Err::Incomplete(_))); + assert_matches!(quoted(br#""\"#), Err(nom::Err::Incomplete(_))); + assert_matches!(quoted(br#""Hello "#), Err(nom::Err::Incomplete(_))); + + // Test Error + assert_matches!(quoted(br#"\"#), Err(nom::Err::Error(_))); + } + + #[test] + fn test_string_literal() { + match string(b"{3}\r\nXYZ") { + Ok((_, value)) => { + assert_eq!(value, b"XYZ"); + } + rsp => panic!("unexpected response {:?}", rsp), + } + } + + #[test] + fn test_astring() { + match astring(b"text ") { + Ok((_, value)) => { + assert_eq!(value, b"text"); + } + rsp => panic!("unexpected response {:?}", rsp), + } + } + + #[test] + fn test_sequence_range() { + match sequence_range(b"23:28 ") { + Ok((_, value)) => { + assert_eq!(*value.start(), 23); + assert_eq!(*value.end(), 28); + assert_eq!(value.collect::>(), vec![23, 24, 25, 26, 27, 28]); + } + rsp => panic!("Unexpected response {:?}", rsp), + } + } + + #[test] + fn test_sequence_set() { + match sequence_set(b"1,2:8,10,15:30 ") { + Ok((_, value)) => { + assert_eq!(value.len(), 4); + let v = &value[0]; + assert_eq!(*v.start(), 1); + assert_eq!(*v.end(), 1); + let v = &value[1]; + assert_eq!(*v.start(), 2); + assert_eq!(*v.end(), 8); + let v = &value[2]; + assert_eq!(*v.start(), 10); + assert_eq!(*v.end(), 10); + let v = &value[3]; + assert_eq!(*v.start(), 15); + assert_eq!(*v.end(), 30); + } + rsp => panic!("Unexpected response {:?}", rsp), + } + } +} diff --git a/imap/src/parser/mod.rs b/imap/src/parser/mod.rs new file mode 100644 index 0000000..a37fd91 --- /dev/null +++ b/imap/src/parser/mod.rs @@ -0,0 +1,25 @@ +use crate::types::Response; +use nom::{branch::alt, IResult}; + +pub mod core; + +pub mod bodystructure; +pub mod rfc3501; +pub mod rfc4315; +pub mod rfc4551; +pub mod rfc5161; +pub mod rfc5464; +pub mod rfc7162; + +#[cfg(test)] +mod tests; + +pub fn parse_response(msg: &[u8]) -> ParseResult { + alt(( + rfc3501::continue_req, + rfc3501::response_data, + rfc3501::response_tagged, + ))(msg) +} + +pub type ParseResult<'a> = IResult<&'a [u8], Response<'a>>; diff --git a/imap/src/parser/rfc3501/body.rs b/imap/src/parser/rfc3501/body.rs new file mode 100644 index 0000000..1bdcf7d --- /dev/null +++ b/imap/src/parser/rfc3501/body.rs @@ -0,0 +1,71 @@ +use nom::{ + branch::alt, + bytes::streaming::{tag, tag_no_case}, + character::streaming::char, + combinator::{map, opt}, + multi::many0, + sequence::{delimited, preceded, tuple}, + IResult, +}; + +use crate::{parser::core::*, types::*}; + +pub fn section_part(i: &[u8]) -> IResult<&[u8], Vec> { + let (i, (part, mut rest)) = tuple((number, many0(preceded(char('.'), number))))(i)?; + rest.insert(0, part); + Ok((i, rest)) +} + +pub fn section_msgtext(i: &[u8]) -> IResult<&[u8], MessageSection> { + alt(( + map( + tuple(( + tag_no_case("HEADER.FIELDS"), + opt(tag_no_case(".NOT")), + tag(" "), + parenthesized_list(astring), + )), + |_| MessageSection::Header, + ), + map(tag_no_case("HEADER"), |_| MessageSection::Header), + map(tag_no_case("TEXT"), |_| MessageSection::Text), + ))(i) +} + +pub fn section_text(i: &[u8]) -> IResult<&[u8], MessageSection> { + alt(( + section_msgtext, + map(tag_no_case("MIME"), |_| MessageSection::Mime), + ))(i) +} + +pub fn section_spec(i: &[u8]) -> IResult<&[u8], SectionPath> { + alt(( + map(section_msgtext, SectionPath::Full), + map( + tuple((section_part, opt(preceded(char('.'), section_text)))), + |(part, text)| SectionPath::Part(part, text), + ), + ))(i) +} + +pub fn section(i: &[u8]) -> IResult<&[u8], Option> { + delimited(char('['), opt(section_spec), char(']'))(i) +} + +pub fn msg_att_body_section(i: &[u8]) -> IResult<&[u8], AttributeValue> { + map( + tuple(( + tag_no_case("BODY"), + section, + opt(delimited(char('<'), number, char('>'))), + tag(" "), + nstring, + )), + |(_, section, index, _, data)| AttributeValue::BodySection { + section, + index, + data, + }, + )(i) +} diff --git a/imap/src/parser/rfc3501/body_structure.rs b/imap/src/parser/rfc3501/body_structure.rs new file mode 100644 index 0000000..72a48d5 --- /dev/null +++ b/imap/src/parser/rfc3501/body_structure.rs @@ -0,0 +1,527 @@ +use nom::{ + branch::alt, + bytes::streaming::{tag, tag_no_case}, + character::streaming::char, + combinator::{map, opt}, + multi::many1, + sequence::{delimited, preceded, tuple}, + IResult, +}; + +use crate::{ + parser::{core::*, rfc3501::envelope}, + types::*, +}; + +// body-fields = body-fld-param SP body-fld-id SP body-fld-desc SP +// body-fld-enc SP body-fld-octets +fn body_fields(i: &[u8]) -> IResult<&[u8], BodyFields> { + let (i, (param, _, id, _, description, _, transfer_encoding, _, octets)) = tuple(( + body_param, + tag(" "), + // body id seems to refer to the Message-ID or possibly Content-ID header, which + // by the definition in RFC 2822 seems to resolve to all ASCII characters (through + // a large amount of indirection which I did not have the patience to fully explore) + nstring_utf8, + tag(" "), + // Per https://tools.ietf.org/html/rfc2045#section-8, description should be all ASCII + nstring_utf8, + tag(" "), + body_encoding, + tag(" "), + number, + ))(i)?; + Ok(( + i, + BodyFields { + param, + id, + description, + transfer_encoding, + octets, + }, + )) +} + +// body-ext-1part = body-fld-md5 [SP body-fld-dsp [SP body-fld-lang +// [SP body-fld-loc *(SP body-extension)]]] +// ; MUST NOT be returned on non-extensible +// ; "BODY" fetch +fn body_ext_1part(i: &[u8]) -> IResult<&[u8], BodyExt1Part> { + let (i, (md5, disposition, language, location, extension)) = tuple(( + // Per RFC 1864, MD5 values are base64-encoded + opt_opt(preceded(tag(" "), nstring_utf8)), + opt_opt(preceded(tag(" "), body_disposition)), + opt_opt(preceded(tag(" "), body_lang)), + // Location appears to reference a URL, which by RFC 1738 (section 2.2) should be ASCII + opt_opt(preceded(tag(" "), nstring_utf8)), + opt(preceded(tag(" "), body_extension)), + ))(i)?; + Ok(( + i, + BodyExt1Part { + md5, + disposition, + language, + location, + extension, + }, + )) +} + +// body-ext-mpart = body-fld-param [SP body-fld-dsp [SP body-fld-lang +// [SP body-fld-loc *(SP body-extension)]]] +// ; MUST NOT be returned on non-extensible +// ; "BODY" fetch +fn body_ext_mpart(i: &[u8]) -> IResult<&[u8], BodyExtMPart> { + let (i, (param, disposition, language, location, extension)) = tuple(( + opt_opt(preceded(tag(" "), body_param)), + opt_opt(preceded(tag(" "), body_disposition)), + opt_opt(preceded(tag(" "), body_lang)), + // Location appears to reference a URL, which by RFC 1738 (section 2.2) should be ASCII + opt_opt(preceded(tag(" "), nstring_utf8)), + opt(preceded(tag(" "), body_extension)), + ))(i)?; + Ok(( + i, + BodyExtMPart { + param, + disposition, + language, + location, + extension, + }, + )) +} + +fn body_encoding(i: &[u8]) -> IResult<&[u8], ContentEncoding> { + alt(( + delimited( + char('"'), + alt(( + map(tag_no_case("7BIT"), |_| ContentEncoding::SevenBit), + map(tag_no_case("8BIT"), |_| ContentEncoding::EightBit), + map(tag_no_case("BINARY"), |_| ContentEncoding::Binary), + map(tag_no_case("BASE64"), |_| ContentEncoding::Base64), + map(tag_no_case("QUOTED-PRINTABLE"), |_| { + ContentEncoding::QuotedPrintable + }), + )), + char('"'), + ), + map(string_utf8, |enc| ContentEncoding::Other(enc)), + ))(i) +} + +fn body_lang(i: &[u8]) -> IResult<&[u8], Option>> { + alt(( + // body language seems to refer to RFC 3066 language tags, which should be ASCII-only + map(nstring_utf8, |v| v.map(|s| vec![s])), + map(parenthesized_nonempty_list(string_utf8), Option::from), + ))(i) +} + +fn body_param(i: &[u8]) -> IResult<&[u8], BodyParams> { + alt(( + map(nil, |_| None), + map( + parenthesized_nonempty_list(map( + tuple((string_utf8, tag(" "), string_utf8)), + |(key, _, val)| (key, val), + )), + Option::from, + ), + ))(i) +} + +fn body_extension(i: &[u8]) -> IResult<&[u8], BodyExtension> { + alt(( + map(number, BodyExtension::Num), + // Cannot find documentation on character encoding for body extension values. + // So far, assuming UTF-8 seems fine, please report if you run into issues here. + map(nstring_utf8, BodyExtension::Str), + map( + parenthesized_nonempty_list(body_extension), + BodyExtension::List, + ), + ))(i) +} + +fn body_disposition(i: &[u8]) -> IResult<&[u8], Option> { + alt(( + map(nil, |_| None), + paren_delimited(map( + tuple((string_utf8, tag(" "), body_param)), + |(ty, _, params)| Some(ContentDisposition { ty, params }), + )), + ))(i) +} + +fn body_type_basic(i: &[u8]) -> IResult<&[u8], BodyStructure> { + map( + tuple(( + string_utf8, + tag(" "), + string_utf8, + tag(" "), + body_fields, + body_ext_1part, + )), + |(ty, _, subtype, _, fields, ext)| BodyStructure::Basic { + common: BodyContentCommon { + ty: ContentType { + ty, + subtype, + params: fields.param, + }, + disposition: ext.disposition, + language: ext.language, + location: ext.location, + }, + other: BodyContentSinglePart { + id: fields.id, + md5: ext.md5, + octets: fields.octets, + description: fields.description, + transfer_encoding: fields.transfer_encoding, + }, + extension: ext.extension, + }, + )(i) +} + +fn body_type_text(i: &[u8]) -> IResult<&[u8], BodyStructure> { + map( + tuple(( + tag_no_case("\"TEXT\""), + tag(" "), + string_utf8, + tag(" "), + body_fields, + tag(" "), + number, + body_ext_1part, + )), + |(_, _, subtype, _, fields, _, lines, ext)| BodyStructure::Text { + common: BodyContentCommon { + ty: ContentType { + ty: "TEXT", + subtype, + params: fields.param, + }, + disposition: ext.disposition, + language: ext.language, + location: ext.location, + }, + other: BodyContentSinglePart { + id: fields.id, + md5: ext.md5, + octets: fields.octets, + description: fields.description, + transfer_encoding: fields.transfer_encoding, + }, + lines, + extension: ext.extension, + }, + )(i) +} + +fn body_type_message(i: &[u8]) -> IResult<&[u8], BodyStructure> { + map( + tuple(( + tag_no_case("\"MESSAGE\" \"RFC822\""), + tag(" "), + body_fields, + tag(" "), + envelope, + tag(" "), + body, + tag(" "), + number, + body_ext_1part, + )), + |(_, _, fields, _, envelope, _, body, _, lines, ext)| BodyStructure::Message { + common: BodyContentCommon { + ty: ContentType { + ty: "MESSAGE", + subtype: "RFC822", + params: fields.param, + }, + disposition: ext.disposition, + language: ext.language, + location: ext.location, + }, + other: BodyContentSinglePart { + id: fields.id, + md5: ext.md5, + octets: fields.octets, + description: fields.description, + transfer_encoding: fields.transfer_encoding, + }, + envelope, + body: Box::new(body), + lines, + extension: ext.extension, + }, + )(i) +} + +fn body_type_multipart(i: &[u8]) -> IResult<&[u8], BodyStructure> { + map( + tuple((many1(body), tag(" "), string_utf8, body_ext_mpart)), + |(bodies, _, subtype, ext)| BodyStructure::Multipart { + common: BodyContentCommon { + ty: ContentType { + ty: "MULTIPART", + subtype, + params: ext.param, + }, + disposition: ext.disposition, + language: ext.language, + location: ext.location, + }, + bodies, + extension: ext.extension, + }, + )(i) +} + +pub(crate) fn body(i: &[u8]) -> IResult<&[u8], BodyStructure> { + paren_delimited(alt(( + body_type_text, + body_type_message, + body_type_basic, + body_type_multipart, + )))(i) +} + +pub(crate) fn msg_att_body_structure(i: &[u8]) -> IResult<&[u8], AttributeValue> { + map(tuple((tag_no_case("BODYSTRUCTURE "), body)), |(_, body)| { + AttributeValue::BodyStructure(body) + })(i) +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + + const EMPTY: &[u8] = &[]; + + // body-fld-param SP body-fld-id SP body-fld-desc SP body-fld-enc SP body-fld-octets + const BODY_FIELDS: &str = r#"("foo" "bar") "id" "desc" "7BIT" 1337"#; + const BODY_FIELD_PARAM_PAIR: (&str, &str) = ("foo", "bar"); + const BODY_FIELD_ID: Option<&str> = Some("id"); + const BODY_FIELD_DESC: Option<&str> = Some("desc"); + const BODY_FIELD_ENC: ContentEncoding = ContentEncoding::SevenBit; + const BODY_FIELD_OCTETS: u32 = 1337; + + fn mock_body_text() -> (String, BodyStructure<'static>) { + ( + format!(r#"("TEXT" "PLAIN" {} 42)"#, BODY_FIELDS), + BodyStructure::Text { + common: BodyContentCommon { + ty: ContentType { + ty: "TEXT", + subtype: "PLAIN", + params: Some(vec![BODY_FIELD_PARAM_PAIR]), + }, + disposition: None, + language: None, + location: None, + }, + other: BodyContentSinglePart { + md5: None, + transfer_encoding: BODY_FIELD_ENC, + octets: BODY_FIELD_OCTETS, + id: BODY_FIELD_ID, + description: BODY_FIELD_DESC, + }, + lines: 42, + extension: None, + }, + ) + } + + #[test] + fn test_body_param_data() { + assert_matches!(body_param(br#"NIL"#), Ok((EMPTY, None))); + + assert_matches!( + body_param(br#"("foo" "bar")"#), + Ok((EMPTY, Some(param))) => { + assert_eq!(param, vec![("foo", "bar")]); + } + ); + } + + #[test] + fn test_body_lang_data() { + assert_matches!( + body_lang(br#""bob""#), + Ok((EMPTY, Some(langs))) => { + assert_eq!(langs, vec!["bob"]); + } + ); + + assert_matches!( + body_lang(br#"("one" "two")"#), + Ok((EMPTY, Some(langs))) => { + assert_eq!(langs, vec!["one", "two"]); + } + ); + + assert_matches!(body_lang(br#"NIL"#), Ok((EMPTY, None))); + } + + #[test] + fn test_body_extension_data() { + assert_matches!( + body_extension(br#""blah""#), + Ok((EMPTY, BodyExtension::Str(Some("blah")))) + ); + + assert_matches!( + body_extension(br#"NIL"#), + Ok((EMPTY, BodyExtension::Str(None))) + ); + + assert_matches!( + body_extension(br#"("hello")"#), + Ok((EMPTY, BodyExtension::List(list))) => { + assert_eq!(list, vec![BodyExtension::Str(Some("hello"))]); + } + ); + + assert_matches!( + body_extension(br#"(1337)"#), + Ok((EMPTY, BodyExtension::List(list))) => { + assert_eq!(list, vec![BodyExtension::Num(1337)]); + } + ); + } + + #[test] + fn test_body_disposition_data() { + assert_matches!(body_disposition(br#"NIL"#), Ok((EMPTY, None))); + + assert_matches!( + body_disposition(br#"("attachment" ("FILENAME" "pages.pdf"))"#), + Ok((EMPTY, Some(disposition))) => { + assert_eq!(disposition, ContentDisposition { + ty: "attachment", + params: Some(vec![ + ("FILENAME", "pages.pdf") + ]) + }); + } + ); + } + + #[test] + fn test_body_structure_text() { + let (body_str, body_struct) = mock_body_text(); + + assert_matches!( + body(body_str.as_bytes()), + Ok((_, text)) => { + assert_eq!(text, body_struct); + } + ); + } + + #[test] + fn test_body_structure_text_with_ext() { + let body_str = format!(r#"("TEXT" "PLAIN" {} 42 NIL NIL NIL NIL)"#, BODY_FIELDS); + let (_, text_body_struct) = mock_body_text(); + + assert_matches!( + body(body_str.as_bytes()), + Ok((_, text)) => { + assert_eq!(text, text_body_struct) + } + ); + } + + #[test] + fn test_body_structure_basic() { + const BODY: &[u8] = br#"("APPLICATION" "PDF" ("NAME" "pages.pdf") NIL NIL "BASE64" 38838 NIL ("attachment" ("FILENAME" "pages.pdf")) NIL NIL)"#; + + assert_matches!( + body(BODY), + Ok((_, basic)) => { + assert_eq!(basic, BodyStructure::Basic { + common: BodyContentCommon { + ty: ContentType { + ty: "APPLICATION", + subtype: "PDF", + params: Some(vec![("NAME", "pages.pdf")]) + }, + disposition: Some(ContentDisposition { + ty: "attachment", + params: Some(vec![("FILENAME", "pages.pdf")]) + }), + language: None, + location: None, + }, + other: BodyContentSinglePart { + transfer_encoding: ContentEncoding::Base64, + octets: 38838, + id: None, + md5: None, + description: None, + }, + extension: None, + }) + } + ); + } + + #[test] + fn test_body_structure_message() { + let (text_body_str, _) = mock_body_text(); + let envelope_str = r#"("Wed, 17 Jul 1996 02:23:25 -0700 (PDT)" "IMAP4rev1 WG mtg summary and minutes" (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) ((NIL NIL "imap" "cac.washington.edu")) ((NIL NIL "minutes" "CNRI.Reston.VA.US") ("John Klensin" NIL "KLENSIN" "MIT.EDU")) NIL NIL "")"#; + let body_str = format!( + r#"("MESSAGE" "RFC822" {} {} {} 42)"#, + BODY_FIELDS, envelope_str, text_body_str + ); + + assert_matches!( + body(body_str.as_bytes()), + Ok((_, BodyStructure::Message { .. })) + ); + } + + #[test] + fn test_body_structure_multipart() { + let (text_body_str1, text_body_struct1) = mock_body_text(); + let (text_body_str2, text_body_struct2) = mock_body_text(); + let body_str = format!( + r#"({}{} "ALTERNATIVE" NIL NIL NIL NIL)"#, + text_body_str1, text_body_str2 + ); + + assert_matches!( + body(body_str.as_bytes()), + Ok((_, multipart)) => { + assert_eq!(multipart, BodyStructure::Multipart { + common: BodyContentCommon { + ty: ContentType { + ty: "MULTIPART", + subtype: "ALTERNATIVE", + params: None + }, + language: None, + location: None, + disposition: None, + }, + bodies: vec![ + text_body_struct1, + text_body_struct2, + ], + extension: None + }); + } + ); + } +} diff --git a/imap/src/parser/rfc3501/mod.rs b/imap/src/parser/rfc3501/mod.rs new file mode 100644 index 0000000..460ae16 --- /dev/null +++ b/imap/src/parser/rfc3501/mod.rs @@ -0,0 +1,739 @@ +//! +//! https://tools.ietf.org/html/rfc3501 +//! +//! INTERNET MESSAGE ACCESS PROTOCOL +//! + +use std::str::from_utf8; + +use nom::{ + branch::alt, + bytes::streaming::{tag, tag_no_case, take_while, take_while1}, + character::streaming::char, + combinator::{map, map_res, opt, recognize}, + multi::{many0, many1}, + sequence::{delimited, pair, preceded, terminated, tuple}, + IResult, +}; + +use crate::{ + parser::{ + core::*, rfc3501::body::*, rfc3501::body_structure::*, rfc4315, rfc4551, rfc5161, rfc5464, + rfc7162, + }, + types::*, +}; + +pub mod body; +pub mod body_structure; + +fn is_tag_char(c: u8) -> bool { + c != b'+' && is_astring_char(c) +} + +fn status_ok(i: &[u8]) -> IResult<&[u8], Status> { + map(tag_no_case("OK"), |_s| Status::Ok)(i) +} +fn status_no(i: &[u8]) -> IResult<&[u8], Status> { + map(tag_no_case("NO"), |_s| Status::No)(i) +} +fn status_bad(i: &[u8]) -> IResult<&[u8], Status> { + map(tag_no_case("BAD"), |_s| Status::Bad)(i) +} +fn status_preauth(i: &[u8]) -> IResult<&[u8], Status> { + map(tag_no_case("PREAUTH"), |_s| Status::PreAuth)(i) +} +fn status_bye(i: &[u8]) -> IResult<&[u8], Status> { + map(tag_no_case("BYE"), |_s| Status::Bye)(i) +} + +fn status(i: &[u8]) -> IResult<&[u8], Status> { + alt((status_ok, status_no, status_bad, status_preauth, status_bye))(i) +} + +fn mailbox(i: &[u8]) -> IResult<&[u8], &str> { + map(astring_utf8, |s| { + if s.eq_ignore_ascii_case("INBOX") { + "INBOX" + } else { + s + } + })(i) +} + +fn flag_extension(i: &[u8]) -> IResult<&[u8], &str> { + map_res( + recognize(pair(tag(b"\\"), take_while(is_atom_char))), + from_utf8, + )(i) +} + +fn flag(i: &[u8]) -> IResult<&[u8], &str> { + alt((flag_extension, atom))(i) +} + +fn flag_list(i: &[u8]) -> IResult<&[u8], Vec<&str>> { + // Correct code is + // parenthesized_list(flag)(i) + // + // Unfortunately, Zoho Mail Server (imap.zoho.com) sends the following response: + // * FLAGS (\Answered \Flagged \Deleted \Seen \Draft \*) + // + // As a workaround, "\*" is allowed here. + parenthesized_list(flag_perm)(i) +} + +fn flag_perm(i: &[u8]) -> IResult<&[u8], &str> { + alt((map_res(tag(b"\\*"), from_utf8), flag))(i) +} + +fn resp_text_code_alert(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map(tag_no_case(b"ALERT"), |_| ResponseCode::Alert)(i) +} + +fn resp_text_code_badcharset(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map( + preceded( + tag_no_case(b"BADCHARSET"), + opt(preceded( + tag(b" "), + parenthesized_nonempty_list(astring_utf8), + )), + ), + ResponseCode::BadCharset, + )(i) +} + +fn resp_text_code_capability(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map(capability_data, ResponseCode::Capabilities)(i) +} + +fn resp_text_code_parse(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map(tag_no_case(b"PARSE"), |_| ResponseCode::Parse)(i) +} + +fn resp_text_code_permanent_flags(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map( + preceded( + tag_no_case(b"PERMANENTFLAGS "), + parenthesized_list(flag_perm), + ), + ResponseCode::PermanentFlags, + )(i) +} + +fn resp_text_code_read_only(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map(tag_no_case(b"READ-ONLY"), |_| ResponseCode::ReadOnly)(i) +} + +fn resp_text_code_read_write(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map(tag_no_case(b"READ-WRITE"), |_| ResponseCode::ReadWrite)(i) +} + +fn resp_text_code_try_create(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map(tag_no_case(b"TRYCREATE"), |_| ResponseCode::TryCreate)(i) +} + +fn resp_text_code_uid_validity(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map( + preceded(tag_no_case(b"UIDVALIDITY "), number), + ResponseCode::UidValidity, + )(i) +} + +fn resp_text_code_uid_next(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map( + preceded(tag_no_case(b"UIDNEXT "), number), + ResponseCode::UidNext, + )(i) +} + +fn resp_text_code_unseen(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map( + preceded(tag_no_case(b"UNSEEN "), number), + ResponseCode::Unseen, + )(i) +} + +fn resp_text_code(i: &[u8]) -> IResult<&[u8], ResponseCode> { + // Per the spec, the closing tag should be "] ". + // See `resp_text` for more on why this is done differently. + delimited( + tag(b"["), + alt(( + resp_text_code_alert, + resp_text_code_badcharset, + resp_text_code_capability, + resp_text_code_parse, + resp_text_code_permanent_flags, + resp_text_code_uid_validity, + resp_text_code_uid_next, + resp_text_code_unseen, + resp_text_code_read_only, + resp_text_code_read_write, + resp_text_code_try_create, + rfc4551::resp_text_code_highest_mod_seq, + rfc4315::resp_text_code_append_uid, + rfc4315::resp_text_code_copy_uid, + rfc4315::resp_text_code_uid_not_sticky, + )), + tag(b"]"), + )(i) +} + +fn capability(i: &[u8]) -> IResult<&[u8], Capability> { + alt(( + map(tag_no_case(b"IMAP4rev1"), |_| Capability::Imap4rev1), + map(preceded(tag_no_case(b"AUTH="), atom), Capability::Auth), + map(atom, Capability::Atom), + ))(i) +} + +fn ensure_capabilities_contains_imap4rev( + capabilities: Vec>, +) -> Result>, ()> { + if capabilities.contains(&Capability::Imap4rev1) { + Ok(capabilities) + } else { + Err(()) + } +} + +fn capability_data(i: &[u8]) -> IResult<&[u8], Vec> { + map_res( + preceded( + tag_no_case(b"CAPABILITY"), + many0(preceded(char(' '), capability)), + ), + ensure_capabilities_contains_imap4rev, + )(i) +} + +fn mailbox_data_search(i: &[u8]) -> IResult<&[u8], MailboxDatum> { + map( + // Technically, trailing whitespace is not allowed here, but multiple + // email servers in the wild seem to have it anyway (see #34, #108). + terminated( + preceded(tag_no_case(b"SEARCH"), many0(preceded(tag(" "), number))), + opt(tag(" ")), + ), + MailboxDatum::Search, + )(i) +} + +fn mailbox_data_flags(i: &[u8]) -> IResult<&[u8], MailboxDatum> { + map( + preceded(tag_no_case("FLAGS "), flag_list), + MailboxDatum::Flags, + )(i) +} + +fn mailbox_data_exists(i: &[u8]) -> IResult<&[u8], MailboxDatum> { + map( + terminated(number, tag_no_case(" EXISTS")), + MailboxDatum::Exists, + )(i) +} + +#[allow(clippy::type_complexity)] +fn mailbox_list(i: &[u8]) -> IResult<&[u8], (Vec<&str>, Option<&str>, &str)> { + map( + tuple(( + flag_list, + tag(b" "), + alt((map(quoted_utf8, Some), map(nil, |_| None))), + tag(b" "), + mailbox, + )), + |(flags, _, delimiter, _, name)| (flags, delimiter, name), + )(i) +} + +fn mailbox_data_list(i: &[u8]) -> IResult<&[u8], MailboxDatum> { + map(preceded(tag_no_case("LIST "), mailbox_list), |data| { + MailboxDatum::List { + flags: data.0, + delimiter: data.1, + name: data.2, + } + })(i) +} + +fn mailbox_data_lsub(i: &[u8]) -> IResult<&[u8], MailboxDatum> { + map(preceded(tag_no_case("LSUB "), mailbox_list), |data| { + MailboxDatum::List { + flags: data.0, + delimiter: data.1, + name: data.2, + } + })(i) +} + +// Unlike `status_att` in the RFC syntax, this includes the value, +// so that it can return a valid enum object instead of just a key. +fn status_att(i: &[u8]) -> IResult<&[u8], StatusAttribute> { + alt(( + rfc4551::status_att_val_highest_mod_seq, + map( + preceded(tag_no_case("MESSAGES "), number), + StatusAttribute::Messages, + ), + map( + preceded(tag_no_case("RECENT "), number), + StatusAttribute::Recent, + ), + map( + preceded(tag_no_case("UIDNEXT "), number), + StatusAttribute::UidNext, + ), + map( + preceded(tag_no_case("UIDVALIDITY "), number), + StatusAttribute::UidValidity, + ), + map( + preceded(tag_no_case("UNSEEN "), number), + StatusAttribute::Unseen, + ), + ))(i) +} + +fn status_att_list(i: &[u8]) -> IResult<&[u8], Vec> { + parenthesized_nonempty_list(status_att)(i) +} + +fn mailbox_data_status(i: &[u8]) -> IResult<&[u8], MailboxDatum> { + map( + tuple((tag_no_case("STATUS "), mailbox, tag(" "), status_att_list)), + |(_, mailbox, _, status)| MailboxDatum::Status { mailbox, status }, + )(i) +} + +fn mailbox_data_recent(i: &[u8]) -> IResult<&[u8], MailboxDatum> { + map( + terminated(number, tag_no_case(" RECENT")), + MailboxDatum::Recent, + )(i) +} + +fn mailbox_data(i: &[u8]) -> IResult<&[u8], MailboxDatum> { + alt(( + mailbox_data_flags, + mailbox_data_exists, + mailbox_data_list, + mailbox_data_lsub, + mailbox_data_status, + mailbox_data_recent, + mailbox_data_search, + ))(i) +} + +// An address structure is a parenthesized list that describes an +// electronic mail address. +fn address(i: &[u8]) -> IResult<&[u8], Address> { + paren_delimited(map( + tuple(( + nstring, + tag(" "), + nstring, + tag(" "), + nstring, + tag(" "), + nstring, + )), + |(name, _, adl, _, mailbox, _, host)| Address { + name, + adl, + mailbox, + host, + }, + ))(i) +} + +fn opt_addresses(i: &[u8]) -> IResult<&[u8], Option>> { + alt(( + map(nil, |_s| None), + map( + paren_delimited(many1(terminated(address, opt(char(' '))))), + Some, + ), + ))(i) +} + +// envelope = "(" env-date SP env-subject SP env-from SP +// env-sender SP env-reply-to SP env-to SP env-cc SP +// env-bcc SP env-in-reply-to SP env-message-id ")" +// +// env-bcc = "(" 1*address ")" / nil +// +// env-cc = "(" 1*address ")" / nil +// +// env-date = nstring +// +// env-from = "(" 1*address ")" / nil +// +// env-in-reply-to = nstring +// +// env-message-id = nstring +// +// env-reply-to = "(" 1*address ")" / nil +// +// env-sender = "(" 1*address ")" / nil +// +// env-subject = nstring +// +// env-to = "(" 1*address ")" / nil +pub(crate) fn envelope(i: &[u8]) -> IResult<&[u8], Envelope> { + paren_delimited(map( + tuple(( + nstring, + tag(" "), + nstring, + tag(" "), + opt_addresses, + tag(" "), + opt_addresses, + tag(" "), + opt_addresses, + tag(" "), + opt_addresses, + tag(" "), + opt_addresses, + tag(" "), + opt_addresses, + tag(" "), + nstring, + tag(" "), + nstring, + )), + |( + date, + _, + subject, + _, + from, + _, + sender, + _, + reply_to, + _, + to, + _, + cc, + _, + bcc, + _, + in_reply_to, + _, + message_id, + )| Envelope { + date, + subject, + from, + sender, + reply_to, + to, + cc, + bcc, + in_reply_to, + message_id, + }, + ))(i) +} + +fn msg_att_envelope(i: &[u8]) -> IResult<&[u8], AttributeValue> { + map(preceded(tag_no_case("ENVELOPE "), envelope), |envelope| { + AttributeValue::Envelope(Box::new(envelope)) + })(i) +} + +fn msg_att_internal_date(i: &[u8]) -> IResult<&[u8], AttributeValue> { + map( + preceded(tag_no_case("INTERNALDATE "), nstring_utf8), + |date| AttributeValue::InternalDate(date.unwrap()), + )(i) +} + +fn msg_att_flags(i: &[u8]) -> IResult<&[u8], AttributeValue> { + map( + preceded(tag_no_case("FLAGS "), flag_list), + AttributeValue::Flags, + )(i) +} + +fn msg_att_rfc822(i: &[u8]) -> IResult<&[u8], AttributeValue> { + map( + preceded(tag_no_case("RFC822 "), nstring), + AttributeValue::Rfc822, + )(i) +} + +fn msg_att_rfc822_header(i: &[u8]) -> IResult<&[u8], AttributeValue> { + // extra space workaround for DavMail + map( + tuple((tag_no_case("RFC822.HEADER "), opt(tag(b" ")), nstring)), + |(_, _, raw)| AttributeValue::Rfc822Header(raw), + )(i) +} + +fn msg_att_rfc822_size(i: &[u8]) -> IResult<&[u8], AttributeValue> { + map( + preceded(tag_no_case("RFC822.SIZE "), number), + AttributeValue::Rfc822Size, + )(i) +} + +fn msg_att_rfc822_text(i: &[u8]) -> IResult<&[u8], AttributeValue> { + map( + preceded(tag_no_case("RFC822.TEXT "), nstring), + AttributeValue::Rfc822Text, + )(i) +} + +fn msg_att_uid(i: &[u8]) -> IResult<&[u8], AttributeValue> { + map(preceded(tag_no_case("UID "), number), AttributeValue::Uid)(i) +} + +// msg-att = "(" (msg-att-dynamic / msg-att-static) +// *(SP (msg-att-dynamic / msg-att-static)) ")" +// +// msg-att-dynamic = "FLAGS" SP "(" [flag-fetch *(SP flag-fetch)] ")" +// ; MAY change for a message +// +// msg-att-static = "ENVELOPE" SP envelope / "INTERNALDATE" SP date-time / +// "RFC822" [".HEADER" / ".TEXT"] SP nstring / +// "RFC822.SIZE" SP number / +// "BODY" ["STRUCTURE"] SP body / +// "BODY" section ["<" number ">"] SP nstring / +// "UID" SP uniqueid +// ; MUST NOT change for a message +fn msg_att(i: &[u8]) -> IResult<&[u8], AttributeValue> { + alt(( + msg_att_body_section, + msg_att_body_structure, + msg_att_envelope, + msg_att_internal_date, + msg_att_flags, + rfc4551::msg_att_mod_seq, + msg_att_rfc822, + msg_att_rfc822_header, + msg_att_rfc822_size, + msg_att_rfc822_text, + msg_att_uid, + ))(i) +} + +fn msg_att_list(i: &[u8]) -> IResult<&[u8], Vec> { + parenthesized_nonempty_list(msg_att)(i) +} + +// message-data = nz-number SP ("EXPUNGE" / ("FETCH" SP msg-att)) +fn message_data_fetch(i: &[u8]) -> IResult<&[u8], Response> { + map( + tuple((number, tag_no_case(" FETCH "), msg_att_list)), + |(num, _, attrs)| Response::Fetch(num, attrs), + )(i) +} + +// message-data = nz-number SP ("EXPUNGE" / ("FETCH" SP msg-att)) +fn message_data_expunge(i: &[u8]) -> IResult<&[u8], u32> { + terminated(number, tag_no_case(" EXPUNGE"))(i) +} + +// tag = 1* +fn imap_tag(i: &[u8]) -> IResult<&[u8], RequestId> { + map(map_res(take_while1(is_tag_char), from_utf8), |s| { + RequestId(s.to_string()) + })(i) +} + +// This is not quite according to spec, which mandates the following: +// ["[" resp-text-code "]" SP] text +// However, examples in RFC 4551 (Conditional STORE) counteract this by giving +// examples of `resp-text` that do not include the trailing space and text. +fn resp_text(i: &[u8]) -> IResult<&[u8], (Option, Option<&str>)> { + map(tuple((opt(resp_text_code), text)), |(code, text)| { + let res = if text.is_empty() { + None + } else if code.is_some() { + Some(&text[1..]) + } else { + Some(text) + }; + (code, res) + })(i) +} + +// continue-req = "+" SP (resp-text / base64) CRLF +pub(crate) fn continue_req(i: &[u8]) -> IResult<&[u8], Response> { + // Some servers do not send the space :/ + // TODO: base64 + map( + tuple((tag("+"), opt(tag(" ")), resp_text, tag("\r\n"))), + |(_, _, text, _)| Response::Continue { + code: text.0, + information: text.1, + }, + )(i) +} + +// response-tagged = tag SP resp-cond-state CRLF +// +// resp-cond-state = ("OK" / "NO" / "BAD") SP resp-text +// ; Status condition +pub(crate) fn response_tagged(i: &[u8]) -> IResult<&[u8], Response> { + map( + tuple(( + imap_tag, + tag(b" "), + status, + tag(b" "), + resp_text, + tag(b"\r\n"), + )), + |(tag, _, status, _, text, _)| Response::Done { + tag, + status, + code: text.0, + information: text.1, + }, + )(i) +} + +// resp-cond-auth = ("OK" / "PREAUTH") SP resp-text +// ; Authentication condition +// +// resp-cond-bye = "BYE" SP resp-text +// +// resp-cond-state = ("OK" / "NO" / "BAD") SP resp-text +// ; Status condition +fn resp_cond(i: &[u8]) -> IResult<&[u8], Response> { + map( + tuple((status, tag(b" "), resp_text)), + |(status, _, text)| Response::Data { + status, + code: text.0, + information: text.1, + }, + )(i) +} + +// response-data = "*" SP (resp-cond-state / resp-cond-bye / +// mailbox-data / message-data / capability-data) CRLF +pub(crate) fn response_data(i: &[u8]) -> IResult<&[u8], Response> { + delimited( + tag(b"* "), + alt(( + resp_cond, + map(mailbox_data, Response::MailboxData), + map(message_data_expunge, Response::Expunge), + message_data_fetch, + map(capability_data, Response::Capabilities), + rfc5161::resp_enabled, + rfc5464::metadata_solicited, + rfc5464::metadata_unsolicited, + rfc7162::resp_vanished, + )), + tag(b"\r\n"), + )(i) +} + +#[cfg(test)] +mod tests { + use crate::types::*; + use assert_matches::assert_matches; + + #[test] + fn test_list() { + match super::mailbox(b"iNboX ") { + Ok((_, mb)) => { + assert_eq!(mb, "INBOX"); + } + rsp => panic!("unexpected response {:?}", rsp), + } + } + + #[test] + fn test_envelope() { + let env = br#"ENVELOPE ("Wed, 17 Jul 1996 02:23:25 -0700 (PDT)" "IMAP4rev1 WG mtg summary and minutes" (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) ((NIL NIL "imap" "cac.washington.edu")) ((NIL NIL "minutes" "CNRI.Reston.VA.US") ("John Klensin" NIL "KLENSIN" "MIT.EDU")) NIL NIL "") "#; + match super::msg_att_envelope(env) { + Ok((_, AttributeValue::Envelope(_))) => {} + rsp => panic!("unexpected response {:?}", rsp), + } + } + + #[test] + fn test_opt_addresses() { + let addr = b"((NIL NIL \"minutes\" \"CNRI.Reston.VA.US\") (\"John Klensin\" NIL \"KLENSIN\" \"MIT.EDU\")) "; + match super::opt_addresses(addr) { + Ok((_, _addresses)) => {} + rsp => panic!("unexpected response {:?}", rsp), + } + } + + #[test] + fn test_opt_addresses_no_space() { + let addr = + br#"((NIL NIL "test" "example@example.com")(NIL NIL "test" "example@example.com"))"#; + match super::opt_addresses(addr) { + Ok((_, _addresses)) => {} + rsp => panic!("unexpected response {:?}", rsp), + } + } + + #[test] + fn test_addresses() { + match super::address(b"(\"John Klensin\" NIL \"KLENSIN\" \"MIT.EDU\") ") { + Ok((_, _address)) => {} + rsp => panic!("unexpected response {:?}", rsp), + } + + // Literal non-UTF8 address + match super::address(b"({12}\r\nJoh\xff Klensin NIL \"KLENSIN\" \"MIT.EDU\") ") { + Ok((_, _address)) => {} + rsp => panic!("unexpected response {:?}", rsp), + } + } + + #[test] + fn test_capability_data() { + // Minimal capabilities + assert_matches!( + super::capability_data(b"CAPABILITY IMAP4rev1\r\n"), + Ok((_, capabilities)) => { + assert_eq!(capabilities, vec![Capability::Imap4rev1]) + } + ); + + assert_matches!( + super::capability_data(b"CAPABILITY IMAP4REV1\r\n"), + Ok((_, capabilities)) => { + assert_eq!(capabilities, vec![Capability::Imap4rev1]) + } + ); + + assert_matches!( + super::capability_data(b"CAPABILITY XPIG-LATIN IMAP4rev1 STARTTLS AUTH=GSSAPI\r\n"), + Ok((_, capabilities)) => { + assert_eq!(capabilities, vec![ + Capability::Atom("XPIG-LATIN"), Capability::Imap4rev1, + Capability::Atom("STARTTLS"), Capability::Auth("GSSAPI") + ]) + } + ); + + assert_matches!( + super::capability_data(b"CAPABILITY IMAP4rev1 AUTH=GSSAPI AUTH=PLAIN\r\n"), + Ok((_, capabilities)) => { + assert_eq!(capabilities, vec![ + Capability::Imap4rev1, Capability::Auth("GSSAPI"), Capability::Auth("PLAIN") + ]) + } + ); + + // Capability command must contain IMAP4rev1 + assert_matches!( + super::capability_data(b"CAPABILITY AUTH=GSSAPI AUTH=PLAIN\r\n"), + Err(_) + ); + } +} diff --git a/imap/src/parser/rfc4315.rs b/imap/src/parser/rfc4315.rs new file mode 100644 index 0000000..9b2b13f --- /dev/null +++ b/imap/src/parser/rfc4315.rs @@ -0,0 +1,95 @@ +//! +//! https://tools.ietf.org/html/rfc4315 +//! +//! The IMAP UIDPLUS Extension +//! + +use nom::{ + branch::alt, + bytes::streaming::{tag, tag_no_case}, + combinator::map, + multi::separated_list1, + sequence::{preceded, tuple}, + IResult, +}; + +use crate::parser::core::number; +use crate::types::*; + +/// Extends resp-text-code as follows: +/// +/// ```ignore +/// resp-text-code =/ resp-code-apnd +/// resp-code-apnd = "APPENDUID" SP nz-number SP append-uid +/// append-uid =/ uid-set +/// ; only permitted if client uses [MULTIAPPEND] +/// ; to append multiple messages. +/// ``` +/// +/// [RFC4315 - 3 Additional Response Codes](https://tools.ietf.org/html/rfc4315#section-3) +pub(crate) fn resp_text_code_append_uid(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map( + preceded( + tag_no_case(b"APPENDUID "), + tuple((number, tag(" "), uid_set)), + ), + |(fst, _, snd)| ResponseCode::AppendUid(fst, snd), + )(i) +} + +/// Extends resp-text-code as follows: +/// +/// ```ignore +/// resp-text-code =/ resp-code-copy +/// resp-code-copy = "COPYUID" SP nz-number SP uid-set +/// ``` +/// +/// [RFC4315 - 3 Additional Response Codes](https://tools.ietf.org/html/rfc4315#section-3) +pub(crate) fn resp_text_code_copy_uid(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map( + preceded( + tag_no_case(b"COPYUID "), + tuple((number, tag(" "), uid_set, tag(" "), uid_set)), + ), + |(fst, _, snd, _, trd)| ResponseCode::CopyUid(fst, snd, trd), + )(i) +} + +/// Extends resp-text-code as follows: +/// +/// ```ignore +/// resp-text-code =/ "UIDNOTSTICKY" +/// ``` +/// +/// [RFC4315 - 3 Additional Response Codes](https://tools.ietf.org/html/rfc4315#section-3) +pub(crate) fn resp_text_code_uid_not_sticky(i: &[u8]) -> IResult<&[u8], ResponseCode> { + map(tag_no_case(b"UIDNOTSTICKY"), |_| ResponseCode::UidNotSticky)(i) +} + +/// Parses the uid-set nonterminal: +/// +/// ```ignore +/// uid-set = (uniqueid / uid-range) *("," uid-set) +/// ``` +/// +/// [RFC4315 - 4 Formal Syntax](https://tools.ietf.org/html/rfc4315#section-4) +fn uid_set(i: &[u8]) -> IResult<&[u8], Vec> { + separated_list1(tag(","), alt((uid_range, map(number, From::from))))(i) +} + +/// Parses the uid-set nonterminal: +/// +/// ```ignore +/// uid-range = (uniqueid ":" uniqueid) +/// ; two uniqueid values and all values +/// ; between these two regards of order. +/// ; Example: 2:4 and 4:2 are equivalent. +/// ``` +/// +/// [RFC4315 - 4 Formal Syntax](https://tools.ietf.org/html/rfc4315#section-4) +fn uid_range(i: &[u8]) -> IResult<&[u8], UidSetMember> { + map( + nom::sequence::separated_pair(number, tag(":"), number), + |(fst, snd)| if fst <= snd { fst..=snd } else { snd..=fst }.into(), + )(i) +} diff --git a/imap/src/parser/rfc4551.rs b/imap/src/parser/rfc4551.rs new file mode 100644 index 0000000..443062d --- /dev/null +++ b/imap/src/parser/rfc4551.rs @@ -0,0 +1,36 @@ +//! +//! https://tools.ietf.org/html/rfc4551 +//! +//! IMAP Extension for Conditional STORE Operation +//! or Quick Flag Changes Resynchronization +//! + +use nom::{bytes::streaming::tag_no_case, sequence::tuple, IResult}; + +use crate::{ + parser::core::{number_64, paren_delimited}, + types::*, +}; + +// The highest mod-sequence value of all messages in the mailbox. +// Extends resp-test-code defined in rfc3501. +// [RFC4551 - 3.6 HIGHESTMODSEQ Status Data Items](https://tools.ietf.org/html/rfc4551#section-3.6) +// [RFC4551 - 4. Formal Syntax - resp-text-code](https://tools.ietf.org/html/rfc4551#section-4) +pub(crate) fn resp_text_code_highest_mod_seq(i: &[u8]) -> IResult<&[u8], ResponseCode> { + let (i, (_, num)) = tuple((tag_no_case("HIGHESTMODSEQ "), number_64))(i)?; + Ok((i, ResponseCode::HighestModSeq(num))) +} + +// Extends status-att/status-att-list defined in rfc3501 +// [RFC4551 - 3.6 - HIGHESTMODSEQ Status Data Items](https://tools.ietf.org/html/rfc4551#section-3.6) +// [RFC4551 - 4. Formal Syntax - status-att-val](https://tools.ietf.org/html/rfc4551#section-4) +pub(crate) fn status_att_val_highest_mod_seq(i: &[u8]) -> IResult<&[u8], StatusAttribute> { + let (i, (_, num)) = tuple((tag_no_case("HIGHESTMODSEQ "), number_64))(i)?; + Ok((i, StatusAttribute::HighestModSeq(num))) +} + +// [RFC4551 - 4. Formal Syntax - fetch-mod-resp](https://tools.ietf.org/html/rfc4551#section-4) +pub(crate) fn msg_att_mod_seq(i: &[u8]) -> IResult<&[u8], AttributeValue> { + let (i, (_, num)) = tuple((tag_no_case("MODSEQ "), paren_delimited(number_64)))(i)?; + Ok((i, AttributeValue::ModSeq(num))) +} diff --git a/imap/src/parser/rfc5161.rs b/imap/src/parser/rfc5161.rs new file mode 100644 index 0000000..3a1f7c4 --- /dev/null +++ b/imap/src/parser/rfc5161.rs @@ -0,0 +1,36 @@ +//! +//! https://tools.ietf.org/html/rfc5161 +//! +//! The IMAP ENABLE Extension +//! + +use nom::{ + bytes::streaming::tag_no_case, + character::streaming::char, + combinator::map, + multi::many0, + sequence::{preceded, tuple}, + IResult, +}; + +use crate::parser::core::atom; +use crate::types::*; + +// The ENABLED response lists capabilities that were enabled in response +// to a ENABLE command. +// [RFC5161 - 3.2 The ENABLED Response](https://tools.ietf.org/html/rfc5161#section-3.2) +pub(crate) fn resp_enabled(i: &[u8]) -> IResult<&[u8], Response> { + map(enabled_data, Response::Capabilities)(i) +} + +fn enabled_data(i: &[u8]) -> IResult<&[u8], Vec> { + let (i, (_, capabilities)) = tuple(( + tag_no_case("ENABLED"), + many0(preceded(char(' '), capability)), + ))(i)?; + Ok((i, capabilities)) +} + +fn capability(i: &[u8]) -> IResult<&[u8], Capability> { + map(atom, Capability::Atom)(i) +} diff --git a/imap/src/parser/rfc5464.rs b/imap/src/parser/rfc5464.rs new file mode 100644 index 0000000..6b2ba3b --- /dev/null +++ b/imap/src/parser/rfc5464.rs @@ -0,0 +1,295 @@ +//! +//! https://tools.ietf.org/html/rfc5464 +//! +//! IMAP METADATA extension +//! + +use nom::{ + branch::alt, + bytes::streaming::{tag, tag_no_case}, + combinator::{map, map_opt}, + multi::separated_list0, + sequence::tuple, + IResult, +}; + +use crate::{parser::core::*, types::*}; + +fn is_entry_component_char(c: u8) -> bool { + c < 0x80 && c > 0x19 && c != b'*' && c != b'%' && c != b'/' +} + +enum EntryParseStage<'a> { + PrivateShared(usize), + Admin(usize), + VendorComment(usize), + Path(usize), + Done(usize), + Fail(nom::Err<&'a [u8]>), +} + +fn check_private_shared(i: &[u8]) -> EntryParseStage { + if i.starts_with(b"/private") { + EntryParseStage::VendorComment(8) + } else if i.starts_with(b"/shared") { + EntryParseStage::Admin(7) + } else { + EntryParseStage::Fail(nom::Err::Error( + b"Entry Name doesn't start with /private or /shared", + )) + } +} + +fn check_admin(i: &[u8], l: usize) -> EntryParseStage { + if i[l..].starts_with(b"/admin") { + EntryParseStage::Path(l + 6) + } else { + EntryParseStage::VendorComment(l) + } +} + +fn check_vendor_comment(i: &[u8], l: usize) -> EntryParseStage { + if i[l..].starts_with(b"/comment") { + EntryParseStage::Path(l + 8) + } else if i[l..].starts_with(b"/vendor") { + //make sure vendor name is present + if i.len() < l + 9 || i[l + 7] != b'/' || !is_entry_component_char(i[l + 8]) { + EntryParseStage::Fail(nom::Err::Incomplete(nom::Needed::Unknown)) + } else { + EntryParseStage::Path(l + 7) + } + } else { + EntryParseStage::Fail(nom::Err::Error( + b"Entry name is not continued with /admin, /vendor or /comment", + )) + } +} + +fn check_path(i: &[u8], l: usize) -> EntryParseStage { + if i.len() == l || i[l] == b' ' || i[l] == b'\r' { + return EntryParseStage::Done(l); + } else if i[l] != b'/' { + return EntryParseStage::Fail(nom::Err::Error(b"Entry name path is corrupted")); + } + for j in 1..(i.len() - l) { + if !is_entry_component_char(i[l + j]) { + return EntryParseStage::Path(l + j); + } + } + EntryParseStage::Done(i.len()) +} + +fn check_entry_name(i: &[u8]) -> IResult<&[u8], &[u8]> { + let mut stage = EntryParseStage::PrivateShared(0); + loop { + match stage { + EntryParseStage::PrivateShared(_) => { + stage = check_private_shared(i); + } + EntryParseStage::Admin(l) => { + stage = check_admin(i, l); + } + EntryParseStage::VendorComment(l) => { + stage = check_vendor_comment(i, l); + } + EntryParseStage::Path(l) => { + stage = check_path(i, l); + } + EntryParseStage::Done(l) => { + return Ok((&i[l..], &i[..l])); + } + EntryParseStage::Fail(nom::Err::Error(err_msg)) => { + return std::result::Result::Err(nom::Err::Error(nom::error::Error::new( + err_msg, + nom::error::ErrorKind::Verify, + ))); + } + EntryParseStage::Fail(nom::Err::Incomplete(reason)) => { + return std::result::Result::Err(nom::Err::Incomplete(reason)); + } + _ => panic!("Entry name verification failure"), + } + } +} + +fn entry_name(i: &[u8]) -> IResult<&[u8], &[u8]> { + let astring_res = astring(i)?; + check_entry_name(astring_res.1)?; + Ok(astring_res) +} + +fn slice_to_str(i: &[u8]) -> &str { + std::str::from_utf8(i).unwrap() +} + +fn nil_value(i: &[u8]) -> IResult<&[u8], Option> { + map_opt(tag_no_case("NIL"), |_| None)(i) +} + +fn string_value(i: &[u8]) -> IResult<&[u8], Option> { + map(alt((quoted, literal)), |s| { + Some(slice_to_str(s).to_string()) + })(i) +} + +fn keyval_list(i: &[u8]) -> IResult<&[u8], Vec> { + parenthesized_nonempty_list(map( + tuple(( + map(entry_name, slice_to_str), + tag(" "), + alt((nil_value, string_value)), + )), + |(key, _, value)| Metadata { + entry: key.to_string(), + value, + }, + ))(i) +} + +fn entry_list(i: &[u8]) -> IResult<&[u8], Vec<&str>> { + separated_list0(tag(" "), map(entry_name, slice_to_str))(i) +} + +fn metadata_common(i: &[u8]) -> IResult<&[u8], &[u8]> { + let (i, (_, mbox, _)) = tuple((tag_no_case("METADATA "), quoted, tag(" ")))(i)?; + Ok((i, mbox)) +} + +// [RFC5464 - 4.4.1 METADATA Response with values] +pub(crate) fn metadata_solicited(i: &[u8]) -> IResult<&[u8], Response> { + let (i, (mailbox, values)) = tuple((metadata_common, keyval_list))(i)?; + Ok(( + i, + Response::MailboxData(MailboxDatum::MetadataSolicited { + mailbox: slice_to_str(mailbox), + values, + }), + )) +} + +// [RFC5464 - 4.4.2 Unsolicited METADATA Response without values] +pub(crate) fn metadata_unsolicited(i: &[u8]) -> IResult<&[u8], Response> { + let (i, (mailbox, values)) = tuple((metadata_common, entry_list))(i)?; + Ok(( + i, + Response::MailboxData(MailboxDatum::MetadataUnsolicited { + mailbox: slice_to_str(mailbox), + values, + }), + )) +} + +#[cfg(test)] +mod tests { + use super::{metadata_solicited, metadata_unsolicited}; + use crate::types::*; + + #[test] + fn test_solicited_fail_1() { + match metadata_solicited(b"METADATA \"\" (/asdfg \"asdf\")\r\n") { + Err(_) => {} + _ => panic!("Error required when entry name is not starting with /private or /shared"), + } + } + + #[test] + fn test_solicited_fail_2() { + match metadata_solicited(b"METADATA \"\" (/shared/asdfg \"asdf\")\r\n") { + Err(_) => {} + _ => panic!( + "Error required when in entry name /shared \ + is not continued with /admin, /comment or /vendor" + ), + } + } + + #[test] + fn test_solicited_fail_3() { + match metadata_solicited(b"METADATA \"\" (/private/admin \"asdf\")\r\n") { + Err(_) => {} + _ => panic!( + "Error required when in entry name /private \ + is not continued with /comment or /vendor" + ), + } + } + + #[test] + fn test_solicited_fail_4() { + match metadata_solicited(b"METADATA \"\" (/shared/vendor \"asdf\")\r\n") { + Err(_) => {} + _ => panic!("Error required when vendor name is not provided."), + } + } + + #[test] + fn test_solicited_success() { + match metadata_solicited( + b"METADATA \"mbox\" (/shared/vendor/vendorname \"asdf\" \ + /private/comment/a \"bbb\")\r\n", + ) { + Ok((i, Response::MailboxData(MailboxDatum::MetadataSolicited { mailbox, values }))) => { + assert_eq!(mailbox, "mbox"); + assert_eq!(i, b"\r\n"); + assert_eq!(values.len(), 2); + assert_eq!(values[0].entry, "/shared/vendor/vendorname"); + assert_eq!( + values[0] + .value + .as_ref() + .expect("None value is not expected"), + "asdf" + ); + assert_eq!(values[1].entry, "/private/comment/a"); + assert_eq!( + values[1] + .value + .as_ref() + .expect("None value is not expected"), + "bbb" + ); + } + _ => panic!("Correct METADATA response is not parsed properly."), + } + } + + #[test] + fn test_literal_success() { + // match metadata_solicited(b"METADATA \"\" (/shared/vendor/vendor.coi/a \"AAA\")\r\n") + match metadata_solicited(b"METADATA \"\" (/shared/vendor/vendor.coi/a {3}\r\nAAA)\r\n") { + Ok((i, Response::MailboxData(MailboxDatum::MetadataSolicited { mailbox, values }))) => { + assert_eq!(mailbox, ""); + assert_eq!(i, b"\r\n"); + assert_eq!(values.len(), 1); + assert_eq!(values[0].entry, "/shared/vendor/vendor.coi/a"); + assert_eq!( + values[0] + .value + .as_ref() + .expect("None value is not expected"), + "AAA" + ); + } + Err(e) => panic!("ERR: {:?}", e), + _ => panic!("Strange failure"), + } + } + + #[test] + fn test_unsolicited_success() { + match metadata_unsolicited(b"METADATA \"theBox\" /shared/admin/qwe /private/comment/a\r\n") + { + Ok(( + i, + Response::MailboxData(MailboxDatum::MetadataUnsolicited { mailbox, values }), + )) => { + assert_eq!(i, b"\r\n"); + assert_eq!(mailbox, "theBox"); + assert_eq!(values.len(), 2); + assert_eq!(values[0], "/shared/admin/qwe"); + assert_eq!(values[1], "/private/comment/a"); + } + _ => panic!("Correct METADATA response is not parsed properly."), + } + } +} diff --git a/imap/src/parser/rfc7162.rs b/imap/src/parser/rfc7162.rs new file mode 100644 index 0000000..afa7ae6 --- /dev/null +++ b/imap/src/parser/rfc7162.rs @@ -0,0 +1,36 @@ +//! +//! +//! https://tools.ietf.org/html/rfc7162 +//! +//! The IMAP QRESYNC Extensions +//! + +use nom::{ + bytes::streaming::tag_no_case, character::streaming::space1, combinator::opt, sequence::tuple, + IResult, +}; + +use crate::parser::core::sequence_set; +use crate::types::*; + +// The VANISHED response reports that the specified UIDs have been +// permanently removed from the mailbox. This response is similar to +// the EXPUNGE response (RFC3501); however, it can return information +// about multiple messages, and it returns UIDs instead of message +// numbers. +// [RFC7162 - VANISHED RESPONSE](https://tools.ietf.org/html/rfc7162#section-3.2.10) +pub(crate) fn resp_vanished(i: &[u8]) -> IResult<&[u8], Response> { + let (rest, (_, earlier, _, uids)) = tuple(( + tag_no_case("VANISHED"), + opt(tuple((space1, tag_no_case("(EARLIER)")))), + space1, + sequence_set, + ))(i)?; + Ok(( + rest, + Response::Vanished { + earlier: earlier.is_some(), + uids, + }, + )) +} diff --git a/imap/src/parser/tests.rs b/imap/src/parser/tests.rs new file mode 100644 index 0000000..fe66a50 --- /dev/null +++ b/imap/src/parser/tests.rs @@ -0,0 +1,499 @@ +use super::{bodystructure::BodyStructParser, parse_response}; +use crate::types::*; +use std::num::NonZeroUsize; + +#[test] +fn test_mailbox_data_response() { + match parse_response(b"* LIST (\\HasNoChildren) \".\" INBOX.Tests\r\n") { + Ok((_, Response::MailboxData(_))) => {} + rsp => panic!("unexpected response {:?}", rsp), + } +} + +#[test] +fn test_number_overflow() { + match parse_response(b"* 2222222222222222222222222222222222222222222C\r\n") { + Err(_) => {} + _ => panic!("error required for integer overflow"), + } +} + +#[test] +fn test_unseen() { + match parse_response(b"* OK [UNSEEN 3] Message 3 is first unseen\r\n").unwrap() { + ( + _, + Response::Data { + status: Status::Ok, + code: Some(ResponseCode::Unseen(3)), + information: Some("Message 3 is first unseen"), + }, + ) => {} + rsp => panic!("unexpected response {:?}", rsp), + } +} + +#[test] +fn test_body_text() { + match parse_response(b"* 2 FETCH (BODY[TEXT] {3}\r\nfoo)\r\n") { + Ok((_, Response::Fetch(_, attrs))) => { + let body = &attrs[0]; + assert_eq!( + body, + &AttributeValue::BodySection { + section: Some(SectionPath::Full(MessageSection::Text)), + index: None, + data: Some(b"foo"), + }, + "body = {:?}", + body + ); + } + rsp => panic!("unexpected response {:?}", rsp), + } +} + +#[test] +fn test_body_structure() { + const RESPONSE: &[u8] = b"* 15 FETCH (BODYSTRUCTURE (\"TEXT\" \"PLAIN\" (\"CHARSET\" \"iso-8859-1\") NIL NIL \"QUOTED-PRINTABLE\" 1315 42 NIL NIL NIL NIL))\r\n"; + match parse_response(RESPONSE) { + Ok((_, Response::Fetch(_, attrs))) => { + let body = &attrs[0]; + assert!( + matches!(*body, AttributeValue::BodyStructure(_)), + "body = {:?}", + body + ); + } + rsp => panic!("unexpected response {:?}", rsp), + } +} + +#[test] +fn test_status() { + match parse_response(b"* STATUS blurdybloop (MESSAGES 231 UIDNEXT 44292)\r\n") { + Ok((_, Response::MailboxData(MailboxDatum::Status { mailbox, status }))) => { + assert_eq!(mailbox, "blurdybloop"); + assert_eq!( + status, + [ + StatusAttribute::Messages(231), + StatusAttribute::UidNext(44292), + ] + ); + } + rsp => panic!("unexpected response {:?}", rsp), + } +} + +#[test] +fn test_notify() { + match parse_response(b"* 3501 EXPUNGE\r\n") { + Ok((_, Response::Expunge(3501))) => {} + rsp => panic!("unexpected response {:?}", rsp), + } + match parse_response(b"* 3501 EXISTS\r\n") { + Ok((_, Response::MailboxData(MailboxDatum::Exists(3501)))) => {} + rsp => panic!("unexpected response {:?}", rsp), + } + match parse_response(b"+ idling\r\n") { + Ok(( + _, + Response::Continue { + code: None, + information: Some("idling"), + }, + )) => {} + rsp => panic!("unexpected response {:?}", rsp), + } +} + +#[test] +fn test_search() { + // also allow trailing whitespace in SEARCH responses + for empty_response in &["* SEARCH\r\n", "* SEARCH \r\n"] { + match parse_response(empty_response.as_bytes()) { + Ok((_, Response::MailboxData(MailboxDatum::Search(ids)))) => { + assert!(ids.is_empty()); + } + rsp => panic!("unexpected response {:?}", rsp), + } + } + for response in &["* SEARCH 12345 67890\r\n", "* SEARCH 12345 67890 \r\n"] { + match parse_response(response.as_bytes()) { + Ok((_, Response::MailboxData(MailboxDatum::Search(ids)))) => { + assert_eq!(ids[0], 12345); + assert_eq!(ids[1], 67890); + } + rsp => panic!("unexpected response {:?}", rsp), + } + } +} + +#[test] +fn test_uid_fetch() { + match parse_response(b"* 4 FETCH (UID 71372 RFC822.HEADER {10275}\r\n") { + Err(nom::Err::Incomplete(nom::Needed::Size(size))) => { + assert_eq!(size, NonZeroUsize::new(10275).unwrap()); + } + rsp => panic!("unexpected response {:?}", rsp), + } +} + +#[test] +fn test_uid_fetch_extra_space() { + // DavMail inserts an extra space after RFC822.HEADER + match parse_response(b"* 4 FETCH (UID 71372 RFC822.HEADER {10275}\r\n") { + Err(nom::Err::Incomplete(nom::Needed::Size(size))) => { + assert_eq!(size, NonZeroUsize::new(10275).unwrap()); + } + rsp => panic!("unexpected response {:?}", rsp), + } +} + +#[test] +fn test_header_fields() { + const RESPONSE: &[u8] = b"* 1 FETCH (UID 1 BODY[HEADER.FIELDS (CHAT-VERSION)] {21}\r\nChat-Version: 1.0\r\n\r\n)\r\n"; + + match parse_response(RESPONSE) { + Ok((_, Response::Fetch(_, _))) => {} + rsp => panic!("unexpected response {:?}", rsp), + } +} + +#[test] +fn test_response_codes() { + match parse_response(b"* OK [ALERT] Alert!\r\n") { + Ok(( + _, + Response::Data { + status: Status::Ok, + code: Some(ResponseCode::Alert), + information: Some("Alert!"), + }, + )) => {} + rsp => panic!("unexpected response {:?}", rsp), + } + + match parse_response(b"* NO [PARSE] Something\r\n") { + Ok(( + _, + Response::Data { + status: Status::No, + code: Some(ResponseCode::Parse), + information: Some("Something"), + }, + )) => {} + rsp => panic!("unexpected response {:?}", rsp), + } + + match parse_response(b"* OK [CAPABILITY IMAP4rev1 IDLE] Logged in\r\n") { + Ok(( + _, + Response::Data { + status: Status::Ok, + code: Some(ResponseCode::Capabilities(c)), + information: Some("Logged in"), + }, + )) => { + assert_eq!(c.len(), 2); + assert_eq!(c[0], Capability::Imap4rev1); + assert_eq!(c[1], Capability::Atom("IDLE")); + } + rsp => panic!("unexpected response {:?}", rsp), + } + + match parse_response(b"* OK [CAPABILITY UIDPLUS IMAP4rev1 IDLE] Logged in\r\n") { + Ok(( + _, + Response::Data { + status: Status::Ok, + code: Some(ResponseCode::Capabilities(c)), + information: Some("Logged in"), + }, + )) => { + assert_eq!(c.len(), 3); + assert_eq!(c[0], Capability::Atom("UIDPLUS")); + assert_eq!(c[1], Capability::Imap4rev1); + assert_eq!(c[2], Capability::Atom("IDLE")); + } + rsp => panic!("unexpected response {:?}", rsp), + } + + // Missing IMAP4rev1 + match parse_response(b"* OK [CAPABILITY UIDPLUS IDLE] Logged in\r\n") { + Ok(( + _, + Response::Data { + status: Status::Ok, + code: None, + information: Some("[CAPABILITY UIDPLUS IDLE] Logged in"), + }, + )) => {} + rsp => panic!("unexpected response {:?}", rsp), + } + + match parse_response(b"* NO [BADCHARSET] error\r\n") { + Ok(( + _, + Response::Data { + status: Status::No, + code: Some(ResponseCode::BadCharset(None)), + information: Some("error"), + }, + )) => {} + rsp => panic!("unexpected response {:?}", rsp), + } + + match parse_response(b"* NO [BADCHARSET (utf-8 latin1)] error\r\n") { + Ok(( + _, + Response::Data { + status: Status::No, + code: Some(ResponseCode::BadCharset(Some(v))), + information: Some("error"), + }, + )) => { + assert_eq!(v.len(), 2); + assert_eq!(v[0], "utf-8"); + assert_eq!(v[1], "latin1"); + } + rsp => panic!("unexpected response {:?}", rsp), + } + + match parse_response(b"* NO [BADCHARSET ()] error\r\n") { + Ok(( + _, + Response::Data { + status: Status::No, + code: None, + information: Some("[BADCHARSET ()] error"), + }, + )) => {} + rsp => panic!("unexpected response {:?}", rsp), + } +} + +#[test] +fn test_incomplete_fetch() { + match parse_response(b"* 4644 FETCH (UID ") { + Err(nom::Err::Incomplete(_)) => {} + rsp => panic!("should be incomplete: {:?}", rsp), + } +} + +#[test] +fn test_continuation() { + // regular RFC compliant + match parse_response(b"+ \r\n") { + Ok(( + _, + Response::Continue { + code: None, + information: None, + }, + )) => {} + rsp => panic!("unexpected response {:?}", rsp), + } + + // short version, sent by yandex + match parse_response(b"+\r\n") { + Ok(( + _, + Response::Continue { + code: None, + information: None, + }, + )) => {} + rsp => panic!("unexpected response {:?}", rsp), + } +} + +#[test] +fn test_enabled() { + match parse_response(b"* ENABLED QRESYNC X-GOOD-IDEA\r\n") { + Ok((_, capabilities)) => assert_eq!( + capabilities, + Response::Capabilities(vec![ + Capability::Atom("QRESYNC"), + Capability::Atom("X-GOOD-IDEA"), + ]) + ), + rsp => panic!("Unexpected response: {:?}", rsp), + } +} + +#[test] +fn test_flags() { + // Invalid response (FLAGS can't include \*) from Zoho Mail server. + // + // As a workaround, such response is parsed without error. + match parse_response(b"* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\*)\r\n") { + Ok((_, capabilities)) => assert_eq!( + capabilities, + Response::MailboxData(MailboxDatum::Flags(vec![ + "\\Answered", + "\\Flagged", + "\\Deleted", + "\\Seen", + "\\Draft", + "\\*" + ])) + ), + rsp => panic!("Unexpected response: {:?}", rsp), + } +} + +#[test] +fn test_vanished() { + match parse_response(b"* VANISHED (EARLIER) 1,2,3:8\r\n") { + Ok((_, Response::Vanished { earlier, uids })) => { + assert_eq!(earlier, true); + assert_eq!(uids.len(), 3); + let v = &uids[0]; + assert_eq!(*v.start(), 1); + assert_eq!(*v.end(), 1); + let v = &uids[1]; + assert_eq!(*v.start(), 2); + assert_eq!(*v.end(), 2); + let v = &uids[2]; + assert_eq!(*v.start(), 3); + assert_eq!(*v.end(), 8); + } + rsp => panic!("Unexpected response: {:?}", rsp), + } + + match parse_response(b"* VANISHED 1,2,3:8,10\r\n") { + Ok((_, Response::Vanished { earlier, uids })) => { + assert_eq!(earlier, false); + assert_eq!(uids.len(), 4); + } + rsp => panic!("Unexpected response: {:?}", rsp), + } + + match parse_response(b"* VANISHED (EARLIER) 1\r\n") { + Ok((_, Response::Vanished { earlier, uids })) => { + assert_eq!(earlier, true); + assert_eq!(uids.len(), 1); + assert_eq!(uids[0].clone().collect::>(), vec![1]); + } + rsp => panic!("Unexpected response: {:?}", rsp), + } + + match parse_response(b"* VANISHED 1\r\n") { + Ok((_, Response::Vanished { earlier, uids })) => { + assert_eq!(earlier, false); + assert_eq!(uids.len(), 1); + } + rsp => panic!("Unexpected response: {:?}", rsp), + } + + assert!(parse_response(b"* VANISHED \r\n").is_err()); + assert!(parse_response(b"* VANISHED (EARLIER) \r\n").is_err()); +} + +#[test] +fn test_uidplus() { + match dbg!(parse_response( + b"* OK [APPENDUID 38505 3955] APPEND completed\r\n" + )) { + Ok(( + _, + Response::Data { + status: Status::Ok, + code: Some(ResponseCode::AppendUid(38505, uid_set)), + information: Some("APPEND completed"), + }, + )) if uid_set == [3955.into()] => {} + rsp => panic!("Unexpected response: {:?}", rsp), + } + match dbg!(parse_response( + b"* OK [COPYUID 38505 304,319:320 3956:3958] Done\r\n" + )) { + Ok(( + _, + Response::Data { + status: Status::Ok, + code: Some(ResponseCode::CopyUid(38505, uid_set_src, uid_set_dst)), + information: Some("Done"), + }, + )) if uid_set_src == [304.into(), (319..=320).into()] + && uid_set_dst == [(3956..=3958).into()] => {} + rsp => panic!("Unexpected response: {:?}", rsp), + } + match dbg!(parse_response( + b"* NO [UIDNOTSTICKY] Non-persistent UIDs\r\n" + )) { + Ok(( + _, + Response::Data { + status: Status::No, + code: Some(ResponseCode::UidNotSticky), + information: Some("Non-persistent UIDs"), + }, + )) => {} + rsp => panic!("Unexpected response: {:?}", rsp), + } +} + +#[test] +fn test_imap_body_structure() { + let test = b"\ + * 1569 FETCH (\ + BODYSTRUCTURE (\ + (\ + (\ + (\ + \"TEXT\" \"PLAIN\" \ + (\"CHARSET\" \"ISO-8859-1\") NIL NIL \ + \"QUOTED-PRINTABLE\" 833 30 NIL NIL NIL\ + )\ + (\ + \"TEXT\" \"HTML\" \ + (\"CHARSET\" \"ISO-8859-1\") NIL NIL \ + \"QUOTED-PRINTABLE\" 3412 62 NIL \ + (\"INLINE\" NIL) NIL\ + ) \ + \"ALTERNATIVE\" (\"BOUNDARY\" \"2__=fgrths\") NIL NIL\ + )\ + (\ + \"IMAGE\" \"GIF\" \ + (\"NAME\" \"485039.gif\") \"<2__=lgkfjr>\" NIL \ + \"BASE64\" 64 NIL (\"INLINE\" (\"FILENAME\" \"485039.gif\")) \ + NIL\ + ) \ + \"RELATED\" (\"BOUNDARY\" \"1__=fgrths\") NIL NIL\ + )\ + (\ + \"APPLICATION\" \"PDF\" \ + (\"NAME\" \"title.pdf\") \ + \"<1__=lgkfjr>\" NIL \"BASE64\" 333980 NIL \ + (\"ATTACHMENT\" (\"FILENAME\" \"title.pdf\")) NIL\ + ) \ + \"MIXED\" (\"BOUNDARY\" \"0__=fgrths\") NIL NIL\ + )\ + )\r\n"; + + let (_, resp) = parse_response(test).unwrap(); + match resp { + Response::Fetch(_, f) => { + let bodystructure = f + .iter() + .flat_map(|f| match f { + AttributeValue::BodyStructure(e) => Some(e), + _ => None, + }) + .next() + .unwrap(); + + let parser = BodyStructParser::new(bodystructure); + + let element = parser.search(|b: &BodyStructure| { + matches!(b, BodyStructure::Basic { ref common, .. } if common.ty.ty == "APPLICATION") + }); + + assert_eq!(element, Some(vec![2])); + } + _ => panic!("invalid FETCH command test"), + }; +} diff --git a/imap/src/types.rs b/imap/src/types.rs new file mode 100644 index 0000000..b83f55a --- /dev/null +++ b/imap/src/types.rs @@ -0,0 +1,329 @@ +use std::ops::RangeInclusive; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Request<'a>(pub &'a [u8], pub &'a [u8]); + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum AttrMacro { + All, + Fast, + Full, +} + +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Response<'a> { + Capabilities(Vec>), + Continue { + code: Option>, + information: Option<&'a str>, + }, + Done { + tag: RequestId, + status: Status, + code: Option>, + information: Option<&'a str>, + }, + Data { + status: Status, + code: Option>, + information: Option<&'a str>, + }, + Expunge(u32), + Vanished { + earlier: bool, + uids: Vec>, + }, + Fetch(u32, Vec>), + MailboxData(MailboxDatum<'a>), +} + +impl<'a> Response<'a> { + pub fn from_bytes(buf: &'a [u8]) -> crate::ParseResult { + crate::parser::parse_response(buf) + } +} + +#[derive(Debug, Eq, PartialEq)] +pub enum Status { + Ok, + No, + Bad, + PreAuth, + Bye, +} + +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResponseCode<'a> { + Alert, + BadCharset(Option>), + Capabilities(Vec>), + HighestModSeq(u64), // RFC 4551, section 3.1.1 + Parse, + PermanentFlags(Vec<&'a str>), + ReadOnly, + ReadWrite, + TryCreate, + UidNext(u32), + UidValidity(u32), + Unseen(u32), + AppendUid(u32, Vec), + CopyUid(u32, Vec, Vec), + UidNotSticky, +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UidSetMember { + UidRange(RangeInclusive), + Uid(u32), +} +impl From> for UidSetMember { + fn from(x: RangeInclusive) -> Self { + UidSetMember::UidRange(x) + } +} +impl From for UidSetMember { + fn from(x: u32) -> Self { + UidSetMember::Uid(x) + } +} + +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum StatusAttribute { + HighestModSeq(u64), // RFC 4551 + Messages(u32), + Recent(u32), + UidNext(u32), + UidValidity(u32), + Unseen(u32), +} + +#[derive(Debug, Eq, PartialEq, Clone)] +pub struct Metadata { + pub entry: String, + pub value: Option, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum MailboxDatum<'a> { + Exists(u32), + Flags(Vec<&'a str>), + List { + flags: Vec<&'a str>, + delimiter: Option<&'a str>, + name: &'a str, + }, + Search(Vec), + Status { + mailbox: &'a str, + status: Vec, + }, + Recent(u32), + MetadataSolicited { + mailbox: &'a str, + values: Vec, + }, + MetadataUnsolicited { + mailbox: &'a str, + values: Vec<&'a str>, + }, +} + +#[derive(Debug, Eq, PartialEq, Hash)] +pub enum Capability<'a> { + Imap4rev1, + Auth(&'a str), + Atom(&'a str), +} + +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Attribute { + Body, + Envelope, + Flags, + InternalDate, + ModSeq, // RFC 4551, section 3.3.2 + Rfc822, + Rfc822Size, + Rfc822Text, + Uid, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum MessageSection { + Header, + Mime, + Text, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum SectionPath { + Full(MessageSection), + Part(Vec, Option), +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum AttributeValue<'a> { + BodySection { + section: Option, + index: Option, + data: Option<&'a [u8]>, + }, + BodyStructure(BodyStructure<'a>), + Envelope(Box>), + Flags(Vec<&'a str>), + InternalDate(&'a str), + ModSeq(u64), // RFC 4551, section 3.3.2 + Rfc822(Option<&'a [u8]>), + Rfc822Header(Option<&'a [u8]>), + Rfc822Size(u32), + Rfc822Text(Option<&'a [u8]>), + Uid(u32), +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Eq, PartialEq)] +pub enum BodyStructure<'a> { + Basic { + common: BodyContentCommon<'a>, + other: BodyContentSinglePart<'a>, + extension: Option>, + }, + Text { + common: BodyContentCommon<'a>, + other: BodyContentSinglePart<'a>, + lines: u32, + extension: Option>, + }, + Message { + common: BodyContentCommon<'a>, + other: BodyContentSinglePart<'a>, + envelope: Envelope<'a>, + body: Box>, + lines: u32, + extension: Option>, + }, + Multipart { + common: BodyContentCommon<'a>, + bodies: Vec>, + extension: Option>, + }, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct BodyContentCommon<'a> { + pub ty: ContentType<'a>, + pub disposition: Option>, + pub language: Option>, + pub location: Option<&'a str>, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct BodyContentSinglePart<'a> { + pub id: Option<&'a str>, + pub md5: Option<&'a str>, + pub description: Option<&'a str>, + pub transfer_encoding: ContentEncoding<'a>, + pub octets: u32, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct ContentType<'a> { + pub ty: &'a str, + pub subtype: &'a str, + pub params: BodyParams<'a>, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct ContentDisposition<'a> { + pub ty: &'a str, + pub params: BodyParams<'a>, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum ContentEncoding<'a> { + SevenBit, + EightBit, + Binary, + Base64, + QuotedPrintable, + Other(&'a str), +} + +#[derive(Debug, Eq, PartialEq)] +pub enum BodyExtension<'a> { + Num(u32), + Str(Option<&'a str>), + List(Vec>), +} + +pub type BodyParams<'a> = Option>; + +#[derive(Debug, Eq, PartialEq)] +pub struct Envelope<'a> { + pub date: Option<&'a [u8]>, + pub subject: Option<&'a [u8]>, + pub from: Option>>, + pub sender: Option>>, + pub reply_to: Option>>, + pub to: Option>>, + pub cc: Option>>, + pub bcc: Option>>, + pub in_reply_to: Option<&'a [u8]>, + pub message_id: Option<&'a [u8]>, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct Address<'a> { + pub name: Option<&'a [u8]>, + pub adl: Option<&'a [u8]>, + pub mailbox: Option<&'a [u8]>, + pub host: Option<&'a [u8]>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RequestId(pub String); + +impl RequestId { + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum State { + NotAuthenticated, + Authenticated, + Selected, + Logout, +} + +// Body Structure + +pub struct BodyFields<'a> { + pub param: BodyParams<'a>, + pub id: Option<&'a str>, + pub description: Option<&'a str>, + pub transfer_encoding: ContentEncoding<'a>, + pub octets: u32, +} + +pub struct BodyExt1Part<'a> { + pub md5: Option<&'a str>, + pub disposition: Option>, + pub language: Option>, + pub location: Option<&'a str>, + pub extension: Option>, +} + +pub struct BodyExtMPart<'a> { + pub param: BodyParams<'a>, + pub disposition: Option>, + pub language: Option>, + pub location: Option<&'a str>, + pub extension: Option>, +} diff --git a/output.log b/output.log deleted file mode 100644 index b26a2d6..0000000 --- a/output.log +++ /dev/null @@ -1,7 +0,0 @@ -[2021-02-12][01:42:31][panorama][INFO] poggers -[2021-02-12][01:42:36][panorama][INFO] poggers -[2021-02-12][01:56:24][panorama][INFO] poggers -[2021-02-12][01:56:50][panorama][INFO] poggers -[2021-02-12][01:56:50][panorama::panorama][DEBUG] starting all apps... -[2021-02-12][02:04:53][panorama][INFO] poggers -[2021-02-12][02:04:53][panorama::panorama][DEBUG] starting all apps... diff --git a/rust-toolchain b/rust-toolchain new file mode 100644 index 0000000..bf867e0 --- /dev/null +++ b/rust-toolchain @@ -0,0 +1 @@ +nightly diff --git a/src/mail.rs b/src/mail.rs new file mode 100644 index 0000000..b15734d --- /dev/null +++ b/src/mail.rs @@ -0,0 +1,186 @@ +use std::collections::HashMap; +use std::fmt::Display; +use std::sync::Arc; + +use anyhow::Result; +use futures::{ + future::{self, Either, FutureExt}, + pin_mut, select, + sink::{Sink, SinkExt}, + stream::{Stream, StreamExt, TryStream}, +}; +use imap::{ + builders::command::{Command, CommandBuilder}, + parser::parse_response, + types::{Capability, RequestId, Response, ResponseCode, State, Status}, +}; +use tokio::{ + net::TcpStream, + sync::{ + mpsc::{self, Receiver}, + oneshot, + }, +}; +use tokio_rustls::{rustls::ClientConfig, webpki::DNSNameRef, TlsConnector}; +use tokio_util::codec::{Decoder, LinesCodec, LinesCodecError}; + +pub async fn run_mail(server: impl AsRef, port: u16) -> Result<()> { + let server = server.as_ref(); + let client = TcpStream::connect((server, port)).await?; + let codec = LinesCodec::new(); + let mut framed = codec.framed(client); + let mut state = State::NotAuthenticated; + let (sink, stream) = framed.split::(); + + let result = listen_loop(&mut state, sink, stream).await?; + if let LoopExit::NegotiateTls(stream, sink) = result { + debug!("negotiating tls"); + let mut config = ClientConfig::new(); + config + .root_store + .add_server_trust_anchors(&webpki_roots::TLS_SERVER_ROOTS); + let config = TlsConnector::from(Arc::new(config)); + let dnsname = DNSNameRef::try_from_ascii_str(server).unwrap(); + + // reconstruct the original stream + let stream = stream.reunite(sink)?.into_inner(); + // let stream = TcpStream::connect((server, port)).await?; + let stream = config.connect(dnsname, stream).await?; + + let codec = LinesCodec::new(); + let mut framed = codec.framed(stream); + let (sink, stream) = framed.split::(); + + listen_loop(&mut state, sink, stream).await?; + } + + Ok(()) +} + +enum LoopExit { + NegotiateTls(S, S2), + Closed, +} + +async fn listen_loop(st: &mut State, mut sink: S2, mut stream: S) -> Result> +where + S: Stream> + Unpin, + S2: Sink + Unpin, + S2::Error: Display, +{ + let (tx, mut rx) = mpsc::unbounded_channel::<()>(); + let mut cmd_mgr = CommandManager::new(sink); + + loop { + let fut1 = stream.next(); + let fut2 = rx.recv(); + pin_mut!(fut1); + pin_mut!(fut2); + + debug!("waiting for next select"); + match future::select(fut1, fut2).await { + Either::Left((line, _)) => { + let mut line = match line { + Some(v) => v?, + None => break, + }; + line += "\r\n"; + let (_, resp) = match parse_response(line.as_bytes()) { + Ok(v) => v, + Err(e) => bail!(e.to_string()), + }; + debug!("<<< {:?}", resp); + + match st { + State::NotAuthenticated => match resp { + Response::Data { + status: Status::Ok, + code: Some(ResponseCode::Capabilities(caps)), + .. + } => { + let mut has_starttls = false; + for cap in caps { + if let Capability::Atom("STARTTLS") = cap { + has_starttls = true; + } + } + if has_starttls { + let cmd = Command { + args: b"STARTTLS".to_vec(), + next_state: None, + }; + let tx = tx.clone(); + cmd_mgr + .send(cmd, move |_| { + tx.send(()).unwrap(); + }) + .await?; + } + } + Response::Done { tag, code, .. } => { + cmd_mgr.process_done(tag, code)?; + } + _ => {} + }, + _ => {} + } + } + Either::Right((_, _)) => { + debug!("ENCOUNTERED EXIT"); + let sink = cmd_mgr.decompose(); + return Ok(LoopExit::NegotiateTls(stream, sink)); + } + } + } + + Ok(LoopExit::Closed) +} + +struct CommandManager { + tag_idx: usize, + in_flight: HashMap) + Send>>, + sink: S, +} + +impl CommandManager +where + S: Sink + Unpin, +{ + pub fn new(sink: S) -> Self { + CommandManager { + tag_idx: 0, + in_flight: HashMap::new(), + sink, + } + } + + pub fn decompose(self) -> S { + self.sink + } + + pub async fn send( + &mut self, + cmd: Command, + cb: impl Fn(Option) + Send + 'static, + ) -> Result<()> { + let tag_idx = self.tag_idx; + self.tag_idx += 1; + let cb = Box::new(cb); + let tag_str = format!("t{}", tag_idx); + let cmd_str = std::str::from_utf8(&cmd.args)?; + let full_str = format!("{} {}", tag_str, cmd_str); + self.in_flight.insert(tag_str.clone(), cb); + self.sink + .send(full_str) + .await + .map_err(|_| anyhow!("failed to send command")) + } + + pub fn process_done(&mut self, id: RequestId, code: Option) -> Result<()> { + let name = std::str::from_utf8(id.as_bytes())?; + if let Some(cb) = self.in_flight.remove(name) { + cb(code); + } + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index b083063..5fb71f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,51 @@ #[macro_use] +extern crate anyhow; +#[macro_use] extern crate crossterm; +#[macro_use] +extern crate log; +mod mail; mod ui; use anyhow::Result; -use lettre::SmtpClient; +use futures::future::TryFutureExt; use tokio::sync::oneshot; type ExitSender = oneshot::Sender<()>; #[tokio::main] async fn main() -> Result<()> { - SmtpClient::new_simple(""); + setup_logger()?; let (exit_tx, exit_rx) = oneshot::channel::<()>(); + tokio::spawn(mail::run_mail("mzhang.io", 143).unwrap_or_else(report_err)); + let stdout = std::io::stdout(); - tokio::spawn(ui::run_ui(stdout, exit_tx)); + tokio::spawn(ui::run_ui(stdout, exit_tx).unwrap_or_else(report_err)); exit_rx.await?; Ok(()) } + +fn report_err(err: anyhow::Error) { + error!("error: {:?}", err); +} + +fn setup_logger() -> Result<()> { + fern::Dispatch::new() + .format(|out, message, record| { + out.finish(format_args!( + "{}[{}][{}] {}", + chrono::Local::now().format("[%Y-%m-%d][%H:%M:%S]"), + record.target(), + record.level(), + message + )) + }) + .level(log::LevelFilter::Debug) + .chain(fern::log_file("output.log")?) + .apply()?; + Ok(()) +} diff --git a/src/ui.rs b/src/ui.rs index a955ff5..fea0e2b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -12,7 +12,7 @@ use tokio::time; use crate::ExitSender; -const FRAME: Duration = Duration::from_millis(16); +const FRAME: Duration = Duration::from_millis(33); pub async fn run_ui(mut w: impl Write, exit: ExitSender) -> Result<()> { execute!(w, cursor::Hide, terminal::EnterAlternateScreen)?; @@ -22,7 +22,7 @@ pub async fn run_ui(mut w: impl Write, exit: ExitSender) -> Result<()> { execute!(w, cursor::MoveTo(0, 0))?; let now = Local::now(); - println!("shiet {}", now); + println!("time {}", now); // approx 60fps time::sleep(FRAME).await;