Commit graph

9 commits

Author SHA1 Message Date
randogoth
801c66d3b0 feat: follow upstream twtxt post rules: reject control characters, allow U+2028, make the length cap configurable 2026-10-15 14:05:33 +03:00
randogoth
db8aee2949 feat: say what happened to each mail
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.
2026-10-15 09:15:51 +03:00
randogoth
72c353cb5a docs: rewrite the author-facing copy in plain language
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.
2026-10-15 09:07:24 +03:00
randogoth
f1084c1056 feat: answer unknown senders, gated on proving they hold their address
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.
2026-10-14 20:21:11 +03:00
randogoth
77d822cebe feat: read the requests tier, drain its backlog once, and survive a bad reply
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.
2026-10-14 20:11:01 +03:00
randogoth
03e54fea49 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.
2026-10-14 19:37:17 +03:00
randogoth
bf6e2eaa15 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-…???".
2026-10-14 18:57:57 +03:00
randogoth
3279fd3a2d refactor: move author-facing mail copy into a Fluent catalogue
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.
2026-10-14 18:48:31 +03:00
randogoth
8330b65ff9 feat: add smolboid daemon: mail loop, feed store and HTTP listener 2026-10-05 11:00:17 +03:00