feat: English everywhere + editable customer letters with preview - #57
Merged
Conversation
The care report and the expiry warning were written in Czech while the rest
of the product is English. Mixed language is worse than either: an operator
reading a preview cannot tell which strings are theirs to change.
Translated the meaning, not the words. The hedging that these letters exist
for survives intact: 'not monitored (uptime checks were not active)' where
the Czech said the same, 'not determined' for a period the audit log does
not span, and the note that a zero would claim something other than 'we
were not looking'. Where the Czech carefully refused to print a number, the
English refuses too.
Czech-specific helpers become English ones rather than being ported
literally: the three-form plural (1 / 2-4 / 5+) collapses to English's two,
thousands group with a comma, and dates spell the month ('1 Sep 2026')
because a numeric date reads as a different day depending on the reader's
country. What stays Czech is genuine data, not language: the 'Kc' currency
an operator typed, and the diacritic table that transliterates a package
name into a URL slug.
Tests updated to assert the English wording, including the singular/plural
boundary the grace-period line depends on.
…eview
The care report and the expiry warning go to paying customers, and the
operator could not see or change a word of them without a rebuild. Both
bodies are now editable in Settings, next to the existing message-template
wrappers, with a live preview and the placeholder list spelled out — an
operator cannot use a placeholder they cannot discover.
Empty means the built-in letter, exactly like the existing wrappers, so an
operator who never opens the card gets byte-identical output. Two separate
paths could drift here — the built-in `format!` and the default template
constant the editor offers — so a test asserts they render identically, for
a mixed report AND for an all-unmeasured one, which covers both branches of
all six sections.
THE HONESTY PROPERTY IS ENFORCED BY CONSTRUCTION, not by comment. The
placeholders expose the RENDERED SECTION STRINGS, never the raw counts
behind them: there is deliberately no {uptime_pct} and no {attack_count},
because that is precisely how an operator would accidentally turn "not
monitored — a figure of 100 % would be invented" into "100 %". Dates, day
counts and the grace period arrive pre-worded and pre-clamped, so a custom
letter cannot name a delete date other than the one the scheduler queues,
or count down past zero. Subjects stay ours in both letters.
The preview's sample data leaves availability unmonitored on purpose: the
unmeasured case is the one the operator most needs to see before their
customer does.
Unknown placeholders are left literal rather than dropped or panicked on —
a typo shows itself instead of silently deleting a paragraph. Textarea CRLF
is normalised (it would otherwise land in a plain-text mail body) and the
templates are length-capped.
934 tests pass.
…scheme case
Adversarial review of everything since v0.11.0 confirmed 16 defects. These
are the ones fixed here; the rest are listed in the PR.
A FAILED SEND WAS RECORDED AS A SUCCESS. `notify_email` returned `()` and
swallowed relay errors, so every caller treated rejection as delivery. For
the care report that was the worst possible shape: the period marker is
BOTH "already sent" and the start of the next period, so a rejected message
advanced it — the customer never got that month, the next report began
after it, and the journal said "care report sent". It now returns whether
the relay accepted, and the tick leaves the period OPEN on failure, exactly
like the three sibling guards beside it (no relay / no owner e-mail /
nothing measurable) already did. The manual send returns an error naming
where to read the relay's message instead of flashing success, and the
expiry warning audits what actually happened.
THE GENERIC WRAPPER WAS BEING APPLIED TO THE CUSTOMER LETTERS. The editable
`{subject}`/`{body}` wrappers are written for short operator alerts — they
typically prefix a tag and append a panel link. Wrapping a complete
customer letter in one produces a mangled subject and, in a letter to a
paying customer, a link they cannot open. Both customer letters are now
exempt; they have their own editable templates and their own signature.
"CLEAR EXPIRY" ALSO WIPED owner_email. That address is the recipient of the
paid care report, so clearing an expiry DATE silently stopped a deliverable
the customer was still paying for — and the address itself was gone, with
nothing in the UI saying so. It is preserved now.
Letter wording: `plural()` was used where English needs the sentence
restructured, producing "how many update we applied" and "how many complete
copy of the site"; the traffic coverage line said "(1 of 1 days)". Fixed by
rewriting the sentences, not by bolting on an "(s)".
TLS scheme matching was case-SENSITIVE, so a master URL typed `HTTPS://`
was classified as plaintext and ran the whole heartbeat — which carries the
node's secret — unverified, silently, because the request still succeeded.
Both scheme tests are case-insensitive now.
Also: the last two Czech strings in the panel were headings on the care
card itself.
941 tests pass.
A full honesty audit of the care report found six defects. The first is
the one that mattered: it is the letter's most invoice-shaped number, and
it was fabricated.
TRAFFIC WAS MULTIPLIED BY ROUGHLY THE BUCKET COUNT. `stats_tick` parsed
the trailing 24 HOURS of access.log and stored the result under an HOURLY
`period` key, upserting with REPLACE. So every hourly bucket held a
rolling 24 h total, and every consumer that SUMs buckets counted each
request once per bucket it fell within 24 h of — about 24 times. A site
serving 20,000 requests and 500 MB in June reported "480,000 requests,
12.0 GB sent", followed by "these figures cover the whole period". The
same inflation reached /stats' per-site breakdown and the hosting
detail's `*_24h` figures. Nothing enforces bandwidth caps, so no site was
suspended over it.
The window is now hour-aligned and half-open, so a request lands in
exactly one bucket. Because the sampler runs every 5 minutes, the tail of
each hour would otherwise be lost, so each tick also backfills the hour
it just left — guarded on `reqs > 0`, since after logrotate a zero parse
must not erase an hour we already measured, and writing only the three
traffic columns so a past hour keeps the disk and memory readings that
belonged to it.
A MIS-GUESSED SYSTEM USER PRODUCED MEASURED ZEROS. The sampler derives
the site's paths from a guess at the system user, which an imported or
renamed site defeats. `du` and the log parse then both answered zero and
a row was written: "0 requests, 0 B sent, peak disk 0 B … covers the
whole period" for a live site with gigabytes of files. A missing hosting
root now skips the sample and warns, so the day drops out of the coverage
count and the letter says the period was only partly measured.
AVAILABILITY HAD NO COVERAGE DENOMINATOR. Zero samples already meant "not
monitored", but PARTIAL monitoring was invisible: a node offline for five
days records no checks for them, and the surviving 25 days were divided
among themselves into "100.00 %, no outage" — printed beside a traffic
section admitting those same five days were missing. `CareUptime` now
carries the same `days_counted`/`days_in_period` pair as `CareUsage`,
from the same helper, so the two sections cannot disagree.
"ATTACKS BLOCKED" USED TODAY'S TOGGLES TO DESCRIBE THE PAST. Protection
enabled on the 29th still produced "0 — protection ran for the whole
period" for a month nobody watched. The scanner now stamps when it
actually started watching a site, and clears it when the site is opted
out; the count is bounded by that evidence rather than by a flag that
only ever describes the present.
A CUSTOM LETTER COULD DROP AN UNMEASURED SECTION. The editor offers
"start from the built-in letter", preamble included — "where something
was not being measured, we say so" — and nothing stopped the operator
deleting the `{uptime}` line because "not monitored" reads badly. The
disclosure is now re-attached at the end for any section that is both
unmeasured and omitted. Dropping a MEASURED section stays their call.
Also: the Settings preview's sample was hand-copied prose in the page's
JavaScript and had already drifted from the mailer, so an operator was
tuning wording against text no customer receives. It is now rendered by
the same functions the letter goes through. And the coverage caveat said
the unmeasured days meant "real traffic was higher" — unknowable, and the
one sentence that drew a conclusion from a gap it had just disclosed.
946 tests pass.
…e a window An adversarial review of the previous commit found two HIGH defects in the coverage work it introduced. Both made the letter overstate. THE COUNT WAS NOT NARROWED TO THE WINDOW THE SENTENCE CLAIMED. The new mid-period wording said "watching this site since 25 Jun, not for the whole period, so this figure covers only that part of it" — while the number beside it was still counted over the WHOLE period. Ban rows are never deleted and can be attributed to a hosting by hand at any time, so the two windows genuinely diverge: a site opted out on the 15th and back in on the 25th reported "42 (since 25 Jun)" when the figure for that window was 2. The narrowing now lives in `attacks_window`, a pure function, so the count and the claim cannot come from different places again. A `since` at or past the period end yields no count at all rather than a zero beside a start date in the future — which is the shape of the first report after this upgrade, when no site has a stamp yet. THE STAMP WAS WRITTEN BEFORE THE LOG WAS OPENED. `derive_user_from_summary` GUESSES the system user from the domain and succeeds for almost any domain, so an imported or renamed site passed the guard and then pointed at a path that does not exist. The site was marked "watched since" forever while its log had never been read once, and its letter said "0 attacks, protection ran for the whole period" beside a traffic section that correctly said "not measured" — one letter, two sections, opposite claims about whether anything was watching. The stamp now requires the log to be there. A STAMP RECORDED A START BUT NOT A GAP. Protection that stopped and resumed — the node down for a week, `[fail2ban] enabled` flipped off and back, the agent simply not running — kept its original `since` and back-claimed the dark days. Nothing cleared it, because when scanning is globally off the scan loop does not run at all. The stamp now carries `"<since>,<last_scan>"`, and a silence longer than an hour starts a new stretch. A stamp that has gone stale reports nothing rather than the switch's opinion. Two sampler defects from the same review: The current-hour parse had no upper bound. `hour_start` is computed once per tick, but the per-site loop runs `du` and a 200 ms /proc pass first, so a busy node reaches the parse in the NEXT hour and filed that traffic into the previous hour's bucket — where its own bucket would later count it again. Logrotate firing mid-hour erased the part of that hour already measured: the hour was re-read from an almost empty file and the result replaced what was there. The freshly rotated sibling is now read alongside the live log (the time window keeps its older entries out), and neither usage writer will LOWER a bucket's traffic — within one hour traffic only grows, so a smaller reading is the log having moved under us, not a correction. Disk, inodes, memory and CPU are levels and still track down. The two tests that changed did so because the contract is now stricter: a toggle set to "on" is no longer evidence that anything was watched, and a report with nothing measurable is still not sent. 948 tests pass.
Five, not six — the four literal zeros in VALUES and the excluded-reading UPDATE clause add none. sqlx binds by position and a miscount fails silently, writing the wrong column or no row, so a comment that gets it wrong is worse than no comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three things: all text in English, the two customer letters made editable with a preview, and the fixes from a complete adversarial review of everything since v0.11.0.
1. English everywhere
The care report and expiry warning were Czech while the rest of the product is English. Mixed language is worse than either — an operator reading a preview cannot tell which strings are theirs to change.
Translated the meaning: every hedge survives ("not monitored (uptime checks were not active)", "not determined" for a period the audit log does not span, the note that a zero would claim something other than "we were not looking"). Czech-specific helpers became English ones rather than being ported literally — the three-form plural collapses to two, thousands group with a comma, dates spell the month (
1 Sep 2026) because a numeric date reads as a different day depending on the reader's country.What stays Czech is data, not language: the
Kčcurrency an operator typed, the diacritic table that transliterates a package name into a URL slug, and WordPress locale names in the language picker (which correctly show each language in its own name).2. Editable letters + preview
Both letter bodies are now editable in Settings with a live preview and the placeholder list spelled out. Empty = the built-in letter, so an operator who never opens the card gets byte-identical output. A test asserts the built-in
format!and the default template constant the editor offers render identically — two paths that could otherwise drift.The honesty property is enforced by construction. Placeholders expose the rendered section strings, never the counts behind them: there is deliberately no
{uptime_pct}and no{attack_count}, because that is exactly how an operator would turn "not monitored — a figure of 100 % would be invented" into "100 %". Dates and day counts arrive pre-worded and pre-clamped, so a custom letter cannot name a delete date other than the one the scheduler queues. Subjects stay ours. Unknown placeholders are left literal — a typo shows itself instead of silently deleting a paragraph.The preview's sample data leaves availability unmonitored on purpose: that is the case the operator most needs to see before their customer does.
3. Review fixes (16 confirmed findings)
A failed send was recorded as a success.
notify_emailswallowed relay errors and returned(). For the care report that was the worst possible shape: the period marker is both "already sent" and the start of the next period, so a rejected message advanced it — the customer never got that month, the next report began after it, and the journal saidcare report sent. Now the tick leaves the period open on failure, like the three sibling guards beside it already did.The generic wrapper was applied to the customer letters — mangled subject, plus a panel link a customer cannot open. Both letters are now exempt.
"Clear expiry" also wiped
owner_email— the care report's recipient — silently stopping a deliverable the customer still pays for.Wording:
plural()used where English needs the sentence restructured ("how many update we applied", "how many complete copy"), and "(1 of 1 days)".TLS scheme matching was case-sensitive, so
HTTPS://was classified as plaintext and ran the heartbeat — which carries the node's secret — unverified, silently, because the request still succeeded.Plus the last two Czech strings in the panel, which were headings on the care card itself.
Tests
941 pass, fmt clean.
Still open from the review
Documented rather than silently dropped: operator-edited letters and the per-hosting off-site backup pin are both resolved on the node that owns the hosting, so on a multi-node cluster they need per-node propagation — the same shape as a bug already fixed once for package bundles. Worth its own PR.
🤖 Generated with Claude Code