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
|
|
@ -8,6 +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.
|
||||||
- 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.
|
||||||
|
|
|
||||||
122
src/app.rs
122
src/app.rs
|
|
@ -93,6 +93,8 @@ pub struct Compose {
|
||||||
pub body: String,
|
pub body: String,
|
||||||
/// The message this replies to, as 64 hex characters.
|
/// The message this replies to, as 64 hex characters.
|
||||||
pub reply_to: Option<String>,
|
pub reply_to: Option<String>,
|
||||||
|
/// Frontmatter rows as typed — `Key: value` text, parsed at send.
|
||||||
|
pub headers: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -146,6 +148,9 @@ pub enum Message {
|
||||||
ComposeToChanged(String),
|
ComposeToChanged(String),
|
||||||
ComposeSubjectChanged(String),
|
ComposeSubjectChanged(String),
|
||||||
ComposeBodyChanged(String),
|
ComposeBodyChanged(String),
|
||||||
|
ComposeHeaderAdded,
|
||||||
|
ComposeHeaderChanged(usize, String),
|
||||||
|
ComposeHeaderRemoved(usize),
|
||||||
ComposeClosed,
|
ComposeClosed,
|
||||||
SendPressed,
|
SendPressed,
|
||||||
ReplyPressed,
|
ReplyPressed,
|
||||||
|
|
@ -225,6 +230,7 @@ pub enum PendingOp {
|
||||||
subject: String,
|
subject: String,
|
||||||
body: String,
|
body: String,
|
||||||
reply_to: Option<String>,
|
reply_to: Option<String>,
|
||||||
|
headers: Vec<(String, String)>,
|
||||||
},
|
},
|
||||||
Delete {
|
Delete {
|
||||||
id: String,
|
id: String,
|
||||||
|
|
@ -372,9 +378,11 @@ impl Carrier {
|
||||||
let Some(compose) = self.compose.take() else {
|
let Some(compose) = self.compose.take() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let rows = compose.headers.iter().any(|row| !row.trim().is_empty());
|
||||||
if compose.to.trim().is_empty()
|
if compose.to.trim().is_empty()
|
||||||
&& compose.subject.trim().is_empty()
|
&& compose.subject.trim().is_empty()
|
||||||
&& compose.body.trim().is_empty()
|
&& compose.body.trim().is_empty()
|
||||||
|
&& !rows
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -384,6 +392,7 @@ impl Carrier {
|
||||||
subject: compose.subject,
|
subject: compose.subject,
|
||||||
body: compose.body,
|
body: compose.body,
|
||||||
reply_to: compose.reply_to,
|
reply_to: compose.reply_to,
|
||||||
|
headers: compose.headers,
|
||||||
at: fumi_core::store::now(),
|
at: fumi_core::store::now(),
|
||||||
});
|
});
|
||||||
ops::save_drafts(&self.drafts_path(), &self.drafts);
|
ops::save_drafts(&self.drafts_path(), &self.drafts);
|
||||||
|
|
@ -403,6 +412,7 @@ impl Carrier {
|
||||||
subject: draft.subject,
|
subject: draft.subject,
|
||||||
body: draft.body,
|
body: draft.body,
|
||||||
reply_to: draft.reply_to,
|
reply_to: draft.reply_to,
|
||||||
|
headers: draft.headers,
|
||||||
});
|
});
|
||||||
self.compose_fresh.set(true);
|
self.compose_fresh.set(true);
|
||||||
}
|
}
|
||||||
|
|
@ -518,6 +528,25 @@ impl Carrier {
|
||||||
compose.body = body;
|
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) => {
|
Message::ImportBodyChanged(text) => {
|
||||||
self.import_body = text;
|
self.import_body = text;
|
||||||
}
|
}
|
||||||
|
|
@ -712,6 +741,7 @@ impl Carrier {
|
||||||
subject: String::new(),
|
subject: String::new(),
|
||||||
body: String::new(),
|
body: String::new(),
|
||||||
reply_to: None,
|
reply_to: None,
|
||||||
|
headers: Vec::new(),
|
||||||
});
|
});
|
||||||
self.compose_fresh.set(true);
|
self.compose_fresh.set(true);
|
||||||
}
|
}
|
||||||
|
|
@ -738,6 +768,7 @@ impl Carrier {
|
||||||
subject,
|
subject,
|
||||||
body: String::new(),
|
body: String::new(),
|
||||||
reply_to: Some(msg.id.clone()),
|
reply_to: Some(msg.id.clone()),
|
||||||
|
headers: Vec::new(),
|
||||||
});
|
});
|
||||||
self.compose_fresh.set(true);
|
self.compose_fresh.set(true);
|
||||||
}
|
}
|
||||||
|
|
@ -752,11 +783,27 @@ impl Carrier {
|
||||||
self.fail("enter a recipient".into());
|
self.fail("enter a recipient".into());
|
||||||
return Vec::new();
|
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 {
|
let op = PendingOp::Send {
|
||||||
to: compose.to.trim().to_string(),
|
to: compose.to.trim().to_string(),
|
||||||
subject: compose.subject.trim().to_string(),
|
subject: compose.subject.trim().to_string(),
|
||||||
body: compose.body.clone(),
|
body: compose.body.clone(),
|
||||||
reply_to: compose.reply_to.clone(),
|
reply_to: compose.reply_to.clone(),
|
||||||
|
headers,
|
||||||
};
|
};
|
||||||
self.compose = None;
|
self.compose = None;
|
||||||
return vec![op];
|
return vec![op];
|
||||||
|
|
@ -850,6 +897,7 @@ impl Carrier {
|
||||||
subject: String::new(),
|
subject: String::new(),
|
||||||
body: String::new(),
|
body: String::new(),
|
||||||
reply_to: None,
|
reply_to: None,
|
||||||
|
headers: Vec::new(),
|
||||||
});
|
});
|
||||||
self.compose_fresh.set(true);
|
self.compose_fresh.set(true);
|
||||||
}
|
}
|
||||||
|
|
@ -1042,7 +1090,6 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn contact_selection_and_message_selection_supersede_each_other() {
|
fn contact_selection_and_message_selection_supersede_each_other() {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
|
|
@ -1090,6 +1137,7 @@ mod tests {
|
||||||
subject: String::new(),
|
subject: String::new(),
|
||||||
body: String::new(),
|
body: String::new(),
|
||||||
reply_to: None,
|
reply_to: None,
|
||||||
|
headers: Vec::new(),
|
||||||
});
|
});
|
||||||
app.busy = Some("fetching".into());
|
app.busy = Some("fetching".into());
|
||||||
app.handle(Message::ComposeBodyChanged("typing while busy".into()));
|
app.handle(Message::ComposeBodyChanged("typing while busy".into()));
|
||||||
|
|
@ -1145,6 +1193,78 @@ mod tests {
|
||||||
assert!(app.drafts.is_empty());
|
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]
|
#[test]
|
||||||
fn fetch_tick_lists_mail_as_it_lands() {
|
fn fetch_tick_lists_mail_as_it_lands() {
|
||||||
use fumi_core::message::{build_frontmatter, message_id, seal};
|
use fumi_core::message::{build_frontmatter, message_id, seal};
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,7 @@ fn plant(dir: &Path) -> Result<(), String> {
|
||||||
to: "grace@example.org".into(),
|
to: "grace@example.org".into(),
|
||||||
subject: "Re: quarterly infrastructure review".into(),
|
subject: "Re: quarterly infrastructure review".into(),
|
||||||
body: "started a reply, will finish it after the review call".into(),
|
body: "started a reply, will finish it after the review call".into(),
|
||||||
|
headers: vec!["Urgency: low".into()],
|
||||||
reply_to: None,
|
reply_to: None,
|
||||||
at: now() - 3 * 3600,
|
at: now() - 3 * 3600,
|
||||||
}],
|
}],
|
||||||
|
|
|
||||||
189
src/ops.rs
189
src/ops.rs
|
|
@ -11,6 +11,7 @@ use fumi_core::address::{Address, DEFAULT_PORT};
|
||||||
use fumi_core::client::{FetchOptions, TrustChange};
|
use fumi_core::client::{FetchOptions, TrustChange};
|
||||||
use fumi_core::crypto::{b32, fingerprint, unb32, KEY_LEN};
|
use fumi_core::crypto::{b32, fingerprint, unb32, KEY_LEN};
|
||||||
use fumi_core::error::Error;
|
use fumi_core::error::Error;
|
||||||
|
use fumi_core::message::{FRONTMATTER_KEYS, FRONTMATTER_MAX};
|
||||||
use fumi_core::store::Store;
|
use fumi_core::store::Store;
|
||||||
use fumi_core::{account, client, export, store};
|
use fumi_core::{account, client, export, store};
|
||||||
|
|
||||||
|
|
@ -116,6 +117,8 @@ pub struct Draft {
|
||||||
pub body: String,
|
pub body: String,
|
||||||
/// The message this draft replies to, as 64 hex characters.
|
/// The message this draft replies to, as 64 hex characters.
|
||||||
pub reply_to: Option<String>,
|
pub reply_to: Option<String>,
|
||||||
|
/// Frontmatter rows as typed — `Key: value` text, parsed at send.
|
||||||
|
pub headers: Vec<String>,
|
||||||
pub at: i64,
|
pub at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,6 +136,7 @@ fn line(text: &str) -> String {
|
||||||
text.replace('\\', "\\\\")
|
text.replace('\\', "\\\\")
|
||||||
.replace('\n', "\\n")
|
.replace('\n', "\\n")
|
||||||
.replace('\r', "\\r")
|
.replace('\r', "\\r")
|
||||||
|
.replace('\u{1f}', "\\\u{1e}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unline(text: &str) -> String {
|
fn unline(text: &str) -> String {
|
||||||
|
|
@ -143,6 +147,7 @@ fn unline(text: &str) -> String {
|
||||||
match chars.next() {
|
match chars.next() {
|
||||||
Some('n') => out.push('\n'),
|
Some('n') => out.push('\n'),
|
||||||
Some('r') => out.push('\r'),
|
Some('r') => out.push('\r'),
|
||||||
|
Some('\u{1e}') => out.push('\u{1f}'),
|
||||||
Some(other) => out.push(other),
|
Some(other) => out.push(other),
|
||||||
None => out.push('\\'),
|
None => out.push('\\'),
|
||||||
}
|
}
|
||||||
|
|
@ -159,11 +164,16 @@ pub fn save_drafts(path: &Path, drafts: &[Draft]) {
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
for draft in drafts {
|
for draft in drafts {
|
||||||
let reply_to = draft.reply_to.as_deref().unwrap_or("-");
|
let reply_to = draft.reply_to.as_deref().unwrap_or("-");
|
||||||
|
let headers: Vec<String> = draft.headers.iter().map(|row| line(row)).collect();
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
let _ = writeln!(text, "draft {} {} {}", draft.id, draft.at, reply_to);
|
let _ = writeln!(text, "draft2 {} {} {}", draft.id, draft.at, reply_to);
|
||||||
let _ = writeln!(text, "{}", line(&draft.to));
|
let _ = writeln!(text, "{}", line(&draft.to));
|
||||||
let _ = writeln!(text, "{}", line(&draft.subject));
|
let _ = writeln!(text, "{}", line(&draft.subject));
|
||||||
let _ = writeln!(text, "{}", line(&draft.body));
|
let _ = writeln!(text, "{}", line(&draft.body));
|
||||||
|
// Rows joined on a control separator, so any typed text is
|
||||||
|
// reversible; keys are [A-Za-z0-9-] but values can hold colons,
|
||||||
|
// semicolons — anything but a newline, which line() escapes.
|
||||||
|
let _ = writeln!(text, "{}", headers.join("\u{1f}"));
|
||||||
}
|
}
|
||||||
let _ = std::fs::write(path, text);
|
let _ = std::fs::write(path, text);
|
||||||
}
|
}
|
||||||
|
|
@ -174,12 +184,12 @@ pub fn load_drafts(path: &Path) -> Vec<Draft> {
|
||||||
let text = std::fs::read_to_string(path).unwrap_or_default();
|
let text = std::fs::read_to_string(path).unwrap_or_default();
|
||||||
let lines: Vec<&str> = text.lines().collect();
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
let mut drafts = Vec::new();
|
let mut drafts = Vec::new();
|
||||||
for record in lines.chunks(4) {
|
let mut index = 0;
|
||||||
let [header, to, subject, body] = record else {
|
while index + 4 <= lines.len() {
|
||||||
break;
|
let header = lines[index];
|
||||||
};
|
let (to, subject, body) = (lines[index + 1], lines[index + 2], lines[index + 3]);
|
||||||
let mut parts = header.split_whitespace();
|
let mut parts = header.split_whitespace();
|
||||||
let (Some("draft"), Some(id), Some(at), reply_to) =
|
let (kind, Some(id), Some(at), reply_to) =
|
||||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||||
else {
|
else {
|
||||||
break;
|
break;
|
||||||
|
|
@ -188,6 +198,22 @@ pub fn load_drafts(path: &Path) -> Vec<Draft> {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let id = id.to_string();
|
let id = id.to_string();
|
||||||
|
index += 4;
|
||||||
|
// draft2 carries a fifth line of header rows; the old draft
|
||||||
|
// records stop at four and the next line is another record.
|
||||||
|
let headers = if kind == Some("draft2") && index < lines.len() {
|
||||||
|
index += 1;
|
||||||
|
let raw = lines[index - 1];
|
||||||
|
if raw.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
raw.split('\u{1f}').map(unline).collect()
|
||||||
|
}
|
||||||
|
} else if kind == Some("draft") {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
drafts.push(Draft {
|
drafts.push(Draft {
|
||||||
id,
|
id,
|
||||||
to: unline(to),
|
to: unline(to),
|
||||||
|
|
@ -197,6 +223,7 @@ pub fn load_drafts(path: &Path) -> Vec<Draft> {
|
||||||
None | Some("-") => None,
|
None | Some("-") => None,
|
||||||
Some(other) => Some(other.to_string()),
|
Some(other) => Some(other.to_string()),
|
||||||
},
|
},
|
||||||
|
headers,
|
||||||
at,
|
at,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -369,6 +396,69 @@ pub fn fetch(
|
||||||
Ok(note)
|
Ok(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fields fumi itself attaches to every message (sec 5.7, 5.8); a
|
||||||
|
/// user copy of any of them would shadow the client's own, since the
|
||||||
|
/// reader keeps the first occurrence of a key.
|
||||||
|
const RESERVED_HEADERS: [&str; 4] = ["subject", "in-reply-to", "reply-to", "accept"];
|
||||||
|
|
||||||
|
/// Parses one compose header row as the user typed it — `Key: value`.
|
||||||
|
/// An empty row is allowed and simply skipped on send; anything else
|
||||||
|
/// that breaks the frontmatter block (sec 5.5) is rejected, because a
|
||||||
|
/// single malformed line makes the reader drop every field.
|
||||||
|
pub fn header_row(text: &str) -> Result<Option<(String, String)>, String> {
|
||||||
|
let text = text.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some((key, value)) = text.split_once(':') else {
|
||||||
|
return Err("a header is `Key: value` — the colon is missing".into());
|
||||||
|
};
|
||||||
|
let key = key.trim();
|
||||||
|
if RESERVED_HEADERS
|
||||||
|
.iter()
|
||||||
|
.any(|reserved| key.eq_ignore_ascii_case(reserved))
|
||||||
|
{
|
||||||
|
return Err(format!("{key} is attached by the client and cannot be set"));
|
||||||
|
}
|
||||||
|
if key.is_empty()
|
||||||
|
|| key.len() > 64
|
||||||
|
|| !key
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||||
|
{
|
||||||
|
return Err("keys are 1-64 characters of letters, digits and dashes".into());
|
||||||
|
}
|
||||||
|
if value.contains('\n') || value.contains('\r') {
|
||||||
|
return Err("a value must stay on one line".into());
|
||||||
|
}
|
||||||
|
Ok(Some((key.to_string(), value.trim().to_string())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rules across all rows: keys are case-insensitive and must be
|
||||||
|
/// unique, and the whole block — fumi's own fields included — has to
|
||||||
|
/// stay inside the reader's budget (sec 5.5).
|
||||||
|
pub fn validate_headers(headers: &[(String, String)]) -> Result<(), String> {
|
||||||
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
for (key, _) in headers {
|
||||||
|
if !seen.insert(key.to_ascii_lowercase()) {
|
||||||
|
return Err(format!("{key} is set twice"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let budget: usize = headers
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| format!("{key}: {value}").len() + 1)
|
||||||
|
.sum();
|
||||||
|
// The client adds Subject, In-Reply-To, a Reply-To URI and possibly an
|
||||||
|
// Accept token on top of the user's rows; 512 bytes covers them all.
|
||||||
|
if budget + 512 > FRONTMATTER_MAX {
|
||||||
|
return Err("the headers exceed the frontmatter budget".into());
|
||||||
|
}
|
||||||
|
if headers.len() + 4 > FRONTMATTER_KEYS {
|
||||||
|
return Err("too many headers".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn send(
|
pub fn send(
|
||||||
store: &Store,
|
store: &Store,
|
||||||
master: &[u8; KEY_LEN],
|
master: &[u8; KEY_LEN],
|
||||||
|
|
@ -376,16 +466,21 @@ pub fn send(
|
||||||
subject: Option<&str>,
|
subject: Option<&str>,
|
||||||
body: &str,
|
body: &str,
|
||||||
reply_to: Option<&str>,
|
reply_to: Option<&str>,
|
||||||
|
headers: &[(String, String)],
|
||||||
timeout: u64,
|
timeout: u64,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let addr = parse_address(to)?;
|
let addr = parse_address(to)?;
|
||||||
let me = account(store, master)?;
|
let me = account(store, master)?;
|
||||||
|
let owned: Vec<(&str, &str)> = headers
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.as_str(), value.as_str()))
|
||||||
|
.collect();
|
||||||
let draft = client::SendDraft {
|
let draft = client::SendDraft {
|
||||||
address: &addr,
|
address: &addr,
|
||||||
text: body.to_string(),
|
text: body.to_string(),
|
||||||
subject,
|
subject,
|
||||||
reply_to,
|
reply_to,
|
||||||
headers: &[],
|
headers: &owned,
|
||||||
anonymous: false,
|
anonymous: false,
|
||||||
no_pad: false,
|
no_pad: false,
|
||||||
};
|
};
|
||||||
|
|
@ -652,6 +747,13 @@ mod tests {
|
||||||
subject: "line breaks\nand \\backslashes\r".into(),
|
subject: "line breaks\nand \\backslashes\r".into(),
|
||||||
body: "a draft\n\nwith paragraphs\nand a \\ literal".into(),
|
body: "a draft\n\nwith paragraphs\nand a \\ literal".into(),
|
||||||
reply_to: Some("ab".repeat(32)),
|
reply_to: Some("ab".repeat(32)),
|
||||||
|
// Rows return as typed: colons and separators included.
|
||||||
|
headers: vec![
|
||||||
|
"Urgency: high".into(),
|
||||||
|
"Note: a: value: with: colons".into(),
|
||||||
|
String::from_utf8_lossy(&[0x1f, b's', b'e', b'p']).into_owned(),
|
||||||
|
String::new(),
|
||||||
|
],
|
||||||
at: now(),
|
at: now(),
|
||||||
},
|
},
|
||||||
Draft {
|
Draft {
|
||||||
|
|
@ -660,6 +762,7 @@ mod tests {
|
||||||
subject: "no reply".into(),
|
subject: "no reply".into(),
|
||||||
body: String::new(),
|
body: String::new(),
|
||||||
reply_to: None,
|
reply_to: None,
|
||||||
|
headers: Vec::new(),
|
||||||
at: now(),
|
at: now(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -671,6 +774,78 @@ mod tests {
|
||||||
let torn = text.lines().take(6).collect::<Vec<_>>().join("\n") + "\n";
|
let torn = text.lines().take(6).collect::<Vec<_>>().join("\n") + "\n";
|
||||||
std::fs::write(&path, torn).unwrap();
|
std::fs::write(&path, torn).unwrap();
|
||||||
assert_eq!(load_drafts(&path).len(), 1);
|
assert_eq!(load_drafts(&path).len(), 1);
|
||||||
|
|
||||||
|
// The old four-line records, written before header rows existed,
|
||||||
|
// still read as drafts with no headers.
|
||||||
|
let legacy = format!(
|
||||||
|
"draft {} {} {}\n{}\n{}\n{}\n",
|
||||||
|
"0f".repeat(8),
|
||||||
|
100,
|
||||||
|
"-",
|
||||||
|
line("carol@example.org"),
|
||||||
|
line("before headers"),
|
||||||
|
line("an old body"),
|
||||||
|
);
|
||||||
|
std::fs::write(&path, legacy).unwrap();
|
||||||
|
let loaded = load_drafts(&path);
|
||||||
|
assert_eq!(loaded.len(), 1);
|
||||||
|
assert_eq!(loaded[0].to, "carol@example.org");
|
||||||
|
assert!(loaded[0].headers.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_rows_follow_the_frontmatter_rules() {
|
||||||
|
// The allowed forms: a pair, and the empty row a send skips.
|
||||||
|
assert_eq!(
|
||||||
|
header_row("Urgency: high"),
|
||||||
|
Ok(Some(("Urgency".into(), "high".into())))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
header_row(" spaced-key : trimmed value "),
|
||||||
|
Ok(Some(("spaced-key".into(), "trimmed value".into())))
|
||||||
|
);
|
||||||
|
assert_eq!(header_row(""), Ok(None));
|
||||||
|
assert_eq!(header_row(" "), Ok(None));
|
||||||
|
|
||||||
|
// Everything the reader would reject the whole block for.
|
||||||
|
assert!(header_row("no colon").is_err());
|
||||||
|
assert!(header_row(": value").is_err());
|
||||||
|
assert!(header_row("bad key: v").is_err());
|
||||||
|
assert!(header_row("ke\ty: v").is_err());
|
||||||
|
assert!(header_row(&format!("{}: v", "k".repeat(65))).is_err());
|
||||||
|
// A trailing newline is trimmed out of the key; an inner one is
|
||||||
|
// not, and the row is rejected.
|
||||||
|
assert_eq!(
|
||||||
|
header_row("value\n: v"),
|
||||||
|
Ok(Some(("value".into(), "v".into())))
|
||||||
|
);
|
||||||
|
assert!(header_row("multi\nline: v").is_err());
|
||||||
|
// The client's own fields would be shadowed by a copy.
|
||||||
|
for reserved in [
|
||||||
|
"Subject: x",
|
||||||
|
"subject: x",
|
||||||
|
"Reply-To: x",
|
||||||
|
"In-Reply-To: x",
|
||||||
|
"accept: x",
|
||||||
|
] {
|
||||||
|
assert!(header_row(reserved).is_err(), "{reserved}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Across rows: keys are case-insensitive and must be unique.
|
||||||
|
assert_eq!(
|
||||||
|
validate_headers(&[("Urgency".into(), "high".into())]),
|
||||||
|
Ok(())
|
||||||
|
);
|
||||||
|
assert!(validate_headers(&[
|
||||||
|
("Urgency".into(), "high".into()),
|
||||||
|
("urgency".into(), "low".into()),
|
||||||
|
])
|
||||||
|
.is_err());
|
||||||
|
// And the whole block stays inside the reader's budget.
|
||||||
|
let big = "v".repeat(1024);
|
||||||
|
let many: Vec<(String, String)> =
|
||||||
|
(0..5).map(|i| (format!("X-Key{i}"), big.clone())).collect();
|
||||||
|
assert!(validate_headers(&many).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn master() -> [u8; KEY_LEN] {
|
fn master() -> [u8; KEY_LEN] {
|
||||||
|
|
|
||||||
98
src/view.rs
98
src/view.rs
|
|
@ -202,6 +202,7 @@ pub struct CarrierWidgets {
|
||||||
|
|
||||||
// compose page
|
// compose page
|
||||||
compose_to: adw::EntryRow,
|
compose_to: adw::EntryRow,
|
||||||
|
compose_headers: gtk::ListBox,
|
||||||
compose_subject: adw::EntryRow,
|
compose_subject: adw::EntryRow,
|
||||||
compose_body: gtk::TextBuffer,
|
compose_body: gtk::TextBuffer,
|
||||||
compose_send: adw::ButtonContent,
|
compose_send: adw::ButtonContent,
|
||||||
|
|
@ -531,6 +532,14 @@ impl CarrierWidgets {
|
||||||
compose_fields.append(&compose_to);
|
compose_fields.append(&compose_to);
|
||||||
compose_fields.append(&compose_subject);
|
compose_fields.append(&compose_subject);
|
||||||
compose.append(&compose_fields);
|
compose.append(&compose_fields);
|
||||||
|
// Frontmatter rows: one `Key: value` entry each, plus a flat
|
||||||
|
// add button below. Rows are rebuilt in render on structural
|
||||||
|
// changes only, so the entries never lose focus while typing.
|
||||||
|
let compose_headers = boxed_list();
|
||||||
|
compose.append(&compose_headers);
|
||||||
|
let add_header = button("+ header", "quiet");
|
||||||
|
add_header.set_halign(Align::Start);
|
||||||
|
compose.append(&add_header);
|
||||||
let compose_view = gtk::TextView::new();
|
let compose_view = gtk::TextView::new();
|
||||||
compose_view.set_wrap_mode(WrapMode::WordChar);
|
compose_view.set_wrap_mode(WrapMode::WordChar);
|
||||||
compose_view.set_top_margin(8);
|
compose_view.set_top_margin(8);
|
||||||
|
|
@ -890,6 +899,8 @@ impl CarrierWidgets {
|
||||||
gtk::glib::Propagation::Stop
|
gtk::glib::Propagation::Stop
|
||||||
});
|
});
|
||||||
|
|
||||||
|
add_header.connect_clicked(input(Message::ComposeHeaderAdded));
|
||||||
|
|
||||||
compose_send_button.connect_clicked(input(Message::SendPressed));
|
compose_send_button.connect_clicked(input(Message::SendPressed));
|
||||||
compose_cancel.connect_clicked(input(Message::ComposeClosed));
|
compose_cancel.connect_clicked(input(Message::ComposeClosed));
|
||||||
let to_sender = sender.clone();
|
let to_sender = sender.clone();
|
||||||
|
|
@ -991,6 +1002,7 @@ impl CarrierWidgets {
|
||||||
article_body,
|
article_body,
|
||||||
actions,
|
actions,
|
||||||
compose_to,
|
compose_to,
|
||||||
|
compose_headers,
|
||||||
compose_subject,
|
compose_subject,
|
||||||
compose_body,
|
compose_body,
|
||||||
compose_send,
|
compose_send,
|
||||||
|
|
@ -1128,6 +1140,7 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender<Carrier>) {
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
reply_to,
|
reply_to,
|
||||||
|
headers,
|
||||||
} => sender.spawn_command(move |out| {
|
} => sender.spawn_command(move |out| {
|
||||||
let res = ops::send(
|
let res = ops::send(
|
||||||
&store,
|
&store,
|
||||||
|
|
@ -1140,6 +1153,7 @@ fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender<Carrier>) {
|
||||||
},
|
},
|
||||||
&body,
|
&body,
|
||||||
reply_to.as_deref(),
|
reply_to.as_deref(),
|
||||||
|
&headers,
|
||||||
timeout,
|
timeout,
|
||||||
);
|
);
|
||||||
let _ = out.send(OpResult::Generic(res));
|
let _ = out.send(OpResult::Generic(res));
|
||||||
|
|
@ -1203,15 +1217,44 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
|
||||||
move |_: >k::Button| sender.input(msg.clone())
|
move |_: >k::Button| sender.input(msg.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prefill entries once after a reply or write-to-contact; afterwards
|
// Prefill entries once after a reply, write or draft reopen;
|
||||||
// typing flows entry -> model only, so the view never fights the user.
|
// afterwards typing flows entry -> model only, so the view never
|
||||||
|
// fights the user. The fresh flag also rebuilds the header rows,
|
||||||
|
// which otherwise sync on count changes alone.
|
||||||
|
let mut compose_fresh = false;
|
||||||
if model.compose_fresh.take() {
|
if model.compose_fresh.take() {
|
||||||
|
compose_fresh = true;
|
||||||
if let Some(compose) = &model.compose {
|
if let Some(compose) = &model.compose {
|
||||||
w.compose_to.set_text(&compose.to);
|
w.compose_to.set_text(&compose.to);
|
||||||
w.compose_subject.set_text(&compose.subject);
|
w.compose_subject.set_text(&compose.subject);
|
||||||
w.compose_body.set_text(&compose.body);
|
w.compose_body.set_text(&compose.body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let rows = model.compose.as_ref().map_or(0, |c| c.headers.len());
|
||||||
|
let mut current = 0;
|
||||||
|
let mut child = w.compose_headers.first_child();
|
||||||
|
while let Some(item) = child {
|
||||||
|
current += 1;
|
||||||
|
child = item.next_sibling();
|
||||||
|
}
|
||||||
|
if compose_fresh || current != rows {
|
||||||
|
let added = rows > current;
|
||||||
|
clear_list(&w.compose_headers);
|
||||||
|
if let Some(compose) = &model.compose {
|
||||||
|
let mut last_entry = None;
|
||||||
|
for (index, text) in compose.headers.iter().enumerate() {
|
||||||
|
let (row, entry) = compose_header_row(index, text, sender);
|
||||||
|
last_entry = Some(entry);
|
||||||
|
w.compose_headers.append(&row);
|
||||||
|
}
|
||||||
|
// A freshly appended row is the one being typed into.
|
||||||
|
if added {
|
||||||
|
if let Some(entry) = last_entry {
|
||||||
|
entry.grab_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Header and sidebar identity.
|
// Header and sidebar identity.
|
||||||
let address = model
|
let address = model
|
||||||
|
|
@ -1711,6 +1754,57 @@ fn mail_row(folder: Folder, row: &Row) -> adw::ActionRow {
|
||||||
item
|
item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One compose header row: a `Key: value` entry plus a remove button.
|
||||||
|
/// The row carries its own validation state — anything the send would
|
||||||
|
/// reject is tinted and explained right where it is typed, and the
|
||||||
|
/// empty rows the send skips are left quiet.
|
||||||
|
fn compose_header_row<'a>(
|
||||||
|
index: usize,
|
||||||
|
text: &str,
|
||||||
|
sender: &ComponentSender<Carrier>,
|
||||||
|
) -> (gtk::ListBoxRow, gtk::Entry) {
|
||||||
|
let entry = gtk::Entry::new();
|
||||||
|
entry.set_hexpand(true);
|
||||||
|
entry.set_placeholder_text(Some("Key: value"));
|
||||||
|
entry.set_text(text);
|
||||||
|
let style = |entry: >k::Entry, text: &str| match crate::ops::header_row(text) {
|
||||||
|
Err(reason) => {
|
||||||
|
entry.add_css_class("error");
|
||||||
|
entry.set_tooltip_text(Some(&reason));
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
entry.remove_css_class("error");
|
||||||
|
entry.set_tooltip_text(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
style(&entry, text);
|
||||||
|
let change_sender = sender.clone();
|
||||||
|
let style_entry = entry.clone();
|
||||||
|
entry.connect_changed(move |changed| {
|
||||||
|
let text = changed.text().to_string();
|
||||||
|
style(&style_entry, &text);
|
||||||
|
change_sender.input(Message::ComposeHeaderChanged(index, text));
|
||||||
|
});
|
||||||
|
|
||||||
|
let remove = icon_button("user-trash-symbolic", "Remove this header");
|
||||||
|
let remove_sender = sender.clone();
|
||||||
|
remove.connect_clicked(move |_| {
|
||||||
|
remove_sender.input(Message::ComposeHeaderRemoved(index));
|
||||||
|
});
|
||||||
|
|
||||||
|
let line = gtk::Box::new(Orientation::Horizontal, 6);
|
||||||
|
line.set_margin_top(6);
|
||||||
|
line.set_margin_bottom(6);
|
||||||
|
line.set_margin_start(12);
|
||||||
|
line.set_margin_end(6);
|
||||||
|
line.append(&entry);
|
||||||
|
line.append(&remove);
|
||||||
|
|
||||||
|
let row = gtk::ListBoxRow::new();
|
||||||
|
row.set_child(Some(&line));
|
||||||
|
(row, entry)
|
||||||
|
}
|
||||||
|
|
||||||
/// One contact in the sidebar's bottom panel: the address over its trust
|
/// One contact in the sidebar's bottom panel: the address over its trust
|
||||||
/// state; activating it opens the detail pane.
|
/// state; activating it opens the detail pane.
|
||||||
fn sidebar_contact_row(contact: &Contact) -> adw::ActionRow {
|
fn sidebar_contact_row(contact: &Contact) -> adw::ActionRow {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue