feat: stash composes closed by sidebar trips as drafts

This commit is contained in:
randogoth 2026-10-03 09:44:19 +03:00
parent f55821213d
commit 84650e50bd
5 changed files with 310 additions and 13 deletions

View file

@ -78,12 +78,15 @@ pub fn snapshot(
})
}
/// The three folders, in sidebar order.
/// The sidebar's folders, in its order. Drafts is carrier's own folder:
/// it has no table in the sealed store, its rows come from the drafts
/// sidecar, and it only appears in the sidebar once it holds one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Folder {
Inbox,
Requests,
Sent,
Drafts,
}
impl Folder {
@ -94,10 +97,110 @@ impl Folder {
Folder::Inbox => "inbox",
Folder::Requests => "requests",
Folder::Sent => "sent",
// Never reached: `handle` routes draft selection away from
// the store before any table lookup happens.
Folder::Drafts => "drafts",
}
}
}
/// A compose set aside instead of sent, kept beside the store like the read
/// marks — it is plaintext by nature until it is sealed on the way out.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Draft {
pub id: String,
pub to: String,
pub subject: String,
pub body: String,
/// The message this draft replies to, as 64 hex characters.
pub reply_to: Option<String>,
pub at: i64,
}
/// A fresh draft id: 8 random bytes as hex, unique within the sidecar.
pub fn draft_id() -> String {
use rand_core::RngCore;
let mut bytes = [0u8; 8];
rand_core::OsRng.fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// One line per field, with backslash escapes so no field can break the
/// line format: `\` becomes `\\`, a newline `\n`, a carriage return `\r`.
fn line(text: &str) -> String {
text.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
fn unline(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some(other) => out.push(other),
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
/// Four lines per draft: header, to, subject, body. The whole file is
/// rewritten on every change, which a handful of drafts makes honest.
pub fn save_drafts(path: &Path, drafts: &[Draft]) {
let mut text = String::new();
for draft in drafts {
let reply_to = draft.reply_to.as_deref().unwrap_or("-");
use std::fmt::Write;
let _ = writeln!(text, "draft {} {} {}", draft.id, draft.at, reply_to);
let _ = writeln!(text, "{}", line(&draft.to));
let _ = writeln!(text, "{}", line(&draft.subject));
let _ = writeln!(text, "{}", line(&draft.body));
}
let _ = std::fs::write(path, text);
}
/// Reads the drafts sidecar; a missing or torn tail only costs the
/// incomplete record at the end.
pub fn load_drafts(path: &Path) -> Vec<Draft> {
let text = std::fs::read_to_string(path).unwrap_or_default();
let lines: Vec<&str> = text.lines().collect();
let mut drafts = Vec::new();
for record in lines.chunks(4) {
let [header, to, subject, body] = record else {
break;
};
let mut parts = header.split_whitespace();
let (Some("draft"), Some(id), Some(at), reply_to) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
break;
};
let Ok(at) = at.parse::<i64>() else {
break;
};
let id = id.to_string();
drafts.push(Draft {
id,
to: unline(to),
subject: unline(subject),
body: unline(body),
reply_to: match reply_to {
None | Some("-") => None,
Some(other) => Some(other.to_string()),
},
at,
});
}
drafts
}
fn explain(e: Error) -> String {
e.to_string()
}
@ -535,6 +638,38 @@ mod tests {
use fumi_core::message::{build_frontmatter, message_id, seal};
use fumi_core::store::now;
#[test]
fn drafts_round_trip_through_the_sidecar() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("drafts.txt");
let drafts = vec![
Draft {
id: draft_id(),
to: "smol://alice@example.org/KEY".into(),
subject: "line breaks\nand \\backslashes\r".into(),
body: "a draft\n\nwith paragraphs\nand a \\ literal".into(),
reply_to: Some("ab".repeat(32)),
at: now(),
},
Draft {
id: draft_id(),
to: "bob@example.org".into(),
subject: "no reply".into(),
body: String::new(),
reply_to: None,
at: now(),
},
];
save_drafts(&path, &drafts);
assert_eq!(load_drafts(&path), drafts);
// A torn tail costs only its own incomplete record.
let text = std::fs::read_to_string(&path).unwrap();
let torn = text.lines().take(6).collect::<Vec<_>>().join("\n") + "\n";
std::fs::write(&path, torn).unwrap();
assert_eq!(load_drafts(&path).len(), 1);
}
fn master() -> [u8; KEY_LEN] {
let mut seed = [0u8; KEY_LEN];
for (i, b) in seed.iter_mut().enumerate() {