feat: show received frontmatter fields in the message detail

This commit is contained in:
randogoth 2026-10-03 17:30:22 +03:00
parent 68f5620d35
commit dacbc9de38
5 changed files with 103 additions and 2 deletions

View file

@ -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. - 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. - 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. - 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. - 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. - 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. - Key rotation with a confirm-first step, server pin management, a network timeout, and gsmol backup export/import that never contains the seed.

View file

@ -1086,6 +1086,7 @@ mod tests {
body: String::new(), body: String::new(),
sender: String::new(), sender: String::new(),
reply_to: None, reply_to: None,
headers: Vec::new(),
kept: false, kept: false,
} }
} }

View file

@ -158,6 +158,30 @@ fn plant(dir: &Path) -> Result<(), String> {
true, 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 // requests: senders not in the contact list
deliver( deliver(
&identity(31), &identity(31),

View file

@ -37,6 +37,9 @@ pub struct OpenedMsg {
pub sender: String, pub sender: String,
/// The sender's self-certifying address, when the payload carried one. /// The sender's self-certifying address, when the payload carried one.
pub reply_to: Option<String>, pub reply_to: Option<String>,
/// 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, pub kept: bool,
} }
@ -669,6 +672,17 @@ pub fn open(
.find(|m| m.id == raw) .find(|m| m.id == raw)
.ok_or_else(|| "message not found".to_string())?; .ok_or_else(|| "message not found".to_string())?;
let described = client::describe(store, &me, &stored).map_err(explain)?; 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 { Ok(OpenedMsg {
id: id.to_string(), id: id.to_string(),
from: match folder { from: match folder {
@ -680,6 +694,7 @@ pub fn open(
body: described.text, body: described.text,
sender: b32(&described.sender), sender: b32(&described.sender),
reply_to: described.fields.get("reply-to").cloned(), reply_to: described.fields.get("reply-to").cloned(),
headers,
kept: stored.kept, kept: stored.kept,
}) })
} }
@ -888,7 +903,12 @@ mod tests {
store.set_account(&addr).unwrap(); store.set_account(&addr).unwrap();
let body = build_frontmatter( 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", "the body text",
); );
let envelope = seal(me.me(), &me.me().pk(), body.as_bytes(), now(), true).unwrap(); 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.subject, "hello");
assert_eq!(opened.body, "the body text"); assert_eq!(opened.body, "the body text");
assert!(opened.reply_to.is_some()); 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(); let mut read = HashSet::new();
read.insert(id_hex); read.insert(id_hex);
@ -975,6 +1004,35 @@ mod tests {
assert!(pins(&store).unwrap().is_empty()); 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] #[test]
fn stamps_are_utc_civil_dates() { fn stamps_are_utc_civil_dates() {
assert_eq!(when(0), "1970-01-01 00:00"); assert_eq!(when(0), "1970-01-01 00:00");

View file

@ -196,6 +196,7 @@ pub struct CarrierWidgets {
article_stack: gtk::Stack, article_stack: gtk::Stack,
article_subject: gtk::Label, article_subject: gtk::Label,
article_meta: gtk::Label, article_meta: gtk::Label,
article_headers: gtk::Label,
article_view: gtk::TextView, article_view: gtk::TextView,
article_body: gtk::TextBuffer, article_body: gtk::TextBuffer,
actions: gtk::Box, actions: gtk::Box,
@ -470,6 +471,15 @@ impl CarrierWidgets {
article_meta.set_wrap(true); article_meta.set_wrap(true);
article_head.append(&subject_row); article_head.append(&subject_row);
article_head.append(&article_meta); 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(&article_head);
article.append(&gtk::Separator::new(Orientation::Horizontal)); article.append(&gtk::Separator::new(Orientation::Horizontal));
let article_view = gtk::TextView::new(); let article_view = gtk::TextView::new();
@ -998,6 +1008,7 @@ impl CarrierWidgets {
article_stack, article_stack,
article_subject, article_subject,
article_meta, article_meta,
article_headers,
article_view, article_view,
article_body, article_body,
actions, actions,
@ -1483,6 +1494,13 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
meta.push_str(" · a server copy still exists"); meta.push_str(" · a server copy still exists");
} }
w.article_meta.set_markup(&meta); w.article_meta.set_markup(&meta);
let headers: Vec<String> = 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); w.article_body.set_text(&msg.body);
clear(&w.actions); clear(&w.actions);