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:
parent
2ef6a0fd24
commit
3ce0067f69
2 changed files with 79 additions and 5 deletions
18
src/inbox.rs
18
src/inbox.rs
|
|
@ -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> {
|
fn parse_emails(fetches: &[imap::types::Fetch]) -> Vec<Email> {
|
||||||
let mut emails = Vec::new();
|
let mut emails = Vec::new();
|
||||||
for message in fetches {
|
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
|
emails
|
||||||
|
|
|
||||||
66
src/lib.rs
66
src/lib.rs
|
|
@ -44,6 +44,7 @@ pub(crate) struct Email {
|
||||||
pub seq: u32,
|
pub seq: u32,
|
||||||
pub subject: String,
|
pub subject: String,
|
||||||
pub from: String,
|
pub from: String,
|
||||||
|
pub from_addr: String,
|
||||||
pub date: 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_to = String::new();
|
||||||
let mut compose_subject = String::new();
|
let mut compose_subject = String::new();
|
||||||
let mut compose_body = 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 compose_field = ComposeField::To;
|
||||||
let mut send_status: Option<String> = None;
|
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));
|
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)
|
let body_block = Paragraph::new(body_lines)
|
||||||
.block(Block::default()
|
.block(Block::default()
|
||||||
.title(Line::from(Span::styled("Body", body_style)))
|
.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 {
|
} else if search_active {
|
||||||
" / new search | Esc clear | q quit | ↑/↓ navigate | Tab switch pane".to_string()
|
" / new search | Esc clear | q quit | ↑/↓ navigate | Tab switch pane".to_string()
|
||||||
} else {
|
} 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)
|
let status = Paragraph::new(status_text)
|
||||||
.style(Style::default().fg(Color::DarkGray));
|
.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 let Event::Key(key) = event::read()? {
|
||||||
if mode == Mode::Compose {
|
if mode == Mode::Compose {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc => { mode = Mode::Normal; }
|
KeyCode::Esc => {
|
||||||
|
mode = Mode::Normal;
|
||||||
|
compose_reply_quote.clear();
|
||||||
|
}
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
compose_field = match compose_field {
|
compose_field = match compose_field {
|
||||||
ComposeField::To => ComposeField::Subject,
|
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::Enter => { compose_body.push('\n'); }
|
||||||
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
if !compose_to.is_empty() {
|
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 {
|
let _ = cmd_tx.send(WorkerCmd::SendEmail {
|
||||||
to: compose_to.clone(),
|
to: compose_to.clone(),
|
||||||
subject: compose_subject.clone(),
|
subject: compose_subject.clone(),
|
||||||
body: compose_body.clone(),
|
body: full_body,
|
||||||
});
|
});
|
||||||
mode = Mode::Normal;
|
mode = Mode::Normal;
|
||||||
|
compose_reply_quote.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
|
|
@ -655,13 +679,47 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
Focus::Message => Focus::Inbox,
|
Focus::Message => Focus::Inbox,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('u') | KeyCode::F(5) => {
|
||||||
if !loading && !search_active {
|
if !loading && !search_active {
|
||||||
let _ = cmd_tx.send(WorkerCmd::Refresh);
|
let _ = cmd_tx.send(WorkerCmd::Refresh);
|
||||||
loading = true;
|
loading = true;
|
||||||
last_fetch = Instant::now();
|
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 {
|
KeyCode::Down | KeyCode::Char('j') => match focus {
|
||||||
Focus::Inbox => {
|
Focus::Inbox => {
|
||||||
if search_active {
|
if search_active {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue