//! Mail-to-action translation (PLAN.md, Management mail): a post mail is //! bare text or a block whose only field is Reply-To; a command mail opens a //! §5.5 block. Pure — the caller owns admission, retention and error replies. use smolboid_core::feed::MailLine; use smolboid_core::rules::validate_text; use smolboid_core::time; use smolboid_core::{repost_body, reply_to_body}; use crate::state::PruneMode; /// One fetched mail, opened and verified by the caller. pub struct Incoming { pub sender: [u8; 32], pub epk: [u8; 32], pub sig: [u8; 64], pub time: i64, pub body: Vec, } pub enum Action { Post { timestamp: String, text: String, /// The Reply-To value the body carried, when the ordinary-send block /// form was used; bound in the feed's `smol:reply_to` metadata. reply_to: Option, mail: MailLine, }, Repost { timestamp: String, args: String, reply_to: Option, mail: MailLine, }, Commands(Vec), /// Nothing to do: recognized by the rules as benign, never published. Ignore, } pub enum Command { Nick(String), Delete(String), Prune(PruneMode), } /// The `Twt-` frontmatter fields a command mail may carry; unknown keys are /// preserved upstream and never alter behaviour (SPEC.md §5.5). const F_NICK: &str = "twt-nick"; const F_DELETE: &str = "twt-delete"; const F_REPOST: &str = "twt-repost"; const F_PRUNE: &str = "twt-prune"; const F_REPLY_TO: &str = "reply-to"; /// Fails closed: a block with unreconstructible fields is never published. pub fn dispatch(incoming: &Incoming) -> Result { let text = std::str::from_utf8(&incoming.body).map_err(|_| "body is not UTF-8".to_owned())?; if !text.starts_with("---\n") { // The bare post form: the body is the twt, byte for byte. return post(incoming, text, None); } let (fields, rest) = fumi_core::message::parse_frontmatter(text); if fields.is_empty() { // An empty or malformed block: nothing reliable, nothing published. return Ok(Action::Ignore); } let reply_to = fields.get(F_REPLY_TO).cloned(); let beyond_reply_to: Vec<&String> = fields .keys() .filter(|k| k.as_str() != F_REPLY_TO) .collect(); // A post in the ordinary-send form: a block whose only field is Reply-To. if beyond_reply_to.is_empty() { if let Some(reply_to) = &reply_to { if text != reply_to_body(reply_to, &rest) { return Err("post mail is not in canonical form".to_owned()); } return post(incoming, &rest, Some(reply_to.clone())); } } // A repost: the Twt-Repost field, optionally with the same Reply-To an // ordinary send carries; both must reconstruct byte-exactly. if fields.len() <= 2 { if let Some(args) = fields.get(F_REPOST) { let canonical_no = repost_body(args, None); let canonical_yes = repost_body(args, reply_to.as_deref()); if text == canonical_no || text == canonical_yes { validate_text(args).map_err(|e| e.to_string())?; return Ok(Action::Repost { timestamp: time::render(incoming.time), args: args.clone(), reply_to, mail: MailLine { sender: incoming.sender, epk: incoming.epk, sig: incoming.sig, }, }); } return Err("repost mail is not in canonical form".to_owned()); } } // A block with extra fields and no Twt field: the author plainly meant to // publish, and silence would hide the problem. if !fields.contains_key(F_NICK) && !fields.contains_key(F_DELETE) && !fields.contains_key(F_PRUNE) && !fields.contains_key(F_REPOST) { return Err( "post mail carries fields beyond Reply-To (Subject, headers or an Accept token); \ resend the bare text" .to_owned(), ); } let mut commands = Vec::new(); if let Some(nick) = fields.get(F_NICK) { if !crate::feedstore::valid_username(nick) { return Err("nick must be 1-30 bytes of a-z, 0-9 or hyphen".to_owned()); } commands.push(Command::Nick(nick.clone())); } if let Some(timestamp) = fields.get(F_DELETE) { if time::parse(timestamp).is_none() { return Err("Twt-Delete needs an RFC 3339 UTC timestamp".to_owned()); } commands.push(Command::Delete(timestamp.clone())); } if let Some(mode) = fields.get(F_PRUNE) { commands.push(Command::Prune(match mode.as_str() { "fifo" => PruneMode::Fifo, "off" => PruneMode::Off, other => return Err(format!("unknown prune mode `{other}`, want fifo or off")), })); } // A mixed mail is a command mail; the body is ignored, never posted. if commands.is_empty() { Ok(Action::Ignore) } else { Ok(Action::Commands(commands)) } } fn post(incoming: &Incoming, text: &str, reply_to: Option) -> Result { validate_text(text).map_err(|e| e.to_string())?; Ok(Action::Post { timestamp: time::render(incoming.time), text: text.to_owned(), reply_to, mail: MailLine { sender: incoming.sender, epk: incoming.epk, sig: incoming.sig, }, }) } #[cfg(test)] mod tests { use super::*; fn incoming(body: &str) -> Incoming { Incoming { sender: [1; 32], epk: [2; 32], sig: [3; 64], time: 1_760_000_000, body: body.as_bytes().to_vec(), } } #[test] fn plain_body_is_a_post() { let Action::Post { timestamp, text, reply_to, mail } = dispatch(&incoming("hello smolverse")).unwrap() else { panic!("expected a post"); }; assert_eq!(timestamp, "2025-10-09T08:53:20Z"); assert_eq!(text, "hello smolverse"); assert!(reply_to.is_none()); assert_eq!(mail.sender, [1; 32]); } #[test] fn ordinary_send_form_is_a_post() { // What `fumi send --body x` emits: a block with only Reply-To. let body = "---\nReply-To: smol://tobias@smol.place/k\n---\nhello smolverse"; let Action::Post { text, reply_to, .. } = dispatch(&incoming(body)).unwrap() else { panic!("expected a post"); }; assert_eq!(text, "hello smolverse"); assert_eq!(reply_to.as_deref(), Some("smol://tobias@smol.place/k")); } #[test] fn dashed_body_is_still_a_post() { // A single line can never open a block, so nothing is escaped. let Action::Post { text, .. } = dispatch(&incoming("--- dashed opening")).unwrap() else { panic!("expected a post"); }; assert_eq!(text, "--- dashed opening"); } #[test] fn post_rules_are_enforced_with_reasons() { assert!(dispatch(&incoming(&"a".repeat(161))).is_err()); assert!(dispatch(&incoming("a\tb")).is_err()); let bad = Incoming { body: vec![0xff, 0xfe], ..incoming("") }; assert!(dispatch(&bad).is_err()); } #[test] fn extra_fields_make_a_rejected_post() { // A Subject, or the Accept token of an accepted recipient. assert!(dispatch(&incoming("---\nReply-To: smol://a/b\nSubject: hi\n---\nbody\n")).is_err()); assert!(dispatch(&incoming("---\nAccept: token\n---\nbody\n")).is_err()); } #[test] fn canonical_repost_command_publishes() { let args = "@ #abc1234"; let no_reply_to = format!("---\nTwt-Repost: {args}\n---\n"); let Action::Repost { args: got, reply_to, mail, .. } = dispatch(&incoming(&no_reply_to)).unwrap() else { panic!("expected a repost"); }; assert_eq!(got, args); assert!(reply_to.is_none()); assert_eq!(mail.sig, [3; 64]); // The ordinary-send form, Twt-Repost before Reply-To as fumi emits. let with_reply_to = format!("---\nTwt-Repost: {args}\nReply-To: smol://tobias@s/k\n---\n"); let Action::Repost { args: got, reply_to, .. } = dispatch(&incoming(&with_reply_to)).unwrap() else { panic!("expected a repost"); }; assert_eq!(got, args); assert_eq!(reply_to.as_deref(), Some("smol://tobias@s/k")); } #[test] fn noncanonical_reposts_are_rejected() { let args = "@ #abc"; assert!(dispatch(&incoming(&format!("---\nTwt-Repost: {args} \n---\n"))).is_err()); assert!(dispatch(&incoming(&format!("---\nTwt-Repost: {args}\n---\nextra\n"))).is_err()); assert!(dispatch(&incoming(&format!( "---\nTwt-Repost: {args}\nTwt-Nick: x\n---\n" ))) .is_err()); // Reply-To after the closing marker, wrong order relative to the // canonical forms. assert!(dispatch(&incoming(&format!( "---\nReply-To: smol://a/b\nTwt-Repost: {args}\n---\n" ))) .is_err()); } #[test] fn command_mails_apply_all_recognized_fields() { let body = "---\nTwt-Nick: toby\nTwt-Prune: fifo\nSubject: ignored\n---\nignored body\n"; let Action::Commands(cmds) = dispatch(&incoming(body)).unwrap() else { panic!("expected commands"); }; assert_eq!(cmds.len(), 2); assert!(matches!(cmds[0], Command::Nick(ref n) if n == "toby")); assert!(matches!(cmds[1], Command::Prune(PruneMode::Fifo))); } #[test] fn command_values_are_validated() { assert!(dispatch(&incoming("---\nTwt-Nick: Bad Nick\n---\n")).is_err()); assert!(dispatch(&incoming("---\nTwt-Delete: not-a-date\n---\n")).is_err()); assert!(dispatch(&incoming("---\nTwt-Prune: sometimes\n---\n")).is_err()); } #[test] fn odd_mail_shapes_fail_closed_to_ignore() { // Malformed block, empty escape block, only unknown fields with a // Twt- field absent — but pure unknown-field blocks are rejected // as posts, so the ignore set is the malformed and empty ones. assert!(matches!(dispatch(&incoming("---\nno colon here\n---\n")), Ok(Action::Ignore))); assert!(matches!(dispatch(&incoming("---\n---\n")), Ok(Action::Ignore))); } }