diff --git a/src/inbox.rs b/src/inbox.rs index 4fbf12a..7364581 100644 --- a/src/inbox.rs +++ b/src/inbox.rs @@ -44,11 +44,7 @@ pub(crate) fn fetch_older_batch( return Ok((Vec::new(), 1)); } - // Ensure session is alive - if session.as_mut().map_or(true, |s| s.noop().is_err()) { - *session = None; - *session = Some(connect::connect(config)?); - } + ensure_session(session, config)?; let s = session.as_mut().unwrap(); let end = oldest_seq - 1; @@ -131,8 +127,50 @@ fn parse_emails(fetches: &[imap::types::Fetch]) -> Vec { } } - emails.push(Email { subject, from, date }); + emails.push(Email { seq: message.message, subject, from, date }); } } emails } + +fn ensure_session<'a>( + session: &'a mut Option, + config: &Config, +) -> Result<&'a mut ImapSession, String> { + if session.as_mut().map_or(true, |s| s.noop().is_err()) { + *session = None; + *session = Some(connect::connect(config)?); + } + Ok(session.as_mut().unwrap()) +} + +/// Fetch the text body of a single email by sequence number. +pub(crate) fn fetch_body( + session: &mut Option, + seq: u32, + config: &Config, +) -> Result { + let s = ensure_session(session, config)?; + let range = seq.to_string(); + let body = match s { + ImapSession::Plain(s) => { + let messages = s + .fetch(&range, "BODY.PEEK[TEXT]") + .map_err(|e| e.to_string())?; + extract_body(&messages) + } + ImapSession::Tls(s) => { + let messages = s + .fetch(&range, "BODY.PEEK[TEXT]") + .map_err(|e| e.to_string())?; + extract_body(&messages) + } + }; + body.ok_or_else(|| "No body found".to_string()) +} + +fn extract_body(fetches: &[imap::types::Fetch]) -> Option { + fetches.first().and_then(|f| { + f.text().map(|b| String::from_utf8_lossy(b).to_string()) + }) +} diff --git a/src/lib.rs b/src/lib.rs index eec4d88..7bbef62 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ mod inbox; const POLL_INTERVAL: Duration = Duration::from_secs(30); pub(crate) struct Email { + pub seq: u32, pub subject: String, pub from: String, pub date: String, @@ -26,12 +27,14 @@ pub(crate) struct Email { enum WorkerCmd { Refresh, FetchMore { oldest_seq: u32 }, + FetchBody { seq: u32 }, Quit, } enum WorkerResult { Refreshed(Result), FetchedMore(Result<(Vec, u32), String>), + Body { seq: u32, result: Result }, } fn worker_loop( @@ -51,6 +54,10 @@ fn worker_loop( let result = inbox::fetch_older_batch(&mut session, oldest_seq, &config); let _ = result_tx.send(WorkerResult::FetchedMore(result)); } + WorkerCmd::FetchBody { seq } => { + let result = inbox::fetch_body(&mut session, seq, &config); + let _ = result_tx.send(WorkerResult::Body { seq, result }); + } WorkerCmd::Quit => break, } } @@ -79,6 +86,9 @@ pub fn main(config: &Config, terminal: &mut Terminal>) let mut loading = true; let mut list_state = ListState::default(); let mut last_fetch = Instant::now(); + let mut preview_body: String = String::new(); + let mut preview_seq: Option = None; + let mut body_loading = false; // --- Main loop --- loop { @@ -93,8 +103,15 @@ pub fn main(config: &Config, terminal: &mut Terminal>) loading = false; if !emails.is_empty() { list_state.select(Some(0)); + let seq = emails[0].seq; + preview_seq = Some(seq); + preview_body.clear(); + body_loading = true; + let _ = cmd_tx.send(WorkerCmd::FetchBody { seq }); } else { list_state.select(None); + preview_seq = None; + preview_body.clear(); } } WorkerResult::Refreshed(Err(e)) => { @@ -111,6 +128,15 @@ pub fn main(config: &Config, terminal: &mut Terminal>) error = Some(e); loading = false; } + WorkerResult::Body { seq, result } => { + body_loading = false; + if preview_seq == Some(seq) { + match result { + Ok(body) => preview_body = body, + Err(e) => preview_body = format!("Error loading body: {}", e), + } + } + } } } @@ -125,9 +151,14 @@ pub fn main(config: &Config, terminal: &mut Terminal>) let area = frame.area(); let layout = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(3), Constraint::Length(1)]) + .constraints([ + Constraint::Percentage(50), + Constraint::Percentage(50), + Constraint::Length(1), + ]) .split(area); + // --- Top: Inbox list --- if let Some(e) = &error { let p = Paragraph::new(format!("IMAP error: {}", e)) .block(Block::default().title("Inbox").borders(Borders::ALL)) @@ -174,9 +205,21 @@ pub fn main(config: &Config, terminal: &mut Terminal>) frame.render_stateful_widget(list, layout[0], &mut list_state); } + // --- Bottom: Email preview --- + let preview_text = if body_loading { + "Loading...".to_string() + } else { + preview_body.clone() + }; + let preview = Paragraph::new(preview_text) + .block(Block::default().title("Preview").borders(Borders::ALL)) + .style(Style::default().fg(Color::White)) + .wrap(ratatui::widgets::Wrap { trim: false }); + frame.render_widget(preview, layout[1]); + let status = Paragraph::new(" 'q' quit | 'r' refresh | ↑/↓ navigate") .style(Style::default().fg(Color::DarkGray)); - frame.render_widget(status, layout[1]); + frame.render_widget(status, layout[2]); })?; // --- Input handling --- @@ -201,12 +244,29 @@ pub fn main(config: &Config, terminal: &mut Terminal>) loading = true; } } - list_state.select(Some(i.min(len.saturating_sub(1)))); + let new_idx = i.min(len.saturating_sub(1)); + list_state.select(Some(new_idx)); + let seq = emails[new_idx].seq; + if preview_seq != Some(seq) { + preview_seq = Some(seq); + preview_body.clear(); + body_loading = true; + let _ = cmd_tx.send(WorkerCmd::FetchBody { seq }); + } } } KeyCode::Up | KeyCode::Char('k') => { let i = list_state.selected().map_or(0, |i| i.saturating_sub(1)); list_state.select(Some(i)); + if !emails.is_empty() { + let seq = emails[i].seq; + if preview_seq != Some(seq) { + preview_seq = Some(seq); + preview_body.clear(); + body_loading = true; + let _ = cmd_tx.send(WorkerCmd::FetchBody { seq }); + } + } } _ => {} }