diff --git a/src/inbox.rs b/src/inbox.rs index fac08df..d0b3041 100644 --- a/src/inbox.rs +++ b/src/inbox.rs @@ -253,6 +253,45 @@ fn clean_markdown(text: &str) -> String { result.trim().to_string() } +/// Search emails on the server using IMAP SEARCH. Matches subject or sender. +pub(crate) fn search( + session: &mut Option, + query: &str, + config: &Config, +) -> Result, String> { + ensure_session(session, config)?; + + let escaped = query.replace('"', "\\\""); + let search_query = format!("OR SUBJECT \"{}\" FROM \"{}\"", escaped, escaped); + + let seq_set: std::collections::HashSet = { + let s = session.as_mut().unwrap(); + match s { + ImapSession::Plain(inner) => { + inner.select("INBOX").map_err(|e| e.to_string())?; + inner.search(&search_query).map_err(|e| e.to_string())? + } + ImapSession::Tls(inner) => { + inner.select("INBOX").map_err(|e| e.to_string())?; + inner.search(&search_query).map_err(|e| e.to_string())? + } + } + }; + + if seq_set.is_empty() { + return Ok(Vec::new()); + } + + let mut seqs: Vec = seq_set.into_iter().collect(); + seqs.sort_unstable(); + let range = seqs.iter().map(|n| n.to_string()).collect::>().join(","); + + let s = session.as_mut().unwrap(); + let mut emails = fetch_range_emails(s, &range)?; + emails.sort_by(|a, b| b.seq.cmp(&a.seq)); // newest first + Ok(emails) +} + fn find_part(mail: &mailparse::ParsedMail, mime_type: &str) -> Option { let content_type = mail.ctype.mimetype.to_lowercase(); if content_type == mime_type { diff --git a/src/lib.rs b/src/lib.rs index cd2dcb3..96e64b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,12 @@ enum Focus { Message, } +#[derive(PartialEq)] +enum Mode { + Normal, + Search, +} + pub(crate) struct Email { pub seq: u32, pub subject: String, @@ -37,6 +43,7 @@ enum WorkerCmd { FetchMore { oldest_seq: u32 }, FetchBody { seq: u32 }, Delete { seq: u32 }, + Search { query: String }, Quit, } @@ -45,6 +52,7 @@ enum WorkerResult { FetchedMore(Result<(Vec, u32), String>), Body { seq: u32, result: Result }, Deleted(Result<(), String>), + Searched(Result, String>), } fn worker_loop( @@ -77,6 +85,10 @@ fn worker_loop( let result = inbox::delete_email(&mut session, seq, &config); let _ = result_tx.send(WorkerResult::Deleted(result)); } + WorkerCmd::Search { query } => { + let result = inbox::search(&mut session, &query, &config); + let _ = result_tx.send(WorkerResult::Searched(result)); + } WorkerCmd::Quit => break, } } @@ -115,6 +127,13 @@ pub fn main(config: &Config, terminal: &mut Terminal>) let mut message_scroll: u16 = 0; let mut pending_fetch_seq: Option = None; let mut last_nav = Instant::now(); + // Search state + let mut mode = Mode::Normal; + let mut search_query = String::new(); + let mut search_results: Vec = Vec::new(); + let mut search_results_state = ListState::default(); + let mut search_active = false; + let mut search_loading = false; // --- Main loop --- loop { @@ -126,9 +145,7 @@ pub fn main(config: &Config, terminal: &mut Terminal>) .and_then(|i| emails.get(i)) .map(|e| e.seq); - // Preserve emails older than the refresh batch (previously loaded via - // FetchMore). Without this, scrolling into older emails and then - // triggering a refresh would drop those emails and reset selection to 0. + // Preserve emails older than the refresh batch (previously loaded via FetchMore) let refresh_oldest = inbox.emails.last().map(|e| e.seq).unwrap_or(0); let mut merged = inbox.emails; for e in emails.drain(..) { @@ -138,7 +155,6 @@ pub fn main(config: &Config, terminal: &mut Terminal>) } emails = merged; - // Recalculate from the merged list oldest_seq = emails.last().map(|e| e.seq); has_older = oldest_seq.map_or(false, |s| s > 1); error = None; @@ -149,8 +165,6 @@ pub fn main(config: &Config, terminal: &mut Terminal>) .unwrap_or(0); list_state.select(Some(new_idx)); let seq = emails[new_idx].seq; - // Fetch if the selected email changed OR a debounce fetch is pending - // (pending means the body shown may be stale from a previous email) if message_seq != Some(seq) || pending_fetch_seq.is_some() { message_seq = Some(seq); message_body.clear(); @@ -199,6 +213,30 @@ pub fn main(config: &Config, terminal: &mut Terminal>) error = Some(format!("Delete failed: {}", e)); } } + WorkerResult::Searched(Ok(results)) => { + search_results = results; + search_results_state + .select(if search_results.is_empty() { None } else { Some(0) }); + search_loading = false; + search_active = true; + // Fetch body of the first result + if let Some(first) = search_results.first() { + let seq = first.seq; + message_seq = Some(seq); + message_body.clear(); + message_rendered = None; + message_scroll = 0; + body_loading = true; + pending_fetch_seq = None; + wanted_body_seq.store(seq, Ordering::Relaxed); + let _ = cmd_tx.send(WorkerCmd::FetchBody { seq }); + } + } + WorkerResult::Searched(Err(e)) => { + error = Some(format!("Search failed: {}", e)); + search_loading = false; + search_active = false; + } } } @@ -232,7 +270,7 @@ pub fn main(config: &Config, terminal: &mut Terminal>) ]) .split(area); - // --- Top: Inbox list --- + // --- Top: Inbox / Search results --- let (inbox_title_style, inbox_prefix) = if focus == Focus::Inbox { (Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "▶ ") } else { @@ -250,19 +288,81 @@ pub fn main(config: &Config, terminal: &mut Terminal>) Line::from(Span::styled(format!("{}{}", inbox_prefix, label), inbox_title_style)) }; - if let Some(e) = &error { + if search_loading { + let p = Paragraph::new(format!("Searching for \"{}\"...", search_query)) + .block(Block::default() + .title(inbox_title("Search")) + .borders(Borders::BOTTOM) + .border_style(inbox_border)) + .style(Style::default().fg(Color::Yellow)); + frame.render_widget(p, layout[0]); + } else if search_active { + let title_str = format!( + "Search: \"{}\" ({} result{})", + search_query, + search_results.len(), + if search_results.len() == 1 { "" } else { "s" } + ); + if search_results.is_empty() { + let p = Paragraph::new("No results found.") + .block(Block::default() + .title(inbox_title(&title_str)) + .borders(Borders::BOTTOM) + .border_style(inbox_border)) + .style(Style::default().fg(Color::Yellow)); + frame.render_widget(p, layout[0]); + } else { + let max_from = search_results.iter() + .map(|e| e.from.chars().count()) + .max() + .unwrap_or(20) + .min(40); + let items: Vec = search_results + .iter() + .map(|e| { + let from_len = e.from.chars().count(); + let from = if from_len > max_from { + format!("{}…", e.from.chars().take(max_from.saturating_sub(1)).collect::()) + } else { + format!("{:> "); + frame.render_stateful_widget(list, layout[0], &mut search_results_state); + } + } else if let Some(e) = &error { let p = Paragraph::new(format!("IMAP error: {}", e)) - .block(Block::default().title(inbox_title("Inbox")).borders(Borders::BOTTOM).border_style(inbox_border)) + .block(Block::default() + .title(inbox_title("Inbox")) + .borders(Borders::BOTTOM) + .border_style(inbox_border)) .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_title("Inbox")).borders(Borders::BOTTOM).border_style(inbox_border)) + .block(Block::default() + .title(inbox_title("Inbox")) + .borders(Borders::BOTTOM) + .border_style(inbox_border)) .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_title("Inbox")).borders(Borders::BOTTOM).border_style(inbox_border)) + .block(Block::default() + .title(inbox_title("Inbox")) + .borders(Borders::BOTTOM) + .border_style(inbox_border)) .style(Style::default().fg(Color::Yellow)); frame.render_widget(p, layout[0]); } else { @@ -342,7 +442,15 @@ pub fn main(config: &Config, terminal: &mut Terminal>) .scroll((message_scroll, 0)); frame.render_widget(preview, layout[1]); - let status = Paragraph::new(" 'q' quit | 'r' refresh | 'd' delete | ↑/↓ navigate | Tab switch pane") + // Status bar + let status_text = if mode == Mode::Search { + format!(" Search: {}_", search_query) + } else if search_active { + " / new search | Esc clear | q quit | ↑/↓ navigate | Tab switch pane".to_string() + } else { + " / search | q quit | r refresh | d delete | ↑/↓ navigate | Tab switch pane".to_string() + }; + let status = Paragraph::new(status_text) .style(Style::default().fg(Color::DarkGray)); frame.render_widget(status, layout[2]); })?; @@ -350,105 +458,169 @@ pub fn main(config: &Config, terminal: &mut Terminal>) // --- Input handling --- if event::poll(Duration::from_millis(200))? { if let Event::Key(key) = event::read()? { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => break, - KeyCode::Tab => { - focus = match focus { - Focus::Inbox => Focus::Message, - Focus::Message => Focus::Inbox, - }; - } - KeyCode::Char('r') => { - if !loading { - let _ = cmd_tx.send(WorkerCmd::Refresh); - loading = true; - last_fetch = Instant::now(); + if mode == Mode::Search { + match key.code { + KeyCode::Char(c) => search_query.push(c), + KeyCode::Backspace => { search_query.pop(); } + KeyCode::Enter => { + if !search_query.is_empty() { + search_loading = true; + search_active = true; + search_results.clear(); + search_results_state.select(None); + let _ = cmd_tx.send(WorkerCmd::Search { query: search_query.clone() }); + } + mode = Mode::Normal; } + KeyCode::Esc => { + mode = Mode::Normal; + } + _ => {} } - KeyCode::Down | KeyCode::Char('j') => match focus { - Focus::Inbox => { - let len = emails.len(); - if len > 0 { - let i = list_state.selected().map_or(0, |i| i + 1); - 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_idx = i.min(len.saturating_sub(1)); - list_state.select(Some(new_idx)); - let seq = emails[new_idx].seq; - if message_seq != Some(seq) { - message_seq = Some(seq); - message_scroll = 0; - pending_fetch_seq = Some(seq); - last_nav = Instant::now(); - } + } else { + match key.code { + KeyCode::Char('q') => break, + KeyCode::Esc => { + if search_active { + search_active = false; + search_results.clear(); + search_results_state.select(None); + } else { + break; } } - Focus::Message => { - message_scroll = message_scroll.saturating_add(1); + KeyCode::Char('/') => { + search_query.clear(); + mode = Mode::Search; } - } - KeyCode::Up | KeyCode::Char('k') => match focus { - Focus::Inbox => { - 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 message_seq != Some(seq) { - message_seq = Some(seq); - message_scroll = 0; - pending_fetch_seq = Some(seq); - last_nav = Instant::now(); - } + KeyCode::Tab => { + focus = match focus { + Focus::Inbox => Focus::Message, + Focus::Message => Focus::Inbox, + }; + } + KeyCode::Char('r') => { + if !loading && !search_active { + let _ = cmd_tx.send(WorkerCmd::Refresh); + loading = true; + last_fetch = Instant::now(); } } - Focus::Message => { - message_scroll = message_scroll.saturating_sub(1); - } - } - KeyCode::Char('d') if focus == Focus::Inbox => { - if let Some(idx) = list_state.selected() { - if idx < emails.len() { - let seq = emails[idx].seq; - // Remove from UI immediately - emails.remove(idx); - // IMAP expunge shifts sequence numbers: every message - // that had seq > deleted seq is renumbered down by 1. - for e in emails.iter_mut() { - if e.seq > seq { - e.seq -= 1; + KeyCode::Down | KeyCode::Char('j') => match focus { + Focus::Inbox => { + if search_active { + let len = search_results.len(); + if len > 0 { + let i = search_results_state + .selected() + .map_or(0, |i| (i + 1).min(len - 1)); + search_results_state.select(Some(i)); + let seq = search_results[i].seq; + if message_seq != Some(seq) { + message_seq = Some(seq); + message_scroll = 0; + pending_fetch_seq = Some(seq); + last_nav = Instant::now(); + } } - } - if emails.is_empty() { - list_state.select(None); - message_seq = None; - message_body.clear(); - message_rendered = None; - pending_fetch_seq = None; } else { - let new_idx = idx.min(emails.len().saturating_sub(1)); - list_state.select(Some(new_idx)); - let new_seq = emails[new_idx].seq; - if message_seq != Some(new_seq) { - message_seq = Some(new_seq); + let len = emails.len(); + if len > 0 { + let i = list_state.selected().map_or(0, |i| i + 1); + 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_idx = i.min(len.saturating_sub(1)); + list_state.select(Some(new_idx)); + let seq = emails[new_idx].seq; + if message_seq != Some(seq) { + message_seq = Some(seq); + message_scroll = 0; + pending_fetch_seq = Some(seq); + last_nav = Instant::now(); + } + } + } + } + Focus::Message => { + message_scroll = message_scroll.saturating_add(1); + } + } + KeyCode::Up | KeyCode::Char('k') => match focus { + Focus::Inbox => { + if search_active { + let i = search_results_state + .selected() + .map_or(0, |i| i.saturating_sub(1)); + search_results_state.select(Some(i)); + if !search_results.is_empty() { + let seq = search_results[i].seq; + if message_seq != Some(seq) { + message_seq = Some(seq); + message_scroll = 0; + pending_fetch_seq = Some(seq); + last_nav = Instant::now(); + } + } + } else { + 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 message_seq != Some(seq) { + message_seq = Some(seq); + message_scroll = 0; + pending_fetch_seq = Some(seq); + last_nav = Instant::now(); + } + } + } + } + Focus::Message => { + message_scroll = message_scroll.saturating_sub(1); + } + } + KeyCode::Char('d') if focus == Focus::Inbox && !search_active => { + if let Some(idx) = list_state.selected() { + if idx < emails.len() { + let seq = emails[idx].seq; + emails.remove(idx); + // IMAP expunge shifts sequence numbers down by 1 for all seqs > deleted + for e in emails.iter_mut() { + if e.seq > seq { + e.seq -= 1; + } + } + if emails.is_empty() { + list_state.select(None); + message_seq = None; message_body.clear(); message_rendered = None; - message_scroll = 0; - body_loading = true; pending_fetch_seq = None; - wanted_body_seq.store(new_seq, Ordering::Relaxed); - let _ = cmd_tx.send(WorkerCmd::FetchBody { seq: new_seq }); + } else { + let new_idx = idx.min(emails.len().saturating_sub(1)); + list_state.select(Some(new_idx)); + let new_seq = emails[new_idx].seq; + if message_seq != Some(new_seq) { + message_seq = Some(new_seq); + message_body.clear(); + message_rendered = None; + message_scroll = 0; + body_loading = true; + pending_fetch_seq = None; + wanted_body_seq.store(new_seq, Ordering::Relaxed); + let _ = cmd_tx.send(WorkerCmd::FetchBody { seq: new_seq }); + } } + let _ = cmd_tx.send(WorkerCmd::Delete { seq }); } - // Delete on server in background - let _ = cmd_tx.send(WorkerCmd::Delete { seq }); } } + _ => {} } - _ => {} } } } @@ -459,4 +631,4 @@ pub fn main(config: &Config, terminal: &mut Terminal>) let _ = worker.join(); Ok(()) -} +} \ No newline at end of file