From 756f53d8002f19231186c5f936f3d500682dc1b2 Mon Sep 17 00:00:00 2001 From: Leandro Forain Date: Mon, 21 Sep 2026 12:28:04 -0300 Subject: [PATCH 1/2] feat(i18n): runtime-configurable fallback locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback UI locale (used when neither the visitor's cookie nor Accept-Language matches a shipped catalogue) was only settable at build time via NEXT_PUBLIC_DEFAULT_LOCALE, so changing it meant a custom image. Add a DEFAULT_LOCALE config key (env var or admin dashboard, Settings → Default Language) resolved at request time: - lib/admin/default-locale.ts resolves admin/env > build default and is used by the next-intl request config, the proxy (one intl middleware per default locale seen, since next-intl bakes it in) and /api/config. - The client mirrors the resolved value (i18n/runtime-default-locale.ts) so 'as-needed' URL prefixing in deep links agrees with the server. - Locale autonyms move to i18n/locale-names.ts, shared by the language switcher and the new admin select. Regional tags still map onto catalogues at match time (pt-BR → pt), so DEFAULT_LOCALE=pt is the setting for a Brazilian deployment. --- .env.example | 12 ++++--- FEATURES.md | 2 +- README.md | 9 +++--- app/(main)/admin/_tabs/settings.tsx | 29 +++++++++++++---- app/api/config/route.ts | 2 ++ components/ui/language-switcher.tsx | 31 +++--------------- hooks/use-config.ts | 4 +++ i18n/__tests__/runtime-default-locale.test.ts | 23 +++++++++++++ i18n/locale-names.ts | 32 +++++++++++++++++++ i18n/request.ts | 5 ++- i18n/runtime-default-locale.ts | 17 ++++++++++ lib/admin/__tests__/default-locale.test.ts | 29 +++++++++++++++++ lib/admin/default-locale.ts | 15 +++++++++ lib/admin/types.ts | 5 +++ lib/deep-links.ts | 3 +- proxy.ts | 20 ++++++++++-- 16 files changed, 191 insertions(+), 47 deletions(-) create mode 100644 i18n/__tests__/runtime-default-locale.test.ts create mode 100644 i18n/locale-names.ts create mode 100644 i18n/runtime-default-locale.ts create mode 100644 lib/admin/__tests__/default-locale.test.ts create mode 100644 lib/admin/default-locale.ts diff --git a/.env.example b/.env.example index c868bce88..d3bf1c0de 100644 --- a/.env.example +++ b/.env.example @@ -444,10 +444,14 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # image, rebuild it with --build-arg (see README "Default UI locale"). # # Fallback UI locale used when the visitor's Accept-Language header does not -# match any supported locale. Defaults to "en". -# Supported: ar, ca, cs, da, de, en, es, fa, fr, he, hu, it, ja, ko, lv, nl, pl, -# pt, ro, ru, sk, tr, uk, zh -# An unsupported value falls back to "en". +# match any supported locale (and they have not picked one). Defaults to "en". +# Supported: ar, ca, cs, da, de, en, es, fa, fr, he, hu, it, ja, ko, lv, mn, nb, +# nl, pl, pt, ro, ru, sk, tr, uk, zh, zh-TW +# An unsupported value is ignored. +# DEFAULT_LOCALE is read at runtime (also settable in the admin dashboard); +# NEXT_PUBLIC_DEFAULT_LOCALE is the build-time equivalent and is what the +# static Lite build uses. +# DEFAULT_LOCALE=pt # NEXT_PUBLIC_DEFAULT_LOCALE=tr # Locale prefix mode for URLs. Recommended "always" when proxying under a diff --git a/FEATURES.md b/FEATURES.md index fb4461820..0dfc80f93 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -119,7 +119,7 @@ - Arabic, Hebrew, and Persian render right-to-left; document direction and logical layout flip automatically - The browser's `Accept-Language` picks the first language, and the choice persists per user -- `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback, `NEXT_PUBLIC_LOCALE_PREFIX` the URL prefix +- `DEFAULT_LOCALE` (runtime env or admin dashboard) sets the fallback, `NEXT_PUBLIC_DEFAULT_LOCALE` the build-time one, `NEXT_PUBLIC_LOCALE_PREFIX` the URL prefix ## Identity & multi-account diff --git a/README.md b/README.md index 038ce44c1..e7bd5e10c 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,7 @@ STALWART_FEATURES=true # password change, Sieve filters, etc. LOG_FORMAT=text # "text" or "json" LOG_LEVEL=info # error | warn | info | debug +DEFAULT_LOCALE=pt # fallback UI language (see "Default UI locale") ``` @@ -371,15 +372,15 @@ The split lets you mount the config volume read-only after the setup wizard comp
Default UI locale -The UI language follows each visitor's `Accept-Language` header and their stored preference. `NEXT_PUBLIC_DEFAULT_LOCALE` sets the fallback used when neither matches a supported locale (default `en`): +The UI language follows each visitor's `Accept-Language` header and their stored preference. The fallback used when neither matches a supported locale is set at runtime with `DEFAULT_LOCALE` (or in the admin dashboard, Settings → Default Language), so the published Docker image works as is: ```env -NEXT_PUBLIC_DEFAULT_LOCALE=de +DEFAULT_LOCALE=pt ``` -Supported: `ar`, `ca`, `cs`, `da`, `de`, `en`, `es`, `fa`, `fr`, `he`, `hu`, `it`, `ja`, `ko`, `lv`, `nl`, `pl`, `pt`, `ro`, `ru`, `sk`, `tr`, `uk`, `zh`. An unsupported value falls back to `en`. +Supported: `ar`, `ca`, `cs`, `da`, `de`, `en`, `es`, `fa`, `fr`, `he`, `hu`, `it`, `ja`, `ko`, `lv`, `mn`, `nb`, `nl`, `pl`, `pt`, `ro`, `ru`, `sk`, `tr`, `uk`, `zh`, `zh-TW`. Regional tags map onto these catalogues (`pt-BR` → `pt`, which is Brazilian Portuguese). An unsupported value is ignored. -Like `NEXT_PUBLIC_BASE_PATH`, this is read at **build time**. To use it with the published Docker image, build your own: +Without it, the build-time `NEXT_PUBLIC_DEFAULT_LOCALE` applies (default `en`). Like `NEXT_PUBLIC_BASE_PATH`, that one is baked in at build time, which is also what the static Lite build uses: ```bash docker build --build-arg NEXT_PUBLIC_DEFAULT_LOCALE=de -t bulwark-webmail . diff --git a/app/(main)/admin/_tabs/settings.tsx b/app/(main)/admin/_tabs/settings.tsx index 66067ad6a..ae70382af 100644 --- a/app/(main)/admin/_tabs/settings.tsx +++ b/app/(main)/admin/_tabs/settings.tsx @@ -5,6 +5,8 @@ import { Save, RotateCcw, Loader2 } from '@/components/icons'; import { apiFetch } from '@/lib/browser-navigation'; import { JmapServersSection } from './_jmap-servers-section'; import type { JmapServerEntry } from '@/lib/admin/jmap-servers'; +import { locales, defaultLocale } from '@/i18n/routing'; +import { LOCALE_NAMES } from '@/i18n/locale-names'; interface ConfigEntry { value?: unknown; @@ -125,6 +127,17 @@ export function SettingsTab() { )} + @@ -259,15 +272,19 @@ function ToggleSetting({ label, description, configKey, value, source, onChange, ); } -function SelectSetting({ label, configKey, value, source, options, onChange, onRevert }: { - label: string; configKey: string; value: string; source?: string; options: string[]; +function SelectSetting({ label, description, configKey, value, source, options, optionLabels, onChange, onRevert }: { + label: string; description?: string; configKey: string; value: string; source?: string; options: string[]; + optionLabels?: Record; onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; }) { return (
-
- {label} - +
+
+ {label} + +
+ {description &&

{description}

}
{source === 'admin' && (