diff --git a/Cargo.lock b/Cargo.lock index 4befe6b..e3387ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -297,6 +297,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "chrono-humanize" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8164ae3089baf04ff71f32aeb70213283dcd236dce8bc976d00b17a458f5f71c" +dependencies = [ + "chrono", +] + [[package]] name = "clap" version = "2.33.3" @@ -1396,6 +1405,7 @@ dependencies = [ "async-trait", "cfg-if 1.0.0", "chrono", + "chrono-humanize", "crossterm 0.19.0", "fern", "format-bytes", diff --git a/Cargo.toml b/Cargo.toml index b84102d..eddec5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ toml = "0.5.8" tui = { version = "0.14.0", default-features = false, features = ["crossterm"] } webpki-roots = "0.21.0" xdg = "2.2.0" +chrono-humanize = "0.1.2" [dependencies.panorama-imap] path = "imap" diff --git a/imap/src/client/inner.rs b/imap/src/client/inner.rs index ea4a12d..633d12f 100644 --- a/imap/src/client/inner.rs +++ b/imap/src/client/inner.rs @@ -191,13 +191,9 @@ where { let codec = ImapCodec::default(); let mut framed = FramedRead::new(conn, codec); - // let mut reader = BufReader::new(conn); let mut greeting_tx = Some(greeting_tx); let mut curr_cmd: Option = None; let mut exit_rx = exit_rx.map_err(|_| ()).shared(); - // let mut exit_fut = Some(exit_rx.fuse()); - // let mut fut1 = None; - let cache = String::new(); loop { // let mut next_line = String::new(); @@ -220,12 +216,14 @@ where break; } + // read a command from the command list cmd = cmd_fut => { if curr_cmd.is_none() { curr_cmd = cmd; } } + // got a response from the server connection resp = read_fut => { let resp = match resp { Some(Ok(v)) => v, @@ -234,7 +232,7 @@ where // if this is the very first response, then it's a greeting if let Some(greeting_tx) = greeting_tx.take() { - greeting_tx.send(()); + greeting_tx.send(()).unwrap(); } if let Response::Done(_) = resp { diff --git a/imap/src/client/mod.rs b/imap/src/client/mod.rs index 8e1a5d9..960cc4a 100644 --- a/imap/src/client/mod.rs +++ b/imap/src/client/mod.rs @@ -39,6 +39,10 @@ mod inner; use std::sync::Arc; use anyhow::Result; +use futures::{ + future::{self, FutureExt}, + stream::{Stream, StreamExt}, +}; use tokio::net::TcpStream; use tokio_rustls::{ client::TlsStream, rustls::ClientConfig as RustlsConfig, webpki::DNSNameRef, TlsConnector, @@ -148,6 +152,14 @@ impl ClientAuthenticated { } } + /// Checks if the server that the client is talking to has support for the given capability. + pub async fn has_capability(&mut self, cap: impl AsRef) -> Result { + match self { + ClientAuthenticated::Encrypted(e) => e.has_capability(cap).await, + ClientAuthenticated::Unencrypted(e) => e.has_capability(cap).await, + } + } + /// Runs the LIST command pub async fn list(&mut self) -> Result> { let cmd = Command::List { @@ -201,24 +213,22 @@ impl ClientAuthenticated { } /// Runs the UID FETCH command - pub async fn uid_fetch(&mut self, uids: &[u32]) -> Result> { + pub async fn uid_fetch( + &mut self, + uids: &[u32], + ) -> Result)>> { let cmd = Command::UidFetch { uids: uids.to_vec(), items: FetchItems::All, }; debug!("uid fetch: {}", cmd); let stream = self.execute(cmd).await?; - let (done, data) = stream.wait().await?; - Ok(data - .into_iter() - .filter_map(|resp| match resp { - Response::Fetch(n, attrs) => attrs.into_iter().find_map(|attr| match attr { - AttributeValue::Envelope(envelope) => Some(envelope), - _ => None, - }), - _ => None, - }) - .collect()) + // let (done, data) = stream.wait().await?; + Ok(stream.filter_map(|resp| match resp { + Response::Fetch(n, attrs) => future::ready(Some((n, attrs))).boxed(), + Response::Done(_) => future::ready(None).boxed(), + _ => future::pending().boxed(), + })) } /// Runs the IDLE command diff --git a/imap/src/codec.rs b/imap/src/codec.rs index 21f8201..8a797d0 100644 --- a/imap/src/codec.rs +++ b/imap/src/codec.rs @@ -13,11 +13,13 @@ impl Decoder for ImapCodec { fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { let s = std::str::from_utf8(src)?; + trace!("codec parsing {:?}", s); match parse_streamed_response(s) { Ok((resp, len)) => { src.advance(len); return Ok(Some(resp)); } + // TODO: distinguish between incomplete data and a parse error Err(e) => {} }; diff --git a/imap/src/parser/rfc3501.pest b/imap/src/parser/rfc3501.pest index e10b737..144dbc1 100644 --- a/imap/src/parser/rfc3501.pest +++ b/imap/src/parser/rfc3501.pest @@ -41,8 +41,6 @@ char8 = @{ '\x01'..'\xff' } continue_req = { "+" ~ sp ~ (resp_text | base64) ~ crlf } date_day_fixed = { (sp ~ digit) | digit{2} } date_month = { "Jan" | "Feb" | "Mar" | "Apr" | "May" | "Jun" | "Jul" | "Aug" | "Sep" | "Oct" | "Nov" | "Dec" } -// TODO: date_time is a real date time -// date_time = ${ string } date_time = { dquote_ ~ date_day_fixed ~ "-" ~ date_month ~ "-" ~ date_year ~ sp ~ time ~ sp ~ zone ~ dquote_ } date_year = @{ digit{4} } digit_nz = @{ '\x31'..'\x39' } diff --git a/src/mail/mod.rs b/src/mail/mod.rs index d4176c0..2edbf36 100644 --- a/src/mail/mod.rs +++ b/src/mail/mod.rs @@ -11,7 +11,7 @@ use panorama_imap::{ ClientBuilder, ClientConfig, }, command::Command as ImapCommand, - response::Envelope, + response::{AttributeValue, Envelope}, }; use tokio::{ sync::mpsc::{UnboundedReceiver, UnboundedSender}, @@ -23,6 +23,7 @@ use crate::config::{Config, ConfigWatcher, ImapAuth, MailAccountConfig, TlsMetho /// Command sent to the mail thread by something else (i.e. UI) #[derive(Debug)] +#[non_exhaustive] pub enum MailCommand { /// Refresh the list Refresh, @@ -33,12 +34,19 @@ pub enum MailCommand { /// Possible events returned from the server that should be sent to the UI #[derive(Debug)] +#[non_exhaustive] pub enum MailEvent { /// Got the list of folders FolderList(Vec), /// Got the current list of messages MessageList(Vec), + + /// A list of the UIDs in the current mail view + MessageUids(Vec), + + /// Update the given UID with the given attribute list + UpdateUid(u32, Vec), } /// Main entrypoint for the mail listener. @@ -145,17 +153,33 @@ async fn imap_main(acct: MailAccountConfig, mail2ui_tx: UnboundedSender>(); - let message_list = authed.uid_fetch(&message_uids).await?; - let _ = mail2ui_tx.send(MailEvent::MessageList(message_list)); + let _ = mail2ui_tx.send(MailEvent::MessageUids(message_uids.clone())); - let mut idle_stream = authed.idle().await?; + // TODO: make this happen concurrently with the main loop? + let mut message_list = authed.uid_fetch(&message_uids).await.unwrap(); + while let Some((uid, attrs)) = message_list.next().await { + let evt = MailEvent::UpdateUid(uid, attrs); + debug!("sent {:?}", evt); + mail2ui_tx.send(evt); + } - loop { - let evt = idle_stream.next().await; - debug!("got an event: {:?}", evt); + // check if IDLE is supported + let supports_idle = authed.has_capability("IDLE").await?; + if supports_idle { + let mut idle_stream = authed.idle().await?; - if false { - break; + loop { + let evt = idle_stream.next().await; + debug!("got an event: {:?}", evt); + + if false { + break; + } + } + } else { + loop { + tokio::time::sleep(std::time::Duration::from_secs(20)).await; + debug!("heartbeat"); } } diff --git a/src/ui/mail_tab.rs b/src/ui/mail_tab.rs index 8c36ac9..9f5bfa1 100644 --- a/src/ui/mail_tab.rs +++ b/src/ui/mail_tab.rs @@ -1,3 +1,7 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Duration, Local, Datelike}; +use chrono_humanize::HumanTime; use panorama_imap::response::Envelope; use tui::{ buffer::Buffer, @@ -12,10 +16,32 @@ use super::FrameType; #[derive(Default)] pub struct MailTabState { pub folders: Vec, + pub message_uids: Vec, + pub message_map: HashMap, pub messages: Vec, pub message_list: TableState, } +#[derive(Debug)] +pub struct EmailMetadata { + pub date: DateTime, + pub from: String, + pub subject: String, +} + +fn humanize_timestamp(date: DateTime) -> String { + let now = Local::now(); + let diff = now - date; + + if diff < Duration::days(1) { + HumanTime::from(date).to_string() + }else if date.year() == now.year() { + date.format("%b %e %T").to_string() + } else { + date.to_rfc2822() + } +} + impl MailTabState { pub fn render(&mut self, f: &mut FrameType, area: Rect) { let chunks = Layout::default() @@ -39,16 +65,34 @@ impl MailTabState { // message list table let rows = self - .messages + .message_uids .iter() - .map(|s| Row::new(vec![s.subject.clone().unwrap_or_default()])) + .map(|id| { + let meta = self.message_map.get(id); + Row::new(vec![ + "".to_owned(), + id.to_string(), + meta.map(|m| humanize_timestamp(m.date)).unwrap_or_default(), + "".to_owned(), + meta.map(|m| m.subject.clone()).unwrap_or_default(), + ]) + }) .collect::>(); let table = Table::new(rows) .style(Style::default().fg(Color::White)) - .widths(&[Constraint::Max(5000)]) - .header(Row::new(vec!["Subject"]).style(Style::default().add_modifier(Modifier::BOLD))) - .highlight_style(Style::default().fg(Color::Black).bg(Color::LightBlue)); + .widths(&[ + Constraint::Length(1), + Constraint::Max(3), + Constraint::Min(25), + Constraint::Min(20), + Constraint::Max(5000), + ]) + .header( + Row::new(vec!["", "UID", "Date", "From", "Subject"]) + .style(Style::default().add_modifier(Modifier::BOLD)), + ) + .highlight_style(Style::default().bg(Color::DarkGray)); f.render_widget(dirlist, chunks[0]); f.render_stateful_widget(table, chunks[1], &mut self.message_list); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index eae1582..123680b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,12 +7,14 @@ use std::mem; use std::time::Duration; use anyhow::Result; +use chrono::{Local, TimeZone}; use crossterm::{ cursor, event::{self, Event, KeyCode, KeyEvent}, style, terminal, }; use futures::{future::FutureExt, select, stream::StreamExt}; +use panorama_imap::response::{AttributeValue, Envelope}; use tokio::{sync::mpsc, time}; use tui::{ backend::CrosstermBackend, @@ -25,7 +27,7 @@ use tui::{ use crate::mail::MailEvent; -use self::mail_tab::MailTabState; +use self::mail_tab::{EmailMetadata, MailTabState}; pub(crate) type FrameType<'a, 'b> = Frame<'a, CrosstermBackend<&'b mut Stdout>>; @@ -73,16 +75,8 @@ pub async fn run_ui( // table.update(&event); if let Event::Key(KeyEvent { code, .. }) = event { - let selected = mail_tab.message_list.selected(); - let len = mail_tab.messages.len(); - let seln = selected - .map(|x| if x < len - 1 { x + 1 } else { x }) - .unwrap_or(0); - let selp = selected.map(|x| if x > 0 { x - 1 } else { 0 }).unwrap_or(0); match code { KeyCode::Char('q') => break, - KeyCode::Char('j') => mail_tab.message_list.select(Some(seln)), - KeyCode::Char('k') => mail_tab.message_list.select(Some(selp)), _ => {} } } @@ -105,6 +99,39 @@ pub async fn run_ui( MailEvent::MessageList(new_messages) => { mail_tab.messages = new_messages; } + MailEvent::MessageUids(new_uids) => { + mail_tab.message_uids = new_uids; + } + MailEvent::UpdateUid(_, attrs) => { + let mut uid = None; + let mut date = None; + let mut from = String::new(); + let mut subject = String::new(); + for attr in attrs { + match attr { + AttributeValue::Uid(new_uid) => uid = Some(new_uid), + AttributeValue::InternalDate(new_date) => { + date = Some(new_date.with_timezone(&Local)); + } + AttributeValue::Envelope(Envelope { + subject: new_subject, from: new_from, .. + }) => { + // if let Some(new_from) = new_from { + // from = new_from; + // } + if let Some(new_subject) = new_subject { + subject = new_subject; + } + } + _ => {} + } + } + if let (Some(uid), Some(date)) = (uid, date) { + let meta = EmailMetadata {date ,from, subject }; + mail_tab.message_map.insert(uid, meta); + } + } + _ => {} } }