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

@ -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();