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"
|
name = "tuimail"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
"chrono",
|
"chrono",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
53
src/inbox.rs
53
src/inbox.rs
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
611
src/lib.rs
611
src/lib.rs
|
|
@ -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,15 +330,20 @@ 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
|
||||||
message_body.clear();
|
{
|
||||||
message_rendered = None;
|
let message_id = emails
|
||||||
body_loading = true;
|
.iter()
|
||||||
wanted_body_seq.store(seq, Ordering::Relaxed);
|
.chain(search_results.iter())
|
||||||
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
|
.find(|e| e.seq == seq)
|
||||||
pending_fetch_seq = None;
|
.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
|
// Periodic refresh
|
||||||
|
|
@ -345,10 +406,8 @@ 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() {
|
||||||
|
|
@ -578,265 +637,285 @@ 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 {
|
{
|
||||||
match key.code {
|
if mode == Mode::Compose {
|
||||||
KeyCode::Esc => {
|
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;
|
mode = Mode::Normal;
|
||||||
compose_reply_quote.clear();
|
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 {
|
KeyCode::Backspace => match compose_field {
|
||||||
match key.code {
|
ComposeField::To => {
|
||||||
KeyCode::Char(c) => search_query.push(c),
|
compose_to.pop();
|
||||||
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::Esc => {
|
ComposeField::Subject => {
|
||||||
mode = Mode::Normal;
|
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 {
|
KeyCode::Enter => {
|
||||||
match key.code {
|
if !search_query.is_empty() {
|
||||||
KeyCode::Char('q') => break,
|
search_loading = true;
|
||||||
KeyCode::Esc => {
|
search_active = true;
|
||||||
if search_active {
|
search_results.clear();
|
||||||
search_active = false;
|
search_results_state.select(None);
|
||||||
search_results.clear();
|
let _ = cmd_tx.send(WorkerCmd::Search {
|
||||||
search_results_state.select(None);
|
query: search_query.clone(),
|
||||||
} else {
|
});
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
KeyCode::Char('/') => {
|
mode = Mode::Normal;
|
||||||
search_query.clear();
|
}
|
||||||
mode = Mode::Search;
|
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();
|
KeyCode::Char('/') => {
|
||||||
compose_subject.clear();
|
search_query.clear();
|
||||||
compose_body.clear();
|
mode = Mode::Search;
|
||||||
compose_field = ComposeField::To;
|
}
|
||||||
send_status = None;
|
KeyCode::Char('c') => {
|
||||||
mode = Mode::Compose;
|
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 {
|
KeyCode::Char('r') if focus == Focus::Inbox => {
|
||||||
Focus::Inbox => Focus::Message,
|
// Reply to the currently selected email.
|
||||||
Focus::Message => Focus::Inbox,
|
let email_opt = if search_active {
|
||||||
};
|
search_results_state
|
||||||
}
|
.selected()
|
||||||
KeyCode::Char('u') | KeyCode::F(5) => {
|
.and_then(|i| search_results.get(i))
|
||||||
if !loading && !search_active {
|
} else {
|
||||||
let _ = cmd_tx.send(WorkerCmd::Refresh);
|
list_state.selected().and_then(|i| emails.get(i))
|
||||||
loading = true;
|
};
|
||||||
last_fetch = Instant::now();
|
if let Some(email) = email_opt {
|
||||||
}
|
compose_to = email.from_addr.clone();
|
||||||
}
|
compose_subject =
|
||||||
KeyCode::Char('r') if focus == Focus::Inbox => {
|
if email.subject.to_ascii_lowercase().starts_with("re:") {
|
||||||
// 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()
|
email.subject.clone()
|
||||||
} else {
|
} else {
|
||||||
format!("Re: {}", email.subject)
|
format!("Re: {}", email.subject)
|
||||||
};
|
};
|
||||||
compose_body.clear();
|
compose_body.clear();
|
||||||
// Pre-fill quoted original if the body is already loaded.
|
// Pre-fill quoted original if the body is already loaded.
|
||||||
if message_seq == Some(email.seq) && !message_body.is_empty() {
|
if message_seq == Some(email.seq) && !message_body.is_empty() {
|
||||||
let quoted = message_body
|
let quoted = message_body
|
||||||
.lines()
|
.lines()
|
||||||
.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 {
|
||||||
);
|
compose_reply_quote.clear();
|
||||||
} else {
|
|
||||||
compose_reply_quote.clear();
|
|
||||||
}
|
|
||||||
compose_field = ComposeField::Body;
|
|
||||||
send_status = None;
|
|
||||||
mode = Mode::Compose;
|
|
||||||
}
|
}
|
||||||
|
compose_field = ComposeField::Body;
|
||||||
|
send_status = None;
|
||||||
|
mode = Mode::Compose;
|
||||||
}
|
}
|
||||||
KeyCode::Down | KeyCode::Char('j') => match focus {
|
}
|
||||||
Focus::Inbox => {
|
KeyCode::Down | KeyCode::Char('j') => match focus {
|
||||||
if search_active {
|
Focus::Inbox => {
|
||||||
let len = search_results.len();
|
if search_active {
|
||||||
if len > 0 {
|
let len = search_results.len();
|
||||||
let i = search_results_state
|
if len > 0 {
|
||||||
.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 {
|
|
||||||
let i = search_results_state
|
let i = search_results_state
|
||||||
.selected()
|
.selected()
|
||||||
.map_or(0, |i| i.saturating_sub(1));
|
.map_or(0, |i| (i + 1).min(len - 1));
|
||||||
search_results_state.select(Some(i));
|
search_results_state.select(Some(i));
|
||||||
if !search_results.is_empty() {
|
let seq = search_results[i].seq;
|
||||||
let seq = search_results[i].seq;
|
if message_seq != Some(seq) {
|
||||||
if message_seq != Some(seq) {
|
message_seq = Some(seq);
|
||||||
message_seq = Some(seq);
|
message_scroll = 0;
|
||||||
message_scroll = 0;
|
pending_fetch_seq = Some(seq);
|
||||||
pending_fetch_seq = Some(seq);
|
last_nav = Instant::now();
|
||||||
last_nav = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
let i = list_state.selected().map_or(0, |i| i.saturating_sub(1));
|
} else {
|
||||||
list_state.select(Some(i));
|
let len = emails.len();
|
||||||
if !emails.is_empty() {
|
if len > 0 {
|
||||||
let seq = emails[i].seq;
|
let i = list_state.selected().map_or(0, |i| i + 1);
|
||||||
if message_seq != Some(seq) {
|
if i >= len
|
||||||
message_seq = Some(seq);
|
&& has_older
|
||||||
message_scroll = 0;
|
&& !loading
|
||||||
pending_fetch_seq = Some(seq);
|
&& let Some(seq) = oldest_seq
|
||||||
last_nav = Instant::now();
|
{
|
||||||
}
|
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 => {
|
Focus::Message => {
|
||||||
if let Some(idx) = list_state.selected() {
|
message_scroll = message_scroll.saturating_add(1);
|
||||||
if idx < emails.len() {
|
}
|
||||||
let seq = emails[idx].seq;
|
},
|
||||||
emails.remove(idx);
|
KeyCode::Up | KeyCode::Char('k') => match focus {
|
||||||
// IMAP expunge shifts sequence numbers down by 1 for all seqs > deleted
|
Focus::Inbox => {
|
||||||
for e in emails.iter_mut() {
|
if search_active {
|
||||||
if e.seq > seq {
|
let i = search_results_state
|
||||||
e.seq -= 1;
|
.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);
|
} else {
|
||||||
message_seq = None;
|
let i = list_state.selected().map_or(0, |i| i.saturating_sub(1));
|
||||||
message_body.clear();
|
list_state.select(Some(i));
|
||||||
message_rendered = None;
|
if !emails.is_empty() {
|
||||||
pending_fetch_seq = None;
|
let seq = emails[i].seq;
|
||||||
} else {
|
if message_seq != Some(seq) {
|
||||||
let new_idx = idx.min(emails.len().saturating_sub(1));
|
message_seq = Some(seq);
|
||||||
list_state.select(Some(new_idx));
|
message_scroll = 0;
|
||||||
let new_seq = emails[new_idx].seq;
|
pending_fetch_seq = Some(seq);
|
||||||
if message_seq != Some(new_seq) {
|
last_nav = Instant::now();
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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