feat: show received frontmatter fields in the message detail
This commit is contained in:
parent
68f5620d35
commit
dacbc9de38
5 changed files with 103 additions and 2 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
src/demo.rs
24
src/demo.rs
|
|
@ -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),
|
||||||
|
|
|
||||||
60
src/ops.rs
60
src/ops.rs
|
|
@ -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");
|
||||||
|
|
|
||||||
18
src/view.rs
18
src/view.rs
|
|
@ -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(>k::Separator::new(Orientation::Horizontal));
|
article.append(>k::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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue