feat: parse mailbox requests and factor out the address claim

dispatch::request recognises Twt-Register, Twt-Help, Twt-Admit, Twt-Block and
Twt-Registration, validating values but deciding nothing: whether the sender
may do what they asked is the caller's business, as with the rest of this
module.

One command per mail. Two requests have no sensible order, and a request mixed
with a feed command would make the reply ambiguous about what took effect.
Twt-Register takes an optional nick, validated against the nick rule rather
than the path rule, since the value lands in feed metadata.

dispatch::claimed is follow_rotation's Reply-To extraction, lifted out and
documented for what it actually proves: the sender signed this URI. It does
not prove they hold the mailbox it names. follow_rotation is safe on that
alone only because it additionally demands a feed already bound to the address
and a signed chain from the key on file; a registration path has neither and
must resolve the address itself.

Twt-Help ignores any value rather than refusing one — failing a request for
help on a typo would be perverse.

Also narrows i18n's id list back to the ids looked up by name: generating it
from the catalogue made that test assert the catalogue against itself. The
reason-* ids are covered by walking the enum instead, which cannot drift.
This commit is contained in:
randogoth 2026-10-14 19:37:17 +03:00
parent ff289671a5
commit 03e54fea49
5 changed files with 254 additions and 18 deletions

View file

@ -71,3 +71,12 @@ 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 }
## Mailbox requests — register, help and the admin commands
reason-one-command-per-mail =
one command per mail: send Twt-Register, Twt-Help, Twt-Admit, Twt-Block or
Twt-Registration on its own, and feed commands in a separate mail
reason-bad-address = `{ $got }` is not a smolmail address
reason-registration-mode-unknown =
unknown registration mode `{ $got }`, want open, closed or silent

View file

@ -7,8 +7,10 @@ use smolboid_core::rules::validate_text;
use smolboid_core::time;
use smolboid_core::{repost_body, reply_to_body};
use fumi_core::address::Address;
use crate::reason::Reason;
use crate::state::PruneMode;
use crate::state::{Mode, PruneMode};
/// One fetched mail, opened and verified by the caller.
pub struct Incoming {
@ -47,6 +49,26 @@ pub enum Command {
Prune(PruneMode),
}
/// A request that is about the mailbox rather than about a feed's contents.
/// These are answered before `dispatch` is consulted, because several of them
/// come from senders who have no feed at all.
// Consumed by the mail loop's request handling; until then only the tests
// construct these.
#[allow(dead_code)]
#[derive(Debug, PartialEq, Eq)]
pub enum Request {
/// Create a feed for the sender, optionally with a starting nick.
Register { nick: Option<String> },
/// Re-send the command reference.
Help,
/// Admin: issue a token and create a feed for someone else.
Admit(String),
/// Admin: withdraw someone's token.
Block(String),
/// Admin: open or close self-service registration.
Registration(Mode),
}
/// 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";
@ -54,6 +76,106 @@ 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";
#[allow(dead_code)]
const F_REGISTER: &str = "twt-register";
#[allow(dead_code)]
const F_HELP: &str = "twt-help";
#[allow(dead_code)]
const F_ADMIT: &str = "twt-admit";
#[allow(dead_code)]
const F_BLOCK: &str = "twt-block";
#[allow(dead_code)]
const F_REGISTRATION: &str = "twt-registration";
/// The fields `dispatch` handles, i.e. everything about a feed's contents.
const FEED_FIELDS: [&str; 4] = [F_NICK, F_DELETE, F_PRUNE, F_REPOST];
/// The fields `request` handles, i.e. everything about the mailbox.
#[allow(dead_code)]
const REQUEST_FIELDS: [&str; 5] = [F_REGISTER, F_HELP, F_ADMIT, F_BLOCK, F_REGISTRATION];
/// The address a mail claims to be from: the `smol://` Reply-To it signed,
/// accepted only when the key it carries is the key that signed the payload.
///
/// That check proves the signer signed this URI — nothing more. It does *not*
/// prove they hold the mailbox it names, so a caller acting on an unknown
/// sender must still resolve the address against its own server. The existing
/// rotation path gets away without that only because it additionally requires
/// a feed already bound to the address and a signed chain from the key on
/// file.
pub fn claimed(incoming: &Incoming) -> Option<Address> {
let text = std::str::from_utf8(&incoming.body).ok()?;
let (fields, _) = fumi_core::message::parse_frontmatter(text);
let addr = Address::parse(fields.get(F_REPLY_TO)?).ok()?;
(addr.identity? == incoming.sender).then_some(addr)
}
/// A mailbox request, if the mail carries one. `Ok(None)` means it is an
/// ordinary post or feed command and belongs to `dispatch`.
///
/// Pure, like `dispatch`: whether the sender may *do* what they asked — admin
/// rights, registration mode, rate budgets — is the caller's decision.
#[allow(dead_code)]
pub fn request(incoming: &Incoming) -> Result<Option<Request>, Reason> {
let Ok(text) = std::str::from_utf8(&incoming.body) else {
// Not UTF-8 is a rejection, but `dispatch` already says so and this
// layer must not answer for mail that was never a request.
return Ok(None);
};
if !text.starts_with("---\n") {
return Ok(None);
}
let (fields, _) = fumi_core::message::parse_frontmatter(text);
let present: Vec<&str> = REQUEST_FIELDS
.iter()
.copied()
.filter(|f| fields.contains_key(*f))
.collect();
let [field] = present[..] else {
if present.is_empty() {
return Ok(None);
}
// Two requests in one mail have no sensible order, and the reply could
// only report one of them.
return Err(Reason::OneCommandPerMail);
};
// A request mail is about the mailbox; a feed command is about its
// contents. Mixing them would make the reply ambiguous about what did and
// did not take effect.
if FEED_FIELDS.iter().any(|f| fields.contains_key(*f)) {
return Err(Reason::OneCommandPerMail);
}
let value = fields.get(field).map(String::as_str).unwrap_or_default();
let request = match field {
F_REGISTER if value.is_empty() => Request::Register { nick: None },
F_REGISTER if crate::feedstore::valid_username(value) => Request::Register {
nick: Some(value.to_owned()),
},
F_REGISTER => return Err(Reason::NickInvalid),
// Any value is ignored rather than refused: there is nothing an author
// could usefully put here, and failing their cry for help on a typo
// would be perverse.
F_HELP => Request::Help,
F_ADMIT | F_BLOCK => {
let addr = Address::parse(value)
.map_err(|_| Reason::BadAddress { got: value.to_owned() })?;
if field == F_ADMIT {
Request::Admit(addr.short())
} else {
Request::Block(addr.short())
}
}
F_REGISTRATION => Request::Registration(match value {
"open" => Mode::Open,
"closed" => Mode::Closed,
"silent" => Mode::Silent,
other => {
return Err(Reason::RegistrationModeUnknown { got: other.to_owned() })
}
}),
_ => unreachable!("field came from REQUEST_FIELDS"),
};
Ok(Some(request))
}
/// Fails closed: a block with unreconstructible fields is never published.
///
@ -309,6 +431,104 @@ mod tests {
);
}
/// The claim check is signature-scoped on purpose: it says the sender
/// signed this URI, not that they hold the mailbox it names.
#[test]
fn a_claim_needs_the_senders_own_key_in_the_uri() {
// `incoming` signs as [1; 32]; base32 of that key is what must appear.
let key = fumi_core::crypto::b32(&[1u8; 32]);
let mine = format!("---\nReply-To: smol://alice@other.org/{key}\n---\n");
let addr = claimed(&incoming(&mine)).expect("own key should be claimable");
assert_eq!(addr.short(), "alice@other.org");
// Someone else's key in the URI: the signer did not sign for it.
let theirs = format!(
"---\nReply-To: smol://alice@other.org/{}\n---\n",
fumi_core::crypto::b32(&[2u8; 32])
);
assert!(claimed(&incoming(&theirs)).is_none());
// A short-form Reply-To carries no key, so it proves nothing.
assert!(claimed(&incoming("---\nReply-To: alice@other.org\n---\n")).is_none());
assert!(claimed(&incoming("hello")).is_none());
}
#[test]
fn mailbox_requests_are_recognised_and_validated() {
let req = |body: &str| request(&incoming(body));
// Ordinary mail is not a request.
assert_eq!(req("hello").unwrap(), None);
assert_eq!(req("---\nTwt-Nick: toby\n---\n").unwrap(), None);
assert_eq!(
req("---\nTwt-Register:\n---\n").unwrap(),
Some(Request::Register { nick: None })
);
assert_eq!(
req("---\nTwt-Register: toby\n---\n").unwrap(),
Some(Request::Register { nick: Some("toby".into()) })
);
// The nick rule, not the path rule: a slug would be wrong in metadata.
assert_eq!(req("---\nTwt-Register: Bad Nick\n---\n").unwrap_err(), Reason::NickInvalid);
assert_eq!(
req("---\nTwt-Register: a@b.org\n---\n").unwrap_err(),
Reason::NickInvalid
);
assert_eq!(req("---\nTwt-Help:\n---\n").unwrap(), Some(Request::Help));
// A value is ignored rather than refused.
assert_eq!(req("---\nTwt-Help: please\n---\n").unwrap(), Some(Request::Help));
assert_eq!(
req("---\nTwt-Admit: alice@other.org\n---\n").unwrap(),
Some(Request::Admit("alice@other.org".into()))
);
// Normalised through short(), so the default port collapses.
assert_eq!(
req("---\nTwt-Block: alice@other.org:1961\n---\n").unwrap(),
Some(Request::Block("alice@other.org".into()))
);
assert_eq!(
req("---\nTwt-Admit: nonsense\n---\n").unwrap_err(),
Reason::BadAddress { got: "nonsense".into() }
);
for (value, mode) in [("open", Mode::Open), ("closed", Mode::Closed), ("silent", Mode::Silent)] {
assert_eq!(
req(&format!("---\nTwt-Registration: {value}\n---\n")).unwrap(),
Some(Request::Registration(mode))
);
}
assert_eq!(
req("---\nTwt-Registration: maybe\n---\n").unwrap_err(),
Reason::RegistrationModeUnknown { got: "maybe".into() }
);
}
#[test]
fn one_command_per_mail() {
let req = |body: &str| request(&incoming(body));
// Two requests have no sensible order.
assert_eq!(
req("---\nTwt-Register:\nTwt-Help:\n---\n").unwrap_err(),
Reason::OneCommandPerMail
);
// A request mixed with a feed command would make the reply ambiguous.
for body in [
"---\nTwt-Register:\nTwt-Nick: toby\n---\n",
"---\nTwt-Help:\nTwt-Prune: fifo\n---\n",
"---\nTwt-Admit: a@b.org\nTwt-Delete: 2026-10-01T12:47:00Z\n---\n",
] {
assert_eq!(req(body).unwrap_err(), Reason::OneCommandPerMail, "{body:?}");
}
// A Reply-To alongside a request is normal, not a conflict.
assert_eq!(
req("---\nReply-To: smol://a@b.org/k\nTwt-Help:\n---\n").unwrap(),
Some(Request::Help)
);
}
#[test]
fn odd_mail_shapes_fail_closed_to_ignore() {
// Malformed block, empty escape block, only unknown fields with a

View file

@ -86,8 +86,10 @@ mod tests {
}
}
/// Every id the daemon looks up. A lookup added without a line here still
/// works, but loses its compile-adjacent guard — keep them in step.
/// The ids looked up by name in code. The `reason-*` ids are deliberately
/// absent: `reason::Reason` covers every one of those by walking the enum,
/// which cannot drift the way a hand-kept list can. Listing them here too
/// would only make this test restate the catalogue back to itself.
const IDS: &[&str] = &[
"reply-subject",
"reply-body",

View file

@ -246,22 +246,12 @@ impl Daemon {
incoming: &Incoming,
state: &mut MutexGuard<'_, State>,
) -> Result<Option<usize>, String> {
let Ok(text) = std::str::from_utf8(&incoming.body) else {
let Some(addr) = dispatch::claimed(incoming) else {
return Ok(None);
};
let (fields, _) = fumi_core::message::parse_frontmatter(text);
let Some(reply_to) = fields.get("reply-to") else {
return Ok(None);
};
let Ok(addr) = Address::parse(reply_to) else {
return Ok(None);
};
let Some(user_key) = addr.identity else {
return Ok(None);
};
if user_key != incoming.sender {
return Ok(None);
}
// The claim alone proves only that the sender signed this URI. What
// makes it safe here is the next two lines: the address must already
// own a feed, and the chain below must lead from the key on file.
let Some(index) = state.feeds.iter().position(|f| f.address == addr.short()) else {
return Ok(None);
};

View file

@ -11,6 +11,7 @@
use fluent_bundle::FluentArgs;
use smolboid_core::rules::RuleError;
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Reason {
BodyNotUtf8,
@ -29,6 +30,12 @@ pub enum Reason {
FeedFull,
ReplyToChanged { author: String },
NoRecordAt { timestamp: String },
/// Two mailbox requests in one mail, or one mixed with a feed command.
// Constructed by `dispatch::request`, which the mail loop does not call
// yet; the tests cover them meanwhile.
OneCommandPerMail,
BadAddress { got: String },
RegistrationModeUnknown { got: String },
}
impl From<RuleError> for Reason {
@ -60,6 +67,9 @@ impl Reason {
Reason::FeedFull => "reason-feed-full",
Reason::ReplyToChanged { .. } => "reason-reply-to-changed",
Reason::NoRecordAt { .. } => "reason-no-record-at",
Reason::OneCommandPerMail => "reason-one-command-per-mail",
Reason::BadAddress { .. } => "reason-bad-address",
Reason::RegistrationModeUnknown { .. } => "reason-registration-mode-unknown",
}
}
@ -67,7 +77,9 @@ impl Reason {
pub fn args(&self) -> Option<FluentArgs<'static>> {
let mut args = FluentArgs::new();
match self {
Reason::PruneModeUnknown { got } => args.set("got", got.clone()),
Reason::PruneModeUnknown { got }
| Reason::BadAddress { got }
| Reason::RegistrationModeUnknown { got } => args.set("got", got.clone()),
Reason::Collision { timestamp } | Reason::NoRecordAt { timestamp } => {
args.set("timestamp", timestamp.clone())
}
@ -112,6 +124,9 @@ mod tests {
Reason::FeedFull,
Reason::ReplyToChanged { author: "tobias@smol.place".into() },
Reason::NoRecordAt { timestamp: "2026-10-01T12:47:00Z".into() },
Reason::OneCommandPerMail,
Reason::BadAddress { got: "not an address".into() },
Reason::RegistrationModeUnknown { got: "sideways".into() },
];
for reason in all {
let text = reason.text();