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

@ -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.

View file

@ -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};

View file

@ -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,
}], }],

View file

@ -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] {

View file

@ -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 |_: &gtk::Button| sender.input(msg.clone()) move |_: &gtk::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: &gtk::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 {