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

View file

@ -10,3 +10,7 @@ smolboid-core = { path = "../core" }
fumi-core = { path = "../../fumi/core" }
serde = { version = "1", features = ["derive"] }
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 {
replies.push((
address.to_owned(),
"twtxt: feed at 90% of its size cap".into(),
format!(
"Your feed has reached 90% of the {}-byte cap.\n\n\
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.",
self.cfg.cap_bytes
crate::i18n::t("cap-warning-subject"),
crate::i18n::t1(
"cap-warning-body",
"cap",
self.cfg.cap_bytes.to_string(),
),
));
}
@ -407,12 +405,13 @@ impl Daemon {
let text = std::str::from_utf8(&incoming.body)
.map(|t| t.chars().take(200).collect::<String>())
.unwrap_or_default();
let mut args = fluent_bundle::FluentArgs::new();
args.set("reason", reason.to_owned());
args.set("quoted", text);
replies.push((
address.to_owned(),
format!("twtxt: {reason}"),
format!(
"Your mail could not be applied.\n\nReason: {reason}\n\nQuoted mail:\n{text}\n"
),
crate::i18n::t1("reply-subject", "reason", reason.to_owned()),
crate::i18n::t_args("reply-body", Some(&args)),
));
}

View file

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