From 3ce0067f691197b12ea3e70cf23a6e063f12ab40 Mon Sep 17 00:00:00 2001 From: Shautvast Date: Tue, 24 Feb 2026 18:30:54 +0100 Subject: [PATCH] Add reply to email (r key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Email struct gains from_addr (bare address extracted from From: header) - r opens compose pre-filled: To = sender, Subject = Re: ..., cursor in Body - Quoted original shown below a dimmed separator in the Body field; sent as part of the message body when the user hits Ctrl+S - Refresh rebound from r → u / F5 to free r for reply - Status bar updated accordingly Co-Authored-By: Claude Sonnet 4.6 --- src/inbox.rs | 18 +++++++++++++- src/lib.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/inbox.rs b/src/inbox.rs index d0b3041..eca164f 100644 --- a/src/inbox.rs +++ b/src/inbox.rs @@ -91,6 +91,16 @@ fn fetch_range_emails(session: &mut ImapSession, range: &str) -> Result"` and bare `"addr"` formats. +fn extract_addr(s: &str) -> String { + if let (Some(start), Some(end)) = (s.find('<'), s.rfind('>')) { + s[start + 1..end].trim().to_string() + } else { + s.trim().to_string() + } +} + fn parse_emails(fetches: &[imap::types::Fetch]) -> Vec { let mut emails = Vec::new(); for message in fetches { @@ -114,7 +124,13 @@ fn parse_emails(fetches: &[imap::types::Fetch]) -> Vec { } } - emails.push(Email { seq: message.message, subject, from, date }); + emails.push(Email { + seq: message.message, + subject, + from_addr: extract_addr(&from), + from, + date, + }); } } emails diff --git a/src/lib.rs b/src/lib.rs index e3e00aa..97a1423 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,7 @@ pub(crate) struct Email { pub seq: u32, pub subject: String, pub from: String, + pub from_addr: String, pub date: String, } @@ -155,6 +156,7 @@ pub fn main(config: &Config, terminal: &mut Terminal>) let mut compose_to = String::new(); let mut compose_subject = String::new(); let mut compose_body = String::new(); + let mut compose_reply_quote = String::new(); // quoted original for replies let mut compose_field = ComposeField::To; let mut send_status: Option = None; @@ -346,6 +348,19 @@ pub fn main(config: &Config, terminal: &mut Terminal>) last.spans.push(Span::styled("_", body_style)); } } + // Show quoted original below the editable area (dimmed, read-only). + if !compose_reply_quote.is_empty() { + body_lines.push(Line::from(Span::styled( + "─────────────────────────────", + Style::default().fg(Color::DarkGray), + ))); + for line in compose_reply_quote.lines() { + body_lines.push(Line::from(Span::styled( + line.to_string(), + Style::default().fg(Color::DarkGray), + ))); + } + } let body_block = Paragraph::new(body_lines) .block(Block::default() .title(Line::from(Span::styled("Body", body_style))) @@ -553,7 +568,7 @@ pub fn main(config: &Config, terminal: &mut Terminal>) } else if search_active { " / new search | Esc clear | q quit | ↑/↓ navigate | Tab switch pane".to_string() } else { - " c compose | / search | q quit | r refresh | d delete | ↑/↓ navigate | Tab switch pane".to_string() + " c compose | r reply | / search | q quit | u/F5 refresh | d delete | ↑/↓ navigate | Tab switch pane".to_string() }; let status = Paragraph::new(status_text) .style(Style::default().fg(Color::DarkGray)); @@ -565,7 +580,10 @@ pub fn main(config: &Config, terminal: &mut Terminal>) if let Event::Key(key) = event::read()? { if mode == Mode::Compose { match key.code { - KeyCode::Esc => { mode = Mode::Normal; } + KeyCode::Esc => { + mode = Mode::Normal; + compose_reply_quote.clear(); + } KeyCode::Tab => { compose_field = match compose_field { ComposeField::To => ComposeField::Subject, @@ -582,12 +600,18 @@ pub fn main(config: &Config, terminal: &mut Terminal>) KeyCode::Enter => { compose_body.push('\n'); } KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { if !compose_to.is_empty() { + let full_body = if compose_reply_quote.is_empty() { + compose_body.clone() + } else { + format!("{}\n\n{}", compose_body, compose_reply_quote) + }; let _ = cmd_tx.send(WorkerCmd::SendEmail { to: compose_to.clone(), subject: compose_subject.clone(), - body: compose_body.clone(), + body: full_body, }); mode = Mode::Normal; + compose_reply_quote.clear(); } } KeyCode::Backspace => { @@ -655,13 +679,47 @@ pub fn main(config: &Config, terminal: &mut Terminal>) Focus::Message => Focus::Inbox, }; } - KeyCode::Char('r') => { + KeyCode::Char('u') | KeyCode::F(5) => { if !loading && !search_active { let _ = cmd_tx.send(WorkerCmd::Refresh); loading = true; last_fetch = Instant::now(); } } + KeyCode::Char('r') if focus == Focus::Inbox => { + // Reply to the currently selected email. + let email_opt = if search_active { + search_results_state.selected().and_then(|i| search_results.get(i)) + } else { + list_state.selected().and_then(|i| emails.get(i)) + }; + if let Some(email) = email_opt { + compose_to = email.from_addr.clone(); + compose_subject = if email.subject.to_ascii_lowercase().starts_with("re:") { + email.subject.clone() + } else { + format!("Re: {}", email.subject) + }; + compose_body.clear(); + // Pre-fill quoted original if the body is already loaded. + if message_seq == Some(email.seq) && !message_body.is_empty() { + let quoted = message_body + .lines() + .map(|l| format!("> {l}")) + .collect::>() + .join("\n"); + compose_reply_quote = format!( + "On {}, {} wrote:\n{}", + email.date, email.from, quoted + ); + } else { + compose_reply_quote.clear(); + } + compose_field = ComposeField::Body; + send_status = None; + mode = Mode::Compose; + } + } KeyCode::Down | KeyCode::Char('j') => match focus { Focus::Inbox => { if search_active {