Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -440,14 +440,20 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# =============================================================================
# Internationalization
# =============================================================================
# These are build-time variables - to change them with the published Docker
# image, rebuild it with --build-arg (see README "Default UI locale").
# DEFAULT_LOCALE is read at runtime and works with the published Docker image
# (also settable in the admin dashboard, Settings -> Default Language). The
# NEXT_PUBLIC_* variables below are build-time: to change them with the
# published 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=pt
#
# Build-time equivalent of DEFAULT_LOCALE; applies when DEFAULT_LOCALE is
# unset and is the only one the static Lite build can use.
# NEXT_PUBLIC_DEFAULT_LOCALE=tr

# Locale prefix mode for URLs. Recommended "always" when proxying under a
Expand Down
2 changes: 1 addition & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
```

</details>
Expand All @@ -371,15 +372,15 @@ The split lets you mount the config volume read-only after the setup wizard comp
<details>
<summary>Default UI locale</summary>

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 .
Expand Down
29 changes: 23 additions & 6 deletions app/(main)/admin/_tabs/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -125,6 +127,17 @@ export function SettingsTab() {
)}
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
<SelectSetting
label="Default Language"
description="UI language for visitors whose browser language is not one Bulwark ships and who have not picked one yet. Browser language and the user's own choice always win."
configKey="defaultLocale"
value={(currentValue('defaultLocale') as string) ?? ''}
source={config.defaultLocale?.source}
options={['', ...locales]}
optionLabels={{ '': `Build default (${defaultLocale})`, ...LOCALE_NAMES }}
onChange={handleChange}
onRevert={handleRevert}
/>
<ToggleSetting label="Search Engine Indexing" description="Allow search engines to index this webmail. Off (the default) sends noindex/nofollow in the page head, recommended for private deployments." configKey="searchEngineIndexing" value={currentValue('searchEngineIndexing') as boolean} source={config.searchEngineIndexing?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>

Expand Down Expand Up @@ -259,23 +272,27 @@ 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<string, string>;
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) {
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<select
value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
{options.map(opt => <option key={opt} value={opt}>{optionLabels?.[opt] ?? opt}</option>)}
</select>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
Expand Down
2 changes: 2 additions & 0 deletions app/api/config/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { resolveDefaultLocale } from '@/lib/admin/default-locale';
import { getOauthScopes } from '@/lib/oauth/tokens';
import {
matchDomainBranding,
Expand Down Expand Up @@ -90,6 +91,7 @@ export async function GET(request: NextRequest) {
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
defaultLocale: resolveDefaultLocale(),
},
{
// Branding varies by host, so any cache between us and the browser
Expand Down
31 changes: 4 additions & 27 deletions components/ui/language-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,13 @@ import { ChevronDown } from '@/components/icons';
import { cn } from '@/lib/utils';
import { useMenuNavigation } from '@/hooks/use-menu-navigation';
import { flagComponents } from './flag-icons';
import { LOCALE_NAMES } from '@/i18n/locale-names';

// Picker order (roughly by script, then name); names come from the shared map.
const LANGUAGE_ORDER = ['ar', 'ca', 'cs', 'sk', 'da', 'de', 'en', 'fa', 'es', 'fr', 'he', 'it', 'hu', 'lv', 'nl', 'nb', 'pl', 'pt', 'ro', 'tr', 'ru', 'uk', 'ko', 'ja', 'mn', 'zh', 'zh-TW'] as const;
const languages = [
{ value: 'auto', label: 'Auto' },
{ value: 'ar', label: 'العربية' },
{ value: 'ca', label: 'Català' },
{ value: 'cs', label: 'Česky' },
{ value: 'sk', label: 'Slovenčina' },
{ value: 'da', label: 'Dansk' },
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
{ value: 'fa', label: 'فارسی' },
{ value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' },
{ value: 'he', label: 'עברית' },
{ value: 'it', label: 'Italiano' },
{ value: 'hu', label: 'Magyar' },
{ value: 'lv', label: 'Latviešu' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'nb', label: 'Norsk bokmål' },
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' },
{ value: 'ro', label: 'Română' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'ru', label: 'Русский' },
{ value: 'uk', label: 'Українська' },
{ value: 'ko', label: '한국어' },
{ value: 'ja', label: '日本語' },
{ value: 'mn', label: 'Монгол' },
{ value: 'zh', label: '简体中文' },
{ value: 'zh-TW', label: '繁體中文(台灣)' },
...LANGUAGE_ORDER.map((value) => ({ value, label: LOCALE_NAMES[value] })),
];

function FlagIcon({ locale }: { locale: string }) {
Expand Down
4 changes: 4 additions & 0 deletions hooks/use-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState, useEffect } from 'react';
import { usePolicyStore } from '@/stores/policy-store';
import { setRuntimeDefaultLocale } from '@/i18n/runtime-default-locale';
import { apiFetch } from '@/lib/browser-navigation';
import type { PublicJmapServerEntry } from '@/lib/admin/jmap-servers';
import { IS_LITE, IS_LITE_STALWART, LITE_CONFIG_PATH, withLiteBuildId } from '@/lib/lite';
Expand Down Expand Up @@ -42,6 +43,8 @@ export interface ConfigData {
jmapServerAutoPickByDomain: boolean;
embeddedMode: boolean;
parentOrigin: string;
/** Fallback UI locale resolved on the server; absent on the Lite build. */
defaultLocale?: string;
}

interface AppConfig extends ConfigData {
Expand Down Expand Up @@ -101,6 +104,7 @@ export async function fetchConfig(): Promise<ConfigData> {
configPromise = (IS_LITE ? fetchLiteConfig() : fetchServerConfig())
.then((data) => {
configCache = data;
setRuntimeDefaultLocale(data.defaultLocale);
// Fetch admin policy alongside config (non-blocking)
usePolicyStore.getState().fetchPolicy();
return data;
Expand Down
23 changes: 23 additions & 0 deletions i18n/__tests__/runtime-default-locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { afterEach, describe, expect, it } from 'vitest';
import { getDefaultLocale, setRuntimeDefaultLocale } from '../runtime-default-locale';
import { routing } from '../routing';

describe('runtime default locale', () => {
afterEach(() => setRuntimeDefaultLocale(undefined));

it('starts at the build-time default', () => {
expect(getDefaultLocale()).toBe(routing.defaultLocale);
});

it('follows the server-resolved value once /api/config arrives', () => {
setRuntimeDefaultLocale('pt');
expect(getDefaultLocale()).toBe('pt');
});

it('drops values that are not shipped locales', () => {
setRuntimeDefaultLocale('pt-BR');
expect(getDefaultLocale()).toBe(routing.defaultLocale);
setRuntimeDefaultLocale(42);
expect(getDefaultLocale()).toBe(routing.defaultLocale);
});
});
32 changes: 32 additions & 0 deletions i18n/locale-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Locale } from './routing';

/** Each shipped locale's name in its own language (autonym). */
export const LOCALE_NAMES: Record<Locale, string> = {
ar: 'العربية',
ca: 'Català',
cs: 'Česky',
sk: 'Slovenčina',
da: 'Dansk',
de: 'Deutsch',
en: 'English',
fa: 'فارسی',
es: 'Español',
fr: 'Français',
he: 'עברית',
it: 'Italiano',
hu: 'Magyar',
lv: 'Latviešu',
nl: 'Nederlands',
nb: 'Norsk bokmål',
pl: 'Polski',
pt: 'Português',
ro: 'Română',
tr: 'Türkçe',
ru: 'Русский',
uk: 'Українська',
ko: '한국어',
ja: '日本語',
mn: 'Монгол',
zh: '简体中文',
'zh-TW': '繁體中文(台灣)',
};
5 changes: 4 additions & 1 deletion i18n/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { mergeMessages } from './merge-messages';
import { localeFromAcceptLanguage } from './locale-matcher';
import { routing, type Locale } from './routing';
import { IS_LITE } from '@/lib/lite';
import { configManager } from '@/lib/admin/config-manager';
import { resolveDefaultLocale } from '@/lib/admin/default-locale';

export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
Expand All @@ -14,8 +16,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
// its locale via setRequestLocale, so this only covers the root layout.
locale = routing.defaultLocale;
} else {
await configManager.ensureLoaded();
const accept = (await headers()).get('accept-language');
locale = localeFromAcceptLanguage(accept, routing.locales) ?? routing.defaultLocale;
locale = localeFromAcceptLanguage(accept, routing.locales) ?? resolveDefaultLocale();
}
}

Expand Down
17 changes: 17 additions & 0 deletions i18n/runtime-default-locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { routing } from './routing';

// Client-side mirror of the server's resolved fallback locale (admin/env
// `defaultLocale`, see lib/admin/default-locale.ts). Filled in from
// /api/config; until then, and on the static Lite build, the build-time
// default applies.
let runtimeDefaultLocale: string | null = null;

export function setRuntimeDefaultLocale(locale: unknown): void {
runtimeDefaultLocale =
typeof locale === 'string' && (routing.locales as readonly string[]).includes(locale) ? locale : null;
}

/** The fallback locale in effect for this deployment. */
export function getDefaultLocale(): string {
return runtimeDefaultLocale ?? routing.defaultLocale;
}
16 changes: 16 additions & 0 deletions lib/__tests__/deep-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@ describe('appPath — locale prefix and mount prefix', () => {
expect(appPath('/calendar', 'fr')).toBe('/fr/calendar');
});

it('follows the runtime default locale in "as-needed" mode', async () => {
const { appPath } = await loadLinks({ localePrefix: 'as-needed' });
const { setRuntimeDefaultLocale } = await import('@/i18n/runtime-default-locale');
setRuntimeDefaultLocale('pt');
try {
// Explicit locales: the deployment default is the unprefixed one now.
expect(appPath('/calendar', 'pt')).toBe('/calendar');
expect(appPath('/calendar', 'en')).toBe('/en/calendar');
// No segment in the current URL means "default locale", not "en".
window.history.replaceState(null, '', '/mail');
expect(appPath('/calendar')).toBe('/calendar');
} finally {
setRuntimeDefaultLocale(undefined);
}
});

it('applies the mount prefix on subpath deployments', async () => {
const { appPath } = await loadLinks({ localePrefix: 'always', basePath: '/webmail' });
expect(appPath('/mail/thread/t1', 'de')).toBe('/webmail/de/mail/thread/t1');
Expand Down
36 changes: 36 additions & 0 deletions lib/admin/__tests__/default-locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const get = vi.hoisted(() => vi.fn());
vi.mock('../config-manager', () => ({ configManager: { get } }));

import { resolveDefaultLocale } from '../default-locale';
import { routing } from '@/i18n/routing';

describe('resolveDefaultLocale', () => {
beforeEach(() => get.mockReset());

it('uses the configured locale when it is one we ship', () => {
get.mockReturnValue('pt');
expect(resolveDefaultLocale()).toBe('pt');
expect(get).toHaveBeenCalledWith('defaultLocale', '');
});

it('falls back to the build-time default when unset', () => {
get.mockReturnValue('');
expect(resolveDefaultLocale()).toBe(routing.defaultLocale);
});

it('survives a malformed config.json value instead of throwing per request', () => {
get.mockReturnValue(null);
expect(resolveDefaultLocale()).toBe(routing.defaultLocale);
get.mockReturnValue(42);
expect(resolveDefaultLocale()).toBe(routing.defaultLocale);
});

it('ignores a locale we do not ship (regional tags are not catalogues)', () => {
get.mockReturnValue('pt-BR');
expect(resolveDefaultLocale()).toBe(routing.defaultLocale);
get.mockReturnValue(' xx ');
expect(resolveDefaultLocale()).toBe(routing.defaultLocale);
});
});
17 changes: 17 additions & 0 deletions lib/admin/default-locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { configManager } from './config-manager';
import { routing, type Locale } from '@/i18n/routing';

/**
* The locale a visitor gets when neither their cookie nor Accept-Language
* picks a supported one: the admin/env `defaultLocale` when set to a locale
* we ship, else the build-time default. Server only; configManager must have
* been loaded (every request path calls ensureLoaded() first).
*/
export function resolveDefaultLocale(): Locale {
// config.json is hand-editable, so the stored value may not be a string.
const raw = configManager.get<unknown>('defaultLocale', '');
const configured = typeof raw === 'string' ? raw.trim() : '';
return (routing.locales as readonly string[]).includes(configured)
? (configured as Locale)
: routing.defaultLocale;
}
5 changes: 5 additions & 0 deletions lib/admin/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Admin dashboard types
import { locales } from '@/i18n/routing';

/**
* Operator-authored admin record. Lives in admin.json under the config dir
Expand Down Expand Up @@ -263,6 +264,10 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
settingsSyncEnabled: { envVar: 'SETTINGS_SYNC_ENABLED', type: 'boolean', defaultValue: false },
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
// UI language for visitors whose Accept-Language matches nothing we ship
// and who have not picked one yet. Runtime counterpart of the build-time
// NEXT_PUBLIC_DEFAULT_LOCALE; '' defers to that (then 'en').
defaultLocale: { envVar: 'DEFAULT_LOCALE', type: 'enum', defaultValue: '', enumValues: ['', ...locales] },
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
// Connector links (connector.bulwarkmail.org): docs and the extension
Expand Down
Loading