diff --git a/src/inbox.rs b/src/inbox.rs index 43c313c..4fbf12a 100644 --- a/src/inbox.rs +++ b/src/inbox.rs @@ -7,19 +7,10 @@ const BATCH_SIZE: u32 = 50; pub(crate) struct Inbox { pub emails: Vec, - /// The lowest IMAP sequence number we've fetched so far (1-based). - /// None means we haven't fetched yet or inbox was empty. - oldest_seq: Option, + pub oldest_seq: Option, } impl Inbox { - pub fn new() -> Self { - Self { - emails: Vec::new(), - oldest_seq: None, - } - } - pub fn has_older(&self) -> bool { self.oldest_seq.map_or(false, |s| s > 1) } @@ -30,14 +21,12 @@ pub(crate) fn refresh( session: &mut Option, config: &Config, ) -> Result { - // If we have a session, try NOOP to keep alive / detect changes if let Some(s) = session.as_mut() { if s.noop().is_ok() { return fetch_latest(s); } } - // Session is dead or missing — reconnect *session = None; let mut new_session = connect::connect(config)?; let result = fetch_latest(&mut new_session); @@ -45,42 +34,39 @@ pub(crate) fn refresh( result } -/// Fetch older emails and append them to the existing inbox. -pub(crate) fn fetch_more( +/// Fetch a batch of older emails. Returns (emails, new_oldest_seq). +pub(crate) fn fetch_older_batch( session: &mut Option, - inbox: &mut Inbox, + oldest_seq: u32, config: &Config, -) -> Result<(), String> { - let oldest = match inbox.oldest_seq { - Some(s) if s > 1 => s, - _ => return Ok(()), - }; +) -> Result<(Vec, u32), String> { + if oldest_seq <= 1 { + return Ok((Vec::new(), 1)); + } - let ensure_session = |session: &mut Option| -> Result<(), String> { - if session.as_mut().map_or(true, |s| s.noop().is_err()) { - *session = None; - *session = Some(connect::connect(config)?); - } - Ok(()) - }; - ensure_session(session)?; + // Ensure session is alive + if session.as_mut().map_or(true, |s| s.noop().is_err()) { + *session = None; + *session = Some(connect::connect(config)?); + } let s = session.as_mut().unwrap(); - let end = oldest - 1; + let end = oldest_seq - 1; let start = end.saturating_sub(BATCH_SIZE - 1).max(1); let range = format!("{}:{}", start, end); - let mut older = fetch_range_emails(s, &range)?; - older.reverse(); - inbox.oldest_seq = Some(start); - inbox.emails.append(&mut older); - Ok(()) + let mut emails = fetch_range_emails(s, &range)?; + emails.reverse(); + Ok((emails, start)) } fn fetch_latest(session: &mut ImapSession) -> Result { let exists = select_inbox(session)?; if exists == 0 { - return Ok(Inbox::new()); + return Ok(Inbox { + emails: Vec::new(), + oldest_seq: None, + }); } let start = exists.saturating_sub(BATCH_SIZE - 1).max(1); let range = format!("{}:{}", start, exists); @@ -149,4 +135,4 @@ fn parse_emails(fetches: &[imap::types::Fetch]) -> Vec { } } emails -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index 8c28acf..eec4d88 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ use std::io::{Error, Stdout}; +use std::sync::mpsc; +use std::thread; use std::time::{Duration, Instant}; use crossterm::event; use crossterm::event::{Event, KeyCode}; @@ -15,27 +17,107 @@ mod inbox; const POLL_INTERVAL: Duration = Duration::from_secs(30); -struct Email { - subject: String, - from: String, - date: String, +pub(crate) struct Email { + pub subject: String, + pub from: String, + pub date: String, } -pub fn main(config: &Config, terminal: &mut Terminal>) -> Result<(), Error> { - let mut session = connect::connect(config).ok(); - let mut inbox = inbox::refresh(&mut session, config); - let mut last_fetch = Instant::now(); - let mut list_state = ListState::default(); - if let Ok(ib) = &inbox { - if !ib.emails.is_empty() { - list_state.select(Some(0)); +enum WorkerCmd { + Refresh, + FetchMore { oldest_seq: u32 }, + Quit, +} + +enum WorkerResult { + Refreshed(Result), + FetchedMore(Result<(Vec, u32), String>), +} + +fn worker_loop( + config: Config, + cmd_rx: mpsc::Receiver, + result_tx: mpsc::Sender, +) { + let mut session = connect::connect(&config).ok(); + + for cmd in &cmd_rx { + match cmd { + WorkerCmd::Refresh => { + let result = inbox::refresh(&mut session, &config); + let _ = result_tx.send(WorkerResult::Refreshed(result)); + } + WorkerCmd::FetchMore { oldest_seq } => { + let result = inbox::fetch_older_batch(&mut session, oldest_seq, &config); + let _ = result_tx.send(WorkerResult::FetchedMore(result)); + } + WorkerCmd::Quit => break, } } + if let Some(mut s) = session.take() { + let _ = s.logout(); + } +} + +pub fn main(config: &Config, terminal: &mut Terminal>) -> Result<(), Error> { + let (cmd_tx, cmd_rx) = mpsc::channel(); + let (result_tx, result_rx) = mpsc::channel(); + + let worker_config = config.clone(); + let worker = thread::spawn(move || { + worker_loop(worker_config, cmd_rx, result_tx); + }); + + // Send initial refresh + let _ = cmd_tx.send(WorkerCmd::Refresh); + + let mut emails: Vec = Vec::new(); + let mut oldest_seq: Option = None; + let mut has_older = false; + let mut error: Option = None; + let mut loading = true; + let mut list_state = ListState::default(); + let mut last_fetch = Instant::now(); + // --- Main loop --- loop { - if last_fetch.elapsed() >= POLL_INTERVAL { - inbox = inbox::refresh(&mut session, config); + // Check for worker results (non-blocking) + while let Ok(result) = result_rx.try_recv() { + match result { + WorkerResult::Refreshed(Ok(inbox)) => { + has_older = inbox.has_older(); + oldest_seq = inbox.oldest_seq; + emails = inbox.emails; + error = None; + loading = false; + if !emails.is_empty() { + list_state.select(Some(0)); + } else { + list_state.select(None); + } + } + WorkerResult::Refreshed(Err(e)) => { + error = Some(e); + loading = false; + } + WorkerResult::FetchedMore(Ok((mut new_emails, new_oldest))) => { + has_older = new_oldest > 1; + oldest_seq = Some(new_oldest); + emails.append(&mut new_emails); + loading = false; + } + WorkerResult::FetchedMore(Err(e)) => { + error = Some(e); + loading = false; + } + } + } + + // Periodic refresh + if !loading && last_fetch.elapsed() >= POLL_INTERVAL { + let _ = cmd_tx.send(WorkerCmd::Refresh); + loading = true; last_fetch = Instant::now(); } @@ -46,43 +128,50 @@ pub fn main(config: &Config, terminal: &mut Terminal>) .constraints([Constraint::Min(3), Constraint::Length(1)]) .split(area); - match &inbox { - Ok(ib) if !ib.emails.is_empty() => { - let items: Vec = ib.emails - .iter() - .map(|e| { - ListItem::new(format!("{} | {} | {}", e.date, e.from, e.subject)) - }) - .collect(); + if let Some(e) = &error { + let p = Paragraph::new(format!("IMAP error: {}", e)) + .block(Block::default().title("Inbox").borders(Borders::ALL)) + .style(Style::default().fg(Color::Red)); + frame.render_widget(p, layout[0]); + } else if emails.is_empty() && loading { + let p = Paragraph::new("Loading...") + .block(Block::default().title("Inbox").borders(Borders::ALL)) + .style(Style::default().fg(Color::Yellow)); + frame.render_widget(p, layout[0]); + } else if emails.is_empty() { + let p = Paragraph::new("No messages in inbox.") + .block(Block::default().title("Inbox").borders(Borders::ALL)) + .style(Style::default().fg(Color::Yellow)); + frame.render_widget(p, layout[0]); + } else { + let items: Vec = emails + .iter() + .map(|e| { + ListItem::new(format!("{} | {} | {}", e.date, e.from, e.subject)) + }) + .collect(); - let list = List::new(items) - .block( - Block::default() - .title(format!("Inbox ({} messages)", ib.emails.len())) - .borders(Borders::ALL), - ) - .style(Style::default().fg(Color::White)) - .highlight_style( - Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(">> "); + let title = if loading { + format!("Inbox ({} messages) [loading...]", emails.len()) + } else { + format!("Inbox ({} messages)", emails.len()) + }; - frame.render_stateful_widget(list, layout[0], &mut list_state); - } - Ok(_) => { - let p = Paragraph::new("No messages in inbox.") - .block(Block::default().title("Inbox").borders(Borders::ALL)) - .style(Style::default().fg(Color::Yellow)); - frame.render_widget(p, layout[0]); - } - Err(e) => { - let p = Paragraph::new(format!("IMAP error: {}", e)) - .block(Block::default().title("Inbox").borders(Borders::ALL)) - .style(Style::default().fg(Color::Red)); - frame.render_widget(p, layout[0]); - } + let list = List::new(items) + .block( + Block::default() + .title(title) + .borders(Borders::ALL), + ) + .style(Style::default().fg(Color::White)) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + + frame.render_stateful_widget(list, layout[0], &mut list_state); } let status = Paragraph::new(" 'q' quit | 'r' refresh | ↑/↓ navigate") @@ -96,32 +185,28 @@ pub fn main(config: &Config, terminal: &mut Terminal>) match key.code { KeyCode::Char('q') | KeyCode::Esc => break, KeyCode::Char('r') => { - inbox = inbox::refresh(&mut session, config); - last_fetch = Instant::now(); - if let Ok(ib) = &inbox { - if !ib.emails.is_empty() { - list_state.select(Some(0)); - } else { - list_state.select(None); - } + if !loading { + let _ = cmd_tx.send(WorkerCmd::Refresh); + loading = true; + last_fetch = Instant::now(); } } KeyCode::Down | KeyCode::Char('j') => { - if let Ok(ib) = &mut inbox { - let len = ib.emails.len(); + let len = emails.len(); + if len > 0 { let i = list_state.selected().map_or(0, |i| i + 1); - if i >= len && ib.has_older() { - let _ = inbox::fetch_more(&mut session, ib, config); + if i >= len && has_older && !loading { + if let Some(seq) = oldest_seq { + let _ = cmd_tx.send(WorkerCmd::FetchMore { oldest_seq: seq }); + loading = true; + } } - let new_len = ib.emails.len(); - list_state.select(Some(i.min(new_len.saturating_sub(1)))); + list_state.select(Some(i.min(len.saturating_sub(1)))); } } KeyCode::Up | KeyCode::Char('k') => { - if let Ok(_) = &inbox { - let i = list_state.selected().map_or(0, |i| i.saturating_sub(1)); - list_state.select(Some(i)); - } + let i = list_state.selected().map_or(0, |i| i.saturating_sub(1)); + list_state.select(Some(i)); } _ => {} } @@ -129,10 +214,9 @@ pub fn main(config: &Config, terminal: &mut Terminal>) } } - // Clean up the session - if let Some(mut s) = session.take() { - let _ = s.logout(); - } + // Clean up worker + let _ = cmd_tx.send(WorkerCmd::Quit); + let _ = worker.join(); Ok(()) }