use std::io;
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
Frame, Terminal,
};
use crate::config::Config;
use crate::store::{Folder, MailStore, Message};
use crate::transport;
const FOLDERS: [Folder; 4] = [Folder::Inbox, Folder::Sent, Folder::Starred, Folder::Trash];
#[derive(Debug, Clone, Copy, PartialEq)]
enum Focus {
Folders,
Messages,
Reading,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ComposeField {
To,
Body,
}
#[derive(Debug, Clone)]
enum Mode {
Normal,
Compose {
to: String,
body: String,
field: ComposeField,
},
}
enum ComposeAction {
None,
Abort,
Send { to: String, body: String },
}
struct AppState<'a> {
cfg: &'a Config,
store: &'a MailStore,
folder_idx: usize,
msg_list_state: ListState,
messages: Vec<(usize, Message)>, reading: Option<Message>,
status: String,
show_contacts: bool,
focus: Focus,
mode: Mode,
}
impl<'a> AppState<'a> {
fn new(cfg: &'a Config, store: &'a MailStore) -> Self {
let mut s = Self {
cfg,
store,
folder_idx: 0,
msg_list_state: ListState::default(),
messages: vec![],
reading: None,
status: format!(" mailrs • {}", cfg.identity),
show_contacts: false,
focus: Focus::Messages,
mode: Mode::Normal,
};
s.reload_messages();
s
}
fn current_folder(&self) -> &Folder {
&FOLDERS[self.folder_idx]
}
fn reload_messages(&mut self) {
self.messages = self.store.messages_in(self.current_folder());
self.messages.reverse(); if self.messages.is_empty() {
self.msg_list_state.select(None);
} else {
let sel = self.msg_list_state.selected().unwrap_or(0);
self.msg_list_state.select(Some(sel.min(self.messages.len() - 1)));
}
self.reading = self.selected_message();
}
fn selected_message(&self) -> Option<Message> {
let i = self.msg_list_state.selected()?;
self.messages.get(i).map(|(_, m)| m.clone())
}
fn selected_store_index(&self) -> Option<usize> {
let i = self.msg_list_state.selected()?;
self.messages.get(i).map(|(idx, _)| *idx)
}
fn next_msg(&mut self) {
if self.messages.is_empty() { return; }
let i = self.msg_list_state.selected().unwrap_or(0);
let next = (i + 1).min(self.messages.len() - 1);
self.msg_list_state.select(Some(next));
self.open_selected();
}
fn prev_msg(&mut self) {
if self.messages.is_empty() { return; }
let i = self.msg_list_state.selected().unwrap_or(0);
let prev = i.saturating_sub(1);
self.msg_list_state.select(Some(prev));
self.open_selected();
}
fn open_selected(&mut self) {
if let Some(idx) = self.selected_store_index() {
self.store.mark_read(idx).ok();
}
self.reading = self.selected_message();
if let Some(ref mut m) = self.reading {
m.read = true;
}
}
fn next_folder(&mut self) {
self.folder_idx = (self.folder_idx + 1) % FOLDERS.len();
self.msg_list_state.select(None);
self.reading = None;
self.reload_messages();
}
fn prev_folder(&mut self) {
self.folder_idx = (self.folder_idx + FOLDERS.len() - 1) % FOLDERS.len();
self.msg_list_state.select(None);
self.reading = None;
self.reload_messages();
}
}
pub async fn run(cfg: &Config, store: &MailStore) -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_loop(cfg, store, &mut terminal).await;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
result
}
async fn run_loop<B: ratatui::backend::Backend + io::Write>(
cfg: &Config,
store: &MailStore,
terminal: &mut Terminal<B>,
) -> Result<()> {
let mut state = AppState::new(cfg, store);
let kb = &cfg.keybinds;
const AUTO_SYNC_INTERVAL: Duration = Duration::from_secs(10);
let mut last_sync = Instant::now();
loop {
terminal.draw(|f| draw(f, &mut state))?;
if matches!(state.mode, Mode::Normal) && last_sync.elapsed() >= AUTO_SYNC_INTERVAL {
match transport::fetch_imap(cfg, store).await {
Ok(_) => {
state.reload_messages();
state.status = format!(" Auto-synced · {} unread", store.unread_count());
}
Err(e) => state.status = format!(" Auto-sync error: {}", e),
}
last_sync = Instant::now();
}
if !event::poll(Duration::from_millis(200))? {
continue;
}
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
let in_compose = matches!(state.mode, Mode::Compose { .. });
if let Mode::Compose { to, body, field } = &mut state.mode {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let action = match key.code {
KeyCode::Char('s') if ctrl => ComposeAction::Send {
to: to.clone(),
body: body.clone(),
},
KeyCode::Esc => ComposeAction::Abort,
KeyCode::Tab | KeyCode::Down => {
*field = match field {
ComposeField::To => ComposeField::Body,
ComposeField::Body => ComposeField::To,
};
ComposeAction::None
}
KeyCode::BackTab | KeyCode::Up => {
*field = match field {
ComposeField::To => ComposeField::Body,
ComposeField::Body => ComposeField::To,
};
ComposeAction::None
}
KeyCode::Enter => match field {
ComposeField::To => { *field = ComposeField::Body; ComposeAction::None }
ComposeField::Body => { body.push('\n'); ComposeAction::None }
},
KeyCode::Backspace => {
match field {
ComposeField::To => { to.pop(); }
ComposeField::Body => { body.pop(); }
}
ComposeAction::None
}
KeyCode::Char(c) if !ctrl => {
match field {
ComposeField::To => to.push(c),
ComposeField::Body => body.push(c),
}
ComposeAction::None
}
_ => ComposeAction::None,
};
match action {
ComposeAction::None => {}
ComposeAction::Abort => {
state.mode = Mode::Normal;
state.status = " Compose aborted".to_string();
}
ComposeAction::Send { to, body } => {
let to = to.trim().to_string();
let body = body.trim().to_string();
if to.is_empty() || body.is_empty() {
state.status = " ✗ Recipient and body required".to_string();
} else {
state.status = " Sending…".to_string();
terminal.draw(|f| draw(f, &mut state))?;
match resolve_recipient(cfg, store, &to) {
Ok(resolved) => {
match transport::send_smtp(cfg, &resolved, &body).await {
Ok(_) => {
store.record_sent(&resolved, &body).ok();
state.reload_messages();
state.status = format!(" ✓ Sent to {}", resolved);
state.mode = Mode::Normal;
}
Err(e) => {
state.status = format!(" ✗ Send failed: {}", e)
}
}
}
Err(e) => state.status = format!(" ✗ {}", e),
}
}
}
}
}
if in_compose {
continue;
}
let ch = match key.code {
KeyCode::Char(c) => Some(c.to_string()),
_ => None,
};
if let Some(ref k) = ch {
if k == &kb.quit {
break;
}
if k == &kb.sync {
state.status = " Syncing…".to_string();
terminal.draw(|f| draw(f, &mut state))?;
match transport::fetch_imap(cfg, store).await {
Ok(_) => {
state.reload_messages();
state.status = format!(
" Sync complete • {} unread",
store.unread_count()
);
}
Err(e) => state.status = format!(" Sync error: {}", e),
}
last_sync = Instant::now(); continue;
}
if k == &kb.compose {
state.mode = Mode::Compose {
to: String::new(),
body: String::new(),
field: ComposeField::To,
};
state.status = " Compose — Ctrl+S send · Tab field · Esc cancel".to_string();
continue;
}
if k == &kb.reply {
if let Some(msg) = state.reading.clone() {
state.mode = Mode::Compose {
to: msg.from.clone(),
body: String::new(),
field: ComposeField::Body,
};
state.status = " Reply — Ctrl+S send · Tab field · Esc cancel".to_string();
}
continue;
}
if k == &kb.star {
if let Some(idx) = state.selected_store_index() {
state.store.toggle_star(idx).ok();
state.reload_messages();
}
continue;
}
if k == &kb.delete {
if let Some(idx) = state.selected_store_index() {
let uid = state.store.get_message(idx).and_then(|m| m.uid);
state.store.move_to_trash(idx).ok();
state.reload_messages();
state.status = " Moved to trash".to_string();
terminal.draw(|f| draw(f, &mut state))?;
if let Some(uid) = uid {
if let Err(e) = transport::imap_move_to_trash(cfg, uid).await {
state.status = format!(
" Local trash OK — IMAP: {}",
e
);
}
}
}
continue;
}
if k == "?" {
state.show_contacts = !state.show_contacts;
continue;
}
}
match key.code {
KeyCode::Tab => {
state.focus = match state.focus {
Focus::Folders => Focus::Messages,
Focus::Messages => Focus::Reading,
Focus::Reading => Focus::Folders,
};
}
KeyCode::BackTab => {
state.focus = match state.focus {
Focus::Folders => Focus::Reading,
Focus::Messages => Focus::Folders,
Focus::Reading => Focus::Messages,
};
}
KeyCode::Left => {
if state.focus == Focus::Messages || state.focus == Focus::Reading {
state.focus = Focus::Folders;
}
}
KeyCode::Right => {
if state.focus == Focus::Folders {
state.focus = Focus::Messages;
}
}
KeyCode::Down | KeyCode::Char('j') => {
match state.focus {
Focus::Folders => state.next_folder(),
Focus::Messages => state.next_msg(),
Focus::Reading => {}
}
}
KeyCode::Up | KeyCode::Char('k') => {
match state.focus {
Focus::Folders => state.prev_folder(),
Focus::Messages => state.prev_msg(),
Focus::Reading => {}
}
}
KeyCode::Enter => state.open_selected(),
_ => {}
}
}
}
Ok(())
}
fn draw(f: &mut Frame, state: &mut AppState) {
let area = f.size();
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(area);
let main_area = outer[0];
let status_area = outer[1];
let main_split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(22), Constraint::Min(1)])
.split(main_area);
let sidebar_area = main_split[0];
let content_area = main_split[1];
let content_split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(content_area);
let list_area = content_split[0];
let read_area = content_split[1];
draw_sidebar(f, state, sidebar_area);
draw_message_list(f, state, list_area);
draw_reading_pane(f, state, read_area);
draw_status_bar(f, state, status_area);
if let Mode::Compose { to, body, field } = &state.mode {
draw_compose(f, to, body, *field, area);
}
}
fn draw_sidebar(f: &mut Frame, state: &mut AppState, area: Rect) {
let border_color = if state.focus == Focus::Folders {
Color::Cyan
} else {
Color::DarkGray
};
let block = Block::default()
.borders(Borders::ALL)
.title(" Folders ")
.border_style(Style::default().fg(border_color));
let inner = block.inner(area);
f.render_widget(block, area);
let folder_items: Vec<ListItem> = FOLDERS
.iter()
.enumerate()
.map(|(i, folder)| {
let unread = state.store.unread_in(folder);
let label = if unread > 0 {
format!(" {} ({})", folder.label(), unread)
} else {
format!(" {}", folder.label())
};
let style = if i == state.folder_idx {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if unread > 0 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::White)
};
ListItem::new(label).style(style)
})
.collect();
let sidebar_split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(FOLDERS.len() as u16 + 2), Constraint::Min(1)])
.split(inner);
let folder_list = List::new(folder_items);
f.render_widget(folder_list, sidebar_split[0]);
let contacts_block = Block::default()
.borders(Borders::TOP)
.title(" Contacts [?] ")
.border_style(Style::default().fg(Color::DarkGray));
if state.show_contacts {
let inner_contacts = contacts_block.inner(sidebar_split[1]);
f.render_widget(contacts_block, sidebar_split[1]);
let by_domain = state.store.contacts_by_domain();
let mut items: Vec<ListItem> = vec![];
let mut domains: Vec<String> = by_domain.keys().cloned().collect();
domains.sort();
for domain in domains {
items.push(ListItem::new(
Span::styled(format!(" @{}", domain), Style::default().fg(Color::Cyan)),
));
for c in &by_domain[&domain] {
let label = format!(" [{}] {}", c.id, c.user);
items.push(ListItem::new(label).style(Style::default().fg(Color::Gray)));
}
}
f.render_widget(List::new(items), inner_contacts);
} else {
let count = state.store.all_contacts().len();
let p = Paragraph::new(format!(" {} contacts\n [?] to view", count))
.style(Style::default().fg(Color::DarkGray));
let inner_contacts = contacts_block.inner(sidebar_split[1]);
f.render_widget(contacts_block, sidebar_split[1]);
f.render_widget(p, inner_contacts);
}
}
fn draw_message_list(f: &mut Frame, state: &mut AppState, area: Rect) {
let folder_name = state.current_folder().label();
let title = format!(" {} — {} messages ", folder_name, state.messages.len());
let items: Vec<ListItem> = state
.messages
.iter()
.map(|(_, msg)| {
let unread_dot = if !msg.read { "●" } else { " " };
let star = if msg.starred { "★" } else { " " };
let date = msg.timestamp.format("%b %d %H:%M").to_string();
let from = truncate(&msg.from, 24);
let preview: String = msg.body.lines().next().unwrap_or("").chars().take(30).collect();
let line = Line::from(vec![
Span::styled(unread_dot, Style::default().fg(Color::Cyan)),
Span::raw(star),
Span::raw(" "),
Span::styled(format!("{:<24}", from), Style::default().fg(Color::White)),
Span::raw(" "),
Span::styled(format!("{:<14}", date), Style::default().fg(Color::DarkGray)),
Span::styled(preview, Style::default().fg(Color::Gray)),
]);
let style = if !msg.read {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(line).style(style)
})
.collect();
let border_color = if state.focus == Focus::Messages {
Color::Cyan
} else {
Color::DarkGray
};
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(border_color)),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(list, area, &mut state.msg_list_state);
}
fn draw_reading_pane(f: &mut Frame, state: &mut AppState, area: Rect) {
let border_color = if state.focus == Focus::Reading {
Color::Cyan
} else {
Color::DarkGray
};
let block = Block::default()
.borders(Borders::ALL)
.title(" Message ")
.border_style(Style::default().fg(border_color));
let lines: Vec<Line> = match &state.reading {
None => vec![Line::from("No message selected.")],
Some(msg) => vec![
Line::from(format!("From : {}", msg.from)),
Line::from(format!("To : {}", msg.to)),
Line::from(format!("Date : {}", msg.timestamp.format("%Y-%m-%d %H:%M:%S"))),
Line::from("─".repeat(60)),
Line::from(msg.body.clone()),
],
};
let paragraph = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false })
.style(Style::default().fg(Color::White));
f.render_widget(paragraph, area);
}
fn draw_status_bar(f: &mut Frame, state: &AppState, area: Rect) {
let kb = &state.cfg.keybinds;
let help = format!(
" {}quit {}compose {}reply {}star {}delete {}sync Tab/←→ panes ↑↓/jk nav ? contacts",
kb.quit, kb.compose, kb.reply, kb.star, kb.delete, kb.sync
);
let bar = Paragraph::new(Line::from(vec![
Span::styled(&state.status, Style::default().fg(Color::Cyan)),
Span::styled(&help, Style::default().fg(Color::DarkGray)),
]))
.style(Style::default().bg(Color::Black));
f.render_widget(bar, area);
}
fn resolve_recipient(cfg: &Config, store: &MailStore, to: &str) -> Result<String> {
let to = to.trim();
if !to.is_empty() && to.chars().all(|c| c.is_ascii_digit()) {
store.resolve_contact_id(to)
} else {
let addr = cfg.resolve_address(to);
store.ensure_contact(&addr)?;
Ok(addr)
}
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1])[1]
}
fn draw_compose(
f: &mut Frame,
to: &str,
body: &str,
field: ComposeField,
area: Rect,
) {
let modal = centered_rect(70, 60, area);
f.render_widget(Clear, modal);
let block = Block::default()
.borders(Borders::ALL)
.title(" Compose — Ctrl+S send · Tab field · Esc cancel ")
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(modal);
f.render_widget(block, modal);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(1), ])
.split(inner);
let label_style = |focused: bool| {
if focused {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
}
};
let cursor = |focused: bool| if focused { "_" } else { "" };
let to_focused = field == ComposeField::To;
let to_line = Line::from(vec![
Span::styled("To: ", label_style(to_focused)),
Span::styled(format!("{}{}", to, cursor(to_focused)), Style::default().fg(Color::White)),
]);
f.render_widget(
Paragraph::new(to_line).block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(Color::DarkGray)),
),
chunks[0],
);
let body_focused = field == ComposeField::Body;
f.render_widget(
Paragraph::new(format!("{}{}", body, cursor(body_focused)))
.wrap(Wrap { trim: false })
.style(Style::default().fg(Color::White)),
chunks[1],
);
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
format!("{}…", s.chars().take(max - 1).collect::<String>())
}
}