Internationalisation
The product renders in the user’s own language. English and Spanish ship; adding a third is one JSON file.
What a user sees
Section titled “What a user sees”Webmail, the admin console and the setup wizard are all translated. So are the parts that are
easy to forget: aria-labels and titles, placeholders, dialog confirmations, and the empty
states nobody screenshots.
Dates, times, numbers, percentages and byte sizes are formatted for the locale rather than merely
translated — Spanish shows 1,5 kB and 1.234.567, English 1.5 kB and 1,234,567. Spanish also
does not group four-digit numbers at all, which is the sort of rule Intl knows and nobody
hand-writes correctly.
<html lang> follows the resolved locale, so a screen reader pronounces Spanish text in Spanish.
Where the choice lives
Section titled “Where the choice lives”| Mailbox users | mailbox_settings.settings.locale — a BCP-47 tag, or auto. Changed in webmail → Settings → General → Language, and applies on every device |
| Administrators | no stored choice and no language menu: the admin panel follows navigator.languages. There is no per-admin column and no migration for one |
auto, and the default |
navigator.languages, which is the list the browser also sends as Accept-Language |
| A tag we do not have | falls through to the browser’s list, then to English |
A user’s stored choice is also written to localStorage when it loads, so the next visit renders in
the right language before the account query has answered.
Offline
Section titled “Offline”The catalogue is a hashed asset under /assets/, which the webmail’s service worker serves
cache-first (Installable webmail & offline reading) — so once a Spanish session has loaded the
webmail once online, its catalogue is in the shell cache and the UI is Spanish offline like
everything else. This is the webmail only: the admin panel registers no service worker and
caches nothing (Admin UI).
The one gap is a first ever load with no network, where the lazy fetch fails: the runtime catches it and renders English rather than key names. That is the same degrade as a translation nobody has finished, and it is why the fetch is wrapped rather than allowed to throw.
What stays in English, and why
Section titled “What stays in English, and why”Anything the server wrote. API error messages, alert titles, DNS record purposes, licence
reason strings, the queue agent’s detail, imapsync’s log, a remote MTA’s refusal text. The control
plane does not know who is asking — the same string goes to an API token, to mailctl and to a
webmail session — so it answers in one language and the UI relays it rather than inventing a generic
sentence that would hide which of a dozen refusals this was.
Where the API returns a code next to the message, the UI keys off the code and translates that
instead. The AI failure taxonomy is the worked example: auth, billing, rate_limit,
context_length, timeout, network (sharing timeout’s “unreachable” text), local_only_refused each have a translated explanation, and only an
unrecognised kind falls through to the provider’s own words.
mailctl is not translated. It runs on a server and reads .env, not a mailbox; there is no
user whose language it could know. Its exit codes are the contract.
Vocabulary is not prose. The value a <Select> stores, licence tiers, audit actions, delivery
classes, queue names, MTA-STS modes, RFC 8460 result types: wire values, shown as they are. Only the
label beside them is translated.
Re: and Fwd:. Every mail client on the internet matches those prefixes to avoid stacking
them; a translated one would start a new thread in the recipient’s client. The attribution line
above a quote is translated, because that is the sender writing in their own language.
For developers
Section titled “For developers”useT()inside a component;translate()for module-level code that cannot use a hook. A component that usestranslate()will not re-render when the language changes.<Rich k="…" />renders*emphasis*inside a translated sentence, so a sentence that needs bold stays one catalogue entry rather than three fragments a translator cannot see whole.- Keep a sentence in one key. Where a sentence wraps a control the two halves are separate keys
(
filters.if/filters.ifSuffix) because their order swaps between languages. Where copy differs by scope, write a key per scope rather than interpolating the noun —for this {scope}cannot agree with the rest of its sentence in most languages. - Never compare against rendered text. The TLS-reports failure tile used to turn red by matching
its own label against
'Failed sessions'; translating the label would have silently stopped the one tile on that screen whose job is to be loud. Branch on a flag or an id. - A lookup table holds keys, not text, and spells each one out in full rather than building it
from a variable — that is what lets
pnpm i18n:checktell a live key from a dead one. - Numbers, dates and sizes go through
formatNumber,formatDate,formatBytesandformatQuotainpackages/web-shared/src/lib/utils.ts.formatQuotais the one for a limit where0means unlimited;formatBytesis for a size that is genuinely zero.
pnpm i18n:check runs in CI and fails on five things:
- a key the source asks for that
en.jsondoes not define — the only thing between a typo andmail.list.emtpyrendering into the UI, since the fallback chain is silent by design; - a key only a translation has, which can never render;
- a plural entry with no
otherform, which renders empty:plural()falls back toentry.other ?? '', so a catalogue that spells out only the categories its author happened to think of produces a blank element for every count outside them.otheris the one category CLDR requires of every language, which is why it is the fallback and why its absence is an error rather than a warning; - a placeholder that drifted between languages, which renders the literal
{contador}to the reader and is the translation defect a human reviewer reliably misses; - English left in the JSX — a new hardcoded string never becomes a key at all, so nothing else
would notice it. It flags only sentence-shaped text (two or more words, at least one of them
all-lowercase), which admits
No mailboxes in this domain.and excludes product names, format examples and units. It finds nothing against the current tree.
Unused keys are a warning, and each language’s coverage is reported rather than enforced, so a half-finished contribution does not fail the build.