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.
|
||||
- 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. 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.
|
||||
- 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.
|
||||
|
|
|
|||
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.
|
||||
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
|
||||
|
|
@ -154,6 +162,8 @@ pub enum Message {
|
|||
ComposeClosed,
|
||||
SendPressed,
|
||||
ReplyPressed,
|
||||
ReplyCancelled,
|
||||
ReplyConfirmed(bool),
|
||||
DeletePressed,
|
||||
DeleteConfirmed(bool),
|
||||
DeleteCancelled,
|
||||
|
|
@ -763,12 +773,49 @@ impl Carrier {
|
|||
} else {
|
||||
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 {
|
||||
to,
|
||||
subject,
|
||||
body: String::new(),
|
||||
reply_to: Some(msg.id.clone()),
|
||||
headers: Vec::new(),
|
||||
reply_to: Some(id),
|
||||
headers: if copy { rows } else { Vec::new() },
|
||||
});
|
||||
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]
|
||||
fn headers_survive_a_stash_and_reopen() {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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(
|
||||
store: &Store,
|
||||
master: &[u8; KEY_LEN],
|
||||
|
|
@ -863,6 +875,22 @@ mod tests {
|
|||
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] {
|
||||
let mut seed = [0u8; KEY_LEN];
|
||||
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()) {
|
||||
(Some(dialog), false) => {
|
||||
let rotating = matches!(dialog, Dialog::Rotate);
|
||||
let replying = matches!(dialog, Dialog::Reply { .. });
|
||||
let alert = match dialog {
|
||||
Dialog::Delete { kept, .. } => {
|
||||
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
|
||||
}
|
||||
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_default_response(Some("cancel"));
|
||||
alert.set_default_response(Some(close_default));
|
||||
let response_sender = sender.clone();
|
||||
alert.connect_response(None, move |_, response| {
|
||||
response_sender.input(match (rotating, response) {
|
||||
(true, "rotate") => Message::RotateConfirmed,
|
||||
(true, _) => Message::RotateCancelled,
|
||||
(false, "here") => Message::DeleteConfirmed(false),
|
||||
(false, "everywhere") => Message::DeleteConfirmed(true),
|
||||
(false, _) => Message::DeleteCancelled,
|
||||
response_sender.input(match (rotating, replying, response) {
|
||||
(true, _, "rotate") => Message::RotateConfirmed,
|
||||
(true, _, _) => Message::RotateCancelled,
|
||||
(_, true, "copy") => Message::ReplyConfirmed(true),
|
||||
(_, true, "without") => Message::ReplyConfirmed(false),
|
||||
(_, true, _) => Message::ReplyCancelled,
|
||||
(false, _, "here") => Message::DeleteConfirmed(false),
|
||||
(false, _, "everywhere") => Message::DeleteConfirmed(true),
|
||||
(false, _, _) => Message::DeleteCancelled,
|
||||
});
|
||||
});
|
||||
alert.present(Some(&w.window));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue