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.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -1086,6 +1086,7 @@ mod tests {
|
|||
body: String::new(),
|
||||
sender: String::new(),
|
||||
reply_to: None,
|
||||
headers: Vec::new(),
|
||||
kept: false,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
src/demo.rs
24
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),
|
||||
|
|
|
|||
60
src/ops.rs
60
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<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,
|
||||
}
|
||||
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
18
src/view.rs
18
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<Carr
|
|||
meta.push_str(" · a server copy still exists");
|
||||
}
|
||||
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);
|
||||
|
||||
clear(&w.actions);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue