Add delete email with optimistic UI update

Press 'd' to delete the selected email. Removes it from the list
immediately and performs the server-side delete in the background
for a snappy user experience.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shautvast 2026-02-17 21:57:10 +01:00
parent e871c1aab8
commit 212fd49534
2 changed files with 62 additions and 1 deletions

View file

@ -170,6 +170,29 @@ pub(crate) fn fetch_body(
extract_plain_text(&raw)
}
/// Delete an email by sequence number (flag as \Deleted + expunge).
pub(crate) fn delete_email(
session: &mut Option<ImapSession>,
seq: u32,
config: &Config,
) -> Result<(), String> {
let s = ensure_session(session, config)?;
let range = seq.to_string();
match s {
ImapSession::Plain(s) => {
s.select("INBOX").map_err(|e| e.to_string())?;
s.store(&range, "+FLAGS (\\Deleted)").map_err(|e| e.to_string())?;
s.expunge().map_err(|e| e.to_string())?;
}
ImapSession::Tls(s) => {
s.select("INBOX").map_err(|e| e.to_string())?;
s.store(&range, "+FLAGS (\\Deleted)").map_err(|e| e.to_string())?;
s.expunge().map_err(|e| e.to_string())?;
}
}
Ok(())
}
fn extract_raw_body(fetches: &[imap::types::Fetch]) -> Option<Vec<u8>> {
fetches.first().and_then(|f| {
f.body().map(|b| b.to_vec())

View file

@ -34,6 +34,7 @@ enum WorkerCmd {
Refresh,
FetchMore { oldest_seq: u32 },
FetchBody { seq: u32 },
Delete { seq: u32 },
Quit,
}
@ -41,6 +42,7 @@ enum WorkerResult {
Refreshed(Result<inbox::Inbox, String>),
FetchedMore(Result<(Vec<Email>, u32), String>),
Body { seq: u32, result: Result<String, String> },
Deleted(Result<(), String>),
}
fn worker_loop(
@ -64,6 +66,10 @@ fn worker_loop(
let result = inbox::fetch_body(&mut session, seq, &config);
let _ = result_tx.send(WorkerResult::Body { seq, result });
}
WorkerCmd::Delete { seq } => {
let result = inbox::delete_email(&mut session, seq, &config);
let _ = result_tx.send(WorkerResult::Deleted(result));
}
WorkerCmd::Quit => break,
}
}
@ -155,6 +161,11 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
}
}
}
WorkerResult::Deleted(result) => {
if let Err(e) = result {
error = Some(format!("Delete failed: {}", e));
}
}
}
}
@ -248,7 +259,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
.scroll((preview_scroll, 0));
frame.render_widget(preview, layout[1]);
let status = Paragraph::new(" 'q' quit | 'r' refresh | ↑/↓ navigate | Tab switch pane")
let status = Paragraph::new(" 'q' quit | 'r' refresh | 'd' delete | ↑/↓ navigate | Tab switch pane")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(status, layout[2]);
})?;
@ -317,6 +328,33 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
preview_scroll = preview_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);
if emails.is_empty() {
list_state.select(None);
preview_seq = None;
preview_body.clear();
} 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 preview_seq != Some(new_seq) {
preview_seq = Some(new_seq);
preview_body.clear();
preview_scroll = 0;
body_loading = true;
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq: new_seq });
}
}
// Delete on server in background
let _ = cmd_tx.send(WorkerCmd::Delete { seq });
}
}
}
_ => {}
}
}