diff --git a/README.md b/README.md index ffe6f40..d127757 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A minimalist desktop client for [Smol Mail](../smolmail): one compact window — - Create or restore an identity from a seed, shown once at creation time. - Pin a server's key — paste the operator's, or connect and pin what it presents — then register a username or recall an existing address. - Fetch (with cancel), read, reply and send sealed mail; the inbox, the requests tier and sent copies each keep their own folder. +- Composes closed by any sidebar trip are stashed as drafts — kept beside the store, reopened from a folder that appears only once one exists; Cancel is the only way to throw one away. - Accept or block a requester when their mail carried a self-certifying reply address; accept tokens attach to accepted contacts automatically. - Contacts with verified/tofu marks and smol:// import; your own address as a URI, a QR code and a clipboard line. - Key rotation with a confirm-first step, server pin management, a network timeout, and gsmol backup export/import that never contains the seed. diff --git a/src/app.rs b/src/app.rs index a91c9c5..638eb87 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,7 @@ use fumi_core::crypto::{unb32, KEY_LEN}; use fumi_core::store::Store; use gtk::prelude::*; -use crate::ops::{self, Folder, OpenedMsg, Row, Snapshot}; +use crate::ops::{self, Draft, Folder, OpenedMsg, Row, Snapshot}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Page { @@ -273,6 +273,9 @@ pub struct Carrier { /// the sidebar's bottom panel; a message selection clears it. pub selected_contact: Option, pub compose: Option, + /// Composes set aside by a sidebar trip instead of Cancel, kept in + /// the drafts sidecar; the folder only shows once it holds one. + pub drafts: Vec, pub snapshot: Option, pub pin_offer: Option<(Address, [u8; KEY_LEN])>, pub backup_text: Option, @@ -301,6 +304,7 @@ impl Carrier { (true, false) => Page::Signup, (true, true) => Page::Mail, }; + let drafts = ops::load_drafts(&dir.join("drafts.txt")); let app = Carrier { marks: ReadMarks::load(dir.join("read.txt")), dir, @@ -330,6 +334,7 @@ impl Carrier { open: None, selected_contact: None, compose: None, + drafts, snapshot: None, pin_offer: None, backup_text: None, @@ -352,6 +357,77 @@ impl Carrier { self.dir.join("carrier.key") } + fn drafts_path(&self) -> PathBuf { + self.dir.join("drafts.txt") + } + + /// Every sidebar trip closes the compose this way: a compose with + /// anything in it becomes a draft, an empty one is thrown away, and + /// Cancel stays the only way to discard work outright. + fn stash_draft(&mut self) { + let Some(compose) = self.compose.take() else { + return; + }; + if compose.to.trim().is_empty() + && compose.subject.trim().is_empty() + && compose.body.trim().is_empty() + { + return; + } + self.drafts.push(Draft { + id: ops::draft_id(), + to: compose.to, + subject: compose.subject, + body: compose.body, + reply_to: compose.reply_to, + at: fumi_core::store::now(), + }); + ops::save_drafts(&self.drafts_path(), &self.drafts); + self.note("draft saved".into()); + } + + /// Tapping a draft in the folder puts it back in the compose it came + /// from, out of the sidecar, so saving it again cannot fork it. + fn open_draft(&mut self, id: &str) { + let Some(index) = self.drafts.iter().position(|d| d.id == id) else { + return; + }; + let draft = self.drafts.remove(index); + ops::save_drafts(&self.drafts_path(), &self.drafts); + self.compose = Some(Compose { + to: draft.to, + subject: draft.subject, + body: draft.body, + reply_to: draft.reply_to, + }); + self.compose_fresh.set(true); + } + + /// The current folder's rows, drafts included: drafts carry the + /// recipient where mail carries the sender, newest first. + pub fn rows(&self) -> Vec { + if self.folder == Folder::Drafts { + let mut rows: Vec = self + .drafts + .iter() + .map(|draft| Row { + id: draft.id.clone(), + from: draft.to.clone(), + subject: draft.subject.clone(), + at: draft.at, + unread: false, + }) + .collect(); + rows.sort_by_key(|row| -row.at); + rows + } else { + self.lists + .get(self.folder as usize) + .cloned() + .unwrap_or_default() + } + } + fn note(&mut self, text: String) { self.status = Some(text); self.status_error = false; @@ -442,6 +518,7 @@ impl Carrier { self.import_body = text; } Message::ContactSelected(address) => { + self.stash_draft(); self.selected_contact = Some(address); self.open = None; self.page = Page::Mail; @@ -577,6 +654,7 @@ impl Carrier { return vec![PendingOp::Restore { addr }]; } Message::GotoMail(folder) => { + self.stash_draft(); if self.page == Page::Mail && self.folder == folder { return Vec::new(); } @@ -587,19 +665,24 @@ impl Carrier { return self.refresh().into_iter().collect(); } Message::Goto(page) => { + self.stash_draft(); self.page = page; self.open = None; self.selected_contact = None; - self.compose = None; if page == Page::Mail || page == Page::Contacts || page == Page::Settings { return self.refresh().into_iter().collect(); } } Message::MessageSelected(folder, id) => { self.selected_contact = None; - self.select_message(folder, id); + if folder == Folder::Drafts { + self.open_draft(&id); + } else { + self.select_message(folder, id); + } } Message::FetchPressed => { + self.stash_draft(); if !self.start("fetching") { return Vec::new(); } @@ -608,6 +691,7 @@ impl Carrier { }]; } Message::ComposePressed => { + self.stash_draft(); self.compose = Some(Compose { to: String::new(), subject: String::new(), @@ -970,8 +1054,51 @@ mod tests { assert_eq!(app.busy.as_deref(), Some("fetching")); } - /// The store is committed envelope by envelope, so a tick mid-fetch - /// sees a message that the running fetch has already verified. + /// A compose closed by any sidebar trip becomes a draft in the + /// sidecar; Cancel stays the only way to throw work away. + #[test] + fn sidebar_trips_stash_the_compose_as_a_draft() { + let mut app = app(); + app.handle(Message::ComposePressed); + app.handle(Message::ComposeToChanged("alice@example.org".into())); + app.handle(Message::ComposeSubjectChanged("half written".into())); + app.handle(Message::ComposeBodyChanged("still typing".into())); + + // The same folder the compose was opened over still closes it. + app.folder = Folder::Inbox; + app.page = Page::Mail; + app.handle(Message::GotoMail(Folder::Inbox)); + assert!(app.compose.is_none()); + assert_eq!(app.drafts.len(), 1); + assert!(app.dir.join("drafts.txt").exists()); + let draft = app.drafts[0].clone(); + assert_eq!(draft.to, "alice@example.org"); + assert_eq!(draft.subject, "half written"); + app.folder = Folder::Drafts; + assert_eq!(app.rows()[0].id, draft.id); + + // Opening the draft moves it back into the compose, out of the + // sidecar, so saving it again cannot fork it. + app.handle(Message::MessageSelected(Folder::Drafts, draft.id.clone())); + assert!(app.compose.is_some()); + assert!(app.drafts.is_empty()); + assert_eq!(app.compose.as_ref().unwrap().subject, "half written"); + + // Write while editing stashes the draft being edited first, and the + // fresh empty compose a trip then discards adds nothing. + app.handle(Message::ComposePressed); + assert_eq!(app.drafts.len(), 1); + app.handle(Message::GotoMail(Folder::Sent)); + assert!(app.compose.is_none()); + assert_eq!(app.drafts.len(), 1); + + // An empty compose discarded by a trip leaves no draft behind. + app.drafts.clear(); + app.handle(Message::ComposePressed); + app.handle(Message::GotoMail(Folder::Sent)); + assert!(app.drafts.is_empty()); + } + #[test] fn fetch_tick_lists_mail_as_it_lands() { use fumi_core::message::{build_frontmatter, message_id, seal}; diff --git a/src/demo.rs b/src/demo.rs index cca9aac..f93e7da 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -219,6 +219,19 @@ fn plant(dir: &Path) -> Result<(), String> { // pre-marked read rows, so unread dots have both states to show std::fs::write(dir.join("read.txt"), read_ids.join("\n") + "\n").map_err(|e| e.to_string())?; + // one sample draft, so the drafts folder has a row to show + crate::ops::save_drafts( + &dir.join("drafts.txt"), + &[crate::ops::Draft { + id: "0000000000000000".into(), + to: "grace@example.org".into(), + subject: "Re: quarterly infrastructure review".into(), + body: "started a reply, will finish it after the review call".into(), + reply_to: None, + at: now() - 3 * 3600, + }], + ); + let _ = b32(&pk); Ok(()) } diff --git a/src/ops.rs b/src/ops.rs index f115b5f..6507c4e 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -78,12 +78,15 @@ pub fn snapshot( }) } -/// The three folders, in sidebar order. +/// The sidebar's folders, in its order. Drafts is carrier's own folder: +/// it has no table in the sealed store, its rows come from the drafts +/// sidecar, and it only appears in the sidebar once it holds one. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Folder { Inbox, Requests, Sent, + Drafts, } impl Folder { @@ -94,10 +97,110 @@ impl Folder { Folder::Inbox => "inbox", Folder::Requests => "requests", Folder::Sent => "sent", + // Never reached: `handle` routes draft selection away from + // the store before any table lookup happens. + Folder::Drafts => "drafts", } } } +/// A compose set aside instead of sent, kept beside the store like the read +/// marks — it is plaintext by nature until it is sealed on the way out. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Draft { + pub id: String, + pub to: String, + pub subject: String, + pub body: String, + /// The message this draft replies to, as 64 hex characters. + pub reply_to: Option, + pub at: i64, +} + +/// A fresh draft id: 8 random bytes as hex, unique within the sidecar. +pub fn draft_id() -> String { + use rand_core::RngCore; + let mut bytes = [0u8; 8]; + rand_core::OsRng.fill_bytes(&mut bytes); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// One line per field, with backslash escapes so no field can break the +/// line format: `\` becomes `\\`, a newline `\n`, a carriage return `\r`. +fn line(text: &str) -> String { + text.replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('\r', "\\r") +} + +fn unline(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('n') => out.push('\n'), + Some('r') => out.push('\r'), + Some(other) => out.push(other), + None => out.push('\\'), + } + } else { + out.push(c); + } + } + out +} + +/// Four lines per draft: header, to, subject, body. The whole file is +/// rewritten on every change, which a handful of drafts makes honest. +pub fn save_drafts(path: &Path, drafts: &[Draft]) { + let mut text = String::new(); + for draft in drafts { + let reply_to = draft.reply_to.as_deref().unwrap_or("-"); + use std::fmt::Write; + let _ = writeln!(text, "draft {} {} {}", draft.id, draft.at, reply_to); + let _ = writeln!(text, "{}", line(&draft.to)); + let _ = writeln!(text, "{}", line(&draft.subject)); + let _ = writeln!(text, "{}", line(&draft.body)); + } + let _ = std::fs::write(path, text); +} + +/// Reads the drafts sidecar; a missing or torn tail only costs the +/// incomplete record at the end. +pub fn load_drafts(path: &Path) -> Vec { + let text = std::fs::read_to_string(path).unwrap_or_default(); + let lines: Vec<&str> = text.lines().collect(); + let mut drafts = Vec::new(); + for record in lines.chunks(4) { + let [header, to, subject, body] = record else { + break; + }; + let mut parts = header.split_whitespace(); + let (Some("draft"), Some(id), Some(at), reply_to) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + break; + }; + let Ok(at) = at.parse::() else { + break; + }; + let id = id.to_string(); + drafts.push(Draft { + id, + to: unline(to), + subject: unline(subject), + body: unline(body), + reply_to: match reply_to { + None | Some("-") => None, + Some(other) => Some(other.to_string()), + }, + at, + }); + } + drafts +} + fn explain(e: Error) -> String { e.to_string() } @@ -535,6 +638,38 @@ mod tests { use fumi_core::message::{build_frontmatter, message_id, seal}; use fumi_core::store::now; + #[test] + fn drafts_round_trip_through_the_sidecar() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("drafts.txt"); + let drafts = vec![ + Draft { + id: draft_id(), + to: "smol://alice@example.org/KEY".into(), + subject: "line breaks\nand \\backslashes\r".into(), + body: "a draft\n\nwith paragraphs\nand a \\ literal".into(), + reply_to: Some("ab".repeat(32)), + at: now(), + }, + Draft { + id: draft_id(), + to: "bob@example.org".into(), + subject: "no reply".into(), + body: String::new(), + reply_to: None, + at: now(), + }, + ]; + save_drafts(&path, &drafts); + assert_eq!(load_drafts(&path), drafts); + + // A torn tail costs only its own incomplete record. + let text = std::fs::read_to_string(&path).unwrap(); + let torn = text.lines().take(6).collect::>().join("\n") + "\n"; + std::fs::write(&path, torn).unwrap(); + assert_eq!(load_drafts(&path).len(), 1); + } + fn master() -> [u8; KEY_LEN] { let mut seed = [0u8; KEY_LEN]; for (i, b) in seed.iter_mut().enumerate() { diff --git a/src/view.rs b/src/view.rs index 32a65f2..7bff2a9 100644 --- a/src/view.rs +++ b/src/view.rs @@ -183,6 +183,8 @@ pub struct CarrierWidgets { // sidebar top: actions and destinations folders: gtk::ListBox, folder_counts: Vec, + /// The Drafts row, hidden until the folder holds one. + drafts_nav: gtk::ListBoxRow, settings_nav: gtk::ListBox, fetch_button: gtk::Button, write_button: gtk::Button, @@ -303,12 +305,21 @@ impl CarrierWidgets { nav_row("folder-download-symbolic", "Inbox"), nav_row("contact-new-symbolic", "Requests"), nav_row("mail-send-symbolic", "Sent"), + nav_row("document-edit-symbolic", "Drafts"), ]; let mut folder_counts = Vec::new(); - for (row, count) in folder_rows { + let mut drafts_nav = None; + for (index, (row, count)) in folder_rows.into_iter().enumerate() { + // The drafts folder is carrier's own: it stays out of the + // sidebar until a compose has been set aside into it. + if index == 3 { + row.set_visible(false); + drafts_nav = Some(row.clone()); + } folders.append(&row); folder_counts.push(count); } + let drafts_nav = drafts_nav.expect("four folder rows"); sidebar_top.append(&folders); // Settings closes the top panel, pinned to its bottom. It is its @@ -782,6 +793,7 @@ impl CarrierWidgets { 0 => Folder::Inbox, 1 => Folder::Requests, 2 => Folder::Sent, + 3 => Folder::Drafts, _ => return, }; folder_sender.input(Message::GotoMail(folder)); @@ -804,6 +816,7 @@ impl CarrierWidgets { "0" => Folder::Inbox, "1" => Folder::Requests, "2" => Folder::Sent, + "3" => Folder::Drafts, _ => return, }; mail_sender.input(Message::MessageSelected(folder, id.to_string())); @@ -902,6 +915,7 @@ impl CarrierWidgets { toolbar, folders, folder_counts, + drafts_nav, settings_nav, fetch_button, write_button, @@ -1158,7 +1172,13 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender 0); + for (count, value) in w + .folder_counts + .iter() + .zip([unread(0), unread(1), 0, draft_count]) + { count.set_text(&value.to_string()); count.set_visible(value > 0); } @@ -1198,11 +1218,7 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender ( "mail-read-symbolic", @@ -1219,6 +1235,11 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender ( + "document-edit-symbolic", + "No drafts", + "A compose closed from the sidebar is kept here.", + ), }; w.list_empty.set_icon_name(Some(icon)); w.list_empty.set_title(title);