291 lines
10 KiB
Rust
291 lines
10 KiB
Rust
|
|
//! 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<u8>,
|
||
|
|
}
|
||
|
|
|
||
|
|
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<String>,
|
||
|
|
mail: MailLine,
|
||
|
|
},
|
||
|
|
Repost {
|
||
|
|
timestamp: String,
|
||
|
|
args: String,
|
||
|
|
reply_to: Option<String>,
|
||
|
|
mail: MailLine,
|
||
|
|
},
|
||
|
|
Commands(Vec<Command>),
|
||
|
|
/// 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<Action, String> {
|
||
|
|
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<String>) -> Result<Action, String> {
|
||
|
|
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 = "@<dana https://smol.place/twtxt/dana.txt> #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 = "@<d u> #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)));
|
||
|
|
}
|
||
|
|
}
|