use std::env;
use std::io;
use std::time::{Duration, Instant};
use anyhow::Result;
use base64::Engine;
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::{Attachment, 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,
Attach,
Body,
}
#[derive(Debug, Clone)]
enum Mode {
Normal,
Compose {
to: String,
body: String,
attachments: Vec<String>,
attach_input: String,
field: ComposeField,
},
ViewAttachment {
filename: String,
content: String,
scroll: u16,
},
}
enum ComposeAction {
None,
Notice(String),
Abort,
Send {
to: String,
body: String,
attachments: Vec<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,
selected_attachment: usize,
}
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,
selected_attachment: 0,
};
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();
self.selected_attachment = 0;
}
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();
self.selected_attachment = 0;
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 {
let unread_before = store.unread_count();
match transport::fetch_imap(cfg, store).await {
Ok(_) => {
state.reload_messages();
let unread_after = store.unread_count();
if unread_after > unread_before {
bell();
}
state.status = format!(" Auto-synced · {} unread", unread_after);
}
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, attachments, attach_input, field } = &mut state.mode {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let action = match key.code {
KeyCode::Char('s') if ctrl => {
let pending = attach_input.trim().to_string();
let attach_err = if !pending.is_empty() {
validate_attachment(&pending).err()
} else {
None
};
if let Some(e) = attach_err {
ComposeAction::Notice(format!(
" ✗ Attachment: {} — fix or clear it first",
e
))
} else {
if !pending.is_empty() {
attachments.push(pending);
attach_input.clear();
}
ComposeAction::Send {
to: to.clone(),
body: body.clone(),
attachments: attachments.clone(),
}
}
}
KeyCode::Esc => ComposeAction::Abort,
KeyCode::Tab | KeyCode::Down => {
let commit_err = if *field == ComposeField::Attach {
let p = attach_input.trim().to_string();
if p.is_empty() { None } else {
match validate_attachment(&p) {
Ok(_) => { attachments.push(p); attach_input.clear(); None }
Err(e) => Some(e),
}
}
} else { None };
if let Some(e) = commit_err {
ComposeAction::Notice(format!(" ✗ {}", e))
} else {
*field = match *field {
ComposeField::To => ComposeField::Body,
ComposeField::Body => ComposeField::Attach,
ComposeField::Attach => ComposeField::Body,
};
ComposeAction::None
}
}
KeyCode::BackTab | KeyCode::Up => {
let commit_err = if *field == ComposeField::Attach {
let p = attach_input.trim().to_string();
if p.is_empty() { None } else {
match validate_attachment(&p) {
Ok(_) => { attachments.push(p); attach_input.clear(); None }
Err(e) => Some(e),
}
}
} else { None };
if let Some(e) = commit_err {
ComposeAction::Notice(format!(" ✗ {}", e))
} else {
*field = match *field {
ComposeField::To => ComposeField::Body,
ComposeField::Body => ComposeField::Attach,
ComposeField::Attach => ComposeField::Body,
};
ComposeAction::None
}
}
KeyCode::Enter => match field {
ComposeField::To => {
*field = ComposeField::Body;
ComposeAction::None
}
ComposeField::Attach => {
let path = attach_input.trim().to_string();
if path.is_empty() {
ComposeAction::None
} else {
match validate_attachment(&path) {
Ok(size) => {
attachments.push(path.clone());
attach_input.clear();
ComposeAction::Notice(format!(
" ✓ Attached {} ({})",
path,
human_size(size)
))
}
Err(e) => ComposeAction::Notice(format!(" ✗ {}", e)),
}
}
}
ComposeField::Body => {
body.push('\n');
ComposeAction::None
}
},
KeyCode::Backspace => {
match field {
ComposeField::To => { to.pop(); }
ComposeField::Attach => {
if attach_input.is_empty() {
attachments.pop();
} else {
attach_input.pop();
}
}
ComposeField::Body => { body.pop(); }
}
ComposeAction::None
}
KeyCode::Char(c) if !ctrl => {
match field {
ComposeField::To => to.push(c),
ComposeField::Attach => attach_input.push(c),
ComposeField::Body => body.push(c),
}
ComposeAction::None
}
_ => ComposeAction::None,
};
match action {
ComposeAction::None => {}
ComposeAction::Notice(msg) => state.status = msg,
ComposeAction::Abort => {
state.mode = Mode::Normal;
state.status = " Compose aborted".to_string();
}
ComposeAction::Send { to, body, attachments } => {
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, &attachments).await {
Ok(_) => {
let store_atts = paths_to_attachments(&attachments);
store.record_sent(&resolved, &body, store_atts).ok();
state.reload_messages();
state.status = if attachments.is_empty() {
format!(" ✓ Sent to {} (no attachments)", resolved)
} else {
format!(" ✓ Sent to {} + {} file(s)", resolved, attachments.len())
};
state.mode = Mode::Normal;
}
Err(e) => {
state.status = format!(" ✗ Send failed: {}", e)
}
}
}
Err(e) => state.status = format!(" ✗ {}", e),
}
}
}
}
}
if in_compose {
continue;
}
let in_view = matches!(state.mode, Mode::ViewAttachment { .. });
if let Mode::ViewAttachment { scroll, .. } = &mut state.mode {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
state.mode = Mode::Normal;
}
KeyCode::Down | KeyCode::Char('j') => {
*scroll = scroll.saturating_add(1);
}
KeyCode::Up | KeyCode::Char('k') => {
*scroll = scroll.saturating_sub(1);
}
KeyCode::PageDown => {
*scroll = scroll.saturating_add(20);
}
KeyCode::PageUp => {
*scroll = scroll.saturating_sub(20);
}
_ => {}
}
}
if in_view {
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))?;
let unread_before = store.unread_count();
match transport::fetch_imap(cfg, store).await {
Ok(_) => {
state.reload_messages();
let unread_after = store.unread_count();
if unread_after > unread_before {
bell();
}
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(),
attachments: Vec::new(),
attach_input: String::new(),
field: ComposeField::To,
};
state.status = " Compose — type To, Tab to body, Tab again for attach, Ctrl+S send, 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(),
attachments: Vec::new(),
attach_input: String::new(),
field: ComposeField::Body,
};
state.status = " Reply — type body, Ctrl+S send, Tab for attach 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;
}
}
if key.code == KeyCode::Char('a')
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& state.focus == Focus::Reading
{
if let Some(msg) = &state.reading {
if let Some(att) = msg.attachments.get(state.selected_attachment) {
match save_attachment_to_disk(att) {
Ok(path) => state.status = format!(" ✓ Saved to {}", path),
Err(e) => state.status = format!(" ✗ Save failed: {}", e),
}
}
}
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 => {
if let Some(msg) = &state.reading {
let n = msg.attachments.len();
if n > 0 && state.selected_attachment + 1 < n {
state.selected_attachment += 1;
}
}
}
}
}
KeyCode::Up | KeyCode::Char('k') => {
match state.focus {
Focus::Folders => state.prev_folder(),
Focus::Messages => state.prev_msg(),
Focus::Reading => {
if state.selected_attachment > 0 {
state.selected_attachment -= 1;
}
}
}
}
KeyCode::Enter => {
if state.focus == Focus::Reading {
if let Some(msg) = &state.reading {
if let Some(att) = msg.attachments.get(state.selected_attachment) {
match try_decode_text(att) {
Ok(content) => {
state.mode = Mode::ViewAttachment {
filename: att.filename.clone(),
content,
scroll: 0,
};
}
Err(e) => {
state.status = format!(
" ✗ Cannot preview: {} — use Ctrl+A to save",
e
);
}
}
}
}
} else {
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, attachments, attach_input, field } = &state.mode {
draw_compose(f, to, body, attachments, attach_input, *field, area);
}
if let Mode::ViewAttachment { filename, content, scroll } = &state.mode {
draw_attachment_viewer(f, filename, content, *scroll, 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 clip = if msg.attachments.is_empty() { " " } else { "📎" };
let date = msg.timestamp.format("%b %d %H:%M").to_string();
let from = truncate(&msg.from, 22);
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(clip),
Span::raw(" "),
Span::styled(format!("{:<22}", 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) => {
let mut lines = 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()),
];
if !msg.attachments.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("📎 Attachments ({})", msg.attachments.len()),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
for (i, att) in msg.attachments.iter().enumerate() {
let selected = i == state.selected_attachment && state.focus == Focus::Reading;
let style = if selected {
Style::default().fg(Color::Black).bg(Color::Yellow)
} else {
Style::default().fg(Color::White)
};
let hint = if selected { " ← Enter preview · Ctrl+A save" } else { "" };
lines.push(Line::from(Span::styled(
format!(" {} ({}){}", att.filename, human_size(att.size), hint),
style,
)));
}
if state.focus != Focus::Reading {
lines.push(Line::from(Span::styled(
" (Tab to this pane · ↑↓ pick · Enter preview text · Ctrl+A save)",
Style::default().fg(Color::DarkGray),
)));
}
}
lines
}
};
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 ^A save ? 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 paths_to_attachments(paths: &[String]) -> Vec<Attachment> {
paths
.iter()
.filter_map(|path| {
let data = std::fs::read(path).ok()?;
let filename = std::path::Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "attachment".to_string());
Some(Attachment {
filename,
size: data.len() as u64,
mime_type: transport::guess_mime(path).to_string(),
data: base64::engine::general_purpose::STANDARD.encode(&data),
})
})
.collect()
}
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,
attachments: &[String],
attach_input: &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 · Enter/Tab next field · Esc cancel ")
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(modal);
f.render_widget(block, modal);
let attach_h = 2 + attachments.len().min(5) as u16;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Length(attach_h), 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 attach_focused = field == ComposeField::Attach;
let attach_first_line = if attach_input.is_empty() {
Line::from(vec![
Span::styled("Attach: ", label_style(attach_focused)),
Span::styled(cursor(attach_focused).to_string(), Style::default().fg(Color::White)),
Span::styled(
" type a path, Enter to queue · any type <25MB",
Style::default().fg(Color::DarkGray),
),
])
} else {
Line::from(vec![
Span::styled("Attach: ", label_style(attach_focused)),
Span::styled(
format!("{}{}", attach_input, cursor(attach_focused)),
Style::default().fg(Color::White),
),
])
};
let mut attach_lines = vec![attach_first_line];
for path in attachments.iter().take(5) {
attach_lines.push(Line::from(Span::styled(
format!(" 📎 {}", path),
Style::default().fg(Color::Green),
)));
}
if attachments.len() > 5 {
attach_lines.push(Line::from(Span::styled(
format!(" …and {} more", attachments.len() - 5),
Style::default().fg(Color::DarkGray),
)));
}
f.render_widget(
Paragraph::new(attach_lines).block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(Color::DarkGray)),
),
chunks[1],
);
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[2],
);
}
fn validate_attachment(path: &str) -> std::result::Result<u64, String> {
let p = std::path::Path::new(path);
let meta = std::fs::metadata(p).map_err(|_| format!("File not found: {}", path))?;
if !meta.is_file() {
return Err(format!("Not a file: {}", path));
}
let size = meta.len();
if size > transport::MAX_ATTACHMENT_BYTES {
return Err(format!("Over 25 MB: {}", path));
}
Ok(size)
}
fn human_size(bytes: u64) -> String {
if bytes >= 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else if bytes >= 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{} B", bytes)
}
}
fn save_attachment_to_disk(att: &Attachment) -> std::result::Result<String, String> {
use std::path::PathBuf;
let dir = if let Some(home) = dirs::home_dir() {
let candidates = [home.join("Downloads"), home.join("Documents"), home.clone()];
candidates
.iter()
.find(|p| p.is_dir())
.cloned()
.unwrap_or_else(|| {
#[cfg(target_os = "windows")]
{ PathBuf::from(env::var("TEMP").unwrap_or_else(|_| ".".to_string())) }
#[cfg(not(target_os = "windows"))]
{ PathBuf::from("/tmp") }
})
} else {
PathBuf::from(".")
};
let path = dir.join(&att.filename);
let data = base64::engine::general_purpose::STANDARD
.decode(&att.data)
.map_err(|e| format!("Decode failed: {}", e))?;
std::fs::write(&path, data).map_err(|e| format!("Write failed: {}", e))?;
Ok(path.display().to_string())
}
fn bell() {
eprint!("\x07");
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
format!("{}…", s.chars().take(max - 1).collect::<String>())
}
}
fn draw_attachment_viewer(f: &mut Frame, filename: &str, content: &str, scroll: u16, area: Rect) {
let modal = centered_rect(88, 85, area);
f.render_widget(Clear, modal);
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" 📄 {} — ↑↓/jk scroll · PgUp/PgDn · Esc close ", filename))
.border_style(Style::default().fg(Color::Yellow));
let inner = block.inner(modal);
f.render_widget(block, modal);
let p = Paragraph::new(content.to_string())
.scroll((scroll, 0))
.wrap(Wrap { trim: false })
.style(Style::default().fg(Color::White));
f.render_widget(p, inner);
}
fn try_decode_text(att: &Attachment) -> std::result::Result<String, String> {
let data = base64::engine::general_purpose::STANDARD
.decode(&att.data)
.map_err(|e| format!("Base64 decode error: {}", e))?;
String::from_utf8(data)
.map_err(|_| "Binary file — not valid UTF-8 text".to_string())
}