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.
This commit is contained in:
randogoth 2026-10-14 18:48:31 +03:00
parent f2d2d75063
commit 3279fd3a2d
6 changed files with 321 additions and 17 deletions

153
Cargo.lock generated
View file

@ -219,6 +219,17 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]] [[package]]
name = "ed25519" name = "ed25519"
version = "2.2.3" version = "2.2.3"
@ -268,6 +279,41 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fluent-bundle"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4"
dependencies = [
"fluent-langneg",
"fluent-syntax",
"intl-memoizer",
"intl_pluralrules",
"rustc-hash",
"self_cell",
"smallvec",
"unic-langid",
]
[[package]]
name = "fluent-langneg"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0"
dependencies = [
"unic-langid",
]
[[package]]
name = "fluent-syntax"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198"
dependencies = [
"memchr",
"thiserror",
]
[[package]] [[package]]
name = "fumi-core" name = "fumi-core"
version = "0.1.0" version = "0.1.0"
@ -362,6 +408,25 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "intl-memoizer"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f"
dependencies = [
"type-map",
"unic-langid",
]
[[package]]
name = "intl_pluralrules"
version = "7.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
dependencies = [
"unic-langid",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@ -483,6 +548,12 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@ -492,6 +563,12 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "self_cell"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
@ -577,10 +654,12 @@ checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
name = "smolboid" name = "smolboid"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"fluent-bundle",
"fumi-core", "fumi-core",
"serde", "serde",
"serde_json", "serde_json",
"smolboid-core", "smolboid-core",
"unic-langid",
] ]
[[package]] [[package]]
@ -646,12 +725,70 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
[[package]]
name = "type-map"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90"
dependencies = [
"rustc-hash",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unic-langid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
dependencies = [
"unic-langid-impl",
]
[[package]]
name = "unic-langid-impl"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
dependencies = [
"tinystr",
]
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.26" version = "1.0.26"
@ -718,6 +855,12 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.9.0" version = "1.9.0"
@ -738,6 +881,16 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"serde",
"zerofrom",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"

View file

@ -10,3 +10,7 @@ smolboid-core = { path = "../core" }
fumi-core = { path = "../../fumi/core" } fumi-core = { path = "../../fumi/core" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
# Author-facing mail copy lives in a Fluent catalogue; see src/i18n.rs. Same
# versions carrier pins, so the two catalogues stay portable between them.
fluent-bundle = "0.16.0"
unic-langid = "0.9.6"

View file

@ -0,0 +1,37 @@
## Loaded once by `src/i18n.rs`; every string the daemon mails to an author
## lives here, grouped by the mail it belongs to. Operator-facing CLI output is
## deliberately not here — see the module header.
##
## Keep the `-32` style placeables out: these bodies are read in plain-text
## mail clients and on phones, so they stay short lines and no markup.
## Error replies — a mail the daemon could not apply
reply-subject = twtxt: { $reason }
reply-body =
Your mail could not be applied.
Reason: { $reason }
Quoted mail:
{ $quoted }
## Retention — the one-time warning as a feed approaches its size cap
cap-warning-subject = twtxt: feed at 90% of its size cap
cap-warning-body =
Your feed has reached 90% of the { $cap }-byte cap.
In hard-limit mode (the default) a full feed rejects new records; delete
some with Twt-Delete, or choose FIFO pruning with Twt-Prune: fifo, which
drops the oldest records to make room.
## Admission — the mail that hands over the accept token
welcome-subject = twtxt: your feed is ready
welcome-body =
Your smolboid feed is live: { $url }
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
(PLAN.md).

110
daemon/src/i18n.rs Normal file
View file

@ -0,0 +1,110 @@
//! A single Fluent bundle, loaded once from `locales/en-US/main.ftl` and baked
//! into the binary via `include_str!`. Every string the daemon mails to an
//! author is looked up through `t`/`t1`/`t_args` rather than written inline, so
//! copy review and translation both have one place to work from. There is only
//! one locale today; a future one would plug in here without touching a call
//! site.
//!
//! Operator-facing output — the CLI's `println!`s — stays inline: the operator
//! runs the binary and reads English, and routing it through a catalogue would
//! be cost with no reader.
use std::sync::OnceLock;
use fluent_bundle::concurrent::FluentBundle;
use fluent_bundle::{FluentArgs, FluentResource, FluentValue};
fn bundle() -> &'static FluentBundle<FluentResource> {
static BUNDLE: OnceLock<FluentBundle<FluentResource>> = OnceLock::new();
BUNDLE.get_or_init(|| {
let ftl = include_str!("../locales/en-US/main.ftl");
let resource =
FluentResource::try_new(ftl.to_string()).expect("main.ftl must parse as Fluent");
let locale = "en-US".parse().expect("\"en-US\" is a valid language tag");
let mut bundle = FluentBundle::new_concurrent(vec![locale]);
// Bidi isolation marks are invisible but real characters; with a single
// left-to-right locale they only cost correctness — these strings go
// into mail bodies that get quoted, diffed and compared — for no
// visible benefit.
bundle.set_use_isolating(false);
bundle
.add_resource(resource)
.expect("main.ftl must not redefine a message id");
bundle
})
}
/// Looks up a message with no variables.
pub fn t(id: &str) -> String {
t_args(id, None)
}
/// Looks up a message with one variable — the common case, since most
/// interpolated strings here carry a single value.
pub fn t1(id: &str, key: &'static str, value: impl Into<FluentValue<'static>>) -> String {
let mut args = FluentArgs::new();
args.set(key, value);
t_args(id, Some(&args))
}
/// Looks up a message against a caller-built argument set, for the rare string
/// that interpolates more than one value.
pub fn t_args(id: &str, args: Option<&FluentArgs>) -> String {
let bundle = bundle();
// A missing id or a value-less term is a copy bug, not a runtime condition
// to hide: surface it in the string itself rather than silently falling
// back to English prose that isn't there to fall back to (there's only the
// one locale).
let Some(message) = bundle.get_message(id) else {
return format!("???{id}???");
};
let Some(pattern) = message.value() else {
return format!("???{id}???");
};
let mut errors = Vec::new();
bundle
.format_pattern(pattern, args, &mut errors)
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
/// `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 this the failure surfaces
/// inside `poll()` on a live instance rather than here.
#[test]
fn catalogue_parses_and_every_id_resolves() {
for id in IDS {
let rendered = t(id);
assert!(
!rendered.starts_with("???"),
"`{id}` is used in code but missing from main.ftl"
);
}
}
/// 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.
const IDS: &[&str] = &[
"reply-subject",
"reply-body",
"cap-warning-subject",
"cap-warning-body",
"welcome-subject",
"welcome-body",
];
#[test]
fn a_missing_id_is_visible_rather_than_silent() {
assert_eq!(t("no-such-message-id"), "???no-such-message-id???");
}
#[test]
fn arguments_interpolate() {
let rendered = t1("cap-warning-body", "cap", 1024);
assert!(rendered.contains("1024"), "{rendered}");
}
}

View file

@ -357,13 +357,11 @@ impl Daemon {
if state.feeds[index].warned && !was_warned { if state.feeds[index].warned && !was_warned {
replies.push(( replies.push((
address.to_owned(), address.to_owned(),
"twtxt: feed at 90% of its size cap".into(), crate::i18n::t("cap-warning-subject"),
format!( crate::i18n::t1(
"Your feed has reached 90% of the {}-byte cap.\n\n\ "cap-warning-body",
In hard-limit mode (the default) a full feed rejects new records; \ "cap",
delete some with Twt-Delete, or choose FIFO pruning with \ self.cfg.cap_bytes.to_string(),
Twt-Prune: fifo, which drops the oldest records to make room.",
self.cfg.cap_bytes
), ),
)); ));
} }
@ -407,12 +405,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 mut args = fluent_bundle::FluentArgs::new();
args.set("reason", reason.to_owned());
args.set("quoted", text);
replies.push(( replies.push((
address.to_owned(), address.to_owned(),
format!("twtxt: {reason}"), crate::i18n::t1("reply-subject", "reason", reason.to_owned()),
format!( crate::i18n::t_args("reply-body", Some(&args)),
"Your mail could not be applied.\n\nReason: {reason}\n\nQuoted mail:\n{text}\n"
),
)); ));
} }

View file

@ -8,6 +8,7 @@ mod config;
mod dispatch; mod dispatch;
mod feedstore; mod feedstore;
mod http; mod http;
mod i18n;
mod mailloop; mod mailloop;
mod state; mod state;
@ -191,18 +192,18 @@ fn admit(config: ConfigAndArg) -> Result<(), String> {
// The welcome mail carries the Accept token (sec 5.8), which is what // The welcome mail carries the Accept token (sec 5.8), which is what
// moves the author's posts into the daemon's main tier; without it their // moves the author's posts into the daemon's main tier; without it their
// mail would stay in requests and never publish. // mail would stay in requests and never publish.
let welcome_subject = i18n::t("welcome-subject");
let sent = client::send( let sent = client::send(
&store, &store,
&account, &account,
&client::SendDraft { &client::SendDraft {
address: &addr, address: &addr,
text: format!( text: i18n::t1(
"Your smolboid feed is live: {}/twtxt/{}.txt\n\n\ "welcome-body",
Post by sending mail to this address with the twt as the body. Manage it with\n\ "url",
Twt-Nick, Twt-Delete, Twt-Repost and Twt-Prune frontmatter fields (PLAN.md).", format!("{}/twtxt/{}.txt", cfg.base_url, addr.user),
cfg.base_url, addr.user
), ),
subject: Some("twtxt: your feed is ready"), subject: Some(&welcome_subject),
reply_to: None, reply_to: None,
headers: &[], headers: &[],
anonymous: false, anonymous: false,