diff --git a/README.md b/README.md index 17285e0..7682a83 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A minimalist desktop client for [Smol Mail](../smolmail): one compact window — - 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. +- Custom `Key: value` frontmatter rows when writing: one line per header under the subject, validated against the frontmatter rules — the client's own fields (subject, reply-to, in-reply-to, accept) can't be shadowed, and rows survive a stash as part of the draft. - 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 identity opens in the same detail view, its address in the header bar with a QR share beside it, key, fingerprint and URI below. - 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 8859f80..b58c462 100644 --- a/src/app.rs +++ b/src/app.rs @@ -93,6 +93,8 @@ pub struct Compose { pub body: String, /// The message this replies to, as 64 hex characters. pub reply_to: Option, + /// Frontmatter rows as typed — `Key: value` text, parsed at send. + pub headers: Vec, } #[derive(Debug, Clone)] @@ -146,6 +148,9 @@ pub enum Message { ComposeToChanged(String), ComposeSubjectChanged(String), ComposeBodyChanged(String), + ComposeHeaderAdded, + ComposeHeaderChanged(usize, String), + ComposeHeaderRemoved(usize), ComposeClosed, SendPressed, ReplyPressed, @@ -225,6 +230,7 @@ pub enum PendingOp { subject: String, body: String, reply_to: Option, + headers: Vec<(String, String)>, }, Delete { id: String, @@ -372,9 +378,11 @@ impl Carrier { let Some(compose) = self.compose.take() else { return; }; + let rows = compose.headers.iter().any(|row| !row.trim().is_empty()); if compose.to.trim().is_empty() && compose.subject.trim().is_empty() && compose.body.trim().is_empty() + && !rows { return; } @@ -384,6 +392,7 @@ impl Carrier { subject: compose.subject, body: compose.body, reply_to: compose.reply_to, + headers: compose.headers, at: fumi_core::store::now(), }); ops::save_drafts(&self.drafts_path(), &self.drafts); @@ -403,6 +412,7 @@ impl Carrier { subject: draft.subject, body: draft.body, reply_to: draft.reply_to, + headers: draft.headers, }); self.compose_fresh.set(true); } @@ -518,6 +528,25 @@ impl Carrier { compose.body = body; } } + Message::ComposeHeaderAdded => { + if let Some(compose) = &mut self.compose { + compose.headers.push(String::new()); + } + } + Message::ComposeHeaderChanged(index, text) => { + if let Some(compose) = &mut self.compose { + if let Some(row) = compose.headers.get_mut(index) { + *row = text; + } + } + } + Message::ComposeHeaderRemoved(index) => { + if let Some(compose) = &mut self.compose { + if index < compose.headers.len() { + compose.headers.remove(index); + } + } + } Message::ImportBodyChanged(text) => { self.import_body = text; } @@ -712,6 +741,7 @@ impl Carrier { subject: String::new(), body: String::new(), reply_to: None, + headers: Vec::new(), }); self.compose_fresh.set(true); } @@ -738,6 +768,7 @@ impl Carrier { subject, body: String::new(), reply_to: Some(msg.id.clone()), + headers: Vec::new(), }); self.compose_fresh.set(true); } @@ -752,11 +783,27 @@ impl Carrier { self.fail("enter a recipient".into()); return Vec::new(); } + let mut headers = Vec::new(); + for (index, row) in compose.headers.iter().enumerate() { + match ops::header_row(row) { + Ok(Some(pair)) => headers.push(pair), + Ok(None) => {} + Err(reason) => { + self.fail(format!("header {}: {reason}", index + 1)); + return Vec::new(); + } + } + } + if let Err(reason) = ops::validate_headers(&headers) { + self.fail(reason); + return Vec::new(); + } let op = PendingOp::Send { to: compose.to.trim().to_string(), subject: compose.subject.trim().to_string(), body: compose.body.clone(), reply_to: compose.reply_to.clone(), + headers, }; self.compose = None; return vec![op]; @@ -850,6 +897,7 @@ impl Carrier { subject: String::new(), body: String::new(), reply_to: None, + headers: Vec::new(), }); self.compose_fresh.set(true); } @@ -1042,7 +1090,6 @@ mod tests { } } - #[test] #[test] fn contact_selection_and_message_selection_supersede_each_other() { let mut app = app(); @@ -1090,6 +1137,7 @@ mod tests { subject: String::new(), body: String::new(), reply_to: None, + headers: Vec::new(), }); app.busy = Some("fetching".into()); app.handle(Message::ComposeBodyChanged("typing while busy".into())); @@ -1145,6 +1193,78 @@ mod tests { assert!(app.drafts.is_empty()); } + #[test] + fn header_rows_pass_the_busy_guard_and_edit_the_compose() { + let mut app = app(); + app.handle(Message::ComposePressed); + app.busy = Some("fetching".into()); + app.handle(Message::ComposeHeaderAdded); + app.handle(Message::ComposeHeaderAdded); + app.handle(Message::ComposeHeaderChanged(0, "Urgency: high".into())); + app.handle(Message::ComposeHeaderRemoved(1)); + assert_eq!( + app.compose.as_ref().expect("compose open").headers, + ["Urgency: high"] + ); + // An index that no longer exists is ignored, not a panic. + app.handle(Message::ComposeHeaderChanged(5, "no".into())); + assert_eq!(app.compose.as_ref().unwrap().headers, ["Urgency: high"]); + } + + #[test] + fn an_invalid_header_blocks_the_send() { + let mut app = app(); + app.handle(Message::ComposePressed); + app.handle(Message::ComposeToChanged("bob@localhost".into())); + + // A reserved key shadows the client's own field, so it is rejected. + app.handle(Message::ComposeHeaderAdded); + app.handle(Message::ComposeHeaderChanged(0, "Subject: shadow".into())); + assert!(app.handle(Message::SendPressed).is_empty()); + assert!(app.compose.is_some()); + assert!(app.status_error); + + // Case-insensitive duplicates are rejected too. + app.handle(Message::ComposeHeaderChanged(0, "Urgency: high".into())); + app.handle(Message::ComposeHeaderAdded); + app.handle(Message::ComposeHeaderChanged(1, "urgency: low".into())); + assert!(app.handle(Message::SendPressed).is_empty()); + assert!(app.status_error); + + // Removing the duplicate lets the send go through with the pair; + // the earlier error note stays until the op result replaces it. + app.handle(Message::ComposeHeaderRemoved(1)); + let ops = app.handle(Message::SendPressed); + assert_eq!(ops.len(), 1); + assert!(app.compose.is_none()); + assert!(matches!( + &ops[0], + PendingOp::Send { headers, .. } + if headers == &[("Urgency".to_string(), "high".to_string())] + )); + } + + #[test] + fn headers_survive_a_stash_and_reopen() { + let mut app = app(); + app.handle(Message::ComposePressed); + app.handle(Message::ComposeToChanged("bob@localhost".into())); + app.handle(Message::ComposeHeaderAdded); + app.handle(Message::ComposeHeaderChanged(0, "Urgency: high".into())); + app.handle(Message::ComposeHeaderAdded); + app.handle(Message::ComposeHeaderChanged(1, "not valid yet".into())); + // A contact trip stashes without starting a refresh, so the + // reopen below is not swallowed by the busy guard. + app.handle(Message::ContactSelected("bob@localhost".into())); + assert_eq!(app.drafts.len(), 1); + let id = app.drafts[0].id.clone(); + app.handle(Message::MessageSelected(Folder::Drafts, id)); + let compose = app.compose.as_ref().expect("draft reopened"); + // Rows return exactly as typed, valid or not: validation is a + // send-time concern, not a stash-time one. + assert_eq!(compose.headers, ["Urgency: high", "not valid yet"]); + } + #[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 f93e7da..f74840e 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -227,6 +227,7 @@ fn plant(dir: &Path) -> Result<(), String> { to: "grace@example.org".into(), subject: "Re: quarterly infrastructure review".into(), body: "started a reply, will finish it after the review call".into(), + headers: vec!["Urgency: low".into()], reply_to: None, at: now() - 3 * 3600, }], diff --git a/src/ops.rs b/src/ops.rs index 2a3a577..eb9f417 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -11,6 +11,7 @@ use fumi_core::address::{Address, DEFAULT_PORT}; use fumi_core::client::{FetchOptions, TrustChange}; use fumi_core::crypto::{b32, fingerprint, unb32, KEY_LEN}; use fumi_core::error::Error; +use fumi_core::message::{FRONTMATTER_KEYS, FRONTMATTER_MAX}; use fumi_core::store::Store; use fumi_core::{account, client, export, store}; @@ -116,6 +117,8 @@ pub struct Draft { pub body: String, /// The message this draft replies to, as 64 hex characters. pub reply_to: Option, + /// Frontmatter rows as typed — `Key: value` text, parsed at send. + pub headers: Vec, pub at: i64, } @@ -133,6 +136,7 @@ fn line(text: &str) -> String { text.replace('\\', "\\\\") .replace('\n', "\\n") .replace('\r', "\\r") + .replace('\u{1f}', "\\\u{1e}") } fn unline(text: &str) -> String { @@ -143,6 +147,7 @@ fn unline(text: &str) -> String { match chars.next() { Some('n') => out.push('\n'), Some('r') => out.push('\r'), + Some('\u{1e}') => out.push('\u{1f}'), Some(other) => out.push(other), None => out.push('\\'), } @@ -159,11 +164,16 @@ 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("-"); + let headers: Vec = draft.headers.iter().map(|row| line(row)).collect(); use std::fmt::Write; - let _ = writeln!(text, "draft {} {} {}", draft.id, draft.at, reply_to); + let _ = writeln!(text, "draft2 {} {} {}", draft.id, draft.at, reply_to); let _ = writeln!(text, "{}", line(&draft.to)); let _ = writeln!(text, "{}", line(&draft.subject)); let _ = writeln!(text, "{}", line(&draft.body)); + // Rows joined on a control separator, so any typed text is + // reversible; keys are [A-Za-z0-9-] but values can hold colons, + // semicolons — anything but a newline, which line() escapes. + let _ = writeln!(text, "{}", headers.join("\u{1f}")); } let _ = std::fs::write(path, text); } @@ -174,12 +184,12 @@ 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 index = 0; + while index + 4 <= lines.len() { + let header = lines[index]; + let (to, subject, body) = (lines[index + 1], lines[index + 2], lines[index + 3]); let mut parts = header.split_whitespace(); - let (Some("draft"), Some(id), Some(at), reply_to) = + let (kind, Some(id), Some(at), reply_to) = (parts.next(), parts.next(), parts.next(), parts.next()) else { break; @@ -188,6 +198,22 @@ pub fn load_drafts(path: &Path) -> Vec { break; }; let id = id.to_string(); + index += 4; + // draft2 carries a fifth line of header rows; the old draft + // records stop at four and the next line is another record. + let headers = if kind == Some("draft2") && index < lines.len() { + index += 1; + let raw = lines[index - 1]; + if raw.is_empty() { + Vec::new() + } else { + raw.split('\u{1f}').map(unline).collect() + } + } else if kind == Some("draft") { + Vec::new() + } else { + break; + }; drafts.push(Draft { id, to: unline(to), @@ -197,6 +223,7 @@ pub fn load_drafts(path: &Path) -> Vec { None | Some("-") => None, Some(other) => Some(other.to_string()), }, + headers, at, }); } @@ -369,6 +396,69 @@ pub fn fetch( Ok(note) } +/// Fields fumi itself attaches to every message (sec 5.7, 5.8); a +/// user copy of any of them would shadow the client's own, since the +/// reader keeps the first occurrence of a key. +const RESERVED_HEADERS: [&str; 4] = ["subject", "in-reply-to", "reply-to", "accept"]; + +/// Parses one compose header row as the user typed it — `Key: value`. +/// An empty row is allowed and simply skipped on send; anything else +/// that breaks the frontmatter block (sec 5.5) is rejected, because a +/// single malformed line makes the reader drop every field. +pub fn header_row(text: &str) -> Result, String> { + let text = text.trim(); + if text.is_empty() { + return Ok(None); + } + let Some((key, value)) = text.split_once(':') else { + return Err("a header is `Key: value` — the colon is missing".into()); + }; + let key = key.trim(); + if RESERVED_HEADERS + .iter() + .any(|reserved| key.eq_ignore_ascii_case(reserved)) + { + return Err(format!("{key} is attached by the client and cannot be set")); + } + if key.is_empty() + || key.len() > 64 + || !key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err("keys are 1-64 characters of letters, digits and dashes".into()); + } + if value.contains('\n') || value.contains('\r') { + return Err("a value must stay on one line".into()); + } + Ok(Some((key.to_string(), value.trim().to_string()))) +} + +/// The rules across all rows: keys are case-insensitive and must be +/// unique, and the whole block — fumi's own fields included — has to +/// stay inside the reader's budget (sec 5.5). +pub fn validate_headers(headers: &[(String, String)]) -> Result<(), String> { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for (key, _) in headers { + if !seen.insert(key.to_ascii_lowercase()) { + return Err(format!("{key} is set twice")); + } + } + let budget: usize = headers + .iter() + .map(|(key, value)| format!("{key}: {value}").len() + 1) + .sum(); + // The client adds Subject, In-Reply-To, a Reply-To URI and possibly an + // Accept token on top of the user's rows; 512 bytes covers them all. + if budget + 512 > FRONTMATTER_MAX { + return Err("the headers exceed the frontmatter budget".into()); + } + if headers.len() + 4 > FRONTMATTER_KEYS { + return Err("too many headers".into()); + } + Ok(()) +} + pub fn send( store: &Store, master: &[u8; KEY_LEN], @@ -376,16 +466,21 @@ pub fn send( subject: Option<&str>, body: &str, reply_to: Option<&str>, + headers: &[(String, String)], timeout: u64, ) -> Result { let addr = parse_address(to)?; let me = account(store, master)?; + let owned: Vec<(&str, &str)> = headers + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); let draft = client::SendDraft { address: &addr, text: body.to_string(), subject, reply_to, - headers: &[], + headers: &owned, anonymous: false, no_pad: false, }; @@ -652,6 +747,13 @@ mod tests { subject: "line breaks\nand \\backslashes\r".into(), body: "a draft\n\nwith paragraphs\nand a \\ literal".into(), reply_to: Some("ab".repeat(32)), + // Rows return as typed: colons and separators included. + headers: vec![ + "Urgency: high".into(), + "Note: a: value: with: colons".into(), + String::from_utf8_lossy(&[0x1f, b's', b'e', b'p']).into_owned(), + String::new(), + ], at: now(), }, Draft { @@ -660,6 +762,7 @@ mod tests { subject: "no reply".into(), body: String::new(), reply_to: None, + headers: Vec::new(), at: now(), }, ]; @@ -671,6 +774,78 @@ mod tests { let torn = text.lines().take(6).collect::>().join("\n") + "\n"; std::fs::write(&path, torn).unwrap(); assert_eq!(load_drafts(&path).len(), 1); + + // The old four-line records, written before header rows existed, + // still read as drafts with no headers. + let legacy = format!( + "draft {} {} {}\n{}\n{}\n{}\n", + "0f".repeat(8), + 100, + "-", + line("carol@example.org"), + line("before headers"), + line("an old body"), + ); + std::fs::write(&path, legacy).unwrap(); + let loaded = load_drafts(&path); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].to, "carol@example.org"); + assert!(loaded[0].headers.is_empty()); + } + + #[test] + fn header_rows_follow_the_frontmatter_rules() { + // The allowed forms: a pair, and the empty row a send skips. + assert_eq!( + header_row("Urgency: high"), + Ok(Some(("Urgency".into(), "high".into()))) + ); + assert_eq!( + header_row(" spaced-key : trimmed value "), + Ok(Some(("spaced-key".into(), "trimmed value".into()))) + ); + assert_eq!(header_row(""), Ok(None)); + assert_eq!(header_row(" "), Ok(None)); + + // Everything the reader would reject the whole block for. + assert!(header_row("no colon").is_err()); + assert!(header_row(": value").is_err()); + assert!(header_row("bad key: v").is_err()); + assert!(header_row("ke\ty: v").is_err()); + assert!(header_row(&format!("{}: v", "k".repeat(65))).is_err()); + // A trailing newline is trimmed out of the key; an inner one is + // not, and the row is rejected. + assert_eq!( + header_row("value\n: v"), + Ok(Some(("value".into(), "v".into()))) + ); + assert!(header_row("multi\nline: v").is_err()); + // The client's own fields would be shadowed by a copy. + for reserved in [ + "Subject: x", + "subject: x", + "Reply-To: x", + "In-Reply-To: x", + "accept: x", + ] { + assert!(header_row(reserved).is_err(), "{reserved}"); + } + + // Across rows: keys are case-insensitive and must be unique. + assert_eq!( + validate_headers(&[("Urgency".into(), "high".into())]), + Ok(()) + ); + assert!(validate_headers(&[ + ("Urgency".into(), "high".into()), + ("urgency".into(), "low".into()), + ]) + .is_err()); + // And the whole block stays inside the reader's budget. + let big = "v".repeat(1024); + let many: Vec<(String, String)> = + (0..5).map(|i| (format!("X-Key{i}"), big.clone())).collect(); + assert!(validate_headers(&many).is_err()); } fn master() -> [u8; KEY_LEN] { diff --git a/src/view.rs b/src/view.rs index 03416ec..e2a794a 100644 --- a/src/view.rs +++ b/src/view.rs @@ -202,6 +202,7 @@ pub struct CarrierWidgets { // compose page compose_to: adw::EntryRow, + compose_headers: gtk::ListBox, compose_subject: adw::EntryRow, compose_body: gtk::TextBuffer, compose_send: adw::ButtonContent, @@ -531,6 +532,14 @@ impl CarrierWidgets { compose_fields.append(&compose_to); compose_fields.append(&compose_subject); compose.append(&compose_fields); + // Frontmatter rows: one `Key: value` entry each, plus a flat + // add button below. Rows are rebuilt in render on structural + // changes only, so the entries never lose focus while typing. + let compose_headers = boxed_list(); + compose.append(&compose_headers); + let add_header = button("+ header", "quiet"); + add_header.set_halign(Align::Start); + compose.append(&add_header); let compose_view = gtk::TextView::new(); compose_view.set_wrap_mode(WrapMode::WordChar); compose_view.set_top_margin(8); @@ -890,6 +899,8 @@ impl CarrierWidgets { gtk::glib::Propagation::Stop }); + add_header.connect_clicked(input(Message::ComposeHeaderAdded)); + compose_send_button.connect_clicked(input(Message::SendPressed)); compose_cancel.connect_clicked(input(Message::ComposeClosed)); let to_sender = sender.clone(); @@ -991,6 +1002,7 @@ impl CarrierWidgets { article_body, actions, compose_to, + compose_headers, compose_subject, compose_body, compose_send, @@ -1128,6 +1140,7 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender) { subject, body, reply_to, + headers, } => sender.spawn_command(move |out| { let res = ops::send( &store, @@ -1140,6 +1153,7 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender) { }, &body, reply_to.as_deref(), + &headers, timeout, ); let _ = out.send(OpResult::Generic(res)); @@ -1203,15 +1217,44 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender model only, so the view never fights the user. + // Prefill entries once after a reply, write or draft reopen; + // afterwards typing flows entry -> model only, so the view never + // fights the user. The fresh flag also rebuilds the header rows, + // which otherwise sync on count changes alone. + let mut compose_fresh = false; if model.compose_fresh.take() { + compose_fresh = true; if let Some(compose) = &model.compose { w.compose_to.set_text(&compose.to); w.compose_subject.set_text(&compose.subject); w.compose_body.set_text(&compose.body); } } + let rows = model.compose.as_ref().map_or(0, |c| c.headers.len()); + let mut current = 0; + let mut child = w.compose_headers.first_child(); + while let Some(item) = child { + current += 1; + child = item.next_sibling(); + } + if compose_fresh || current != rows { + let added = rows > current; + clear_list(&w.compose_headers); + if let Some(compose) = &model.compose { + let mut last_entry = None; + for (index, text) in compose.headers.iter().enumerate() { + let (row, entry) = compose_header_row(index, text, sender); + last_entry = Some(entry); + w.compose_headers.append(&row); + } + // A freshly appended row is the one being typed into. + if added { + if let Some(entry) = last_entry { + entry.grab_focus(); + } + } + } + } // Header and sidebar identity. let address = model @@ -1711,6 +1754,57 @@ fn mail_row(folder: Folder, row: &Row) -> adw::ActionRow { item } +/// One compose header row: a `Key: value` entry plus a remove button. +/// The row carries its own validation state — anything the send would +/// reject is tinted and explained right where it is typed, and the +/// empty rows the send skips are left quiet. +fn compose_header_row<'a>( + index: usize, + text: &str, + sender: &ComponentSender, +) -> (gtk::ListBoxRow, gtk::Entry) { + let entry = gtk::Entry::new(); + entry.set_hexpand(true); + entry.set_placeholder_text(Some("Key: value")); + entry.set_text(text); + let style = |entry: >k::Entry, text: &str| match crate::ops::header_row(text) { + Err(reason) => { + entry.add_css_class("error"); + entry.set_tooltip_text(Some(&reason)); + } + Ok(_) => { + entry.remove_css_class("error"); + entry.set_tooltip_text(None); + } + }; + style(&entry, text); + let change_sender = sender.clone(); + let style_entry = entry.clone(); + entry.connect_changed(move |changed| { + let text = changed.text().to_string(); + style(&style_entry, &text); + change_sender.input(Message::ComposeHeaderChanged(index, text)); + }); + + let remove = icon_button("user-trash-symbolic", "Remove this header"); + let remove_sender = sender.clone(); + remove.connect_clicked(move |_| { + remove_sender.input(Message::ComposeHeaderRemoved(index)); + }); + + let line = gtk::Box::new(Orientation::Horizontal, 6); + line.set_margin_top(6); + line.set_margin_bottom(6); + line.set_margin_start(12); + line.set_margin_end(6); + line.append(&entry); + line.append(&remove); + + let row = gtk::ListBoxRow::new(); + row.set_child(Some(&line)); + (row, entry) +} + /// One contact in the sidebar's bottom panel: the address over its trust /// state; activating it opens the detail pane. fn sidebar_contact_row(contact: &Contact) -> adw::ActionRow {