implemented safe cache

This commit is contained in:
Shautvast 2026-02-25 16:48:40 +01:00
parent 2c70eb300a
commit a709a3a946
6 changed files with 477 additions and 295 deletions

1
Cargo.lock generated
View file

@ -4683,6 +4683,7 @@ dependencies = [
name = "tuimail" name = "tuimail"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes-gcm",
"chrono", "chrono",
"crossterm", "crossterm",
"dirs", "dirs",

View file

@ -26,6 +26,7 @@ regex = "1"
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "native-tls", "builder"] } lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "native-tls", "builder"] }
dirs = "5" dirs = "5"
rand = { version = "0.8", features = ["getrandom"] } rand = { version = "0.8", features = ["getrandom"] }
aes-gcm = "0.10"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", features = ["apple-native"] } keyring = { version = "3", features = ["apple-native"] }

View file

@ -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 { 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. // 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 st = state.lock().await;
let count = st.store.count(); let count = st.store.count();
parse_seq_set(seq_set, count) parse_seq_set(seq_set, count)
.into_iter() .into_iter()
.filter_map(|seq| { .filter_map(|seq| {
st.store.get_meta(seq).map(|m| { st.store.get_meta(seq).map(|m| {
let proton_id = st.store.proton_id(seq).unwrap_or("").to_string();
( (
seq, seq,
m.subject.clone(), m.subject.clone(),
m.sender.name.clone(), m.sender.name.clone(),
m.sender.address.clone(), m.sender.address.clone(),
proton_id,
m.time, m.time,
) )
}) })
@ -198,15 +200,15 @@ async fn fetch_headers(tag: &str, seq_set: &str, state: &SharedState) -> String
}; };
let mut out = Vec::new(); 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!( 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), date = rfc2822(*time),
); );
let n = block.as_bytes().len(); let n = block.as_bytes().len();
out.extend_from_slice( out.extend_from_slice(
format!( 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(), .as_bytes(),
); );

View file

@ -1,7 +1,7 @@
use chrono::{DateTime, FixedOffset, Local};
use crate::config::Config; use crate::config::Config;
use crate::connect::ImapSession; use crate::connect::ImapSession;
use crate::{connect, Email}; use crate::{Email, connect};
use chrono::{DateTime, FixedOffset, Local};
const BATCH_SIZE: u32 = 50; const BATCH_SIZE: u32 = 50;
@ -10,10 +10,7 @@ pub(crate) struct Inbox {
} }
/// Refresh inbox (full reload). Reconnects on error. /// Refresh inbox (full reload). Reconnects on error.
pub(crate) fn refresh( pub(crate) fn refresh(session: &mut Option<ImapSession>, config: &Config) -> Result<Inbox, String> {
session: &mut Option<ImapSession>,
config: &Config,
) -> Result<Inbox, String> {
if let Some(s) = session.as_mut() { if let Some(s) = session.as_mut() {
if s.noop().is_ok() { if s.noop().is_ok() {
return fetch_latest(s); return fetch_latest(s);
@ -78,13 +75,13 @@ fn fetch_range_emails(session: &mut ImapSession, range: &str) -> Result<Vec<Emai
match session { match session {
ImapSession::Plain(s) => { ImapSession::Plain(s) => {
let messages = 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())?; .map_err(|e| e.to_string())?;
Ok(parse_emails(&messages)) Ok(parse_emails(&messages))
} }
ImapSession::Tls(s) => { ImapSession::Tls(s) => {
let messages = 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())?; .map_err(|e| e.to_string())?;
Ok(parse_emails(&messages)) Ok(parse_emails(&messages))
} }
@ -109,6 +106,7 @@ fn parse_emails(fetches: &[imap::types::Fetch]) -> Vec<Email> {
let mut subject = String::new(); let mut subject = String::new();
let mut from = String::new(); let mut from = String::new();
let mut date = String::new(); let mut date = String::new();
let mut message_id: Option<String> = None;
for line in header.lines() { for line in header.lines() {
if let Some(val) = line.strip_prefix("Subject: ") { if let Some(val) = line.strip_prefix("Subject: ") {
@ -118,14 +116,19 @@ fn parse_emails(fetches: &[imap::types::Fetch]) -> Vec<Email> {
} else if let Some(val) = line.strip_prefix("Date: ") { } else if let Some(val) = line.strip_prefix("Date: ") {
date = DateTime::parse_from_rfc2822(val) date = DateTime::parse_from_rfc2822(val)
.map(|dt: DateTime<FixedOffset>| { .map(|dt: DateTime<FixedOffset>| {
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()); .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 { emails.push(Email {
seq: message.message, seq: message.message,
message_id,
subject, subject,
from_addr: extract_addr(&from), from_addr: extract_addr(&from),
from, from,
@ -140,7 +143,7 @@ fn ensure_session<'a>(
session: &'a mut Option<ImapSession>, session: &'a mut Option<ImapSession>,
config: &Config, config: &Config,
) -> Result<&'a mut ImapSession, String> { ) -> 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 = None;
*session = Some(connect::connect(config)?); *session = Some(connect::connect(config)?);
} }
@ -157,15 +160,11 @@ pub(crate) fn fetch_body(
let range = seq.to_string(); let range = seq.to_string();
let raw = match s { let raw = match s {
ImapSession::Plain(s) => { ImapSession::Plain(s) => {
let messages = s let messages = s.fetch(&range, "BODY.PEEK[]").map_err(|e| e.to_string())?;
.fetch(&range, "BODY.PEEK[]")
.map_err(|e| e.to_string())?;
extract_raw_body(&messages) extract_raw_body(&messages)
} }
ImapSession::Tls(s) => { ImapSession::Tls(s) => {
let messages = s let messages = s.fetch(&range, "BODY.PEEK[]").map_err(|e| e.to_string())?;
.fetch(&range, "BODY.PEEK[]")
.map_err(|e| e.to_string())?;
extract_raw_body(&messages) extract_raw_body(&messages)
} }
}; };
@ -184,12 +183,14 @@ pub(crate) fn delete_email(
match s { match s {
ImapSession::Plain(s) => { ImapSession::Plain(s) => {
s.select("INBOX").map_err(|e| e.to_string())?; 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())?; s.expunge().map_err(|e| e.to_string())?;
} }
ImapSession::Tls(s) => { ImapSession::Tls(s) => {
s.select("INBOX").map_err(|e| e.to_string())?; 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())?; 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<Vec<u8>> { fn extract_raw_body(fetches: &[imap::types::Fetch]) -> Option<Vec<u8>> {
fetches.first().and_then(|f| { fetches.first().and_then(|f| f.body().map(|b| b.to_vec()))
f.body().map(|b| b.to_vec())
})
} }
fn extract_plain_text(raw: &[u8]) -> Result<String, String> { fn extract_plain_text(raw: &[u8]) -> Result<String, String> {
@ -214,7 +213,10 @@ fn extract_plain_text(raw: &[u8]) -> Result<String, String> {
return Ok(clean_markdown(&clean_text(&md))); return Ok(clean_markdown(&clean_text(&md)));
} }
// Last resort: top-level body // 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) /// Remove leftover quoted-printable artifacts (=XX sequences)
@ -300,7 +302,11 @@ pub(crate) fn search(
let mut seqs: Vec<u32> = seq_set.into_iter().collect(); let mut seqs: Vec<u32> = seq_set.into_iter().collect();
seqs.sort_unstable(); seqs.sort_unstable();
let range = seqs.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(","); let range = seqs
.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(",");
let s = session.as_mut().unwrap(); let s = session.as_mut().unwrap();
let mut emails = fetch_range_emails(s, &range)?; let mut emails = fetch_range_emails(s, &range)?;
@ -320,4 +326,3 @@ fn find_part(mail: &mailparse::ParsedMail, mime_type: &str) -> Option<String> {
} }
None None
} }

View file

@ -1,23 +1,24 @@
use std::io::{Error, Stdout}; use crate::config::Config;
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;
use crossterm::event::{Event, KeyCode, KeyModifiers}; use crossterm::event::{Event, KeyCode, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::{Color, Line, Modifier, Span, Style}; use ratatui::prelude::{Color, Line, Modifier, Span, Style};
use ratatui::Terminal;
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; 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 config;
pub mod credentials;
pub mod setup;
mod connect; mod connect;
pub mod credentials;
mod inbox; mod inbox;
pub mod setup;
mod smtp; mod smtp;
mod store;
const POLL_INTERVAL: Duration = Duration::from_secs(30); const POLL_INTERVAL: Duration = Duration::from_secs(30);
const NAV_DEBOUNCE: Duration = Duration::from_millis(150); const NAV_DEBOUNCE: Duration = Duration::from_millis(150);
@ -44,6 +45,7 @@ enum ComposeField {
pub(crate) struct Email { pub(crate) struct Email {
pub seq: u32, pub seq: u32,
pub message_id: Option<String>,
pub subject: String, pub subject: String,
pub from: String, pub from: String,
pub from_addr: String, pub from_addr: String,
@ -52,17 +54,34 @@ pub(crate) struct Email {
enum WorkerCmd { enum WorkerCmd {
Refresh, Refresh,
FetchMore { oldest_seq: u32 }, FetchMore {
FetchBody { seq: u32 }, oldest_seq: u32,
Delete { seq: u32 }, },
Search { query: String }, FetchBody {
SendEmail { to: String, subject: String, body: String }, seq: u32,
message_id: Option<String>,
},
Delete {
seq: u32,
message_id: Option<String>,
},
Search {
query: String,
},
SendEmail {
to: String,
subject: String,
body: String,
},
} }
enum WorkerResult { enum WorkerResult {
Refreshed(Result<inbox::Inbox, String>), Refreshed(Result<inbox::Inbox, String>),
FetchedMore(Result<(Vec<Email>, u32), String>), FetchedMore(Result<(Vec<Email>, u32), String>),
Body { seq: u32, result: Result<String, String> }, Body {
seq: u32,
result: Result<String, String>,
},
Deleted(Result<(), String>), Deleted(Result<(), String>),
Searched(Result<Vec<Email>, String>), Searched(Result<Vec<Email>, String>),
Sent(Result<(), String>), Sent(Result<(), String>),
@ -73,6 +92,7 @@ fn worker_loop(
cmd_rx: mpsc::Receiver<WorkerCmd>, cmd_rx: mpsc::Receiver<WorkerCmd>,
result_tx: mpsc::Sender<WorkerResult>, result_tx: mpsc::Sender<WorkerResult>,
wanted_body_seq: Arc<AtomicU32>, wanted_body_seq: Arc<AtomicU32>,
store: Option<store::MessageStore>,
) { ) {
let mut session: Option<connect::ImapSession> = None; let mut session: Option<connect::ImapSession> = None;
@ -86,16 +106,36 @@ fn worker_loop(
let result = inbox::fetch_older_batch(&mut session, oldest_seq, &config); let result = inbox::fetch_older_batch(&mut session, oldest_seq, &config);
let _ = result_tx.send(WorkerResult::FetchedMore(result)); let _ = result_tx.send(WorkerResult::FetchedMore(result));
} }
WorkerCmd::FetchBody { seq } => { WorkerCmd::FetchBody { seq, message_id } => {
// Skip if a newer body request has been made // Skip if a newer body request has been made
if wanted_body_seq.load(Ordering::Relaxed) != seq { if wanted_body_seq.load(Ordering::Relaxed) != seq {
continue; 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); 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 }); 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); 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)); let _ = result_tx.send(WorkerResult::Deleted(result));
} }
WorkerCmd::Search { query } => { WorkerCmd::Search { query } => {
@ -118,15 +158,25 @@ fn worker_loop(
} }
} }
pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), Error> { pub fn main(
config: &Config,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> Result<(), Error> {
let (cmd_tx, cmd_rx) = mpsc::channel(); let (cmd_tx, cmd_rx) = mpsc::channel();
let (result_tx, result_rx) = mpsc::channel(); let (result_tx, result_rx) = mpsc::channel();
let wanted_body_seq = Arc::new(AtomicU32::new(0)); let wanted_body_seq = Arc::new(AtomicU32::new(0));
let worker_config = config.clone(); let worker_config = config.clone();
let worker_wanted = Arc::clone(&wanted_body_seq); 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 || { 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 // Send initial refresh
@ -168,7 +218,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
while let Ok(result) = result_rx.try_recv() { while let Ok(result) = result_rx.try_recv() {
match result { match result {
WorkerResult::Refreshed(Ok(inbox)) => { WorkerResult::Refreshed(Ok(inbox)) => {
let prev_selected_seq = list_state.selected() let prev_selected_seq = list_state
.selected()
.and_then(|i| emails.get(i)) .and_then(|i| emails.get(i))
.map(|e| e.seq); .map(|e| e.seq);
@ -192,6 +243,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
.unwrap_or(0); .unwrap_or(0);
list_state.select(Some(new_idx)); list_state.select(Some(new_idx));
let seq = emails[new_idx].seq; 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() { if message_seq != Some(seq) || pending_fetch_seq.is_some() {
message_seq = Some(seq); message_seq = Some(seq);
message_body.clear(); message_body.clear();
@ -200,7 +252,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
body_loading = true; body_loading = true;
pending_fetch_seq = None; pending_fetch_seq = None;
wanted_body_seq.store(seq, Ordering::Relaxed); wanted_body_seq.store(seq, Ordering::Relaxed);
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq }); let _ = cmd_tx.send(WorkerCmd::FetchBody { seq, message_id });
} }
} else { } else {
list_state.select(None); list_state.select(None);
@ -242,13 +294,17 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
} }
WorkerResult::Searched(Ok(results)) => { WorkerResult::Searched(Ok(results)) => {
search_results = results; search_results = results;
search_results_state search_results_state.select(if search_results.is_empty() {
.select(if search_results.is_empty() { None } else { Some(0) }); None
} else {
Some(0)
});
search_loading = false; search_loading = false;
search_active = true; search_active = true;
// Fetch body of the first result // Fetch body of the first result
if let Some(first) = search_results.first() { if let Some(first) = search_results.first() {
let seq = first.seq; let seq = first.seq;
let message_id = first.message_id.clone();
message_seq = Some(seq); message_seq = Some(seq);
message_body.clear(); message_body.clear();
message_rendered = None; message_rendered = None;
@ -256,7 +312,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
body_loading = true; body_loading = true;
pending_fetch_seq = None; pending_fetch_seq = None;
wanted_body_seq.store(seq, Ordering::Relaxed); 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)) => { WorkerResult::Searched(Err(e)) => {
@ -274,16 +330,21 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
} }
// Fire pending body fetch after nav debounce elapses // Fire pending body fetch after nav debounce elapses
if let Some(seq) = pending_fetch_seq { if let Some(seq) = pending_fetch_seq
if last_nav.elapsed() >= NAV_DEBOUNCE { && 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_body.clear();
message_rendered = None; message_rendered = None;
body_loading = true; body_loading = true;
wanted_body_seq.store(seq, Ordering::Relaxed); wanted_body_seq.store(seq, Ordering::Relaxed);
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq }); let _ = cmd_tx.send(WorkerCmd::FetchBody { seq, message_id });
pending_fetch_seq = None; pending_fetch_seq = None;
} }
}
// Periodic refresh // Periodic refresh
if !loading && last_fetch.elapsed() >= POLL_INTERVAL { if !loading && last_fetch.elapsed() >= POLL_INTERVAL {
@ -345,11 +406,9 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
.split('\n') .split('\n')
.map(|l| Line::from(Span::styled(l.to_string(), body_style))) .map(|l| Line::from(Span::styled(l.to_string(), body_style)))
.collect(); .collect();
if compose_field == ComposeField::Body { if compose_field == ComposeField::Body && let Some(last) = body_lines.last_mut(){
if let Some(last) = body_lines.last_mut() {
last.spans.push(Span::styled("_", body_style)); last.spans.push(Span::styled("_", body_style));
} }
}
// Show quoted original below the editable area (dimmed, read-only). // Show quoted original below the editable area (dimmed, read-only).
if !compose_reply_quote.is_empty() { if !compose_reply_quote.is_empty() {
body_lines.push(Line::from(Span::styled( body_lines.push(Line::from(Span::styled(
@ -578,8 +637,9 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
})?; })?;
// --- Input handling --- // --- Input handling ---
if event::poll(Duration::from_millis(200))? { if event::poll(Duration::from_millis(200))?
if let Event::Key(key) = event::read()? { && let Event::Key(key) = event::read()?
{
if mode == Mode::Compose { if mode == Mode::Compose {
match key.code { match key.code {
KeyCode::Esc => { KeyCode::Esc => {
@ -599,7 +659,9 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
_ => ComposeField::Body, _ => ComposeField::Body,
}; };
} }
KeyCode::Enter => { compose_body.push('\n'); } KeyCode::Enter => {
compose_body.push('\n');
}
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if !compose_to.is_empty() { if !compose_to.is_empty() {
let full_body = if compose_reply_quote.is_empty() { let full_body = if compose_reply_quote.is_empty() {
@ -616,33 +678,39 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
compose_reply_quote.clear(); compose_reply_quote.clear();
} }
} }
KeyCode::Backspace => { KeyCode::Backspace => match compose_field {
match compose_field { ComposeField::To => {
ComposeField::To => { compose_to.pop(); } compose_to.pop();
ComposeField::Subject => { compose_subject.pop(); }
ComposeField::Body => { compose_body.pop(); }
} }
ComposeField::Subject => {
compose_subject.pop();
} }
KeyCode::Char(c) => { ComposeField::Body => {
match compose_field { compose_body.pop();
}
},
KeyCode::Char(c) => match compose_field {
ComposeField::To => compose_to.push(c), ComposeField::To => compose_to.push(c),
ComposeField::Subject => compose_subject.push(c), ComposeField::Subject => compose_subject.push(c),
ComposeField::Body => compose_body.push(c), ComposeField::Body => compose_body.push(c),
} },
}
_ => {} _ => {}
} }
} else if mode == Mode::Search { } else if mode == Mode::Search {
match key.code { match key.code {
KeyCode::Char(c) => search_query.push(c), KeyCode::Char(c) => search_query.push(c),
KeyCode::Backspace => { search_query.pop(); } KeyCode::Backspace => {
search_query.pop();
}
KeyCode::Enter => { KeyCode::Enter => {
if !search_query.is_empty() { if !search_query.is_empty() {
search_loading = true; search_loading = true;
search_active = true; search_active = true;
search_results.clear(); search_results.clear();
search_results_state.select(None); search_results_state.select(None);
let _ = cmd_tx.send(WorkerCmd::Search { query: search_query.clone() }); let _ = cmd_tx.send(WorkerCmd::Search {
query: search_query.clone(),
});
} }
mode = Mode::Normal; mode = Mode::Normal;
} }
@ -691,13 +759,16 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
KeyCode::Char('r') if focus == Focus::Inbox => { KeyCode::Char('r') if focus == Focus::Inbox => {
// Reply to the currently selected email. // Reply to the currently selected email.
let email_opt = if search_active { let email_opt = if search_active {
search_results_state.selected().and_then(|i| search_results.get(i)) search_results_state
.selected()
.and_then(|i| search_results.get(i))
} else { } else {
list_state.selected().and_then(|i| emails.get(i)) list_state.selected().and_then(|i| emails.get(i))
}; };
if let Some(email) = email_opt { if let Some(email) = email_opt {
compose_to = email.from_addr.clone(); compose_to = email.from_addr.clone();
compose_subject = if email.subject.to_ascii_lowercase().starts_with("re:") { compose_subject =
if email.subject.to_ascii_lowercase().starts_with("re:") {
email.subject.clone() email.subject.clone()
} else { } else {
format!("Re: {}", email.subject) format!("Re: {}", email.subject)
@ -710,10 +781,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
.map(|l| format!("> {l}")) .map(|l| format!("> {l}"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
compose_reply_quote = format!( compose_reply_quote =
"On {}, {} wrote:\n{}", format!("On {}, {} wrote:\n{}", email.date, email.from, quoted);
email.date, email.from, quoted
);
} else { } else {
compose_reply_quote.clear(); compose_reply_quote.clear();
} }
@ -743,12 +812,15 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
let len = emails.len(); let len = emails.len();
if len > 0 { if len > 0 {
let i = list_state.selected().map_or(0, |i| i + 1); let i = list_state.selected().map_or(0, |i| i + 1);
if i >= len && has_older && !loading { if i >= len
if let Some(seq) = oldest_seq { && has_older
let _ = cmd_tx.send(WorkerCmd::FetchMore { oldest_seq: seq }); && !loading
&& let Some(seq) = oldest_seq
{
let _ =
cmd_tx.send(WorkerCmd::FetchMore { oldest_seq: seq });
loading = true; loading = true;
} }
}
let new_idx = i.min(len.saturating_sub(1)); let new_idx = i.min(len.saturating_sub(1));
list_state.select(Some(new_idx)); list_state.select(Some(new_idx));
let seq = emails[new_idx].seq; let seq = emails[new_idx].seq;
@ -764,7 +836,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
Focus::Message => { Focus::Message => {
message_scroll = message_scroll.saturating_add(1); message_scroll = message_scroll.saturating_add(1);
} }
} },
KeyCode::Up | KeyCode::Char('k') => match focus { KeyCode::Up | KeyCode::Char('k') => match focus {
Focus::Inbox => { Focus::Inbox => {
if search_active { if search_active {
@ -798,11 +870,13 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
Focus::Message => { Focus::Message => {
message_scroll = message_scroll.saturating_sub(1); message_scroll = message_scroll.saturating_sub(1);
} }
} },
KeyCode::Char('d') if focus == Focus::Inbox && !search_active => { KeyCode::Char('d') if focus == Focus::Inbox && !search_active => {
if let Some(idx) = list_state.selected() { if let Some(idx) = list_state.selected()
if idx < emails.len() { && idx < emails.len()
{
let seq = emails[idx].seq; let seq = emails[idx].seq;
let deleted_mid = emails[idx].message_id.clone();
emails.remove(idx); emails.remove(idx);
// IMAP expunge shifts sequence numbers down by 1 for all seqs > deleted // IMAP expunge shifts sequence numbers down by 1 for all seqs > deleted
for e in emails.iter_mut() { for e in emails.iter_mut() {
@ -820,6 +894,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
let new_idx = idx.min(emails.len().saturating_sub(1)); let new_idx = idx.min(emails.len().saturating_sub(1));
list_state.select(Some(new_idx)); list_state.select(Some(new_idx));
let new_seq = emails[new_idx].seq; let new_seq = emails[new_idx].seq;
let new_mid = emails[new_idx].message_id.clone();
if message_seq != Some(new_seq) { if message_seq != Some(new_seq) {
message_seq = Some(new_seq); message_seq = Some(new_seq);
message_body.clear(); message_body.clear();
@ -828,11 +903,16 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
body_loading = true; body_loading = true;
pending_fetch_seq = None; pending_fetch_seq = None;
wanted_body_seq.store(new_seq, Ordering::Relaxed); wanted_body_seq.store(new_seq, Ordering::Relaxed);
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq: new_seq }); let _ = cmd_tx.send(WorkerCmd::FetchBody {
seq: new_seq,
message_id: new_mid,
});
} }
} }
let _ = cmd_tx.send(WorkerCmd::Delete { seq }); let _ = cmd_tx.send(WorkerCmd::Delete {
} seq,
message_id: deleted_mid,
});
} }
} }
_ => {} _ => {}
@ -840,7 +920,6 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
} }
} }
} }
}
// Signal the worker to stop and close the channel. // Signal the worker to stop and close the channel.
// Don't join — if a network op is in flight, join blocks indefinitely. // Don't join — if a network op is in flight, join blocks indefinitely.

94
src/store.rs Normal file
View file

@ -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<Self, String> {
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::<Aes256Gcm>::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<String> {
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<Vec<u8>, 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()
}