feat: let composes carry custom frontmatter headers
This commit is contained in:
parent
74ec9a2492
commit
68f5620d35
5 changed files with 401 additions and 10 deletions
122
src/app.rs
122
src/app.rs
|
|
@ -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};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue