Add reply to email (r key)

- 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 <noreply@anthropic.com>
This commit is contained in:
Shautvast 2026-02-24 18:30:54 +01:00
parent 2ef6a0fd24
commit 3ce0067f69
2 changed files with 79 additions and 5 deletions

View file

@ -91,6 +91,16 @@ fn fetch_range_emails(session: &mut ImapSession, range: &str) -> Result<Vec<Emai
}
}
/// Extract the bare email address from a `From:` header value.
/// Handles both `"Name <addr>"` 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<Email> {
let mut emails = Vec::new();
for message in fetches {
@ -114,7 +124,13 @@ fn parse_emails(fetches: &[imap::types::Fetch]) -> Vec<Email> {
}
}
emails.push(Email { seq: message.message, subject, from, date });
emails.push(Email {
seq: message.message,
subject,
from_addr: extract_addr(&from),
from,
date,
});
}
}
emails

View file

@ -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<CrosstermBackend<Stdout>>)
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<String> = None;
@ -346,6 +348,19 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
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<CrosstermBackend<Stdout>>)
} 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<CrosstermBackend<Stdout>>)
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<CrosstermBackend<Stdout>>)
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<CrosstermBackend<Stdout>>)
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::<Vec<_>>()
.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 {