use anyhow::Result;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use mecha_core::mail_triage::{
contact_candidates, handle, recipient_token, Bucket, Contact, Record, TriageStore, CLASSIFIED,
FAILED, PARKED,
};
pub struct MailRow {
pub thread_id: String,
pub handle: String,
pub account: String,
pub urgency: String,
pub tags: String,
pub summary: String,
pub from: String,
pub state: String,
pub needs_me: bool,
}
pub struct MailModal {
pub rows: Vec<MailRow>,
pub selected: usize,
pub input: Option<MailInput>,
pub confirm: Option<String>,
pub status: Option<String>,
pub reading: Option<Reader>,
pub loading: Option<String>,
pub help: bool,
}
pub struct Reader {
pub handle: String,
pub lines: Vec<String>,
pub scroll: u16,
}
impl Reader {
pub fn new(handle: String, text: &str) -> Self {
Reader {
handle,
lines: text.lines().map(str::to_string).collect(),
scroll: 0,
}
}
pub fn scroll_by(&mut self, delta: i16) {
let max = self.lines.len().saturating_sub(1) as u16;
self.scroll = self.scroll.saturating_add_signed(delta).min(max);
}
}
pub struct MailInput {
pub label: String,
pub verb: &'static str,
pub buffer: String,
pub cursor: usize,
pub contacts: Vec<Contact>,
pub pick: usize,
}
impl MailInput {
pub fn text(label: &str, verb: &'static str) -> Self {
MailInput {
label: label.into(),
verb,
buffer: String::new(),
cursor: 0,
contacts: Vec::new(),
pick: 0,
}
}
pub fn recipients(label: &str, verb: &'static str, contacts: Vec<Contact>) -> Self {
MailInput {
label: label.into(),
verb,
buffer: String::new(),
cursor: 0,
contacts,
pick: 0,
}
}
pub fn candidates(&self) -> Vec<&Contact> {
if self.contacts.is_empty() {
return Vec::new();
}
let (_, partial) = recipient_token(&self.buffer, self.cursor);
contact_candidates(partial, &self.contacts, 6)
}
pub fn accept(&mut self, address: &str) {
let (start, partial) = recipient_token(&self.buffer, self.cursor);
let end = start
+ self.buffer[start..].len().min(
self.buffer[start..]
.find(',')
.unwrap_or(self.buffer.len() - start),
);
let lead = if start == 0 { "" } else { " " };
let replaced = format!("{lead}{address}, ");
let _ = partial;
self.buffer.replace_range(start..end, &replaced);
self.cursor = start + replaced.len();
self.pick = 0;
}
pub fn insert(&mut self, c: char) {
self.buffer.insert(self.cursor, c);
self.cursor += c.len_utf8();
self.pick = 0;
}
pub fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let prev = self.buffer[..self.cursor]
.chars()
.next_back()
.map(char::len_utf8)
.unwrap_or(1);
self.cursor -= prev;
self.buffer.remove(self.cursor);
self.pick = 0;
}
}
impl MailModal {
pub fn new(rows: Vec<MailRow>) -> Self {
MailModal {
rows,
selected: 0,
input: None,
confirm: None,
status: None,
reading: None,
loading: None,
help: false,
}
}
pub fn move_by(&mut self, delta: isize) {
if self.rows.is_empty() {
return;
}
let n = self.rows.len() as isize;
self.selected = ((self.selected as isize + delta).rem_euclid(n)) as usize;
}
pub fn counts(&self) -> (usize, usize) {
(
self.rows.iter().filter(|r| r.needs_me).count(),
self.rows.iter().filter(|r| r.state == PARKED).count(),
)
}
}
pub fn load() -> Result<Vec<MailRow>> {
let Some(store) = TriageStore::open_existing_default() else {
return Ok(Vec::new());
};
let mut rows: Vec<MailRow> = store.list()?.iter().map(row).collect();
rows.sort_by_key(|r| {
(
!r.needs_me,
match r.urgency.as_str() {
"now" => 0,
"today" => 1,
"week" => 2,
_ => 3,
},
)
});
Ok(rows)
}
fn row(r: &Record) -> MailRow {
let v = r.verdict.as_ref();
MailRow {
thread_id: r.thread_id.clone(),
handle: handle(&r.thread_id),
account: r.account.clone(),
urgency: v
.map(|v| v.urgency.as_str().to_string())
.unwrap_or_default(),
tags: v
.map(|v| {
v.tags
.iter()
.map(|t| format!("#{t}"))
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default(),
summary: v
.map(|v| v.one_line.clone())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| r.subject.clone()),
from: r.from.clone(),
state: r.state.clone(),
needs_me: r.state == CLASSIFIED && v.is_some_and(|v| v.bucket == Bucket::Respond)
|| r.state == FAILED,
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Action {
Now(&'static str),
Confirm(&'static str),
Prompt(&'static str, &'static str),
Detached(&'static str),
Recipients(&'static str),
Close,
}
pub struct Key {
pub key: char,
pub short: &'static str,
pub note: &'static str,
}
pub const KEYS: &[Key] = &[
Key {
key: 'j',
short: "",
note: "move down (↓ too)",
},
Key {
key: 'k',
short: "",
note: "move up (↑ too)",
},
Key {
key: '\n',
short: "enter read",
note: "read the whole thread, in full",
},
Key {
key: 'a',
short: "a archive",
note: "archive it — reversible, and nobody else learns anything",
},
Key {
key: 's',
short: "s spam",
note: "mark spam — asks first: it trains the provider's filter",
},
Key {
key: 't',
short: "t task",
note: "turn it into a task",
},
Key {
key: 'd',
short: "d dismiss",
note: "dismiss it — the mailbox is left alone",
},
Key {
key: 'n',
short: "n needs-info",
note: "park it until someone answers a question",
},
Key {
key: '!',
short: "! wrong",
note: "correct the classifier's bucket",
},
Key {
key: 'r',
short: "r reply",
note: "draft a reply — it lands in /outbox for review",
},
Key {
key: 'f',
short: "f forward",
note: "forward it — lands in /outbox",
},
Key {
key: 'e',
short: "e schedule",
note: "draft a calendar reply — lands in /outbox",
},
Key {
key: '?',
short: "",
note: "this list",
},
Key {
key: 'q',
short: "",
note: "close (esc too)",
},
];
pub fn key_strip() -> String {
KEYS.iter()
.filter(|k| !k.short.is_empty())
.map(|k| k.short)
.collect::<Vec<_>>()
.join(" · ")
}
pub fn action_for(key: char) -> Option<Action> {
Some(match key {
'a' => Action::Now("archive"),
's' => Action::Confirm("spam"),
't' => Action::Now("task"),
'd' => Action::Now("dismiss"),
'n' => Action::Prompt("needs-info", "waiting for"),
'!' => Action::Prompt("correct", "should have been"),
'r' => Action::Detached("reply"),
'f' => Action::Recipients("forward"),
'e' => Action::Detached("schedule"),
'q' => Action::Close,
_ => return None,
})
}
impl MailInput {
pub fn title(&self) -> String {
format!(" {} — enter to confirm, esc to cancel ", self.label)
}
}
impl MailModal {
fn title(&self) -> String {
if let Some(status) = &self.status {
return format!(" mail — {status} ");
}
if let Some(confirm) = &self.confirm {
return format!(" {confirm} ");
}
if let Some(handle) = &self.loading {
return format!(" mail — reading {handle}… ");
}
let (need, parked) = self.counts();
format!(" mail — {need} need you · {parked} parked · ? keys · esc ")
}
fn list_scroll(&self, visible: u16) -> u16 {
let visible = visible.max(1) as usize;
(self.selected + 1).saturating_sub(visible) as u16
}
pub fn draw(&self, frame: &mut Frame) {
if self.help {
self.draw_help(frame);
return;
}
if let Some(reader) = &self.reading {
draw_reader(frame, reader);
return;
}
if let Some(input) = &self.input {
if input.contacts.is_empty() {
super::outbox::draw_reason_input(frame, &input.title(), &input.buffer);
} else {
draw_recipient_input(frame, input);
}
return;
}
let strip = Line::styled(format!(" {}", key_strip()), Style::new().fg(Color::Cyan));
let body: Vec<Line> = if self.rows.is_empty() {
vec![Line::styled(
" nothing classified yet — `mecha mail classify` fills the queue",
Style::new().fg(Color::DarkGray),
)]
} else {
self.rows
.iter()
.enumerate()
.map(|(i, row)| {
let selected = i == self.selected;
let marker = if selected { "›" } else { " " };
let bullet = if row.needs_me { "●" } else { " " };
let text = format!(
"{marker} {bullet} {:<7} {:<18} {:<58} {}",
row.urgency,
truncate(&row.tags, 18),
truncate(&row.summary, 58),
truncate(&row.from, 26),
);
if selected {
Line::styled(text, Style::new().fg(Color::Black).bg(Color::Cyan))
} else if row.state == FAILED {
Line::styled(text, Style::new().fg(Color::Red))
} else if row.state == PARKED {
Line::styled(text, Style::new().fg(Color::DarkGray))
} else if row.needs_me {
Line::styled(text, Style::new().fg(Color::White))
} else {
Line::styled(text, Style::new().fg(Color::DarkGray))
}
})
.collect()
};
let height = super::list_height_reserving(body.len() as u16, frame.area().height, 1);
let area = super::centered(frame.area(), 120, height);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(self.title());
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
return;
}
frame.render_widget(Paragraph::new(strip), Rect { height: 1, ..inner });
let list = Rect {
y: inner.y + 1,
height: inner.height.saturating_sub(1),
..inner
};
frame.render_widget(
Paragraph::new(body).scroll((self.list_scroll(list.height), 0)),
list,
);
}
fn draw_help(&self, frame: &mut Frame) {
let body: Vec<Line> = KEYS
.iter()
.map(|k| {
let key = if k.key == '\n' {
"enter".to_string()
} else {
k.key.to_string()
};
Line::from(vec![
Span::styled(format!(" {key:<8}"), Style::new().fg(Color::Cyan)),
Span::styled(k.note, Style::new().fg(Color::White)),
])
})
.collect();
let area = super::centered(frame.area(), 74, body.len() as u16 + 2);
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(body).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(" mail keys · any key closes "),
),
area,
);
}
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
format!(
"{}…",
s.chars().take(n.saturating_sub(1)).collect::<String>()
)
}
fn draw_reader(frame: &mut Frame, reader: &Reader) {
let grey = Style::new().fg(Color::DarkGray);
let body: Vec<Line> = reader
.lines
.iter()
.map(|line| {
let meta = [
"account:",
"from:",
"subject:",
"date:",
"verdict:",
"tags:",
"reasoning:",
"looks like",
]
.iter()
.any(|p| line.starts_with(p));
if meta {
Line::styled(line.clone(), grey)
} else {
Line::styled(line.clone(), Style::new().fg(Color::White))
}
})
.collect();
let area = super::centered(frame.area(), 100, frame.area().height.saturating_sub(4));
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(body)
.wrap(Wrap { trim: false })
.scroll((reader.scroll, 0))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(format!(
" {} · ↑↓ scroll · r reply · a archive · ? keys · esc back ",
reader.handle
)),
),
area,
);
}
fn draw_recipient_input(frame: &mut Frame, input: &MailInput) {
let candidates = input.candidates();
let mut body = vec![
Line::styled(format!("{}▏", input.buffer), Style::new().fg(Color::White)),
Line::styled("", Style::new()),
];
if candidates.is_empty() {
body.push(Line::styled(
" no match — type a whole address to use one anyway",
Style::new().fg(Color::DarkGray),
));
}
for (i, c) in candidates.iter().enumerate() {
let text = format!(
" {} {:<38} {}",
if i == input.pick { "›" } else { " " },
c.address,
c.name
);
body.push(if i == input.pick {
Line::styled(text, Style::new().fg(Color::Black).bg(Color::Cyan))
} else {
Line::styled(text, Style::new().fg(Color::White))
});
}
let height = body.len() as u16 + 2;
let area = super::centered(frame.area(), 90, height);
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(body).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Yellow))
.title(format!(
" {} — tab completes · ↑↓ picks · comma for another · enter sends to /outbox ",
input.label
)),
),
area,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_legend_and_the_key_map_are_the_same_set() {
for key in KEYS.iter().filter(|k| !k.short.is_empty() && k.key != '\n') {
assert!(
action_for(key.key).is_some(),
"the strip advertises `{}`, which the map does not answer to",
key.key
);
}
for c in ' '..='~' {
if let Some(action) = action_for(c) {
assert!(
KEYS.iter().any(|k| k.key == c),
"`{c}` runs {action:?} and is in no legend",
);
}
}
}
#[test]
fn the_key_strip_names_the_actions_and_fits_a_line() {
let strip = key_strip();
for expected in ["enter read", "a archive", "s spam", "r reply"] {
assert!(strip.contains(expected), "{expected} missing from {strip}");
}
assert!(!strip.contains(" j "), "{strip}");
assert!(strip.chars().count() < 116, "{} wide: {strip}", strip.len());
}
fn row(summary: &str) -> MailRow {
MailRow {
thread_id: "AAQkADFiNjVjOWI1LTlkNGEtNDcxMi00ZDVmLWM3ZWI=".into(),
handle: handle("AAQkADFiNjVjOWI1LTlkNGEtNDcxMi00ZDVmLWM3ZWI="),
account: "dartmouth".into(),
urgency: "week".into(),
tags: "#research".into(),
summary: summary.into(),
from: "someone@example.org".into(),
state: CLASSIFIED.into(),
needs_me: true,
}
}
#[test]
fn the_reader_scrolls_within_its_own_text() {
let mut reader = Reader::new("ubwPPLw=".into(), "one\ntwo\nthree");
reader.scroll_by(-5);
assert_eq!(reader.scroll, 0);
reader.scroll_by(50);
assert_eq!(reader.scroll, 2);
}
#[test]
fn the_list_spends_its_width_on_the_subject_not_the_id() {
let modal = MailModal::new(vec![row("Peer review request for manuscript 2026-25921")]);
let mut buffer = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(0, 0, 130, 20));
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(130, 20)).unwrap();
terminal.draw(|f| modal.draw(f)).unwrap();
buffer.clone_from(terminal.backend().buffer());
let screen: String = buffer
.content()
.chunks(130)
.map(|row| row.iter().map(|c| c.symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
assert!(
!screen.contains(&modal.rows[0].handle),
"the handle is noise in a list you select with a cursor:\n{screen}"
);
assert!(
screen.contains("Peer review request for manuscript 2026-25921"),
"the subject survives the width it freed:\n{screen}"
);
assert!(screen.contains("a archive"), "{screen}");
}
#[test]
fn a_tiny_terminal_shrinks_the_list_rather_than_panicking() {
let modal = MailModal::new(vec![row("a"), row("b"), row("c")]);
for height in 0..=8u16 {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, height.max(1)))
.unwrap();
terminal.draw(|f| modal.draw(f)).unwrap();
}
}
}