From dacbc9de382595a3bacfe69ee04de1c23b741ff9 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 3 Oct 2026 17:30:22 +0300 Subject: [PATCH] feat: show received frontmatter fields in the message detail --- README.md | 2 +- src/app.rs | 1 + src/demo.rs | 24 +++++++++++++++++++++ src/ops.rs | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++- src/view.rs | 18 ++++++++++++++++ 5 files changed, 103 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7682a83..1178d6f 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. +- 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. - 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 b58c462..82e0a11 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1086,6 +1086,7 @@ mod tests { body: String::new(), sender: String::new(), reply_to: None, + headers: Vec::new(), kept: false, } } diff --git a/src/demo.rs b/src/demo.rs index f74840e..9c0bfba 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -158,6 +158,30 @@ fn plant(dir: &Path) -> Result<(), String> { true, )?; + // one inbox mail with the sender's own frontmatter fields, so the + // detail view has a custom header block to show + { + let sender = identity(11); + let uri = Address::parse("alice@example.org") + .unwrap() + .uri(&sender.pk()); + let text = build_frontmatter( + &[ + ("Subject", "canary rollout report"), + ("Reply-To", &uri), + ("Priority", "high"), + ("X-Stage", "canary at 5%"), + ], + "The custom fields on this one are the sender's own — carrier shows them between the meta line and the body.", + ); + let envelope = seal(&sender, &pk, text.as_bytes(), now() - 12 * 3600, true) + .map_err(|e| e.to_string())?; + let id = message_id(&envelope); + store + .store_inbox(&id, &envelope, now() - 12 * 3600, false, false) + .map_err(|e| e.to_string())?; + } + // requests: senders not in the contact list deliver( &identity(31), diff --git a/src/ops.rs b/src/ops.rs index eb9f417..c1bd6bb 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -37,6 +37,9 @@ pub struct OpenedMsg { pub sender: String, /// The sender's self-certifying address, when the payload carried one. pub reply_to: Option, + /// The frontmatter fields carrier does not already show or act on, + /// in the order the reader's map keeps them (sorted by key). + pub headers: Vec<(String, String)>, pub kept: bool, } @@ -669,6 +672,17 @@ pub fn open( .find(|m| m.id == raw) .ok_or_else(|| "message not found".to_string())?; let described = client::describe(store, &me, &stored).map_err(explain)?; + // The subject sits in the header, the reply address powers Reply and + // the accept/block actions, and the Accept token is consumed by the + // contact flow — everything else the sender put in the frontmatter + // is theirs to say and ours to show. + const CONSUMED: [&str; 3] = ["subject", "reply-to", "accept"]; + let headers = described + .fields + .iter() + .filter(|(key, _)| !CONSUMED.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); Ok(OpenedMsg { id: id.to_string(), from: match folder { @@ -680,6 +694,7 @@ pub fn open( body: described.text, sender: b32(&described.sender), reply_to: described.fields.get("reply-to").cloned(), + headers, kept: stored.kept, }) } @@ -888,7 +903,12 @@ mod tests { store.set_account(&addr).unwrap(); let body = build_frontmatter( - &[("Subject", "hello"), ("Reply-To", &addr.uri(&me.me().pk()))], + &[ + ("Subject", "hello"), + ("Reply-To", &addr.uri(&me.me().pk())), + ("Urgency", "high"), + ("X-Source", "nightly build"), + ], "the body text", ); let envelope = seal(me.me(), &me.me().pk(), body.as_bytes(), now(), true).unwrap(); @@ -910,6 +930,15 @@ mod tests { assert_eq!(opened.subject, "hello"); assert_eq!(opened.body, "the body text"); assert!(opened.reply_to.is_some()); + // The sender's own fields come through; the ones carrier already + // consumes do not. + assert_eq!( + opened.headers, + [ + ("urgency".to_string(), "high".to_string()), + ("x-source".to_string(), "nightly build".to_string()), + ] + ); let mut read = HashSet::new(); read.insert(id_hex); @@ -975,6 +1004,35 @@ mod tests { assert!(pins(&store).unwrap().is_empty()); } + /// The dev-build demo seed grows a mail that carries the sender's own + /// frontmatter, and it opens with those fields intact — the case the + /// message detail's header block is laid out around. + #[cfg(debug_assertions)] + #[test] + fn demo_seed_carries_a_mail_with_custom_fields() { + let dir = tempfile::tempdir().unwrap(); + crate::demo::seed(dir.path()); + let store = Store::open(&dir.path().join("carrier.db")).unwrap(); + // The demo master is fixed bytes in demo.rs. + let master: [u8; KEY_LEN] = core::array::from_fn(|i| i as u8 + 1); + let empty = HashSet::new(); + + let folders = lists(&store, &master, &empty).unwrap(); + let row = folders[0] + .iter() + .find(|row| row.subject == "canary rollout report") + .expect("the custom-fields mail in the demo inbox"); + + let opened = open(&store, &master, &row.id, Folder::Inbox).unwrap(); + assert_eq!( + opened.headers, + [ + ("priority".to_string(), "high".to_string()), + ("x-stage".to_string(), "canary at 5%".to_string()), + ] + ); + } + #[test] fn stamps_are_utc_civil_dates() { assert_eq!(when(0), "1970-01-01 00:00"); diff --git a/src/view.rs b/src/view.rs index e2a794a..9d1d57e 100644 --- a/src/view.rs +++ b/src/view.rs @@ -196,6 +196,7 @@ pub struct CarrierWidgets { article_stack: gtk::Stack, article_subject: gtk::Label, article_meta: gtk::Label, + article_headers: gtk::Label, article_view: gtk::TextView, article_body: gtk::TextBuffer, actions: gtk::Box, @@ -470,6 +471,15 @@ impl CarrierWidgets { article_meta.set_wrap(true); article_head.append(&subject_row); article_head.append(&article_meta); + // The sender's own frontmatter fields, when a mail carries any: + // envelope data, so it reads like the contact detail's key block + // rather than like body prose. + let article_headers = label("", "caption mono"); + article_headers.set_wrap(true); + article_headers.set_wrap_mode(gtk::pango::WrapMode::WordChar); + article_headers.set_selectable(true); + article_headers.set_visible(false); + article_head.append(&article_headers); article.append(&article_head); article.append(>k::Separator::new(Orientation::Horizontal)); let article_view = gtk::TextView::new(); @@ -998,6 +1008,7 @@ impl CarrierWidgets { article_stack, article_subject, article_meta, + article_headers, article_view, article_body, actions, @@ -1483,6 +1494,13 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender = msg + .headers + .iter() + .map(|(key, value)| format!("{key}: {value}")) + .collect(); + w.article_headers.set_visible(!headers.is_empty()); + w.article_headers.set_text(&headers.join("\n")); w.article_body.set_text(&msg.body); clear(&w.actions);