feat: ask whether a reply carries the sender's frontmatter
This commit is contained in:
parent
dacbc9de38
commit
cda8985218
4 changed files with 167 additions and 10 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. 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.
|
||||||
|
|
|
||||||
108
src/app.rs
108
src/app.rs
|
|
@ -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();
|
||||||
|
|
|
||||||
28
src/ops.rs
28
src/ops.rs
|
|
@ -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() {
|
||||||
|
|
|
||||||
39
src/view.rs
39
src/view.rs
|
|
@ -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));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue