diff --git a/README.md b/README.md index 1178d6f..dfe0fa3 100644 --- a/README.md +++ b/README.md @@ -8,7 +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. Received mail shows the sender's own fields in the message detail, between the meta line and the body. +- 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. Received mail shows the sender's own fields in the message detail, between the meta line and the body; replying to such a mail asks whether to carry copies of the fields into the reply, where they stay editable. - 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 82e0a11..4e83f83 100644 --- a/src/app.rs +++ b/src/app.rs @@ -107,6 +107,14 @@ pub enum Dialog { }, /// Rotation only moves the index forward, so the action asks first. Rotate, + /// The message being answered carries the sender's own frontmatter + /// fields; the reply asks whether to carry copies of them. + Reply { + to: String, + subject: String, + id: String, + rows: Vec, + }, } /// UI inputs. Background results arrive as [`OpResult`] instead, so the @@ -154,6 +162,8 @@ pub enum Message { ComposeClosed, SendPressed, ReplyPressed, + ReplyCancelled, + ReplyConfirmed(bool), DeletePressed, DeleteConfirmed(bool), DeleteCancelled, @@ -763,12 +773,49 @@ impl Carrier { } else { format!("Re: {}", msg.subject) }; + // The sender's own fields are the one thing a reply has to + // decide about: carry copies, or start clean. Only the + // fields that would survive send-time validation ask — + // a mail whose only extra field is the threading id + // replies straight through. + let rows = ops::reply_rows(&msg.headers); + if rows.is_empty() { + self.compose = Some(Compose { + to, + subject, + body: String::new(), + reply_to: Some(msg.id.clone()), + headers: Vec::new(), + }); + self.compose_fresh.set(true); + } else { + self.dialog = Some(Dialog::Reply { + to, + subject, + id: msg.id.clone(), + rows, + }); + } + } + Message::ReplyCancelled => { + self.dialog = None; + } + Message::ReplyConfirmed(copy) => { + let Some(Dialog::Reply { + to, + subject, + id, + rows, + }) = self.dialog.take() + else { + return Vec::new(); + }; self.compose = Some(Compose { to, subject, body: String::new(), - reply_to: Some(msg.id.clone()), - headers: Vec::new(), + reply_to: Some(id), + headers: if copy { rows } else { Vec::new() }, }); self.compose_fresh.set(true); } @@ -1245,6 +1292,63 @@ mod tests { )); } + #[test] + fn replying_to_custom_fields_asks_what_to_carry() { + let mut app = app(); + app.open = Some(( + Folder::Inbox, + OpenedMsg { + subject: "hello".into(), + headers: vec![ + ("priority".to_string(), "high".to_string()), + ("in-reply-to".to_string(), "cd".repeat(32)), + ], + ..opened() + }, + )); + + // The ask: a dialog, not a compose yet, and cancelling aborts. + app.handle(Message::ReplyPressed); + assert!(app.compose.is_none()); + assert!(matches!(app.dialog, Some(Dialog::Reply { .. }))); + app.handle(Message::ReplyCancelled); + assert!(app.dialog.is_none()); + assert!(app.compose.is_none()); + + // Either answer opens the same reply; the fields follow the choice. + app.handle(Message::ReplyPressed); + app.handle(Message::ReplyConfirmed(false)); + let compose = app.compose.as_ref().expect("reply open"); + assert_eq!(compose.subject, "Re: hello"); + assert!(compose.headers.is_empty()); + assert_eq!(compose.reply_to.as_deref(), Some("ab".repeat(32).as_str())); + + app.compose = None; + app.handle(Message::ReplyPressed); + app.handle(Message::ReplyConfirmed(true)); + let compose = app.compose.as_ref().expect("reply open"); + // The sender's field arrives; the threading id does not — the + // reply carries its own. + assert_eq!(compose.headers, ["priority: high"]); + } + + #[test] + fn a_thread_only_mail_replies_straight_through() { + let mut app = app(); + app.open = Some(( + Folder::Inbox, + OpenedMsg { + subject: "hello".into(), + headers: vec![("in-reply-to".to_string(), "cd".repeat(32))], + ..opened() + }, + )); + app.handle(Message::ReplyPressed); + assert!(app.dialog.is_none()); + assert!(app.compose.is_some()); + assert!(app.compose.as_ref().unwrap().headers.is_empty()); + } + #[test] fn headers_survive_a_stash_and_reopen() { let mut app = app(); diff --git a/src/ops.rs b/src/ops.rs index c1bd6bb..5cf1d66 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -462,6 +462,18 @@ pub fn validate_headers(headers: &[(String, String)]) -> Result<(), String> { Ok(()) } +/// The frontmatter rows a reply could carry over from the message it +/// answers. The reserved fields never make it — `in-reply-to` in +/// particular shows in the detail but belongs to the reply context the +/// client sets itself — so anything left is the sender's own metadata. +pub fn reply_rows(headers: &[(String, String)]) -> Vec { + headers + .iter() + .filter_map(|(key, value)| header_row(&format!("{key}: {value}")).ok().flatten()) + .map(|(key, value)| format!("{key}: {value}")) + .collect() +} + pub fn send( store: &Store, master: &[u8; KEY_LEN], @@ -863,6 +875,22 @@ mod tests { assert!(validate_headers(&many).is_err()); } + #[test] + fn reply_rows_drop_what_a_reply_sets_itself() { + // Only the sender's own, writable fields carry over; the reserved + // ones — threading included — are the reply's own context. + let rows = reply_rows(&[ + ("priority".to_string(), "high".to_string()), + ("in-reply-to".to_string(), "ab".repeat(32)), + ("subject".to_string(), "shadow".to_string()), + ("accept".to_string(), "token".to_string()), + ]); + assert_eq!(rows, ["priority: high"]); + // A mail whose only extra field is the threading id carries + // nothing to ask about. + assert!(reply_rows(&[("in-reply-to".to_string(), "ab".repeat(32))]).is_empty()); + } + 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 9d1d57e..90f059a 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1649,6 +1649,7 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { let rotating = matches!(dialog, Dialog::Rotate); + let replying = matches!(dialog, Dialog::Reply { .. }); let alert = match dialog { Dialog::Delete { kept, .. } => { let alert = adw::AlertDialog::new( @@ -1681,17 +1682,41 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { + let list = rows + .iter() + .map(String::as_str) + .collect::>() + .join("\n"); + let alert = adw::AlertDialog::new( + Some("Carry the sender's fields into the reply?"), + Some(&format!( + "This message carries its own frontmatter:\n\n{list}\n\nReply with copies of these fields, or start with none. The fields stay editable either way." + )), + ); + alert.add_response("cancel", "Cancel Reply"); + alert.add_response("without", "Reply Without"); + alert.add_response("copy", "Copy Fields"); + alert.set_response_appearance("copy", adw::ResponseAppearance::Suggested); + alert + } }; + // The reply dialog carries its own default: Enter answers the + // question instead of cancelling the reply the user asked for. + let close_default = if replying { "without" } else { "cancel" }; alert.set_close_response("cancel"); - alert.set_default_response(Some("cancel")); + alert.set_default_response(Some(close_default)); let response_sender = sender.clone(); alert.connect_response(None, move |_, response| { - response_sender.input(match (rotating, response) { - (true, "rotate") => Message::RotateConfirmed, - (true, _) => Message::RotateCancelled, - (false, "here") => Message::DeleteConfirmed(false), - (false, "everywhere") => Message::DeleteConfirmed(true), - (false, _) => Message::DeleteCancelled, + response_sender.input(match (rotating, replying, response) { + (true, _, "rotate") => Message::RotateConfirmed, + (true, _, _) => Message::RotateCancelled, + (_, true, "copy") => Message::ReplyConfirmed(true), + (_, true, "without") => Message::ReplyConfirmed(false), + (_, true, _) => Message::ReplyCancelled, + (false, _, "here") => Message::DeleteConfirmed(false), + (false, _, "everywhere") => Message::DeleteConfirmed(true), + (false, _, _) => Message::DeleteCancelled, }); }); alert.present(Some(&w.window));