diff --git a/Cargo.lock b/Cargo.lock index 20c991d..ef4de45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4683,6 +4683,7 @@ dependencies = [ name = "tuimail" version = "0.1.0" dependencies = [ + "aes-gcm", "chrono", "crossterm", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 623e7e0..77a5133 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ regex = "1" lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "native-tls", "builder"] } dirs = "5" rand = { version = "0.8", features = ["getrandom"] } +aes-gcm = "0.10" [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3", features = ["apple-native"] } diff --git a/proton-bridge/src/imap_server.rs b/proton-bridge/src/imap_server.rs index 401b80e..8795488 100644 --- a/proton-bridge/src/imap_server.rs +++ b/proton-bridge/src/imap_server.rs @@ -178,18 +178,20 @@ async fn cmd_fetch(tag: &str, rest: &str, state: &SharedState) -> String { async fn fetch_headers(tag: &str, seq_set: &str, state: &SharedState) -> String { // Collect all metadata under a single short lock — no I/O while locked. - let rows: Vec<(u32, String, String, String, i64)> = { + let rows: Vec<(u32, String, String, String, String, i64)> = { let st = state.lock().await; let count = st.store.count(); parse_seq_set(seq_set, count) .into_iter() .filter_map(|seq| { st.store.get_meta(seq).map(|m| { + let proton_id = st.store.proton_id(seq).unwrap_or("").to_string(); ( seq, m.subject.clone(), m.sender.name.clone(), m.sender.address.clone(), + proton_id, m.time, ) }) @@ -198,15 +200,15 @@ async fn fetch_headers(tag: &str, seq_set: &str, state: &SharedState) -> String }; let mut out = Vec::new(); - for (seq, subject, name, addr, time) in &rows { + for (seq, subject, name, addr, proton_id, time) in &rows { let block = format!( - "Subject: {subject}\r\nFrom: {name} <{addr}>\r\nDate: {date}\r\n\r\n", + "Subject: {subject}\r\nFrom: {name} <{addr}>\r\nDate: {date}\r\nMessage-ID: <{proton_id}>\r\n\r\n", date = rfc2822(*time), ); let n = block.as_bytes().len(); out.extend_from_slice( format!( - "* {seq} FETCH (BODY[HEADER.FIELDS (SUBJECT FROM DATE)] {{{n}}}\r\n" + "* {seq} FETCH (BODY[HEADER.FIELDS (SUBJECT FROM DATE MESSAGE-ID)] {{{n}}}\r\n" ) .as_bytes(), ); diff --git a/src/inbox.rs b/src/inbox.rs index eca164f..b5c9c4a 100644 --- a/src/inbox.rs +++ b/src/inbox.rs @@ -1,7 +1,7 @@ -use chrono::{DateTime, FixedOffset, Local}; use crate::config::Config; use crate::connect::ImapSession; -use crate::{connect, Email}; +use crate::{Email, connect}; +use chrono::{DateTime, FixedOffset, Local}; const BATCH_SIZE: u32 = 50; @@ -10,10 +10,7 @@ pub(crate) struct Inbox { } /// Refresh inbox (full reload). Reconnects on error. -pub(crate) fn refresh( - session: &mut Option, - config: &Config, -) -> Result { +pub(crate) fn refresh(session: &mut Option, config: &Config) -> Result { if let Some(s) = session.as_mut() { if s.noop().is_ok() { return fetch_latest(s); @@ -78,13 +75,13 @@ fn fetch_range_emails(session: &mut ImapSession, range: &str) -> Result { let messages = s - .fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)]") + .fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE MESSAGE-ID)]") .map_err(|e| e.to_string())?; Ok(parse_emails(&messages)) } ImapSession::Tls(s) => { let messages = s - .fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)]") + .fetch(range, "BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE MESSAGE-ID)]") .map_err(|e| e.to_string())?; Ok(parse_emails(&messages)) } @@ -109,6 +106,7 @@ fn parse_emails(fetches: &[imap::types::Fetch]) -> Vec { let mut subject = String::new(); let mut from = String::new(); let mut date = String::new(); + let mut message_id: Option = None; for line in header.lines() { if let Some(val) = line.strip_prefix("Subject: ") { @@ -118,14 +116,19 @@ fn parse_emails(fetches: &[imap::types::Fetch]) -> Vec { } else if let Some(val) = line.strip_prefix("Date: ") { date = DateTime::parse_from_rfc2822(val) .map(|dt: DateTime| { - dt.with_timezone(&Local).format("%Y-%m-%d %H:%M").to_string() + dt.with_timezone(&Local) + .format("%Y-%m-%d %H:%M") + .to_string() }) .unwrap_or_else(|_| val.to_string()); + } else if line.to_ascii_lowercase().starts_with("message-id:") { + message_id = Some(line["message-id:".len()..].trim().to_string()); } } emails.push(Email { seq: message.message, + message_id, subject, from_addr: extract_addr(&from), from, @@ -140,7 +143,7 @@ fn ensure_session<'a>( session: &'a mut Option, config: &Config, ) -> Result<&'a mut ImapSession, String> { - if session.as_mut().map_or(true, |s| s.noop().is_err()) { + if session.as_mut().is_none_or(|s| s.noop().is_err()) { *session = None; *session = Some(connect::connect(config)?); } @@ -157,15 +160,11 @@ pub(crate) fn fetch_body( let range = seq.to_string(); let raw = match s { ImapSession::Plain(s) => { - let messages = s - .fetch(&range, "BODY.PEEK[]") - .map_err(|e| e.to_string())?; + let messages = s.fetch(&range, "BODY.PEEK[]").map_err(|e| e.to_string())?; extract_raw_body(&messages) } ImapSession::Tls(s) => { - let messages = s - .fetch(&range, "BODY.PEEK[]") - .map_err(|e| e.to_string())?; + let messages = s.fetch(&range, "BODY.PEEK[]").map_err(|e| e.to_string())?; extract_raw_body(&messages) } }; @@ -184,12 +183,14 @@ pub(crate) fn delete_email( match s { ImapSession::Plain(s) => { s.select("INBOX").map_err(|e| e.to_string())?; - s.store(&range, "+FLAGS (\\Deleted)").map_err(|e| e.to_string())?; + s.store(&range, "+FLAGS (\\Deleted)") + .map_err(|e| e.to_string())?; s.expunge().map_err(|e| e.to_string())?; } ImapSession::Tls(s) => { s.select("INBOX").map_err(|e| e.to_string())?; - s.store(&range, "+FLAGS (\\Deleted)").map_err(|e| e.to_string())?; + s.store(&range, "+FLAGS (\\Deleted)") + .map_err(|e| e.to_string())?; s.expunge().map_err(|e| e.to_string())?; } } @@ -197,9 +198,7 @@ pub(crate) fn delete_email( } fn extract_raw_body(fetches: &[imap::types::Fetch]) -> Option> { - fetches.first().and_then(|f| { - f.body().map(|b| b.to_vec()) - }) + fetches.first().and_then(|f| f.body().map(|b| b.to_vec())) } fn extract_plain_text(raw: &[u8]) -> Result { @@ -214,7 +213,10 @@ fn extract_plain_text(raw: &[u8]) -> Result { return Ok(clean_markdown(&clean_text(&md))); } // Last resort: top-level body - parsed.get_body().map(|s| clean_text(&s)).map_err(|e| e.to_string()) + parsed + .get_body() + .map(|s| clean_text(&s)) + .map_err(|e| e.to_string()) } /// Remove leftover quoted-printable artifacts (=XX sequences) @@ -300,7 +302,11 @@ pub(crate) fn search( let mut seqs: Vec = seq_set.into_iter().collect(); seqs.sort_unstable(); - let range = seqs.iter().map(|n| n.to_string()).collect::>().join(","); + let range = seqs + .iter() + .map(|n| n.to_string()) + .collect::>() + .join(","); let s = session.as_mut().unwrap(); let mut emails = fetch_range_emails(s, &range)?; @@ -320,4 +326,3 @@ fn find_part(mail: &mailparse::ParsedMail, mime_type: &str) -> Option { } None } - diff --git a/src/lib.rs b/src/lib.rs index 940f961..b277c49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,23 +1,24 @@ -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 crate::config::Config; use crossterm::event; use crossterm::event::{Event, KeyCode, KeyModifiers}; +use ratatui::Terminal; 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; +use std::io::{Error, Stdout}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; pub mod config; -pub mod credentials; -pub mod setup; mod connect; +pub mod credentials; mod inbox; +pub mod setup; mod smtp; +mod store; const POLL_INTERVAL: Duration = Duration::from_secs(30); const NAV_DEBOUNCE: Duration = Duration::from_millis(150); @@ -44,6 +45,7 @@ enum ComposeField { pub(crate) struct Email { pub seq: u32, + pub message_id: Option, pub subject: String, pub from: String, pub from_addr: String, @@ -52,17 +54,34 @@ pub(crate) struct Email { enum WorkerCmd { Refresh, - FetchMore { oldest_seq: u32 }, - FetchBody { seq: u32 }, - Delete { seq: u32 }, - Search { query: String }, - SendEmail { to: String, subject: String, body: String }, + FetchMore { + oldest_seq: u32, + }, + FetchBody { + seq: u32, + message_id: Option, + }, + Delete { + seq: u32, + message_id: Option, + }, + Search { + query: String, + }, + SendEmail { + to: String, + subject: String, + body: String, + }, } enum WorkerResult { Refreshed(Result), FetchedMore(Result<(Vec, u32), String>), - Body { seq: u32, result: Result }, + Body { + seq: u32, + result: Result, + }, Deleted(Result<(), String>), Searched(Result, String>), Sent(Result<(), String>), @@ -73,6 +92,7 @@ fn worker_loop( cmd_rx: mpsc::Receiver, result_tx: mpsc::Sender, wanted_body_seq: Arc, + store: Option, ) { let mut session: Option = None; @@ -86,16 +106,36 @@ fn worker_loop( let result = inbox::fetch_older_batch(&mut session, oldest_seq, &config); let _ = result_tx.send(WorkerResult::FetchedMore(result)); } - WorkerCmd::FetchBody { seq } => { + WorkerCmd::FetchBody { seq, message_id } => { // Skip if a newer body request has been made if wanted_body_seq.load(Ordering::Relaxed) != seq { continue; } + // Cache hit — return immediately without touching IMAP + if let (Some(s), Some(mid)) = (&store, &message_id) + && let Some(cached) = s.get(mid) + { + let _ = result_tx.send(WorkerResult::Body { + seq, + result: Ok(cached), + }); + continue; + } + // Cache miss — fetch from IMAP let result = inbox::fetch_body(&mut session, seq, &config); + // Persist on success + if let (Some(s), Some(mid), Ok(body)) = (&store, &message_id, &result) { + let _ = s.put(mid, body); + } let _ = result_tx.send(WorkerResult::Body { seq, result }); } - WorkerCmd::Delete { seq } => { + WorkerCmd::Delete { seq, message_id } => { let result = inbox::delete_email(&mut session, seq, &config); + if result.is_ok() + && let (Some(s), Some(mid)) = (&store, &message_id) + { + s.delete(mid); + } let _ = result_tx.send(WorkerResult::Deleted(result)); } WorkerCmd::Search { query } => { @@ -118,15 +158,25 @@ fn worker_loop( } } -pub fn main(config: &Config, terminal: &mut Terminal>) -> Result<(), Error> { +pub fn main( + config: &Config, + terminal: &mut Terminal>, +) -> 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 store = match store::MessageStore::init() { + Ok(s) => Some(s), + Err(e) => { + eprintln!("Warning: message cache unavailable: {e}"); + None + } + }; thread::spawn(move || { - worker_loop(worker_config, cmd_rx, result_tx, worker_wanted); + worker_loop(worker_config, cmd_rx, result_tx, worker_wanted, store); }); // Send initial refresh @@ -168,7 +218,8 @@ pub fn main(config: &Config, terminal: &mut Terminal>) while let Ok(result) = result_rx.try_recv() { match result { WorkerResult::Refreshed(Ok(inbox)) => { - let prev_selected_seq = list_state.selected() + let prev_selected_seq = list_state + .selected() .and_then(|i| emails.get(i)) .map(|e| e.seq); @@ -192,6 +243,7 @@ pub fn main(config: &Config, terminal: &mut Terminal>) .unwrap_or(0); list_state.select(Some(new_idx)); let seq = emails[new_idx].seq; + let message_id = emails[new_idx].message_id.clone(); if message_seq != Some(seq) || pending_fetch_seq.is_some() { message_seq = Some(seq); message_body.clear(); @@ -200,7 +252,7 @@ pub fn main(config: &Config, terminal: &mut Terminal>) body_loading = true; pending_fetch_seq = None; wanted_body_seq.store(seq, Ordering::Relaxed); - let _ = cmd_tx.send(WorkerCmd::FetchBody { seq }); + let _ = cmd_tx.send(WorkerCmd::FetchBody { seq, message_id }); } } else { list_state.select(None); @@ -242,13 +294,17 @@ pub fn main(config: &Config, terminal: &mut Terminal>) } WorkerResult::Searched(Ok(results)) => { search_results = results; - search_results_state - .select(if search_results.is_empty() { None } else { Some(0) }); + search_results_state.select(if search_results.is_empty() { + None + } else { + Some(0) + }); search_loading = false; search_active = true; // Fetch body of the first result if let Some(first) = search_results.first() { let seq = first.seq; + let message_id = first.message_id.clone(); message_seq = Some(seq); message_body.clear(); message_rendered = None; @@ -256,7 +312,7 @@ pub fn main(config: &Config, terminal: &mut Terminal>) body_loading = true; pending_fetch_seq = None; wanted_body_seq.store(seq, Ordering::Relaxed); - let _ = cmd_tx.send(WorkerCmd::FetchBody { seq }); + let _ = cmd_tx.send(WorkerCmd::FetchBody { seq, message_id }); } } WorkerResult::Searched(Err(e)) => { @@ -274,15 +330,20 @@ pub fn main(config: &Config, terminal: &mut Terminal>) } // 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; - } + if let Some(seq) = pending_fetch_seq + && last_nav.elapsed() >= NAV_DEBOUNCE + { + let message_id = emails + .iter() + .chain(search_results.iter()) + .find(|e| e.seq == seq) + .and_then(|e| e.message_id.clone()); + message_body.clear(); + message_rendered = None; + body_loading = true; + wanted_body_seq.store(seq, Ordering::Relaxed); + let _ = cmd_tx.send(WorkerCmd::FetchBody { seq, message_id }); + pending_fetch_seq = None; } // Periodic refresh @@ -345,10 +406,8 @@ pub fn main(config: &Config, terminal: &mut Terminal>) .split('\n') .map(|l| Line::from(Span::styled(l.to_string(), body_style))) .collect(); - if compose_field == ComposeField::Body { - if let Some(last) = body_lines.last_mut() { - last.spans.push(Span::styled("_", body_style)); - } + if compose_field == ComposeField::Body && let Some(last) = body_lines.last_mut(){ + last.spans.push(Span::styled("_", body_style)); } // Show quoted original below the editable area (dimmed, read-only). if !compose_reply_quote.is_empty() { @@ -578,265 +637,285 @@ pub fn main(config: &Config, terminal: &mut Terminal>) })?; // --- Input handling --- - if event::poll(Duration::from_millis(200))? { - if let Event::Key(key) = event::read()? { - if mode == Mode::Compose { - match key.code { - KeyCode::Esc => { + if event::poll(Duration::from_millis(200))? + && let Event::Key(key) = event::read()? + { + if mode == Mode::Compose { + match key.code { + KeyCode::Esc => { + mode = Mode::Normal; + compose_reply_quote.clear(); + } + KeyCode::Tab => { + compose_field = match compose_field { + ComposeField::To => ComposeField::Subject, + ComposeField::Subject => ComposeField::Body, + ComposeField::Body => ComposeField::To, + }; + } + KeyCode::Enter if compose_field != ComposeField::Body => { + compose_field = match compose_field { + ComposeField::To => ComposeField::Subject, + _ => ComposeField::Body, + }; + } + KeyCode::Enter => { + compose_body.push('\n'); + } + KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { + 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 { + to: compose_to.clone(), + subject: compose_subject.clone(), + body: full_body, + }); mode = Mode::Normal; compose_reply_quote.clear(); } - KeyCode::Tab => { - compose_field = match compose_field { - ComposeField::To => ComposeField::Subject, - ComposeField::Subject => ComposeField::Body, - ComposeField::Body => ComposeField::To, - }; - } - KeyCode::Enter if compose_field != ComposeField::Body => { - compose_field = match compose_field { - ComposeField::To => ComposeField::Subject, - _ => ComposeField::Body, - }; - } - KeyCode::Enter => { compose_body.push('\n'); } - KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { - 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 { - to: compose_to.clone(), - subject: compose_subject.clone(), - body: full_body, - }); - mode = Mode::Normal; - compose_reply_quote.clear(); - } - } - KeyCode::Backspace => { - match compose_field { - ComposeField::To => { compose_to.pop(); } - ComposeField::Subject => { compose_subject.pop(); } - ComposeField::Body => { compose_body.pop(); } - } - } - KeyCode::Char(c) => { - match compose_field { - ComposeField::To => compose_to.push(c), - ComposeField::Subject => compose_subject.push(c), - ComposeField::Body => compose_body.push(c), - } - } - _ => {} } - } else if mode == Mode::Search { - match key.code { - KeyCode::Char(c) => search_query.push(c), - KeyCode::Backspace => { search_query.pop(); } - KeyCode::Enter => { - if !search_query.is_empty() { - search_loading = true; - search_active = true; - search_results.clear(); - search_results_state.select(None); - let _ = cmd_tx.send(WorkerCmd::Search { query: search_query.clone() }); - } - mode = Mode::Normal; + KeyCode::Backspace => match compose_field { + ComposeField::To => { + compose_to.pop(); } - KeyCode::Esc => { - mode = Mode::Normal; + ComposeField::Subject => { + compose_subject.pop(); } - _ => {} + ComposeField::Body => { + compose_body.pop(); + } + }, + KeyCode::Char(c) => match compose_field { + ComposeField::To => compose_to.push(c), + ComposeField::Subject => compose_subject.push(c), + ComposeField::Body => compose_body.push(c), + }, + _ => {} + } + } else if mode == Mode::Search { + match key.code { + KeyCode::Char(c) => search_query.push(c), + KeyCode::Backspace => { + search_query.pop(); } - } else { - match key.code { - KeyCode::Char('q') => break, - KeyCode::Esc => { - if search_active { - search_active = false; - search_results.clear(); - search_results_state.select(None); - } else { - break; - } + KeyCode::Enter => { + if !search_query.is_empty() { + search_loading = true; + search_active = true; + search_results.clear(); + search_results_state.select(None); + let _ = cmd_tx.send(WorkerCmd::Search { + query: search_query.clone(), + }); } - KeyCode::Char('/') => { - search_query.clear(); - mode = Mode::Search; + mode = Mode::Normal; + } + KeyCode::Esc => { + mode = Mode::Normal; + } + _ => {} + } + } else { + match key.code { + KeyCode::Char('q') => break, + KeyCode::Esc => { + if search_active { + search_active = false; + search_results.clear(); + search_results_state.select(None); + } else { + break; } - KeyCode::Char('c') => { - compose_to.clear(); - compose_subject.clear(); - compose_body.clear(); - compose_field = ComposeField::To; - send_status = None; - mode = Mode::Compose; + } + KeyCode::Char('/') => { + search_query.clear(); + mode = Mode::Search; + } + KeyCode::Char('c') => { + compose_to.clear(); + compose_subject.clear(); + compose_body.clear(); + compose_field = ComposeField::To; + send_status = None; + mode = Mode::Compose; + } + KeyCode::Tab => { + focus = match focus { + Focus::Inbox => Focus::Message, + Focus::Message => Focus::Inbox, + }; + } + KeyCode::Char('u') | KeyCode::F(5) => { + if !loading && !search_active { + let _ = cmd_tx.send(WorkerCmd::Refresh); + loading = true; + last_fetch = Instant::now(); } - KeyCode::Tab => { - focus = match focus { - Focus::Inbox => Focus::Message, - Focus::Message => Focus::Inbox, - }; - } - KeyCode::Char('u') | KeyCode::F(5) => { - if !loading && !search_active { - let _ = cmd_tx.send(WorkerCmd::Refresh); - loading = true; - 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:") { + } + 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::>() - .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; + 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::>() + .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 { - Focus::Inbox => { - if search_active { - let len = search_results.len(); - if len > 0 { - let i = search_results_state - .selected() - .map_or(0, |i| (i + 1).min(len - 1)); - search_results_state.select(Some(i)); - let seq = search_results[i].seq; - if message_seq != Some(seq) { - message_seq = Some(seq); - message_scroll = 0; - pending_fetch_seq = Some(seq); - last_nav = Instant::now(); - } - } - } else { - 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 => { - if search_active { + } + KeyCode::Down | KeyCode::Char('j') => match focus { + Focus::Inbox => { + if search_active { + let len = search_results.len(); + if len > 0 { let i = search_results_state .selected() - .map_or(0, |i| i.saturating_sub(1)); + .map_or(0, |i| (i + 1).min(len - 1)); search_results_state.select(Some(i)); - if !search_results.is_empty() { - let seq = search_results[i].seq; - if message_seq != Some(seq) { - message_seq = Some(seq); - message_scroll = 0; - pending_fetch_seq = Some(seq); - last_nav = Instant::now(); - } + let seq = search_results[i].seq; + if message_seq != Some(seq) { + message_seq = Some(seq); + message_scroll = 0; + pending_fetch_seq = Some(seq); + last_nav = Instant::now(); } - } else { - 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(); - } + } + } else { + let len = emails.len(); + if len > 0 { + let i = list_state.selected().map_or(0, |i| i + 1); + if i >= len + && has_older + && !loading + && 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_sub(1); - } } - KeyCode::Char('d') if focus == Focus::Inbox && !search_active => { - if let Some(idx) = list_state.selected() { - if idx < emails.len() { - let seq = emails[idx].seq; - emails.remove(idx); - // IMAP expunge shifts sequence numbers down by 1 for all seqs > deleted - for e in emails.iter_mut() { - if e.seq > seq { - e.seq -= 1; - } + Focus::Message => { + message_scroll = message_scroll.saturating_add(1); + } + }, + KeyCode::Up | KeyCode::Char('k') => match focus { + Focus::Inbox => { + if search_active { + let i = search_results_state + .selected() + .map_or(0, |i| i.saturating_sub(1)); + search_results_state.select(Some(i)); + if !search_results.is_empty() { + let seq = search_results[i].seq; + if message_seq != Some(seq) { + message_seq = Some(seq); + message_scroll = 0; + pending_fetch_seq = Some(seq); + last_nav = Instant::now(); } - 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 }); - } + } + } else { + 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(); } - let _ = cmd_tx.send(WorkerCmd::Delete { seq }); } } } - _ => {} + Focus::Message => { + message_scroll = message_scroll.saturating_sub(1); + } + }, + KeyCode::Char('d') if focus == Focus::Inbox && !search_active => { + if let Some(idx) = list_state.selected() + && idx < emails.len() + { + let seq = emails[idx].seq; + let deleted_mid = emails[idx].message_id.clone(); + emails.remove(idx); + // IMAP expunge shifts sequence numbers down by 1 for all seqs > deleted + 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; + let new_mid = emails[new_idx].message_id.clone(); + 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, + message_id: new_mid, + }); + } + } + let _ = cmd_tx.send(WorkerCmd::Delete { + seq, + message_id: deleted_mid, + }); + } } + _ => {} } } } @@ -847,4 +926,4 @@ pub fn main(config: &Config, terminal: &mut Terminal>) drop(cmd_tx); Ok(()) -} \ No newline at end of file +} diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..a15f11b --- /dev/null +++ b/src/store.rs @@ -0,0 +1,94 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::path::PathBuf; +use aes_gcm::{Aes256Gcm, Key, Nonce, aead::{Aead, KeyInit}}; +use rand::RngCore; + +const NONCE_LEN: usize = 12; + +pub struct MessageStore { + cipher: Aes256Gcm, + dir: PathBuf, +} + +impl MessageStore { + pub fn init() -> Result { + let data_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("tuimail"); + std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?; + + let key_file = data_dir.join("storage.key"); + let key_hex = if key_file.exists() { + std::fs::read_to_string(&key_file).map_err(|e| e.to_string())? + } else { + let mut key = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut key); + let hex = to_hex(&key); + std::fs::write(&key_file, &hex).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + let _ = std::fs::set_permissions(&key_file, perms); + } + hex + }; + + let key_bytes = from_hex(key_hex.trim())?; + if key_bytes.len() != 32 { + return Err("storage key has wrong length".into()); + } + let cipher = Aes256Gcm::new(Key::::from_slice(&key_bytes)); + + let dir = data_dir.join("bodies"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + + Ok(Self { cipher, dir }) + } + + fn path(&self, message_id: &str) -> PathBuf { + let mut h = DefaultHasher::new(); + message_id.hash(&mut h); + self.dir.join(format!("{:016x}.enc", h.finish())) + } + + pub fn get(&self, message_id: &str) -> Option { + let data = std::fs::read(self.path(message_id)).ok()?; + if data.len() < NONCE_LEN { + return None; + } + let (nonce_bytes, ciphertext) = data.split_at(NONCE_LEN); + let plaintext = self.cipher + .decrypt(Nonce::from_slice(nonce_bytes), ciphertext) + .ok()?; + String::from_utf8(plaintext).ok() + } + + pub fn put(&self, message_id: &str, body: &str) -> Result<(), String> { + let mut nonce_bytes = [0u8; NONCE_LEN]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ciphertext = self.cipher + .encrypt(nonce, body.as_bytes()) + .map_err(|e| e.to_string())?; + let mut file_data = nonce_bytes.to_vec(); + file_data.extend_from_slice(&ciphertext); + std::fs::write(self.path(message_id), file_data).map_err(|e| e.to_string()) + } + + pub fn delete(&self, message_id: &str) { + let _ = std::fs::remove_file(self.path(message_id)); + } +} + +fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn from_hex(s: &str) -> Result, String> { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string())) + .collect() +}