Compute the max sender width across the loaded emails (capped at 40 chars) and pad each sender field to that width. Long senders are truncated with an ellipsis. Subject column now starts at a consistent position regardless of sender name length. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
462 lines
20 KiB
Rust
462 lines
20 KiB
Rust
use std::io::{Error, Stdout};
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::sync::{mpsc, Arc};
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
use crossterm::event;
|
|
use crossterm::event::{Event, KeyCode};
|
|
use ratatui::backend::CrosstermBackend;
|
|
use ratatui::layout::{Constraint, Direction, Layout};
|
|
use ratatui::prelude::{Color, Line, Modifier, Span, Style};
|
|
use ratatui::Terminal;
|
|
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
|
use crate::config::Config;
|
|
|
|
pub mod config;
|
|
mod connect;
|
|
mod inbox;
|
|
|
|
const POLL_INTERVAL: Duration = Duration::from_secs(30);
|
|
const NAV_DEBOUNCE: Duration = Duration::from_millis(150);
|
|
|
|
#[derive(PartialEq)]
|
|
enum Focus {
|
|
Inbox,
|
|
Message,
|
|
}
|
|
|
|
pub(crate) struct Email {
|
|
pub seq: u32,
|
|
pub subject: String,
|
|
pub from: String,
|
|
pub date: String,
|
|
}
|
|
|
|
enum WorkerCmd {
|
|
Refresh,
|
|
FetchMore { oldest_seq: u32 },
|
|
FetchBody { seq: u32 },
|
|
Delete { seq: u32 },
|
|
Quit,
|
|
}
|
|
|
|
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(
|
|
config: Config,
|
|
cmd_rx: mpsc::Receiver<WorkerCmd>,
|
|
result_tx: mpsc::Sender<WorkerResult>,
|
|
wanted_body_seq: Arc<AtomicU32>,
|
|
) {
|
|
let mut session: Option<connect::ImapSession> = None;
|
|
|
|
for cmd in &cmd_rx {
|
|
match cmd {
|
|
WorkerCmd::Refresh => {
|
|
let result = inbox::refresh(&mut session, &config);
|
|
let _ = result_tx.send(WorkerResult::Refreshed(result));
|
|
}
|
|
WorkerCmd::FetchMore { oldest_seq } => {
|
|
let result = inbox::fetch_older_batch(&mut session, oldest_seq, &config);
|
|
let _ = result_tx.send(WorkerResult::FetchedMore(result));
|
|
}
|
|
WorkerCmd::FetchBody { seq } => {
|
|
// Skip if a newer body request has been made
|
|
if wanted_body_seq.load(Ordering::Relaxed) != seq {
|
|
continue;
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
|
|
if let Some(mut s) = session.take() {
|
|
let _ = s.logout();
|
|
}
|
|
}
|
|
|
|
pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), Error> {
|
|
let (cmd_tx, cmd_rx) = mpsc::channel();
|
|
let (result_tx, result_rx) = mpsc::channel();
|
|
|
|
let wanted_body_seq = Arc::new(AtomicU32::new(0));
|
|
let worker_config = config.clone();
|
|
let worker_wanted = Arc::clone(&wanted_body_seq);
|
|
let worker = thread::spawn(move || {
|
|
worker_loop(worker_config, cmd_rx, result_tx, worker_wanted);
|
|
});
|
|
|
|
// Send initial refresh
|
|
let _ = cmd_tx.send(WorkerCmd::Refresh);
|
|
|
|
let mut emails: Vec<Email> = Vec::new();
|
|
let mut oldest_seq: Option<u32> = None;
|
|
let mut has_older = false;
|
|
let mut error: Option<String> = None;
|
|
let mut loading = true;
|
|
let mut list_state = ListState::default();
|
|
let mut last_fetch = Instant::now();
|
|
let mut message_body: String = String::new();
|
|
let mut message_rendered: Option<ratatui::text::Text<'static>> = None;
|
|
let mut message_seq: Option<u32> = None;
|
|
let mut body_loading = false;
|
|
let mut focus = Focus::Inbox;
|
|
let mut message_scroll: u16 = 0;
|
|
let mut pending_fetch_seq: Option<u32> = None;
|
|
let mut last_nav = Instant::now();
|
|
|
|
// --- Main loop ---
|
|
loop {
|
|
// Check for worker results (non-blocking)
|
|
while let Ok(result) = result_rx.try_recv() {
|
|
match result {
|
|
WorkerResult::Refreshed(Ok(inbox)) => {
|
|
let prev_selected_seq = list_state.selected()
|
|
.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.
|
|
let refresh_oldest = inbox.emails.last().map(|e| e.seq).unwrap_or(0);
|
|
let mut merged = inbox.emails;
|
|
for e in emails.drain(..) {
|
|
if e.seq < refresh_oldest {
|
|
merged.push(e);
|
|
}
|
|
}
|
|
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;
|
|
loading = false;
|
|
if !emails.is_empty() {
|
|
let new_idx = prev_selected_seq
|
|
.and_then(|seq| emails.iter().position(|e| e.seq == seq))
|
|
.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();
|
|
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 });
|
|
}
|
|
} else {
|
|
list_state.select(None);
|
|
message_seq = None;
|
|
message_body.clear();
|
|
message_rendered = None;
|
|
pending_fetch_seq = None;
|
|
}
|
|
}
|
|
WorkerResult::Refreshed(Err(e)) => {
|
|
error = Some(e);
|
|
loading = false;
|
|
}
|
|
WorkerResult::FetchedMore(Ok((mut new_emails, new_oldest))) => {
|
|
has_older = new_oldest > 1;
|
|
oldest_seq = Some(new_oldest);
|
|
emails.append(&mut new_emails);
|
|
loading = false;
|
|
}
|
|
WorkerResult::FetchedMore(Err(e)) => {
|
|
error = Some(e);
|
|
loading = false;
|
|
}
|
|
WorkerResult::Body { seq, result } => {
|
|
body_loading = false;
|
|
if message_seq == Some(seq) {
|
|
message_scroll = 0;
|
|
message_rendered = None;
|
|
match result {
|
|
Ok(body) => message_body = body,
|
|
Err(e) => message_body = format!("Error loading body: {}", e),
|
|
}
|
|
}
|
|
}
|
|
WorkerResult::Deleted(result) => {
|
|
if let Err(e) = result {
|
|
error = Some(format!("Delete failed: {}", e));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fire pending body fetch after nav debounce elapses
|
|
if let Some(seq) = pending_fetch_seq {
|
|
if last_nav.elapsed() >= NAV_DEBOUNCE {
|
|
message_body.clear();
|
|
message_rendered = None;
|
|
body_loading = true;
|
|
wanted_body_seq.store(seq, Ordering::Relaxed);
|
|
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
|
|
pending_fetch_seq = None;
|
|
}
|
|
}
|
|
|
|
// Periodic refresh
|
|
if !loading && last_fetch.elapsed() >= POLL_INTERVAL {
|
|
let _ = cmd_tx.send(WorkerCmd::Refresh);
|
|
loading = true;
|
|
last_fetch = Instant::now();
|
|
}
|
|
|
|
terminal.draw(|frame| {
|
|
let area = frame.area();
|
|
let layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage(50),
|
|
Constraint::Percentage(50),
|
|
Constraint::Length(1),
|
|
])
|
|
.split(area);
|
|
|
|
// --- Top: Inbox list ---
|
|
let (inbox_title_style, inbox_prefix) = if focus == Focus::Inbox {
|
|
(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "▶ ")
|
|
} else {
|
|
(Style::default().fg(Color::DarkGray), " ")
|
|
};
|
|
let (message_title_style, message_prefix) = if focus == Focus::Message {
|
|
(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "▶ ")
|
|
} else {
|
|
(Style::default().fg(Color::DarkGray), " ")
|
|
};
|
|
let inbox_border = inbox_title_style;
|
|
let message_border = message_title_style;
|
|
|
|
let inbox_title = |label: &str| {
|
|
Line::from(Span::styled(format!("{}{}", inbox_prefix, label), inbox_title_style))
|
|
};
|
|
|
|
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))
|
|
.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))
|
|
.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))
|
|
.style(Style::default().fg(Color::Yellow));
|
|
frame.render_widget(p, layout[0]);
|
|
} else {
|
|
let max_from = emails.iter()
|
|
.map(|e| e.from.chars().count())
|
|
.max()
|
|
.unwrap_or(20)
|
|
.min(40);
|
|
let items: Vec<ListItem> = emails
|
|
.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 title_str = if loading {
|
|
format!("Inbox ({} messages) [loading...]", emails.len())
|
|
} else {
|
|
format!("Inbox ({} messages)", emails.len())
|
|
};
|
|
|
|
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 list_state);
|
|
}
|
|
|
|
// --- Bottom: Message ---
|
|
let message_text = if body_loading {
|
|
ratatui::text::Text::raw("Loading...")
|
|
} else {
|
|
message_rendered
|
|
.get_or_insert_with(|| {
|
|
// Convert to owned Text<'static> so it can be cached across frames
|
|
let t = tui_markdown::from_str(&message_body);
|
|
ratatui::text::Text {
|
|
alignment: t.alignment,
|
|
style: t.style,
|
|
lines: t.lines.into_iter().map(|line| ratatui::text::Line {
|
|
style: line.style,
|
|
alignment: line.alignment,
|
|
spans: line.spans.into_iter().map(|span| ratatui::text::Span {
|
|
style: span.style,
|
|
content: std::borrow::Cow::Owned(span.content.into_owned()),
|
|
}).collect(),
|
|
}).collect(),
|
|
}
|
|
})
|
|
.clone()
|
|
};
|
|
let message_title = Line::from(Span::styled(
|
|
format!("{}Message", message_prefix),
|
|
message_title_style,
|
|
));
|
|
let preview = Paragraph::new(message_text)
|
|
.block(Block::default().title(message_title).borders(Borders::BOTTOM).border_style(message_border))
|
|
.style(Style::default().fg(Color::White))
|
|
.wrap(ratatui::widgets::Wrap { trim: false })
|
|
.scroll((message_scroll, 0));
|
|
frame.render_widget(preview, layout[1]);
|
|
|
|
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]);
|
|
})?;
|
|
|
|
// --- Input handling ---
|
|
if event::poll(Duration::from_millis(200))? {
|
|
if let Event::Key(key) = event::read()? {
|
|
match key.code {
|
|
KeyCode::Char('q') | KeyCode::Esc => break,
|
|
KeyCode::Tab => {
|
|
focus = match focus {
|
|
Focus::Inbox => Focus::Message,
|
|
Focus::Message => Focus::Inbox,
|
|
};
|
|
}
|
|
KeyCode::Char('r') => {
|
|
if !loading {
|
|
let _ = cmd_tx.send(WorkerCmd::Refresh);
|
|
loading = true;
|
|
last_fetch = Instant::now();
|
|
}
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => match focus {
|
|
Focus::Inbox => {
|
|
let len = emails.len();
|
|
if len > 0 {
|
|
let i = list_state.selected().map_or(0, |i| i + 1);
|
|
if i >= len && has_older && !loading {
|
|
if let Some(seq) = oldest_seq {
|
|
let _ = cmd_tx.send(WorkerCmd::FetchMore { oldest_seq: seq });
|
|
loading = true;
|
|
}
|
|
}
|
|
let new_idx = i.min(len.saturating_sub(1));
|
|
list_state.select(Some(new_idx));
|
|
let seq = emails[new_idx].seq;
|
|
if message_seq != Some(seq) {
|
|
message_seq = Some(seq);
|
|
message_scroll = 0;
|
|
pending_fetch_seq = Some(seq);
|
|
last_nav = Instant::now();
|
|
}
|
|
}
|
|
}
|
|
Focus::Message => {
|
|
message_scroll = message_scroll.saturating_add(1);
|
|
}
|
|
}
|
|
KeyCode::Up | KeyCode::Char('k') => match focus {
|
|
Focus::Inbox => {
|
|
let i = list_state.selected().map_or(0, |i| i.saturating_sub(1));
|
|
list_state.select(Some(i));
|
|
if !emails.is_empty() {
|
|
let seq = emails[i].seq;
|
|
if message_seq != Some(seq) {
|
|
message_seq = Some(seq);
|
|
message_scroll = 0;
|
|
pending_fetch_seq = Some(seq);
|
|
last_nav = Instant::now();
|
|
}
|
|
}
|
|
}
|
|
Focus::Message => {
|
|
message_scroll = message_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);
|
|
// IMAP expunge shifts sequence numbers: every message
|
|
// that had seq > deleted seq is renumbered down by 1.
|
|
for e in emails.iter_mut() {
|
|
if e.seq > seq {
|
|
e.seq -= 1;
|
|
}
|
|
}
|
|
if emails.is_empty() {
|
|
list_state.select(None);
|
|
message_seq = None;
|
|
message_body.clear();
|
|
message_rendered = None;
|
|
pending_fetch_seq = None;
|
|
} 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 message_seq != Some(new_seq) {
|
|
message_seq = Some(new_seq);
|
|
message_body.clear();
|
|
message_rendered = None;
|
|
message_scroll = 0;
|
|
body_loading = true;
|
|
pending_fetch_seq = None;
|
|
wanted_body_seq.store(new_seq, Ordering::Relaxed);
|
|
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq: new_seq });
|
|
}
|
|
}
|
|
// Delete on server in background
|
|
let _ = cmd_tx.send(WorkerCmd::Delete { seq });
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up worker
|
|
let _ = cmd_tx.send(WorkerCmd::Quit);
|
|
let _ = worker.join();
|
|
|
|
Ok(())
|
|
}
|