Skip to content

Internationalisation

The product renders in the user’s own language. English and Spanish ship; adding a third is one JSON file.

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.

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.

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.

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.

  • useT() inside a component; translate() for module-level code that cannot use a hook. A component that uses translate() 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:check tell a live key from a dead one.
  • Numbers, dates and sizes go through formatNumber, formatDate, formatBytes and formatQuota in packages/web-shared/src/lib/utils.ts. formatQuota is the one for a limit where 0 means unlimited; formatBytes is 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.json does not define — the only thing between a typo and mail.list.emtpy rendering 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 other form, which renders empty: plural() falls back to entry.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. other is 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.