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"
version = "0.1.0"
dependencies = [
"aes-gcm",
"chrono",
"crossterm",
"dirs",

View file

@ -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"] }

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 {
// 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(),
);

View file

@ -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
}

View file

@ -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,16 +330,21 @@ 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 {
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 });
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq, message_id });
pending_fetch_seq = None;
}
}
// Periodic refresh
if !loading && last_fetch.elapsed() >= POLL_INTERVAL {
@ -345,11 +406,9 @@ 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() {
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() {
body_lines.push(Line::from(Span::styled(
@ -578,8 +637,9 @@ 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 event::poll(Duration::from_millis(200))?
&& let Event::Key(key) = event::read()?
{
if mode == Mode::Compose {
match key.code {
KeyCode::Esc => {
@ -599,7 +659,9 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
_ => ComposeField::Body,
};
}
KeyCode::Enter => { compose_body.push('\n'); }
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() {
@ -616,33 +678,39 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
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::Backspace => match compose_field {
ComposeField::To => {
compose_to.pop();
}
ComposeField::Subject => {
compose_subject.pop();
}
KeyCode::Char(c) => {
match compose_field {
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::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() });
let _ = cmd_tx.send(WorkerCmd::Search {
query: search_query.clone(),
});
}
mode = Mode::Normal;
}
@ -691,13 +759,16 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
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))
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:") {
compose_subject =
if email.subject.to_ascii_lowercase().starts_with("re:") {
email.subject.clone()
} else {
format!("Re: {}", email.subject)
@ -710,10 +781,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
.map(|l| format!("> {l}"))
.collect::<Vec<_>>()
.join("\n");
compose_reply_quote = format!(
"On {}, {} wrote:\n{}",
email.date, email.from, quoted
);
compose_reply_quote =
format!("On {}, {} wrote:\n{}", email.date, email.from, quoted);
} else {
compose_reply_quote.clear();
}
@ -743,12 +812,15 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
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 });
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;
@ -764,7 +836,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
Focus::Message => {
message_scroll = message_scroll.saturating_add(1);
}
}
},
KeyCode::Up | KeyCode::Char('k') => match focus {
Focus::Inbox => {
if search_active {
@ -798,11 +870,13 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
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() {
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() {
@ -820,6 +894,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
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();
@ -828,11 +903,16 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
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::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.
// 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()
}