Add IMAP search with / keybinding and live results pane

- Press / to enter search mode; status bar shows query input
- IMAP SEARCH OR SUBJECT/FROM sent in background worker thread
- Results replace inbox list with match count in title
- Navigation and body preview work the same as in regular inbox
- Esc clears search and returns to normal inbox view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Shautvast 2026-02-18 17:01:00 +01:00
parent c3a7c62214
commit 7883b35dad
2 changed files with 306 additions and 95 deletions

View file

@ -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<ImapSession>,
query: &str,
config: &Config,
) -> Result<Vec<Email>, String> {
ensure_session(session, config)?;
let escaped = query.replace('"', "\\\"");
let search_query = format!("OR SUBJECT \"{}\" FROM \"{}\"", escaped, escaped);
let seq_set: std::collections::HashSet<u32> = {
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<u32> = seq_set.into_iter().collect();
seqs.sort_unstable();
let range = seqs.iter().map(|n| n.to_string()).collect::<Vec<_>>().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<String> {
let content_type = mail.ctype.mimetype.to_lowercase();
if content_type == mime_type {

View file

@ -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<Email>, u32), String>),
Body { seq: u32, result: Result<String, String> },
Deleted(Result<(), String>),
Searched(Result<Vec<Email>, 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<CrosstermBackend<Stdout>>)
let mut message_scroll: u16 = 0;
let mut pending_fetch_seq: Option<u32> = 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<Email> = 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<CrosstermBackend<Stdout>>)
.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<CrosstermBackend<Stdout>>)
}
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<CrosstermBackend<Stdout>>)
.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<CrosstermBackend<Stdout>>)
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<CrosstermBackend<Stdout>>)
])
.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<CrosstermBackend<Stdout>>)
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<ListItem> = 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::<String>())
} else {
format!("{:<width$}", e.from, width = max_from)
};
ListItem::new(format!("{} | {} | {}", e.date, from, e.subject))
})
.collect();
let list = List::new(items)
.block(Block::default()
.title(inbox_title(&title_str))
.borders(Borders::BOTTOM)
.border_style(inbox_border))
.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 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<CrosstermBackend<Stdout>>)
.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,8 +458,41 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
// --- Input handling ---
if event::poll(Duration::from_millis(200))? {
if let Event::Key(key) = event::read()? {
if mode == Mode::Search {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
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;
}
_ => {}
}
} 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;
}
}
KeyCode::Char('/') => {
search_query.clear();
mode = Mode::Search;
}
KeyCode::Tab => {
focus = match focus {
Focus::Inbox => Focus::Message,
@ -359,7 +500,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
};
}
KeyCode::Char('r') => {
if !loading {
if !loading && !search_active {
let _ = cmd_tx.send(WorkerCmd::Refresh);
loading = true;
last_fetch = Instant::now();
@ -367,6 +508,22 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
}
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();
}
}
} else {
let len = emails.len();
if len > 0 {
let i = list_state.selected().map_or(0, |i| i + 1);
@ -387,12 +544,28 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
}
}
}
}
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() {
@ -405,18 +578,17 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
}
}
}
}
Focus::Message => {
message_scroll = message_scroll.saturating_sub(1);
}
}
KeyCode::Char('d') if focus == Focus::Inbox => {
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;
// Remove from UI immediately
emails.remove(idx);
// IMAP expunge shifts sequence numbers: every message
// that had seq > deleted seq is renumbered down by 1.
// 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;
@ -443,7 +615,6 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq: new_seq });
}
}
// Delete on server in background
let _ = cmd_tx.send(WorkerCmd::Delete { seq });
}
}
@ -453,6 +624,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
}
}
}
}
// Clean up worker
let _ = cmd_tx.send(WorkerCmd::Quit);