implemented safe cache
This commit is contained in:
parent
2c70eb300a
commit
a709a3a946
6 changed files with 477 additions and 295 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4683,6 +4683,7 @@ dependencies = [
|
|||
name = "tuimail"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"chrono",
|
||||
"crossterm",
|
||||
"dirs",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
53
src/inbox.rs
53
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<ImapSession>,
|
||||
config: &Config,
|
||||
) -> Result<Inbox, String> {
|
||||
pub(crate) fn refresh(session: &mut Option<ImapSession>, config: &Config) -> Result<Inbox, String> {
|
||||
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<Vec<Emai
|
|||
match session {
|
||||
ImapSession::Plain(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))
|
||||
}
|
||||
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<Email> {
|
|||
let mut subject = String::new();
|
||||
let mut from = String::new();
|
||||
let mut date = String::new();
|
||||
let mut message_id: Option<String> = 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<Email> {
|
|||
} else if let Some(val) = line.strip_prefix("Date: ") {
|
||||
date = DateTime::parse_from_rfc2822(val)
|
||||
.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());
|
||||
} 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<ImapSession>,
|
||||
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<Vec<u8>> {
|
||||
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<String, String> {
|
||||
|
|
@ -214,7 +213,10 @@ fn extract_plain_text(raw: &[u8]) -> Result<String, String> {
|
|||
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<u32> = seq_set.into_iter().collect();
|
||||
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 mut emails = fetch_range_emails(s, &range)?;
|
||||
|
|
@ -320,4 +326,3 @@ fn find_part(mail: &mailparse::ParsedMail, mime_type: &str) -> Option<String> {
|
|||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
|
|
|||
611
src/lib.rs
611
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<String>,
|
||||
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<String>,
|
||||
},
|
||||
Delete {
|
||||
seq: u32,
|
||||
message_id: Option<String>,
|
||||
},
|
||||
Search {
|
||||
query: String,
|
||||
},
|
||||
SendEmail {
|
||||
to: String,
|
||||
subject: String,
|
||||
body: String,
|
||||
},
|
||||
}
|
||||
|
||||
enum WorkerResult {
|
||||
Refreshed(Result<inbox::Inbox, String>),
|
||||
FetchedMore(Result<(Vec<Email>, u32), String>),
|
||||
Body { seq: u32, result: Result<String, String> },
|
||||
Body {
|
||||
seq: u32,
|
||||
result: Result<String, String>,
|
||||
},
|
||||
Deleted(Result<(), String>),
|
||||
Searched(Result<Vec<Email>, String>),
|
||||
Sent(Result<(), String>),
|
||||
|
|
@ -73,6 +92,7 @@ fn worker_loop(
|
|||
cmd_rx: mpsc::Receiver<WorkerCmd>,
|
||||
result_tx: mpsc::Sender<WorkerResult>,
|
||||
wanted_body_seq: Arc<AtomicU32>,
|
||||
store: Option<store::MessageStore>,
|
||||
) {
|
||||
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_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<CrosstermBackend<Stdout>>) -> Result<(), Error> {
|
||||
pub fn main(
|
||||
config: &Config,
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
) -> 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<CrosstermBackend<Stdout>>)
|
|||
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<CrosstermBackend<Stdout>>)
|
|||
.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<CrosstermBackend<Stdout>>)
|
|||
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<CrosstermBackend<Stdout>>)
|
|||
}
|
||||
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<CrosstermBackend<Stdout>>)
|
|||
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<CrosstermBackend<Stdout>>)
|
|||
}
|
||||
|
||||
// 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<CrosstermBackend<Stdout>>)
|
|||
.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<CrosstermBackend<Stdout>>)
|
|||
})?;
|
||||
|
||||
// --- 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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
94
src/store.rs
Normal file
94
src/store.rs
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue