refactor: reject mail with a reason identifier, not a sentence

dispatch and apply_commands returned English prose that became the reply
subject, so the pure layers owned author-facing copy. They now return
reason::Reason and the mail-building edge resolves it through the catalogue.

core::rules needed no change: RuleError was already a typed enum, and its
Display stays for author-side tools. The daemon maps the variants itself
rather than borrowing that prose.

Error-path tests now assert on variants, so rewording main.ftl cannot break
them, and PruneModeUnknown carries the value the author actually sent.

Reason::every_variant_resolves_to_copy walks the enum, so a variant added
without a catalogue entry fails in tests rather than mailing someone
"???reason-…???".
This commit is contained in:
randogoth 2026-10-14 18:57:56 +03:00
parent 3279fd3a2d
commit bf6e2eaa15
5 changed files with 244 additions and 42 deletions

View file

@ -35,3 +35,39 @@ welcome-body =
Post by sending mail to this address with the twt as the body. Manage it 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 with Twt-Nick, Twt-Delete, Twt-Repost and Twt-Prune frontmatter fields
(PLAN.md). (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 }

View file

@ -7,6 +7,7 @@ use smolboid_core::rules::validate_text;
use smolboid_core::time; use smolboid_core::time;
use smolboid_core::{repost_body, reply_to_body}; use smolboid_core::{repost_body, reply_to_body};
use crate::reason::Reason;
use crate::state::PruneMode; use crate::state::PruneMode;
/// One fetched mail, opened and verified by the caller. /// One fetched mail, opened and verified by the caller.
@ -18,6 +19,7 @@ pub struct Incoming {
pub body: Vec<u8>, pub body: Vec<u8>,
} }
#[derive(Debug)]
pub enum Action { pub enum Action {
Post { Post {
timestamp: String, timestamp: String,
@ -38,6 +40,7 @@ pub enum Action {
Ignore, Ignore,
} }
#[derive(Debug)]
pub enum Command { pub enum Command {
Nick(String), Nick(String),
Delete(String), Delete(String),
@ -53,8 +56,11 @@ const F_PRUNE: &str = "twt-prune";
const F_REPLY_TO: &str = "reply-to"; const F_REPLY_TO: &str = "reply-to";
/// Fails closed: a block with unreconstructible fields is never published. /// 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())?; /// 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<Action, Reason> {
let text = std::str::from_utf8(&incoming.body).map_err(|_| Reason::BodyNotUtf8)?;
if !text.starts_with("---\n") { if !text.starts_with("---\n") {
// The bare post form: the body is the twt, byte for byte. // The bare post form: the body is the twt, byte for byte.
@ -77,7 +83,7 @@ pub fn dispatch(incoming: &Incoming) -> Result<Action, String> {
if beyond_reply_to.is_empty() { if beyond_reply_to.is_empty() {
if let Some(reply_to) = &reply_to { if let Some(reply_to) = &reply_to {
if text != reply_to_body(reply_to, &rest) { 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())); return post(incoming, &rest, Some(reply_to.clone()));
} }
@ -90,7 +96,7 @@ pub fn dispatch(incoming: &Incoming) -> Result<Action, String> {
let canonical_no = repost_body(args, None); let canonical_no = repost_body(args, None);
let canonical_yes = repost_body(args, reply_to.as_deref()); let canonical_yes = repost_body(args, reply_to.as_deref());
if text == canonical_no || text == canonical_yes { if text == canonical_no || text == canonical_yes {
validate_text(args).map_err(|e| e.to_string())?; validate_text(args)?;
return Ok(Action::Repost { return Ok(Action::Repost {
timestamp: time::render(incoming.time), timestamp: time::render(incoming.time),
args: args.clone(), args: args.clone(),
@ -102,7 +108,7 @@ pub fn dispatch(incoming: &Incoming) -> Result<Action, String> {
}, },
}); });
} }
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<Action, String> {
&& !fields.contains_key(F_PRUNE) && !fields.contains_key(F_PRUNE)
&& !fields.contains_key(F_REPOST) && !fields.contains_key(F_REPOST)
{ {
return Err( return Err(Reason::FieldsBeyondReplyTo);
"post mail carries fields beyond Reply-To (Subject, headers or an Accept token); \
resend the bare text"
.to_owned(),
);
} }
let mut commands = Vec::new(); let mut commands = Vec::new();
if let Some(nick) = fields.get(F_NICK) { if let Some(nick) = fields.get(F_NICK) {
if !crate::feedstore::valid_username(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())); commands.push(Command::Nick(nick.clone()));
} }
if let Some(timestamp) = fields.get(F_DELETE) { if let Some(timestamp) = fields.get(F_DELETE) {
if time::parse(timestamp).is_none() { 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())); commands.push(Command::Delete(timestamp.clone()));
} }
@ -137,7 +139,7 @@ pub fn dispatch(incoming: &Incoming) -> Result<Action, String> {
commands.push(Command::Prune(match mode.as_str() { commands.push(Command::Prune(match mode.as_str() {
"fifo" => PruneMode::Fifo, "fifo" => PruneMode::Fifo,
"off" => PruneMode::Off, "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. // A mixed mail is a command mail; the body is ignored, never posted.
@ -148,8 +150,8 @@ pub fn dispatch(incoming: &Incoming) -> Result<Action, String> {
} }
} }
fn post(incoming: &Incoming, text: &str, reply_to: Option<String>) -> Result<Action, String> { fn post(incoming: &Incoming, text: &str, reply_to: Option<String>) -> Result<Action, Reason> {
validate_text(text).map_err(|e| e.to_string())?; validate_text(text)?;
Ok(Action::Post { Ok(Action::Post {
timestamp: time::render(incoming.time), timestamp: time::render(incoming.time),
text: text.to_owned(), text: text.to_owned(),
@ -207,19 +209,36 @@ mod tests {
assert_eq!(text, "--- dashed opening"); 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] #[test]
fn post_rules_are_enforced_with_reasons() { fn post_rules_are_enforced_with_reasons() {
assert!(dispatch(&incoming(&"a".repeat(161))).is_err()); use smolboid_core::rules::RuleError;
assert!(dispatch(&incoming("a\tb")).is_err()); 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("") }; let bad = Incoming { body: vec![0xff, 0xfe], ..incoming("") };
assert!(dispatch(&bad).is_err()); assert_eq!(dispatch(&bad).unwrap_err(), Reason::BodyNotUtf8);
} }
#[test] #[test]
fn extra_fields_make_a_rejected_post() { fn extra_fields_make_a_rejected_post() {
// A Subject, or the Accept token of an accepted recipient. // 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()); for body in [
assert!(dispatch(&incoming("---\nAccept: token\n---\nbody\n")).is_err()); "---\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] #[test]
@ -274,9 +293,20 @@ mod tests {
#[test] #[test]
fn command_values_are_validated() { fn command_values_are_validated() {
assert!(dispatch(&incoming("---\nTwt-Nick: Bad Nick\n---\n")).is_err()); assert_eq!(
assert!(dispatch(&incoming("---\nTwt-Delete: not-a-date\n---\n")).is_err()); dispatch(&incoming("---\nTwt-Nick: Bad Nick\n---\n")).unwrap_err(),
assert!(dispatch(&incoming("---\nTwt-Prune: sometimes\n---\n")).is_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] #[test]

View file

@ -13,6 +13,7 @@ use smolboid_core::envelope;
use crate::config::Config; use crate::config::Config;
use crate::dispatch::{self, Action, Command, Incoming}; use crate::dispatch::{self, Action, Command, Incoming};
use crate::feedstore::FeedStore; use crate::feedstore::FeedStore;
use crate::reason::Reason;
use crate::state::{payload_key, reply_allowed, FeedState, PruneMode, State}; use crate::state::{payload_key, reply_allowed, FeedState, PruneMode, State};
use std::sync::{Mutex, MutexGuard}; use std::sync::{Mutex, MutexGuard};
@ -131,7 +132,7 @@ pub fn apply_commands(
feed: &mut Feed, feed: &mut Feed,
fs: &mut FeedState, fs: &mut FeedState,
commands: &[Command], commands: &[Command],
) -> Result<usize, String> { ) -> Result<usize, Reason> {
for command in commands { for command in commands {
match command { match command {
Command::Nick(nick) => { Command::Nick(nick) => {
@ -140,7 +141,7 @@ pub fn apply_commands(
} }
Command::Delete(timestamp) => { Command::Delete(timestamp) => {
if !feed.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, Command::Prune(mode) => fs.prune = *mode,
@ -368,20 +369,13 @@ impl Daemon {
Ok(true) Ok(true)
} }
Err(e) => { Err(e) => {
let reason = match &e { let reason = match e {
ApplyError::OverQuota(repost) => format!( ApplyError::OverQuota(repost) => Reason::OverQuota { repost },
"daily {} quota exceeded; try tomorrow", ApplyError::Collision(timestamp) => Reason::Collision { timestamp },
if *repost { "repost" } else { "post" } ApplyError::FeedFull => Reason::FeedFull,
), ApplyError::ReplyToChanged => Reason::ReplyToChanged {
ApplyError::Collision(ts) => { author: feed.author().unwrap_or_default().to_owned(),
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()
),
}; };
self.queue_reply(state, incoming, address, &reason, replies); self.queue_reply(state, incoming, address, &reason, replies);
Ok(false) Ok(false)
@ -395,7 +389,7 @@ impl Daemon {
state: &mut MutexGuard<'_, State>, state: &mut MutexGuard<'_, State>,
incoming: &Incoming, incoming: &Incoming,
address: &str, address: &str,
reason: &str, reason: &Reason,
replies: &mut Vec<(String, String, String)>, replies: &mut Vec<(String, String, String)>,
) { ) {
let key = payload_key(&incoming.sender, incoming.time, &incoming.body); let key = payload_key(&incoming.sender, incoming.time, &incoming.body);
@ -405,12 +399,13 @@ impl Daemon {
let text = std::str::from_utf8(&incoming.body) let text = std::str::from_utf8(&incoming.body)
.map(|t| t.chars().take(200).collect::<String>()) .map(|t| t.chars().take(200).collect::<String>())
.unwrap_or_default(); .unwrap_or_default();
let sentence = reason.text();
let mut args = fluent_bundle::FluentArgs::new(); let mut args = fluent_bundle::FluentArgs::new();
args.set("reason", reason.to_owned()); args.set("reason", sentence.clone());
args.set("quoted", text); args.set("quoted", text);
replies.push(( replies.push((
address.to_owned(), 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)), crate::i18n::t_args("reply-body", Some(&args)),
)); ));
} }

View file

@ -10,6 +10,7 @@ mod feedstore;
mod http; mod http;
mod i18n; mod i18n;
mod mailloop; mod mailloop;
mod reason;
mod state; mod state;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};

140
daemon/src/reason.rs Normal file
View file

@ -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<RuleError> 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<FluentArgs<'static>> {
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"));
}
}