Move IMAP operations to background thread for responsive UI

Spawn a worker thread that owns the IMAP session and processes
fetch commands via channels. The UI thread polls for results
non-blockingly, keeping the app responsive during network operations.
Shows loading indicator while fetching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shautvast 2026-02-17 21:13:42 +01:00
parent 2f23ea7686
commit df9f9f0b2f
2 changed files with 177 additions and 107 deletions

View file

@ -7,19 +7,10 @@ const BATCH_SIZE: u32 = 50;
pub(crate) struct Inbox { pub(crate) struct Inbox {
pub emails: Vec<Email>, pub emails: Vec<Email>,
/// The lowest IMAP sequence number we've fetched so far (1-based). pub oldest_seq: Option<u32>,
/// None means we haven't fetched yet or inbox was empty.
oldest_seq: Option<u32>,
} }
impl Inbox { impl Inbox {
pub fn new() -> Self {
Self {
emails: Vec::new(),
oldest_seq: None,
}
}
pub fn has_older(&self) -> bool { pub fn has_older(&self) -> bool {
self.oldest_seq.map_or(false, |s| s > 1) self.oldest_seq.map_or(false, |s| s > 1)
} }
@ -30,14 +21,12 @@ pub(crate) fn refresh(
session: &mut Option<ImapSession>, session: &mut Option<ImapSession>,
config: &Config, config: &Config,
) -> Result<Inbox, String> { ) -> Result<Inbox, String> {
// If we have a session, try NOOP to keep alive / detect changes
if let Some(s) = session.as_mut() { if let Some(s) = session.as_mut() {
if s.noop().is_ok() { if s.noop().is_ok() {
return fetch_latest(s); return fetch_latest(s);
} }
} }
// Session is dead or missing — reconnect
*session = None; *session = None;
let mut new_session = connect::connect(config)?; let mut new_session = connect::connect(config)?;
let result = fetch_latest(&mut new_session); let result = fetch_latest(&mut new_session);
@ -45,42 +34,39 @@ pub(crate) fn refresh(
result result
} }
/// Fetch older emails and append them to the existing inbox. /// Fetch a batch of older emails. Returns (emails, new_oldest_seq).
pub(crate) fn fetch_more( pub(crate) fn fetch_older_batch(
session: &mut Option<ImapSession>, session: &mut Option<ImapSession>,
inbox: &mut Inbox, oldest_seq: u32,
config: &Config, config: &Config,
) -> Result<(), String> { ) -> Result<(Vec<Email>, u32), String> {
let oldest = match inbox.oldest_seq { if oldest_seq <= 1 {
Some(s) if s > 1 => s, return Ok((Vec::new(), 1));
_ => return Ok(()), }
};
let ensure_session = |session: &mut Option<ImapSession>| -> Result<(), String> { // Ensure session is alive
if session.as_mut().map_or(true, |s| s.noop().is_err()) { if session.as_mut().map_or(true, |s| s.noop().is_err()) {
*session = None; *session = None;
*session = Some(connect::connect(config)?); *session = Some(connect::connect(config)?);
} }
Ok(())
};
ensure_session(session)?;
let s = session.as_mut().unwrap(); 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 start = end.saturating_sub(BATCH_SIZE - 1).max(1);
let range = format!("{}:{}", start, end); let range = format!("{}:{}", start, end);
let mut older = fetch_range_emails(s, &range)?; let mut emails = fetch_range_emails(s, &range)?;
older.reverse(); emails.reverse();
inbox.oldest_seq = Some(start); Ok((emails, start))
inbox.emails.append(&mut older);
Ok(())
} }
fn fetch_latest(session: &mut ImapSession) -> Result<Inbox, String> { fn fetch_latest(session: &mut ImapSession) -> Result<Inbox, String> {
let exists = select_inbox(session)?; let exists = select_inbox(session)?;
if exists == 0 { 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 start = exists.saturating_sub(BATCH_SIZE - 1).max(1);
let range = format!("{}:{}", start, exists); let range = format!("{}:{}", start, exists);

View file

@ -1,4 +1,6 @@
use std::io::{Error, Stdout}; use std::io::{Error, Stdout};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crossterm::event; use crossterm::event;
use crossterm::event::{Event, KeyCode}; use crossterm::event::{Event, KeyCode};
@ -15,27 +17,107 @@ mod inbox;
const POLL_INTERVAL: Duration = Duration::from_secs(30); const POLL_INTERVAL: Duration = Duration::from_secs(30);
struct Email { pub(crate) struct Email {
subject: String, pub subject: String,
from: String, pub from: String,
date: String, pub date: String,
}
enum WorkerCmd {
Refresh,
FetchMore { oldest_seq: u32 },
Quit,
}
enum WorkerResult {
Refreshed(Result<inbox::Inbox, String>),
FetchedMore(Result<(Vec<Email>, u32), String>),
}
fn worker_loop(
config: Config,
cmd_rx: mpsc::Receiver<WorkerCmd>,
result_tx: mpsc::Sender<WorkerResult>,
) {
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<CrosstermBackend<Stdout>>) -> Result<(), Error> { pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), Error> {
let mut session = connect::connect(config).ok(); let (cmd_tx, cmd_rx) = mpsc::channel();
let mut inbox = inbox::refresh(&mut session, config); let (result_tx, result_rx) = mpsc::channel();
let mut last_fetch = Instant::now();
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<Email> = Vec::new();
let mut oldest_seq: Option<u32> = None;
let mut has_older = false;
let mut error: Option<String> = None;
let mut loading = true;
let mut list_state = ListState::default(); let mut list_state = ListState::default();
if let Ok(ib) = &inbox { let mut last_fetch = Instant::now();
if !ib.emails.is_empty() {
list_state.select(Some(0));
}
}
// --- Main loop --- // --- Main loop ---
loop { loop {
if last_fetch.elapsed() >= POLL_INTERVAL { // Check for worker results (non-blocking)
inbox = inbox::refresh(&mut session, config); 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(); last_fetch = Instant::now();
} }
@ -46,19 +128,39 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
.constraints([Constraint::Min(3), Constraint::Length(1)]) .constraints([Constraint::Min(3), Constraint::Length(1)])
.split(area); .split(area);
match &inbox { if let Some(e) = &error {
Ok(ib) if !ib.emails.is_empty() => { let p = Paragraph::new(format!("IMAP error: {}", e))
let items: Vec<ListItem> = ib.emails .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<ListItem> = emails
.iter() .iter()
.map(|e| { .map(|e| {
ListItem::new(format!("{} | {} | {}", e.date, e.from, e.subject)) ListItem::new(format!("{} | {} | {}", e.date, e.from, e.subject))
}) })
.collect(); .collect();
let title = if loading {
format!("Inbox ({} messages) [loading...]", emails.len())
} else {
format!("Inbox ({} messages)", emails.len())
};
let list = List::new(items) let list = List::new(items)
.block( .block(
Block::default() Block::default()
.title(format!("Inbox ({} messages)", ib.emails.len())) .title(title)
.borders(Borders::ALL), .borders(Borders::ALL),
) )
.style(Style::default().fg(Color::White)) .style(Style::default().fg(Color::White))
@ -71,19 +173,6 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
frame.render_stateful_widget(list, layout[0], &mut list_state); 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 status = Paragraph::new(" 'q' quit | 'r' refresh | ↑/↓ navigate") let status = Paragraph::new(" 'q' quit | 'r' refresh | ↑/↓ navigate")
.style(Style::default().fg(Color::DarkGray)); .style(Style::default().fg(Color::DarkGray));
@ -96,43 +185,38 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
match key.code { match key.code {
KeyCode::Char('q') | KeyCode::Esc => break, KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char('r') => { KeyCode::Char('r') => {
inbox = inbox::refresh(&mut session, config); if !loading {
let _ = cmd_tx.send(WorkerCmd::Refresh);
loading = true;
last_fetch = Instant::now(); last_fetch = Instant::now();
if let Ok(ib) = &inbox {
if !ib.emails.is_empty() {
list_state.select(Some(0));
} else {
list_state.select(None);
}
} }
} }
KeyCode::Down | KeyCode::Char('j') => { KeyCode::Down | KeyCode::Char('j') => {
if let Ok(ib) = &mut inbox { let len = emails.len();
let len = ib.emails.len(); if len > 0 {
let i = list_state.selected().map_or(0, |i| i + 1); let i = list_state.selected().map_or(0, |i| i + 1);
if i >= len && ib.has_older() { if i >= len && has_older && !loading {
let _ = inbox::fetch_more(&mut session, ib, config); 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') => { KeyCode::Up | KeyCode::Char('k') => {
if let Ok(_) = &inbox {
let i = list_state.selected().map_or(0, |i| i.saturating_sub(1)); let i = list_state.selected().map_or(0, |i| i.saturating_sub(1));
list_state.select(Some(i)); list_state.select(Some(i));
} }
}
_ => {} _ => {}
} }
} }
} }
} }
// Clean up the session // Clean up worker
if let Some(mut s) = session.take() { let _ = cmd_tx.send(WorkerCmd::Quit);
let _ = s.logout(); let _ = worker.join();
}
Ok(()) Ok(())
} }