diff --git a/daemon/locales/en-US/main.ftl b/daemon/locales/en-US/main.ftl index fb3b900..e0505ce 100644 --- a/daemon/locales/en-US/main.ftl +++ b/daemon/locales/en-US/main.ftl @@ -35,3 +35,39 @@ welcome-body = Post by sending mail to this address with the twt as the body. Manage it with Twt-Nick, Twt-Delete, Twt-Repost and Twt-Prune frontmatter fields (PLAN.md). + +## Rejection reasons — one per `reason::Reason` variant, rendered into the +## reply subject and body above. Each is a sentence fragment completing +## "your mail could not be applied because …", so they start lowercase and +## carry the fix where there is one. + +reason-body-not-utf8 = body is not UTF-8 +reason-post-not-canonical = post mail is not in canonical form +reason-repost-not-canonical = repost mail is not in canonical form +reason-fields-beyond-reply-to = + post mail carries fields beyond Reply-To (Subject, headers or an Accept + token); resend the bare text +reason-nick-invalid = nick must be 1-30 bytes of a-z, 0-9 or hyphen +reason-delete-needs-timestamp = Twt-Delete needs an RFC 3339 UTC timestamp +reason-prune-mode-unknown = unknown prune mode `{ $got }`, want fifo or off + +## Post rules, from core::rules::RuleError + +reason-too-many-codepoints = more than 160 Unicode code points +reason-too-many-bytes = more than 640 bytes +reason-forbidden-char = contains TAB, LF, CR or U+2028 +reason-bad-timestamp = not an RFC 3339 UTC timestamp at second precision +reason-duplicate-timestamp = timestamp already exists in this feed + +## Retention and quota, from the apply step + +reason-over-quota-post = daily post quota exceeded; try tomorrow +reason-over-quota-repost = daily repost quota exceeded; try tomorrow +reason-collision = timestamp { $timestamp } already exists; wait a second and resend +reason-feed-full = + feed is at its size cap; delete records with Twt-Delete or choose + Twt-Prune: fifo +reason-reply-to-changed = + your Reply-To does not match the feed's bound author { $author }; send from + that address or mail the operator +reason-no-record-at = no record at { $timestamp } diff --git a/daemon/src/dispatch.rs b/daemon/src/dispatch.rs index a061b02..81efae7 100644 --- a/daemon/src/dispatch.rs +++ b/daemon/src/dispatch.rs @@ -7,6 +7,7 @@ use smolboid_core::rules::validate_text; use smolboid_core::time; use smolboid_core::{repost_body, reply_to_body}; +use crate::reason::Reason; use crate::state::PruneMode; /// One fetched mail, opened and verified by the caller. @@ -18,6 +19,7 @@ pub struct Incoming { pub body: Vec, } +#[derive(Debug)] pub enum Action { Post { timestamp: String, @@ -38,6 +40,7 @@ pub enum Action { Ignore, } +#[derive(Debug)] pub enum Command { Nick(String), Delete(String), @@ -53,8 +56,11 @@ 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())?; +/// +/// Returns a `Reason` rather than a sentence: this layer is pure, and what the +/// author reads is the caller's business (see `reason`). +pub fn dispatch(incoming: &Incoming) -> Result { + let text = std::str::from_utf8(&incoming.body).map_err(|_| Reason::BodyNotUtf8)?; if !text.starts_with("---\n") { // The bare post form: the body is the twt, byte for byte. @@ -77,7 +83,7 @@ pub fn dispatch(incoming: &Incoming) -> Result { 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 Err(Reason::PostNotCanonical); } return post(incoming, &rest, Some(reply_to.clone())); } @@ -90,7 +96,7 @@ pub fn dispatch(incoming: &Incoming) -> Result { 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())?; + validate_text(args)?; return Ok(Action::Repost { timestamp: time::render(incoming.time), args: args.clone(), @@ -102,7 +108,7 @@ pub fn dispatch(incoming: &Incoming) -> Result { }, }); } - return Err("repost mail is not in canonical form".to_owned()); + return Err(Reason::RepostNotCanonical); } } @@ -113,23 +119,19 @@ pub fn dispatch(incoming: &Incoming) -> Result { && !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(), - ); + return Err(Reason::FieldsBeyondReplyTo); } 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()); + return Err(Reason::NickInvalid); } 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()); + return Err(Reason::DeleteNeedsTimestamp); } commands.push(Command::Delete(timestamp.clone())); } @@ -137,7 +139,7 @@ pub fn dispatch(incoming: &Incoming) -> Result { 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")), + other => return Err(Reason::PruneModeUnknown { got: other.to_owned() }), })); } // A mixed mail is a command mail; the body is ignored, never posted. @@ -148,8 +150,8 @@ pub fn dispatch(incoming: &Incoming) -> Result { } } -fn post(incoming: &Incoming, text: &str, reply_to: Option) -> Result { - validate_text(text).map_err(|e| e.to_string())?; +fn post(incoming: &Incoming, text: &str, reply_to: Option) -> Result { + validate_text(text)?; Ok(Action::Post { timestamp: time::render(incoming.time), text: text.to_owned(), @@ -207,19 +209,36 @@ mod tests { assert_eq!(text, "--- dashed opening"); } + /// Asserting the variant, not the sentence: the copy lives in main.ftl + /// and may be reworded without these tests caring. #[test] fn post_rules_are_enforced_with_reasons() { - assert!(dispatch(&incoming(&"a".repeat(161))).is_err()); - assert!(dispatch(&incoming("a\tb")).is_err()); + use smolboid_core::rules::RuleError; + assert_eq!( + dispatch(&incoming(&"a".repeat(161))).unwrap_err(), + Reason::Rule(RuleError::TooManyCodepoints) + ); + assert_eq!( + dispatch(&incoming("a\tb")).unwrap_err(), + Reason::Rule(RuleError::ForbiddenChar) + ); let bad = Incoming { body: vec![0xff, 0xfe], ..incoming("") }; - assert!(dispatch(&bad).is_err()); + assert_eq!(dispatch(&bad).unwrap_err(), Reason::BodyNotUtf8); } #[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()); + for body in [ + "---\nReply-To: smol://a/b\nSubject: hi\n---\nbody\n", + "---\nAccept: token\n---\nbody\n", + ] { + assert_eq!( + dispatch(&incoming(body)).unwrap_err(), + Reason::FieldsBeyondReplyTo, + "{body:?}" + ); + } } #[test] @@ -274,9 +293,20 @@ mod tests { #[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()); + assert_eq!( + dispatch(&incoming("---\nTwt-Nick: Bad Nick\n---\n")).unwrap_err(), + Reason::NickInvalid + ); + assert_eq!( + dispatch(&incoming("---\nTwt-Delete: not-a-date\n---\n")).unwrap_err(), + Reason::DeleteNeedsTimestamp + ); + // The offending value comes back with the reason, so the author is + // told what they actually sent. + assert_eq!( + dispatch(&incoming("---\nTwt-Prune: sometimes\n---\n")).unwrap_err(), + Reason::PruneModeUnknown { got: "sometimes".into() } + ); } #[test] diff --git a/daemon/src/mailloop.rs b/daemon/src/mailloop.rs index f72765d..86a72b9 100644 --- a/daemon/src/mailloop.rs +++ b/daemon/src/mailloop.rs @@ -13,6 +13,7 @@ use smolboid_core::envelope; use crate::config::Config; use crate::dispatch::{self, Action, Command, Incoming}; use crate::feedstore::FeedStore; +use crate::reason::Reason; use crate::state::{payload_key, reply_allowed, FeedState, PruneMode, State}; use std::sync::{Mutex, MutexGuard}; @@ -131,7 +132,7 @@ pub fn apply_commands( feed: &mut Feed, fs: &mut FeedState, commands: &[Command], -) -> Result { +) -> Result { for command in commands { match command { Command::Nick(nick) => { @@ -140,7 +141,7 @@ pub fn apply_commands( } Command::Delete(timestamp) => { if !feed.delete(timestamp) { - return Err(format!("no record at {timestamp}")); + return Err(Reason::NoRecordAt { timestamp: timestamp.clone() }); } } Command::Prune(mode) => fs.prune = *mode, @@ -368,20 +369,13 @@ impl Daemon { Ok(true) } Err(e) => { - let reason = match &e { - ApplyError::OverQuota(repost) => format!( - "daily {} quota exceeded; try tomorrow", - if *repost { "repost" } else { "post" } - ), - ApplyError::Collision(ts) => { - format!("timestamp {ts} already exists; wait a second and resend") - } - ApplyError::FeedFull => "feed is at its size cap; delete records with Twt-Delete or choose Twt-Prune: fifo".to_owned(), - ApplyError::ReplyToChanged => format!( - "your Reply-To does not match the feed's bound author {}; \ - send from that address or mail the operator", - feed.author().unwrap_or_default() - ), + let reason = match e { + ApplyError::OverQuota(repost) => Reason::OverQuota { repost }, + ApplyError::Collision(timestamp) => Reason::Collision { timestamp }, + ApplyError::FeedFull => Reason::FeedFull, + ApplyError::ReplyToChanged => Reason::ReplyToChanged { + author: feed.author().unwrap_or_default().to_owned(), + }, }; self.queue_reply(state, incoming, address, &reason, replies); Ok(false) @@ -395,7 +389,7 @@ impl Daemon { state: &mut MutexGuard<'_, State>, incoming: &Incoming, address: &str, - reason: &str, + reason: &Reason, replies: &mut Vec<(String, String, String)>, ) { let key = payload_key(&incoming.sender, incoming.time, &incoming.body); @@ -405,12 +399,13 @@ impl Daemon { let text = std::str::from_utf8(&incoming.body) .map(|t| t.chars().take(200).collect::()) .unwrap_or_default(); + let sentence = reason.text(); let mut args = fluent_bundle::FluentArgs::new(); - args.set("reason", reason.to_owned()); + args.set("reason", sentence.clone()); args.set("quoted", text); replies.push(( address.to_owned(), - crate::i18n::t1("reply-subject", "reason", reason.to_owned()), + crate::i18n::t1("reply-subject", "reason", sentence), crate::i18n::t_args("reply-body", Some(&args)), )); } diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 05bd9f2..bb7c18e 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -10,6 +10,7 @@ mod feedstore; mod http; mod i18n; mod mailloop; +mod reason; mod state; use std::path::{Path, PathBuf}; diff --git a/daemon/src/reason.rs b/daemon/src/reason.rs new file mode 100644 index 0000000..8cea608 --- /dev/null +++ b/daemon/src/reason.rs @@ -0,0 +1,140 @@ +//! Why a mail was rejected, as an identifier rather than a sentence. +//! +//! The pure layers — `dispatch`, `core::rules`, `apply_record` — decide *that* +//! a mail fails and *why*; they do not decide what the author reads. Keeping +//! the two apart is what lets `dispatch` stay pure and testable against +//! variants instead of prose, and what lets the copy live in one Fluent +//! catalogue (`locales/en-US/main.ftl`). +//! +//! Resolution happens at the mail-building edge, in `mailloop::queue_reply`. + +use fluent_bundle::FluentArgs; +use smolboid_core::rules::RuleError; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Reason { + BodyNotUtf8, + PostNotCanonical, + RepostNotCanonical, + FieldsBeyondReplyTo, + NickInvalid, + DeleteNeedsTimestamp, + PruneModeUnknown { got: String }, + /// A post-rule violation from `core::rules`, carried whole so the daemon + /// maps it to copy and the library keeps its own `Display` for + /// author-side tools. + Rule(RuleError), + OverQuota { repost: bool }, + Collision { timestamp: String }, + FeedFull, + ReplyToChanged { author: String }, + NoRecordAt { timestamp: String }, +} + +impl From for Reason { + fn from(e: RuleError) -> Reason { + Reason::Rule(e) + } +} + +impl Reason { + /// The Fluent message id for this reason. Every one of these must appear + /// in `main.ftl`; `i18n`'s catalogue test is what enforces that. + pub fn id(&self) -> &'static str { + match self { + Reason::BodyNotUtf8 => "reason-body-not-utf8", + Reason::PostNotCanonical => "reason-post-not-canonical", + Reason::RepostNotCanonical => "reason-repost-not-canonical", + Reason::FieldsBeyondReplyTo => "reason-fields-beyond-reply-to", + Reason::NickInvalid => "reason-nick-invalid", + Reason::DeleteNeedsTimestamp => "reason-delete-needs-timestamp", + Reason::PruneModeUnknown { .. } => "reason-prune-mode-unknown", + Reason::Rule(RuleError::TooManyCodepoints) => "reason-too-many-codepoints", + Reason::Rule(RuleError::TooManyBytes) => "reason-too-many-bytes", + Reason::Rule(RuleError::ForbiddenChar) => "reason-forbidden-char", + Reason::Rule(RuleError::BadTimestamp) => "reason-bad-timestamp", + Reason::Rule(RuleError::DuplicateTimestamp) => "reason-duplicate-timestamp", + Reason::OverQuota { repost: false } => "reason-over-quota-post", + Reason::OverQuota { repost: true } => "reason-over-quota-repost", + Reason::Collision { .. } => "reason-collision", + Reason::FeedFull => "reason-feed-full", + Reason::ReplyToChanged { .. } => "reason-reply-to-changed", + Reason::NoRecordAt { .. } => "reason-no-record-at", + } + } + + /// The variables its message interpolates, if any. + pub fn args(&self) -> Option> { + let mut args = FluentArgs::new(); + match self { + Reason::PruneModeUnknown { got } => args.set("got", got.clone()), + Reason::Collision { timestamp } | Reason::NoRecordAt { timestamp } => { + args.set("timestamp", timestamp.clone()) + } + Reason::ReplyToChanged { author } => args.set("author", author.clone()), + _ => return None, + } + Some(args) + } + + /// The author-facing sentence, used both as the reply's subject suffix and + /// in its body. + pub fn text(&self) -> String { + crate::i18n::t_args(self.id(), self.args().as_ref()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every variant must resolve to real copy. `i18n`'s own test covers the + /// ids it knows about; this one walks the enum, so a variant added without + /// a catalogue entry fails here. + #[test] + fn every_variant_resolves_to_copy() { + let all = [ + Reason::BodyNotUtf8, + Reason::PostNotCanonical, + Reason::RepostNotCanonical, + Reason::FieldsBeyondReplyTo, + Reason::NickInvalid, + Reason::DeleteNeedsTimestamp, + Reason::PruneModeUnknown { got: "sideways".into() }, + Reason::Rule(RuleError::TooManyCodepoints), + Reason::Rule(RuleError::TooManyBytes), + Reason::Rule(RuleError::ForbiddenChar), + Reason::Rule(RuleError::BadTimestamp), + Reason::Rule(RuleError::DuplicateTimestamp), + Reason::OverQuota { repost: false }, + Reason::OverQuota { repost: true }, + Reason::Collision { timestamp: "2026-10-01T12:47:00Z".into() }, + Reason::FeedFull, + Reason::ReplyToChanged { author: "tobias@smol.place".into() }, + Reason::NoRecordAt { timestamp: "2026-10-01T12:47:00Z".into() }, + ]; + for reason in all { + let text = reason.text(); + assert!( + !text.starts_with("???"), + "{:?} has no entry in main.ftl (id `{}`)", + reason, + reason.id() + ); + assert!(!text.is_empty(), "{reason:?} resolves to empty copy"); + } + } + + #[test] + fn interpolated_reasons_carry_their_value() { + assert!(Reason::PruneModeUnknown { got: "sideways".into() } + .text() + .contains("sideways")); + let ts = "2026-10-01T12:47:00Z"; + assert!(Reason::Collision { timestamp: ts.into() }.text().contains(ts)); + assert!(Reason::NoRecordAt { timestamp: ts.into() }.text().contains(ts)); + assert!(Reason::ReplyToChanged { author: "a@b".into() } + .text() + .contains("a@b")); + } +}