Document the nine languages, and what Beta means on them

The translations shipped today and the docs still said "no languages but
English". They also need to say the harder thing, which is that eight of
the nine have never been read by anybody who speaks them.

- README gains the language list, with the Beta caveat in the same line
  rather than a footnote.
- FEATURES.md gains an Interface language section: the list, why it is a
  separate setting from the date locale, and the two design properties
  that follow — a missing entry renders English, and plurals are asked of
  Intl.PluralRules rather than assumed, which is why Russian carries
  three forms and Japanese one.
- ROADMAP.md no longer lists translations as "not yet". What replaces it
  is the half that is genuinely not done: a translation anybody has
  checked. RTL is split out as its own entry, because holding Arabic,
  Hebrew and Persian back is a layout decision and not a queue position.
- KNOWN-ISSUES.md gains two entries. The unread catalogues, which is the
  one thing on that page that cannot be closed by testing. And the
  coverage number that read 100% while two hundred strings rendered
  English in every language — recorded as a general lesson rather than an
  i18n one, since a coverage number measures what it can see and the rest
  is exactly what nobody is checking.
This commit is contained in:
2026-08-31 15:18:01 -07:00
parent 2380ed282e
commit c26ca90e01
4 changed files with 55 additions and 9 deletions
+48 -6
View File
@@ -594,7 +594,7 @@ not reach another that already has ihasmail open until it signs in again.
| Section | Holds |
| --- | --- |
| **General** | Reading pane, mark-as-read delay, auto-advance, remote images, conversation view, snippets, avatars, confirm-before-delete; compose format, undo-send window, quoting, signature placement, attachment reminder, read-receipt policy, spell check; time zone, week start, language & region, date format, time format; `mailto:` handler; export / import / reset |
| **Appearance** | Theme, accent colour, density, font size, sidebar, swipe actions |
| **Appearance** | Theme, accent colour, density, font size, sidebar, swipe actions, interface language |
| **Identities & signatures** | Addresses, names, Reply-To, HTML signatures, the default, and which to hide from the picker |
| **Filters & rules** | The visual builder and raw Sieve editor |
| **Out of office** | Vacation response |
@@ -629,6 +629,47 @@ Settings **export** to a JSON file and **import** back, and reset to defaults.
not round-trip against a Gregorian grid. Non-Gregorian calendars are not
implemented.
## Interface language
Nine languages, chosen in **Appearance → Language**, and separate from the
date-and-time locale above. Wanting German dates on an English interface is a
real preference and so is the reverse, which is why they are two settings and
not one.
| | |
| --- | --- |
| English | the source language, and what every other catalogue falls back to |
| Deutsch · Español · Français · Nederlands · Português (Brasil) | Beta |
| Русский · Українська · 简体中文 · 日本語 | Beta |
**Every language but English is marked Beta, and the label is not modesty.**
The catalogues were produced by AI against standard dictionaries and have not
been read by anybody who speaks the language. That is stated in Settings, next
to a link for reporting anything that reads wrongly, because the alternative —
shipping them quietly — would ask people to trust text nobody has checked. A
language loses the Beta mark when a speaker has read it and said so, which is a
deliberate act by a person and not something a percentage earns.
Two things follow from the design rather than the translation:
- **A missing entry renders its English source.** So deleting a bad line is a
valid fix, not a regression, and a catalogue is never in a half-broken state.
- **Plurals are asked for, never assumed.** `Intl.PluralRules` decides the form,
so Russian and Ukrainian get their three (1 письмо, 2–4 письма, 5+ писем) and
Japanese and Chinese get the one they actually have — with counters doing the
work a plural would: 通 for messages, 件 for conversations.
The interface language also feeds the *automatic* date locale, so choosing
日本語 gives Japanese month and weekday names without setting the region too.
`<html lang>` follows it, which is what stops a browser offering to translate a
page that is already in the reader's language — and accepting that offer is
what rewrites the DOM underneath React.
Only languages with a catalogue shipped appear in the picker. A language
offered without strings behind it would leave the page claiming to be in a
language it is not, which is worse than not offering it: it stops a browser
offering to translate a page the reader cannot read.
## Themes
Follow the system, light, dark, or **ihasmail** — the palette this project's
@@ -917,8 +958,9 @@ tested.
The full list with reasons is [ROADMAP.md](ROADMAP.md). In short: no snooze
(nothing in JMAP or Stalwart supports it, and ihasmail holds no password to act
on a mailbox while you are away), no languages but English, no two-factor
sign-in without an app password, no sharing of mail folders (the server stores
the share and never delivers it), no public links (JMAP shares with accounts on
the same server, and ihasmail has no storage of its own to mint a link from),
and no per-occurrence *this and future* edits (the server refuses them).
on a mailbox while you are away), no language yet checked by a native speaker,
no two-factor sign-in without an app password, no sharing of mail folders (the
server stores the share and never delivers it), no public links (JMAP shares
with accounts on the same server, and ihasmail has no storage of its own to
mint a link from), and no per-occurrence *this and future* edits (the server
refuses them).
+4
View File
@@ -27,6 +27,10 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
[`stalwart-0.15-support`](https://github.com/Coffey-Labs/ihasmail/releases/tag/stalwart-0.15-support).
- **Eight of the nine interface languages have never been read by anybody who speaks them.** They were produced by AI against standard dictionaries on 2026-08-31 — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese — and every one is marked **Beta** in the picker, with that stated in Settings beside a link for reporting anything that reads wrongly. This is the entry that matters most on this page, because it is the one thing here that cannot be closed by testing: a translation can be complete, consistent, pass every check, and still read like a machine wrote it, and nobody on this project can tell which. What *is* verified is the machinery around them. A missing key renders its English source, so a bad line can simply be deleted; a stale key — one whose English no longer exists — is caught by `npm run i18n:check` rather than sitting in the file looking correct and never being looked up. Plurals are asked of `Intl.PluralRules` rather than assumed, which is why Russian and Ukrainian carry three forms and Japanese and Chinese carry one; supplying `one` for Japanese would have been filling in a distinction the language does not draw. Confirmed live on the deployed instance (2026-08-31) against a 6,289-message mailbox: role folders localise and the ~20 custom folders keep the names their owner gave them, dates and the calendar follow the language, and 6,289 renders as *6289 листувань* — the genitive plural a number ending in nine takes, which is the first time the plural machinery ran on anything but a hand-picked value.
- **`npm run i18n:coverage` reported 100% while about two hundred strings rendered English in every language.** It reads JSX text, and it was not wrong about what it measured — none of them were JSX text. They were `toast.error(...)` arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and `aria-label=` attributes, and template literals: every one built from an expression a codemod cannot read. The calendar's own view switcher was the clearest case, spelling its labels `v[0].toUpperCase() + v.slice(1)` — correct English, untranslatable anywhere else, and galling because **Day**, **Week**, **Month** and **Agenda** were already in all nine catalogues and the buttons simply never asked for them. Reported from production, where the switcher stayed English in a Japanese interface. All of them are now wrapped, and `npm run i18n:check` grew a second half (`scripts/i18n-literals.mjs`) that accepts a string wrapped where it is written *or* present as a catalogue key — the constant-table convention, where `SECTIONS` holds `label: "About"` and the render site calls `t(s.label)` — and refuses one that is neither, because that is a string no catalogue can translate however many languages ship. It found twenty more than a hand sweep had. Worth recording as a general lesson rather than an i18n one: a coverage number measures the thing it can see, and the strings it cannot see are exactly the ones nobody is checking.
- **A compressing hop in front of Stalwart truncated every blob download, and nothing said so.** Node decompresses a gzip response before the code ever sees the body, but leaves the `content-length` header describing the *compressed* bytes. The blob proxy copied that header onto the longer body it forwarded, so the browser stopped reading exactly that many bytes in and called the download complete. Reported on [#76](https://github.com/Coffey-Labs/ihasmail/issues/76) against a Coolify deployment, where Traefik's compress middleware only engages above 1 KiB: filter rules one and two were fine and the third pushed the script past the threshold, after which it came back cut off mid-rule — 384 bytes of a 1.3 KB script. The size threshold is what made it look like a race. This is the *second* cause behind that issue, and the first fix did not touch it: a truncated script is neither unknown nor empty, so the "refuse to save from a baseline we could not read" guard never fired — the script parsed, just with rules missing, and the next save wrote the short version back over the real one. Every blob download shared the fault, not just Sieve: message source, vCards, signature HTML, attachments being forwarded, and the `settings.json` sync. Settings degraded honestly by luck rather than design — a truncated file fails `JSON.parse`, which is caught and leaves the local cache in charge — so it stopped syncing between devices instead of being overwritten. The proxy now asks upstream for `identity` and, for a hop that compresses anyway, forwards no length at all rather than one describing different bytes. The image proxy is unaffected: it uses `node:http` directly, sends no `accept-encoding`, and never decompresses. The save path no longer trusts the transport either: a script is now checked for completeness against the shape the generator emits — every `# rule:` comment parses, every enabled rule has an `if` and a closed body below it, every block ends with a blank line — and saving refuses on anything short, as does the rule editor, which reports the script as unreadable rather than showing the rules that happened to parse. The check is structural rather than a re-serialize-and-compare, so a script written by an older version with a different serializer is still editable; refusing over a changed byte would be the worse bug. It catches a cut at every offset except the end of a complete rule block, which is a legitimately shorter script and indistinguishable from one in the bytes alone — that residual is what the proxy fix covers.
- **Delete all spam destroys, and does not pass through Deleted Items** — this is the point of the feature and the thing worth checking on a real server, since a folder that empties into another folder has solved nothing. `Email/set destroy`, walked a page at a time so it survives `maxObjectsInSet` the way emptying Deleted Items already had to. **Confirmed live on 0.16.19 (2026-08-26)**: Junk Mail emptied and Deleted Items stayed empty afterwards. There is no undo, which is why all three entry points share one dialog that says so. Only Deleted Items and Junk Mail can be emptied this way, enforced in the store rather than only hidden in the menus.
+1
View File
@@ -51,6 +51,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
- **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files, so ihasmail itself stays stateless
- **Runs read-only** — one optional write path, and with it switched off the container needs no volume and no writable root. `IMMUTABLE=1` is checked at startup rather than trusted, so a half-applied switch refuses to boot instead of failing quietly. See [Running immutably](#running-immutably)
- **Nine interface languages** — English plus German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, separate from the date-and-time locale. All but English are marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
- **On a phone** — swipe a message to archive or delete it (either direction, your choice), hold one to select it, hold a folder for its menu, pull the list to refresh, swipe back from a conversation
- **Platform** — installable PWA, Web Push with ihasmail closed, `mailto:` handler, no credentials in the browser, strict CSP, SSRF-safe image proxy
+2 -3
View File
@@ -10,7 +10,6 @@ See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about
- **Sharing a mail folder.** Stalwart stores the share and never delivers it; see [KNOWN-ISSUES.md](KNOWN-ISSUES.md). Withdrawn until the server does something with it. Sharing files, calendars and address books is unaffected and works.
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
- **Translations.** In progress, and the only entry here that is "not yet" rather than "no". The groundwork shipped in [#145](https://github.com/Coffey-Labs/ihasmail/pull/145): an interface-language setting separate from the date-and-time locale, `<html lang>` served from it, and the structural work that keeps a browser's own translator from rewriting the page underneath React. What is left is the part that was always the hard part — extracting every user-facing string, and having each catalogue read by somebody who speaks the language. A language is offered in Settings only once its catalogue is complete, so a half-translated build shows English and nothing else; the picker is the gate, not the calendar.
Planned order, and it is an order rather than a wish list: **German, French, Dutch, Spanish, Portuguese (Brazil)** first, then **Russian, Ukrainian, Chinese (Simplified), Japanese**. Arabic, Hebrew and Persian are deliberately not on either list. They are right-to-left, and that is a layout and bidi problem rather than a longer catalogue — shipping them as though they were the same kind of work is how an RTL build ends up unusable and nobody says so.
- **A translation anybody has checked.** The translations themselves shipped on 2026-08-31 and are no longer on this page: nine languages, and the extraction that had always been the hard half is done — see [FEATURES.md](FEATURES.md#interface-language). What is *not* done is the other half, and it is the half that cannot be bought or automated. Every catalogue but English was produced by AI against standard dictionaries and **has not been read by anybody who speaks the language**, which is exactly where a bad translation does harm rather than merely looking untidy. They ship marked Beta, with that said in Settings and a link for reporting anything wrong, because shipping them quietly would ask people to trust text nobody has checked. A language loses the Beta mark when a speaker reads it and says so — a deliberate act by a person, not something a coverage percentage earns. If you speak one of them and are willing to read a few hundred strings, that is the single most useful thing anyone could contribute right now.
- **Right-to-left languages.** Arabic, Hebrew and Persian are held back deliberately, and not for want of translators. RTL is bidi and layout work throughout — mirrored panes, gesture directions, icon sides, the message list's own geometry — and a catalogue without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.