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

@ -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<usize, String> {
) -> Result<usize, Reason> {
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::<String>())
.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)),
));
}