feat: ask whether a reply carries the sender's frontmatter

This commit is contained in:
randogoth 2026-10-03 17:58:30 +03:00
parent dacbc9de38
commit cda8985218
4 changed files with 167 additions and 10 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. 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. - 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

@ -107,6 +107,14 @@ pub enum Dialog {
}, },
/// Rotation only moves the index forward, so the action asks first. /// Rotation only moves the index forward, so the action asks first.
Rotate, 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<String>,
},
} }
/// UI inputs. Background results arrive as [`OpResult`] instead, so the /// UI inputs. Background results arrive as [`OpResult`] instead, so the
@ -154,6 +162,8 @@ pub enum Message {
ComposeClosed, ComposeClosed,
SendPressed, SendPressed,
ReplyPressed, ReplyPressed,
ReplyCancelled,
ReplyConfirmed(bool),
DeletePressed, DeletePressed,
DeleteConfirmed(bool), DeleteConfirmed(bool),
DeleteCancelled, DeleteCancelled,
@ -763,12 +773,49 @@ impl Carrier {
} else { } else {
format!("Re: {}", msg.subject) 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 { self.compose = Some(Compose {
to, to,
subject, subject,
body: String::new(), body: String::new(),
reply_to: Some(msg.id.clone()), reply_to: Some(id),
headers: Vec::new(), headers: if copy { rows } else { Vec::new() },
}); });
self.compose_fresh.set(true); 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] #[test]
fn headers_survive_a_stash_and_reopen() { fn headers_survive_a_stash_and_reopen() {
let mut app = app(); let mut app = app();

View file

@ -462,6 +462,18 @@ pub fn validate_headers(headers: &[(String, String)]) -> Result<(), String> {
Ok(()) 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<String> {
headers
.iter()
.filter_map(|(key, value)| header_row(&format!("{key}: {value}")).ok().flatten())
.map(|(key, value)| format!("{key}: {value}"))
.collect()
}
pub fn send( pub fn send(
store: &Store, store: &Store,
master: &[u8; KEY_LEN], master: &[u8; KEY_LEN],
@ -863,6 +875,22 @@ mod tests {
assert!(validate_headers(&many).is_err()); 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] { fn master() -> [u8; KEY_LEN] {
let mut seed = [0u8; KEY_LEN]; let mut seed = [0u8; KEY_LEN];
for (i, b) in seed.iter_mut().enumerate() { for (i, b) in seed.iter_mut().enumerate() {

View file

@ -1649,6 +1649,7 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
match (&model.dialog, w.dialog.is_some()) { match (&model.dialog, w.dialog.is_some()) {
(Some(dialog), false) => { (Some(dialog), false) => {
let rotating = matches!(dialog, Dialog::Rotate); let rotating = matches!(dialog, Dialog::Rotate);
let replying = matches!(dialog, Dialog::Reply { .. });
let alert = match dialog { let alert = match dialog {
Dialog::Delete { kept, .. } => { Dialog::Delete { kept, .. } => {
let alert = adw::AlertDialog::new( let alert = adw::AlertDialog::new(
@ -1681,17 +1682,41 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
alert.set_response_appearance("rotate", adw::ResponseAppearance::Destructive); alert.set_response_appearance("rotate", adw::ResponseAppearance::Destructive);
alert alert
} }
Dialog::Reply { rows, .. } => {
let list = rows
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.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_close_response("cancel");
alert.set_default_response(Some("cancel")); alert.set_default_response(Some(close_default));
let response_sender = sender.clone(); let response_sender = sender.clone();
alert.connect_response(None, move |_, response| { alert.connect_response(None, move |_, response| {
response_sender.input(match (rotating, response) { response_sender.input(match (rotating, replying, response) {
(true, "rotate") => Message::RotateConfirmed, (true, _, "rotate") => Message::RotateConfirmed,
(true, _) => Message::RotateCancelled, (true, _, _) => Message::RotateCancelled,
(false, "here") => Message::DeleteConfirmed(false), (_, true, "copy") => Message::ReplyConfirmed(true),
(false, "everywhere") => Message::DeleteConfirmed(true), (_, true, "without") => Message::ReplyConfirmed(false),
(false, _) => Message::DeleteCancelled, (_, true, _) => Message::ReplyCancelled,
(false, _, "here") => Message::DeleteConfirmed(false),
(false, _, "everywhere") => Message::DeleteConfirmed(true),
(false, _, _) => Message::DeleteCancelled,
}); });
}); });
alert.present(Some(&w.window)); alert.present(Some(&w.window));