feat: let composes carry custom frontmatter headers

This commit is contained in:
randogoth 2026-10-03 16:45:07 +03:00
parent 74ec9a2492
commit 68f5620d35
5 changed files with 401 additions and 10 deletions

View file

@ -93,6 +93,8 @@ pub struct Compose {
pub body: String,
/// The message this replies to, as 64 hex characters.
pub reply_to: Option<String>,
/// Frontmatter rows as typed — `Key: value` text, parsed at send.
pub headers: Vec<String>,
}
#[derive(Debug, Clone)]
@ -146,6 +148,9 @@ pub enum Message {
ComposeToChanged(String),
ComposeSubjectChanged(String),
ComposeBodyChanged(String),
ComposeHeaderAdded,
ComposeHeaderChanged(usize, String),
ComposeHeaderRemoved(usize),
ComposeClosed,
SendPressed,
ReplyPressed,
@ -225,6 +230,7 @@ pub enum PendingOp {
subject: String,
body: String,
reply_to: Option<String>,
headers: Vec<(String, String)>,
},
Delete {
id: String,
@ -372,9 +378,11 @@ impl Carrier {
let Some(compose) = self.compose.take() else {
return;
};
let rows = compose.headers.iter().any(|row| !row.trim().is_empty());
if compose.to.trim().is_empty()
&& compose.subject.trim().is_empty()
&& compose.body.trim().is_empty()
&& !rows
{
return;
}
@ -384,6 +392,7 @@ impl Carrier {
subject: compose.subject,
body: compose.body,
reply_to: compose.reply_to,
headers: compose.headers,
at: fumi_core::store::now(),
});
ops::save_drafts(&self.drafts_path(), &self.drafts);
@ -403,6 +412,7 @@ impl Carrier {
subject: draft.subject,
body: draft.body,
reply_to: draft.reply_to,
headers: draft.headers,
});
self.compose_fresh.set(true);
}
@ -518,6 +528,25 @@ impl Carrier {
compose.body = body;
}
}
Message::ComposeHeaderAdded => {
if let Some(compose) = &mut self.compose {
compose.headers.push(String::new());
}
}
Message::ComposeHeaderChanged(index, text) => {
if let Some(compose) = &mut self.compose {
if let Some(row) = compose.headers.get_mut(index) {
*row = text;
}
}
}
Message::ComposeHeaderRemoved(index) => {
if let Some(compose) = &mut self.compose {
if index < compose.headers.len() {
compose.headers.remove(index);
}
}
}
Message::ImportBodyChanged(text) => {
self.import_body = text;
}
@ -712,6 +741,7 @@ impl Carrier {
subject: String::new(),
body: String::new(),
reply_to: None,
headers: Vec::new(),
});
self.compose_fresh.set(true);
}
@ -738,6 +768,7 @@ impl Carrier {
subject,
body: String::new(),
reply_to: Some(msg.id.clone()),
headers: Vec::new(),
});
self.compose_fresh.set(true);
}
@ -752,11 +783,27 @@ impl Carrier {
self.fail("enter a recipient".into());
return Vec::new();
}
let mut headers = Vec::new();
for (index, row) in compose.headers.iter().enumerate() {
match ops::header_row(row) {
Ok(Some(pair)) => headers.push(pair),
Ok(None) => {}
Err(reason) => {
self.fail(format!("header {}: {reason}", index + 1));
return Vec::new();
}
}
}
if let Err(reason) = ops::validate_headers(&headers) {
self.fail(reason);
return Vec::new();
}
let op = PendingOp::Send {
to: compose.to.trim().to_string(),
subject: compose.subject.trim().to_string(),
body: compose.body.clone(),
reply_to: compose.reply_to.clone(),
headers,
};
self.compose = None;
return vec![op];
@ -850,6 +897,7 @@ impl Carrier {
subject: String::new(),
body: String::new(),
reply_to: None,
headers: Vec::new(),
});
self.compose_fresh.set(true);
}
@ -1042,7 +1090,6 @@ mod tests {
}
}
#[test]
#[test]
fn contact_selection_and_message_selection_supersede_each_other() {
let mut app = app();
@ -1090,6 +1137,7 @@ mod tests {
subject: String::new(),
body: String::new(),
reply_to: None,
headers: Vec::new(),
});
app.busy = Some("fetching".into());
app.handle(Message::ComposeBodyChanged("typing while busy".into()));
@ -1145,6 +1193,78 @@ mod tests {
assert!(app.drafts.is_empty());
}
#[test]
fn header_rows_pass_the_busy_guard_and_edit_the_compose() {
let mut app = app();
app.handle(Message::ComposePressed);
app.busy = Some("fetching".into());
app.handle(Message::ComposeHeaderAdded);
app.handle(Message::ComposeHeaderAdded);
app.handle(Message::ComposeHeaderChanged(0, "Urgency: high".into()));
app.handle(Message::ComposeHeaderRemoved(1));
assert_eq!(
app.compose.as_ref().expect("compose open").headers,
["Urgency: high"]
);
// An index that no longer exists is ignored, not a panic.
app.handle(Message::ComposeHeaderChanged(5, "no".into()));
assert_eq!(app.compose.as_ref().unwrap().headers, ["Urgency: high"]);
}
#[test]
fn an_invalid_header_blocks_the_send() {
let mut app = app();
app.handle(Message::ComposePressed);
app.handle(Message::ComposeToChanged("bob@localhost".into()));
// A reserved key shadows the client's own field, so it is rejected.
app.handle(Message::ComposeHeaderAdded);
app.handle(Message::ComposeHeaderChanged(0, "Subject: shadow".into()));
assert!(app.handle(Message::SendPressed).is_empty());
assert!(app.compose.is_some());
assert!(app.status_error);
// Case-insensitive duplicates are rejected too.
app.handle(Message::ComposeHeaderChanged(0, "Urgency: high".into()));
app.handle(Message::ComposeHeaderAdded);
app.handle(Message::ComposeHeaderChanged(1, "urgency: low".into()));
assert!(app.handle(Message::SendPressed).is_empty());
assert!(app.status_error);
// Removing the duplicate lets the send go through with the pair;
// the earlier error note stays until the op result replaces it.
app.handle(Message::ComposeHeaderRemoved(1));
let ops = app.handle(Message::SendPressed);
assert_eq!(ops.len(), 1);
assert!(app.compose.is_none());
assert!(matches!(
&ops[0],
PendingOp::Send { headers, .. }
if headers == &[("Urgency".to_string(), "high".to_string())]
));
}
#[test]
fn headers_survive_a_stash_and_reopen() {
let mut app = app();
app.handle(Message::ComposePressed);
app.handle(Message::ComposeToChanged("bob@localhost".into()));
app.handle(Message::ComposeHeaderAdded);
app.handle(Message::ComposeHeaderChanged(0, "Urgency: high".into()));
app.handle(Message::ComposeHeaderAdded);
app.handle(Message::ComposeHeaderChanged(1, "not valid yet".into()));
// A contact trip stashes without starting a refresh, so the
// reopen below is not swallowed by the busy guard.
app.handle(Message::ContactSelected("bob@localhost".into()));
assert_eq!(app.drafts.len(), 1);
let id = app.drafts[0].id.clone();
app.handle(Message::MessageSelected(Folder::Drafts, id));
let compose = app.compose.as_ref().expect("draft reopened");
// Rows return exactly as typed, valid or not: validation is a
// send-time concern, not a stash-time one.
assert_eq!(compose.headers, ["Urgency: high", "not valid yet"]);
}
#[test]
fn fetch_tick_lists_mail_as_it_lands() {
use fumi_core::message::{build_frontmatter, message_id, seal};