Post rules follow upstream twtxt: control characters are rejected, U+2028 multiline is allowed, and the length cap is configurable as max_post_chars, default 288.
The release job sources FORGEJO_TOKEN from a .env beside the flake, and
nothing was holding that file out of a commit. target/ and .devbox/ were
only excluded by .git/info/exclude, which is local to one clone and never
travels, so a fresh checkout had no protection for either.
Also covers what the daemon and the test rigs write into a checkout:
master.key, mail.db, state.json and an operator's own smolboid.toml. No
tracked file becomes ignored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
poll printed only "{applied} applied, {dropped} dropped", and dropped meant
every outcome that wasn't a published post. Answering a newcomer and refusing
a post both read "0 applied, 1 dropped", as did a mail with no Reply-To, a
blocked sender, a host outside register_hosts, silent mode, and a spent rate
limit. Eleven ways out of the stranger path, all indistinguishable.
That cost real time. Twice I read a successful info reply as a failure and had
to open state.json to tell, and once I had to ask the author to forward the
refusal, because the reason is nowhere in the log and the daemon's sent copies
are sealed to itself.
Outcome now carries its own line and poll prints it, so the phrase is written
where the context is and emitted in one place. A new branch can't return
Dropped without saying why, because it won't compile. Answered splits from
dropped in the counters too: a reply sent is not a mail ignored.
The silence toward senders is deliberate and unchanged — a reply would confirm
whether an address exists. The operator's journal is a different audience and
reaches nobody else. The enum says so, so nobody later "fixes" a quiet drop by
answering the sender.
The copy was written for someone who had read the spec. It named UTF-8, RFC
3339, canonical form, code points and frontmatter fields, and the one place it
gave instructions it gave them as fumi command lines, which say nothing to
anyone using carrier or gsmol.
Now it uses the words the clients put on screen: "header", with the "+ header"
button both of them have, and "Requests folder", which is what both call it.
One word per idea throughout — post, feed, header, Requests folder — and
reasons are whole sentences that say what happened and what to do, so the
reply no longer opens "Reason:" followed by a lowercase fragment.
The first-contact mail is the one a stranger reads before anything else, so it
now covers accepting this address: a recommendation, not the mechanism behind
it. The mechanism would also have aged badly, since a client that delivers its
token once makes accepting cost a single refused post rather than all of them.
A refused post gets a detail paragraph where one line cannot hold the fix. It
tells the author their token arrived and to send again, and what to do if it
is refused a second time, which is what happens on a client that attaches the
token to everything. A first post on an empty feed says so differently: the
author is watching their first attempt fail and needs to hear the feed is
fine.
Seven admin strings were built with format! and never reached the catalogue,
so an ack opened its body in lowercase and could not be translated. They are
entries now, and say what blocking does rather than naming the mechanism.
The cap warning said "90% of its 1048576 byte limit". It says 1 MB.
Four rig assertions matched the old wording and now match the new. They have
to read rendered mail, so they stay coupled to it in a way the unit tests,
which assert on Reason variants, do not.
Self-service registration by mail, the admin commands, host-namespaced feed
paths for remote authors, and the Fluent catalogue for author-facing copy.
A deploy must take the binary and the config together: config.rs hard-errors
on an unknown key, so the 0.1.0 binary refuses a config carrying the new
registration keys outright.
README gains an Admission section covering both routes, the admin commands,
and the six new config keys. The author's side leads with Twt-Register and
says plainly not to accept this mailbox: doing so makes a client attach an
accept token to every later mail, and a record is verified by rebuilding the
exact signed bytes, which cannot include one — so every post would be refused.
audit.md's "requests-tier mail is structurally invisible" was a stated
hardening property, and this change deliberately reverses it. Rewritten as the
chain of gates that replaces it, each named with the probe that proves it, and
the feed-path property restated: the grammar widened for host-namespaced
slugs, so traversal stopped being structurally impossible and became a rule
that has to be right. Added the subject-truncation property the rig found.
PLAN.md was the design document and is kept as the record of intent, with its
status corrected — it still claimed nothing in src/ existed — and a "since
implemented" note wherever the shipped behaviour diverges, rather than quietly
editing the original intent away.
Rig legs for the whole author path with no CLI admit anywhere: an unknown
sender drawing the info mail, Twt-Register creating the feed, the token
arriving so she can publish, Twt-Help, the admin notification, Twt-Block
stopping a feed and Twt-Registration closing the door. Pentest probes for the
attacks: no Reply-To, a claim on someone else's address, a claim on an address
nobody holds, an admin command from a non-admin, and a blocked author coming
back.
Two real bugs, both found by asserting on what the author receives rather than
on what the daemon meant to send.
Five catalogue entries were wrapped across lines, and a reason is interpolated
into the reply's Subject. A frontmatter value carrying a line break makes the
whole block malformed, so smolmail fails closed toward display (SPEC.md sec
5.5) and the reply arrives with no subject at all — every rejection, silently.
The same bug class the parent commit fixed sender-side; step 2 reintroduced it
from the catalogue, because the Rust prose it replaced used backslash
continuations that join without a newline. Reasons are single-line now, with a
test over the enum that fails if one ever wraps again.
And the flow had a step that could not work. Telling an author to accept this
mailbox makes their client attach an Accept token to every later mail, and a
published record is verified by rebuilding the exact bytes that were signed —
which cannot include a token without publishing it. So every post from an
author who followed that advice was refused forever. The info mail now says
not to accept the address and where to find replies instead, and that refusal
has its own reason, since "resend the bare text" is useless advice when the
client adds the field by itself.
Three of my own probes were wrong too, kept here as comments so they are not
rewritten the same way: grep -q under `set -o pipefail` SIGPIPEs its producer
and can report failure for a subject that is plainly present; the refusal
reply lands in the main tier precisely because accepting the mailbox is what
moves replies there; and blocking an address that was never accepted is a
store no-op, so the probe has to register first.
process now splits by whether the sender owns a feed. The owner path runs
first, so a rotated owner is never mistaken for a registration, and behaves
exactly as before for ordinary mail.
The stranger path is the new one, and every branch of it is gated. No signed
Reply-To means no address to verify or answer, so it stays a silent drop. The
blocked check runs before anything touches the network, so Twt-Block cannot be
sidestepped by re-registering. Silent mode stays silent, which is how every
version before this behaved — except for an admin, who would otherwise have no
way to turn registration back on.
Then the gate the whole change rests on: before any outbound mail, the claimed
address is resolved on its own server and the resolved key must equal the key
that signed the request. Without it a signed Reply-To naming someone else's
address would be enough to take their feed, and replying to the claimed
smol:// URI would pin the sender's key as verified under that address — a
permanent denial of service against whoever really owns it. Every reply is
queued against the short form for the same reason; nothing hands a
self-certifying URI to send.
Registration attempts are counted before the dial rather than after, since one
that fails its key check costs the same connection. A per-cycle dial budget
bounds how long the state lock is held, and a mail over budget keeps its row
instead of being dropped.
Admin commands are recognised only from an admin address, and only Admit,
Block and Registration count as admin commands — an admin's Twt-Help means
Twt-Help. A non-admin sending one gets the ordinary answer and is never told
admin commands exist.
Rig still passes, so the owner path is unchanged in practice as well as in
intent.
The daemon fetched requests-tier mail and then never looked at that folder, so
an unknown sender's mail sat in the local store unseen and undeleted. poll now
reads both tiers and deletes from both; delete_local removes by id from the
inbox table whatever the tier, so nothing can loop.
Dropped the `summary.stored == 0` early return. It meant a row left behind by
an error path on one cycle was only revisited if something new arrived later.
Emptiness is now decided from the rows actually in hand.
A live mail.db holds every requests-tier mail it ever received, so the first
poll of a version that reads them would answer every stranger in that history
at once. drain_backlog discards that backlog once and records it in state.
Not a received_at watermark: that timestamp is the server's, and clock skew
would silently discard real mail. It drains only the requests rows, so inbox
mail in the same cycle is still processed.
send_replies no longer aborts the batch on the first failure. It returned Err
on one unreachable recipient, dropping every later reply — tolerable when
replies only went to admitted authors, not once they go to arbitrary servers
and an author's accept token can be queued behind a host that is down. It now
counts failures and reports them in the poll summary.
The per-poll network cap is deliberately not here: the dials it bounds live in
the handlers, so it lands with them.
The CLI grew the only admission implementation, which a mail-driven one would
have had to duplicate. register::admit is now that path for both callers; it
sends no mail and prints nothing, so the caller decides what the author and
the operator see.
expect is the point of the exercise. A mail-driven registration knows which
key signed the request and must require the address to resolve to that same
key, because a signed Reply-To naming someone else's address proves only that
the sender signed the URI. The CLI passes None and prints the resolved key and
fingerprint instead, since the operator vouches out of band for a target who
has not written in.
slug_of compares the daemon's own registered address, host and port, not
smolmail_host: that setting carries no port while short() does, so two servers
on one host at different ports would both collapse onto bare local slugs and
collide. A test pins that, and another checks every derived slug is a legal
feed path.
Three details that were wrong or silent before:
- synced is reported rather than assumed. push_tokens is inert when the local
token set may not replace the server's, and the author then stays in the
requests tier forever; the CLI now says so loudly.
- the welcome mail is sent only when a feed was created, so a re-admit no
longer mails someone a second time.
- a re-registration may update the nick; the path never moves.
Verified with the rig: admit, post, commands, repost, delete, collision, both
retention modes and an author rotation all pass against a real bunshin.
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.
Six keys, and the parser's first list, bool and word-valued ones:
registration, registrations_per_hour, info_replies_per_hour, admins,
admin_notify, register_hosts.
Lists drop empty items, because `admins =` is exactly what the Nix module
renders for an unset option, and addresses are validated at load rather than
at first use — a typo would otherwise pass the start and then silently never
match an admin command. Validated values are normalised through
Address::short(), so a spelling carrying the default port collapses to the one
the daemon will compare against.
registration defaults to silent, so adding these keys changes nothing for
anyone who writes in.
Verified the generator and the parser agree by feeding the module's rendered
output to the binary, in both the fully-set and all-defaults shapes — which
matters because config.rs hard-errors on an unknown key.
The bucur half waits for the lock: that host pins this flake at the rev before
these options existed, so setting them there now would break its eval. It
lands with the deploy.
Mode is persisted rather than read from config alone, so an admin can flip
registration by mail instead of needing a redeploy; config supplies only the
initial value. It defaults to Silent, which is today's behaviour byte for
byte, so landing this cannot start mailing strangers on its own.
Limits holds the two daemon-wide hourly counters. Identity keys are free to
mint, so a per-key budget does not bind an attacker and these are what
actually do; each vector is pruned to the hour on read and so is bounded by
its own cap.
Also fixes reply_allowed's ledger growth, which stops being a wart here and
becomes a vulnerability: until now only accepted authors reached it, but once
a stranger can draw a reply any throwaway keypair appends a permanent row.
Ledgers idle for an hour are dropped and the count is capped, with a
regression test that drives it past the cap.
drained carries the one-time backlog guard: a live mail.db holds every
requests-tier mail ever received, and the first poll after upgrading must not
mail all of them.
A remote author's feed needs a namespaced path or two servers' `alice` collide
on one file. valid_slug accepts `local`, `local@host` and `local@host:port`;
local authors keep their bare path, so the published feed keeps its URL and
nothing migrates.
valid_username is untouched and is now only the nick rule: a nick reaches
`# nick =` metadata that other twtxt clients parse, so widening it with the
path rule would have leaked `@` and `:` in there. A test pins the two apart.
The traversal guard changes character here. A path could not contain a dot
before, which is why `/twtxt/../etc/passwd` 404'd for free; dots are legal now,
so the rejections are explicit and path() also asserts the join stayed inside
the feed directory. username_of rejects `/` before decoding and decodes only a
literal `%40`, since some twtxt clients encode the `@`.
Incidentally fixes a legal smolmail user never being able to get a feed: the
old rule banned `.` and `_`, so `admit alice.b@smol.place` failed at create.
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-…???".
Ports carrier's src/i18n.rs and its locales/en-US/main.ftl layout, same
fluent-bundle and unic-langid versions, so the two catalogues stay portable
between them. Only mail the daemon sends an author moves; operator CLI output
stays inline, since the operator runs the binary and reads English.
The error-reply reasons from dispatch and ApplyError are deliberately left
behind: dispatch is pure by contract, so those become reason identifiers
resolved at the mail-building edge in a separate change rather than having the
pure layer reach for a bundle.
Adds a test carrier lacks. FluentResource::try_new and add_resource both panic
on a malformed or duplicate-id catalogue, and nothing touches the bundle until
the daemon first mails someone, so without it a bad catalogue surfaces inside
poll() on a live instance.
Sentences are verbatim; the bodies are now hard-wrapped where they were single
long lines, which reads better in a plain-text client.
packages.static is a static musl binary so a low-power deploy target can
fetch a prebuilt one rather than compile Rust; apps.release-static publishes
it to a Forgejo release, guarded on HEAD carrying the version's tag.
fumi-core is a path dependency on ../fumi, which escapes the flake source, so
fumi is vendored as its own input and re-laid beside the source tree in the
store. Over https, not a local path: a flake importing this one has to resolve
this input too when it locks.
services.smolboid runs as a real user rather than DynamicUser, because admit,
block and the fumi bootstrap are operator commands against the same master and
store and need a stable uid to sudo into.