Merge branch 'main' into ux/mail

This commit is contained in:
Linus Rath
2026-10-28 06:11:18 +01:00
102 changed files with 11374 additions and 2078 deletions
@@ -0,0 +1,53 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
// The admin reports what an action did in the app's toasts, like Settings,
// not in a banner under the page header.
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast, useToastStore: () => [] }));
let fail = false;
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'POST' && fail) {
return new Response(JSON.stringify({ error: 'not a URL' }), { status: 400, headers: { 'content-type': 'application/json' } });
}
return new Response(JSON.stringify({
consent: 'off', consentSource: 'file', endpoint: 'https://t.example', defaultEndpoint: 'https://t.example',
consentedAt: null, lastSentAt: null, nextScheduledAt: null, payloadPreview: {}, accountCounts: { total: 0, active7d: 0 },
}), { status: 200, headers: { 'content-type': 'application/json' } });
}),
withBasePath: (p: string) => p,
}));
vi.mock('next/link', () => ({ default: ({ children }: { children: React.ReactNode }) => children }));
import { TelemetryPage } from '../_pages/telemetry';
afterEach(() => {
fail = false;
toast.success.mockClear();
toast.error.mockClear();
useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null });
});
async function saveEndpoint(value: string) {
render(<TelemetryPage />);
fireEvent.change(await screen.findByLabelText('Collector URL'), { target: { value } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
}
describe('admin feedback', () => {
it('says a save worked in a success toast, with no banner', async () => {
await saveEndpoint('https://collector.example');
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Telemetry settings saved.'));
expect(screen.queryByRole('status')).toBeNull();
});
it('says a save failed in an error toast', async () => {
fail = true;
await saveEndpoint('nope');
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Couldn't save: Endpoint: not a URL"));
expect(screen.queryByRole('alert')).toBeNull();
});
});
@@ -28,8 +28,16 @@ describe('installVersion', () => {
it('asks before installing an older version and repeats with force when the admin agrees', async () => {
responses = [json({ error: 'Version 2.0.9 is older than the installed 2.1.0.', downgrade: { installedVersion: '2.1.0' } }, 409), json({ warnings: [] })];
const asked: string[] = [];
const result = await installVersion(BODY, (message) => { asked.push(message); return true; });
expect(asked).toEqual(['Version 2.0.9 is older than the installed 2.1.0.\n\nInstall the older version anyway?']);
const result = await installVersion(BODY, (q) => { asked.push(`${q.title} ${q.message}`); return true; });
expect(asked).toEqual(['Install the older version anyway? Version 2.0.9 is older than the installed 2.1.0.']);
expect(calls.map((c) => c.body.force)).toEqual([undefined, true]);
expect(result.ok).toBe(true);
});
// The question is the page's ConfirmDialog, not window.confirm.
it('waits for the answer of the page dialog', async () => {
responses = [json({ error: 'withdrawn', yanked: { reason: null } }, 409), json({ warnings: [] })];
const result = await installVersion(BODY, async (q) => q.title === 'Install it anyway?');
expect(calls.map((c) => c.body.force)).toEqual([undefined, true]);
expect(result.ok).toBe(true);
});
@@ -0,0 +1,84 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
// On an admin page with a save bar, every control waits for Save: the
// per-plugin switch and the telemetry consent used to save at once, next to
// policy switches that waited.
const calls: Array<{ url: string; method: string; body: Record<string, unknown> }> = [];
const routes: Record<string, unknown> = {};
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(async (url: string, init?: RequestInit) => {
const method = init?.method ?? 'GET';
calls.push({ url, method, body: JSON.parse(String(init?.body ?? '{}')) });
const data = method === 'GET' ? routes[url.split('?')[0]] ?? {} : { ok: true };
return new Response(JSON.stringify(data), { status: 200, headers: { 'content-type': 'application/json' } });
}),
withBasePath: (p: string) => p,
}));
vi.mock('next/link', () => ({ default: ({ children }: { children: React.ReactNode }) => children }));
vi.mock('../_pages/plugin-approvals-panel', () => ({ PluginApprovalsPanel: () => null }));
vi.mock('../_pages/use-extension-icons', () => ({ useExtensionIcons: () => () => null }));
import { PluginsPage } from '../_pages/plugins';
import { TelemetryPage } from '../_pages/telemetry';
const writes = () => calls.filter((c) => c.method !== 'GET');
beforeEach(() => {
calls.length = 0;
routes['/api/admin/plugins'] = [
{ id: 'jitsi', name: 'Jitsi', version: '1.0.0', enabled: true, author: 'x', description: '', permissions: [] },
];
routes['/api/admin/policy'] = { features: {}, themePolicy: {} };
routes['/api/admin/telemetry'] = {
consent: 'off', consentSource: 'file', endpoint: 'https://t.example', defaultEndpoint: 'https://t.example',
consentedAt: null, lastSentAt: null, nextScheduledAt: null, payloadPreview: {}, accountCounts: { total: 0, active7d: 0 },
};
});
afterEach(() => {
useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null });
});
describe('admin plugins page', () => {
it('turns a plugin off only when Save is pressed', async () => {
render(<PluginsPage />);
const toggle = await screen.findByRole('switch', { name: 'Jitsi enabled' });
fireEvent.click(toggle);
expect(toggle.getAttribute('aria-checked')).toBe('false');
expect(writes()).toEqual([]);
expect(useAdminUnsavedStore.getState().dirty).toEqual({ plugins: true });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(writes()).toEqual([
expect.objectContaining({ url: '/api/admin/plugins', method: 'PATCH', body: { id: 'jitsi', enabled: false } }),
]));
await waitFor(() => expect(useAdminUnsavedStore.getState().dirty).toEqual({}));
});
it('puts the switch back on Discard without saving', async () => {
render(<PluginsPage />);
const toggle = await screen.findByRole('switch', { name: 'Jitsi enabled' });
fireEvent.click(toggle);
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
expect(toggle.getAttribute('aria-checked')).toBe('true');
expect(writes()).toEqual([]);
});
});
describe('admin telemetry page', () => {
it('sends the consent only when Save is pressed', async () => {
render(<TelemetryPage />);
const consent = await screen.findByRole('switch', { name: 'Send anonymous heartbeats' });
fireEvent.click(consent);
expect(writes()).toEqual([]);
expect(screen.getByTestId('admin-save-bar')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(writes()).toEqual([
expect.objectContaining({ url: '/api/admin/telemetry', body: { action: 'set-consent', consent: 'on' } }),
]));
});
});
@@ -92,3 +92,45 @@ describe('admin unsaved changes guard (#1255)', () => {
expect(cleanEvent.defaultPrevented).toBe(false);
});
});
// Admin pages switch through pushState, so browser Back and the phone's back
// gesture are a same-document popstate. With unsaved edits they must ask too.
describe('admin unsaved changes guard on Back', () => {
const popped = () => new Promise<void>((resolve) => window.addEventListener('popstate', () => resolve(), { once: true }));
const back = async () => {
const done = popped();
window.history.back();
await act(async () => { await done; });
};
it('keeps the page and asks when Back is pressed with unsaved edits; Stay keeps guarding', async () => {
window.history.pushState(null, '', '/admin/policy');
window.history.pushState(null, '', '/admin/telemetry');
render(<><UnsavedChangesGuard /><DirtySection dirty /></>);
await back();
expect(window.location.pathname).toBe('/admin/telemetry');
expect(screen.getByRole('alertdialog')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Stay' }));
expect(useAdminUnsavedStore.getState().dirty).toEqual({ settings: true });
// Back asks again: the page is still guarded.
await back();
expect(window.location.pathname).toBe('/admin/telemetry');
expect(screen.getByRole('alertdialog')).toBeTruthy();
});
it('Leave goes back to the previous page', async () => {
window.history.pushState(null, '', '/admin/policy');
window.history.pushState(null, '', '/admin/telemetry');
render(<><UnsavedChangesGuard /><DirtySection dirty /></>);
await back();
const done = popped();
fireEvent.click(screen.getByRole('button', { name: 'Leave' }));
await act(async () => { await done; });
expect(window.location.pathname).toBe('/admin/policy');
expect(useAdminUnsavedStore.getState().dirty).toEqual({});
});
});
+11 -1
View File
@@ -7,6 +7,15 @@ import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
import { AdminShell } from '@/components/admin/admin-shell';
import { UnsavedChangesGuard } from './_unsaved-changes-guard';
import { ToastContainer } from '@/components/ui/toast';
import { useToastStore } from '@/stores/toast-store';
/** The app's toasts, which the admin reports its results in (R9). */
function AdminToasts() {
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
return <ToastContainer toasts={toasts} onClose={removeToast} />;
}
/**
* Gates the admin panel behind the admin session and draws its shell. The
@@ -107,12 +116,13 @@ export function AdminLayoutClient({ children }: { children: React.ReactNode }) {
// Don't gate the login page
if (pathname === '/admin/login') {
return <>{children}</>;
return <>{children}<AdminToasts /></>;
}
return (
<>
<UnsavedChangesGuard />
<AdminToasts />
<AdminShell
authenticated={authenticated === true}
isStalwartAutoLogin={isStalwartAutoLogin}
+2 -3
View File
@@ -10,7 +10,7 @@ import { SettingsSection, SettingItem, SectionNav } from '@/components/settings/
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { useAdminFeedback } from '@/components/admin/feedback';
import { ConfigTextItem, ConfigToggleItem, ConfigSelectItem, inputClass } from '@/components/admin/config-items';
import { cn } from '@/lib/utils';
@@ -30,7 +30,7 @@ export function AuthPage() {
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useFlash();
const setMessage = useAdminFeedback();
const [configReadOnly, setConfigReadOnly] = useState(false);
useEffect(() => {
@@ -163,7 +163,6 @@ export function AuthPage() {
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<Flash message={message} onDismiss={() => setMessage(null)} />
{setupOpen && (
<div
+23 -10
View File
@@ -16,9 +16,12 @@ import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Badge } from '@/components/admin/badge';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { useAdminFeedback } from '@/components/admin/feedback';
import { inputClass, RevertButton } from '@/components/admin/config-items';
import { cn } from '@/lib/utils';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
interface ConfigEntry {
value?: unknown;
@@ -76,7 +79,7 @@ export function BrandingPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState<string | null>(null);
const [message, setMessage] = useFlash();
const setMessage = useAdminFeedback();
const [selectedHost, setSelectedHost] = useState<string | null>(null);
const [addingHost, setAddingHost] = useState(false);
const [newHostInput, setNewHostInput] = useState('');
@@ -326,9 +329,14 @@ export function BrandingPage() {
async function handleDeleteDomain() {
if (!selectedHost) return;
if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) {
return;
}
const ok = await confirm({
title: `Remove the branding for ${selectedHost}?`,
message: 'The settings for this domain are removed. Files uploaded for it stay on disk.',
confirmText: 'Remove',
cancelText: 'Cancel',
variant: 'destructive',
});
if (!ok) return;
const next = domainEntries.filter(e => e.host !== selectedHost);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
@@ -346,17 +354,22 @@ export function BrandingPage() {
}
}
// Another domain's settings replace the form: the page's own leave prompt
// asks first when this one holds edits.
function handleScopeChange(host: string | null) {
if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return;
setSelectedHost(host);
setEdits({});
setMessage(null);
requestLeave(() => {
setSelectedHost(host);
setEdits({});
setMessage(null);
}, 'branding');
}
const hasEdits = Object.keys(edits).length > 0;
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
useAdminUnsavedChanges('branding', hasEdits);
const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
const { dialogProps: confirmProps, confirm } = useConfirmDialog();
if (loading) {
return (
@@ -418,7 +431,7 @@ export function BrandingPage() {
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<Flash message={message} onDismiss={() => setMessage(null)} />
<ConfirmDialog {...confirmProps} />
<div className="space-y-10">
<SectionNav />
+57 -22
View File
@@ -15,11 +15,13 @@ import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminLink } from '@/components/admin/admin-link';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Badge } from '@/components/admin/badge';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { useAdminFeedback } from '@/components/admin/feedback';
import { inputClass } from '@/components/admin/config-items';
import { ExtensionIcon } from '@/components/admin/extension-icon';
import { cn } from '@/lib/utils';
import { useExtensionIcons } from './use-extension-icons';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
export interface ConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
@@ -72,6 +74,7 @@ interface Props {
* can turn it off, what it may do, and the versions kept for a rollback.
*/
export function ExtensionDetailPage({ kind, id }: Props) {
const { dialogProps: confirmProps, confirm } = useConfirmDialog();
const words = KIND_LABEL[kind];
const listHref = adminPath(kind === 'plugin' ? 'plugins' : 'themes');
const [entry, setEntry] = useState<ExtensionEntry | null>(null);
@@ -81,7 +84,7 @@ export function ExtensionDetailPage({ kind, id }: Props) {
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
const [saving, setSaving] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useFlash();
const setMessage = useAdminFeedback();
const iconFor = useExtensionIcons();
const load = useCallback(async () => {
@@ -124,7 +127,12 @@ export function ExtensionDetailPage({ kind, id }: Props) {
}, [schema, savedValues]);
const dirty = Object.keys(savedValues).some((key) => (formValues[key] ?? '') !== savedValues[key]);
useAdminUnsavedChanges(`${kind}-config`, dirty);
// On/off and "force on" wait for Save like the settings below.
const [availabilityDraft, setAvailabilityDraft] = useState<{ enabled?: boolean; forceEnabled?: boolean }>({});
const shownForced = availabilityDraft.forceEnabled ?? !!entry?.forceEnabled;
const shownEnabled = shownForced || (availabilityDraft.enabled ?? !!entry?.enabled);
const availabilityDirty = !!entry && (shownEnabled !== entry.enabled || shownForced !== !!entry.forceEnabled);
useAdminUnsavedChanges(`${kind}-config`, dirty || availabilityDirty);
async function saveConfig() {
if (!schema) return;
@@ -202,21 +210,32 @@ export function ExtensionDetailPage({ kind, id }: Props) {
}
}
async function setEnabled(enabled: boolean) {
if (await patch({ enabled }, 'enabled')) setEntry((prev) => (prev ? { ...prev, enabled } : prev));
async function saveAvailability(): Promise<boolean> {
if (!entry) return true;
const body: Record<string, unknown> = {};
if (shownForced !== !!entry.forceEnabled) body.forceEnabled = shownForced;
if (shownEnabled !== entry.enabled) body.enabled = shownEnabled;
const ok = await patch(body, 'availability');
if (ok) setEntry((prev) => (prev ? { ...prev, enabled: shownEnabled, forceEnabled: shownForced } : prev));
setAvailabilityDraft({});
return ok;
}
async function setForced(forceEnabled: boolean) {
const body: Record<string, unknown> = { forceEnabled };
if (forceEnabled) body.enabled = true;
if (await patch(body, 'forced')) {
setEntry((prev) => (prev ? { ...prev, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : prev));
}
async function save() {
if (availabilityDirty && !(await saveAvailability())) return;
if (dirty) await saveConfig();
else setMessage({ type: 'success', text: 'Saved' });
}
async function rollback(version: string) {
if (!entry) return;
if (!confirm(`Put v${version} of "${entry.name}" back in place of v${entry.version}? The copy kept on this server is used.`)) return;
const ok = await confirm({
title: `Roll back to v${version}?`,
message: `"${entry.name}" v${version} replaces v${entry.version}. The copy kept on this server is used.`,
confirmText: 'Roll back',
cancelText: 'Cancel',
});
if (!ok) return;
setBusy(`rollback:${version}`);
setMessage(null);
try {
@@ -239,7 +258,14 @@ export function ExtensionDetailPage({ kind, id }: Props) {
async function remove() {
if (!entry) return;
if (!confirm(`Remove "${entry.name}" for every user? Its kept versions are removed too; this cannot be undone.`)) return;
const ok = await confirm({
title: `Remove "${entry.name}"?`,
message: 'It is removed for every user, with the versions kept on this server. This cannot be undone.',
confirmText: 'Remove',
cancelText: 'Cancel',
variant: 'destructive',
});
if (!ok) return;
setBusy('remove');
setMessage(null);
const res = await apiFetch(words.endpoint, {
@@ -310,8 +336,13 @@ export function ExtensionDetailPage({ kind, id }: Props) {
actions={(
<>
<label className="flex h-9 items-center gap-2.5 rounded-md border border-input bg-background px-3 text-sm">
<span className={cn('font-medium', entry.enabled ? 'text-foreground' : 'text-muted-foreground')}>{entry.enabled ? 'On' : 'Off'}</span>
<ToggleSwitch checked={entry.enabled} disabled={busy === 'enabled'} onChange={(v) => void setEnabled(v)} ariaLabel={`${entry.name} enabled`} />
<span className={cn('font-medium', shownEnabled ? 'text-foreground' : 'text-muted-foreground')}>{shownEnabled ? 'On' : 'Off'}</span>
<ToggleSwitch
checked={shownEnabled}
disabled={busy === 'availability' || shownForced}
onChange={(v) => { setMessage(null); setAvailabilityDraft((prev) => ({ ...prev, enabled: v })); }}
ariaLabel={`${entry.name} enabled`}
/>
</label>
<Button variant="outline" size="sm" className="gap-2 text-destructive hover:text-destructive" disabled={busy === 'remove'} onClick={() => void remove()}>
{busy === 'remove' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
@@ -323,7 +354,7 @@ export function ExtensionDetailPage({ kind, id }: Props) {
{entry.description && <p className="-mt-2 mb-6 text-sm text-foreground">{entry.description}</p>}
<Flash message={message} onDismiss={() => setMessage(null)} />
<ConfirmDialog {...confirmProps} />
<div className="space-y-10">
<SettingsSection id="availability" title="Availability" description={`Whether users get this ${words.one} and whether they can turn it off.`}>
@@ -333,7 +364,11 @@ export function ExtensionDetailPage({ kind, id }: Props) {
? 'Users cannot turn it off in their settings. Turning this on also enables the plugin.'
: 'Users cannot deactivate it. Turning this on also enables the theme.'}
>
<ToggleSwitch checked={!!entry.forceEnabled} disabled={busy === 'forced'} onChange={(v) => void setForced(v)} />
<ToggleSwitch
checked={shownForced}
disabled={busy === 'availability'}
onChange={(v) => { setMessage(null); setAvailabilityDraft((prev) => ({ ...prev, forceEnabled: v })); }}
/>
</SettingItem>
<SettingItem label="Source" description={entry.source?.kind === 'marketplace' ? 'Installed from the extension directory; updates show up in the marketplace.' : 'Installed from an uploaded ZIP.'}>
{entry.source?.kind === 'marketplace' ? (
@@ -407,11 +442,11 @@ export function ExtensionDetailPage({ kind, id }: Props) {
</div>
<AdminSaveBar
dirty={dirty}
saving={saving}
onSave={saveConfig}
onDiscard={() => setFormValues(savedValues)}
saveLabel="Save settings"
dirty={dirty || availabilityDirty}
saving={saving || busy === 'availability'}
onSave={() => void save()}
onDiscard={() => { setFormValues(savedValues); setAvailabilityDraft({}); }}
saveLabel="Save"
/>
</div>
);
+22 -6
View File
@@ -35,8 +35,10 @@ import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminLink } from '@/components/admin/admin-link';
import { AdminButtonLink } from '@/components/admin/button-link';
import { Badge } from '@/components/admin/badge';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { useAdminFeedback } from '@/components/admin/feedback';
import { installVersion } from './marketplace';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
@@ -129,13 +131,14 @@ const tdClass = 'px-4 py-2 align-top';
const smallButtonClass = 'h-8 gap-1 text-xs';
export function MarketplaceDetailPage({ slug }: { slug: string }) {
const { dialogProps: confirmProps, confirm } = useConfirmDialog();
const [data, setData] = useState<PreviewData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [uninstalling, setUninstalling] = useState(false);
const [message, setMessage] = useFlash();
const setMessage = useAdminFeedback();
const [showSource, setShowSource] = useState(false);
const [showManifest, setShowManifest] = useState(false);
// The version being previewed, from the install link (?version=) or a row
@@ -184,7 +187,7 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
const isUpdate = data.installed;
setBusy(`install:${version}`);
setMessage(null);
const result = await installVersion({ slug: data.extension.slug, version, type: data.extension.type });
const result = await installVersion({ slug: data.extension.slug, version, type: data.extension.type }, confirm);
setBusy(null);
if (!result.ok) {
setMessage({ type: 'error', text: result.error });
@@ -203,7 +206,13 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
async function handleRollback(version: string) {
if (!data) return;
if (!confirm(`Put v${version} of "${data.extension.name}" back in place of v${data.installedVersion}? The copy kept on this server is used; the directory is not asked.`)) return;
const ok = await confirm({
title: `Roll back to v${version}?`,
message: `"${data.extension.name}" v${version} replaces v${data.installedVersion}. The copy kept on this server is used; the directory is not asked.`,
confirmText: 'Roll back',
cancelText: 'Cancel',
});
if (!ok) return;
setBusy(`rollback:${version}`);
setMessage(null);
try {
@@ -228,7 +237,14 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
async function handleUninstall() {
if (!data) return;
if (!confirm(`Remove "${data.extension.name}"? Its kept versions are removed too; this cannot be undone.`)) return;
const ok = await confirm({
title: `Remove "${data.extension.name}"?`,
message: 'The versions kept on this server are removed too. This cannot be undone.',
confirmText: 'Remove',
cancelText: 'Cancel',
variant: 'destructive',
});
if (!ok) return;
setUninstalling(true);
setMessage(null);
@@ -413,7 +429,7 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
</div>
)}
<Flash message={message} onDismiss={() => setMessage(null)} />
<ConfirmDialog {...confirmProps} />
<div className="mb-6 space-y-3 empty:hidden">
{data.requestedVersionMissing && (
+23 -9
View File
@@ -18,8 +18,10 @@ import { AdminButtonLink } from '@/components/admin/button-link';
import { AdminList, AdminListRow } from '@/components/admin/list';
import { Badge } from '@/components/admin/badge';
import { EmptyState } from '@/components/admin/empty-state';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { useAdminFeedback } from '@/components/admin/feedback';
import { ConfigNote, inputClass } from '@/components/admin/config-items';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
@@ -78,12 +80,19 @@ const pillClass = (active: boolean) => cn(
/**
* Installs one version of an extension. A version the publisher withdrew,
* or one older than the installed version, comes back as 409; the admin is
* asked, and the install is repeated with `force` if they insist. Returns
* the error text to show, or null.
* asked (in the page's ConfirmDialog), and the install is repeated with
* `force` if they insist. Returns the error text to show, or null.
*/
export interface InstallQuestion {
title: string;
message: string;
confirmText: string;
cancelText: string;
}
export async function installVersion(
body: { slug: string; version: string; type: 'plugin' | 'theme' },
confirm: (message: string) => boolean = (m) => window.confirm(m),
confirm: (question: InstallQuestion) => boolean | Promise<boolean>,
): Promise<{ ok: true; warnings: string[]; signature: string | null; updated: boolean } | { ok: false; error: string }> {
const send = (force: boolean) => apiFetch('/api/admin/marketplace', {
method: 'POST',
@@ -95,10 +104,14 @@ export async function installVersion(
if (res.status === 409) {
const data = await res.json().catch(() => ({}));
if (data.yanked) {
if (!confirm(`${data.error}\n\nInstall it anyway?`)) return { ok: false, error: 'Not installed.' };
if (!(await confirm({ title: 'Install it anyway?', message: data.error, confirmText: 'Install', cancelText: 'Cancel' }))) {
return { ok: false, error: 'Not installed.' };
}
res = await send(true);
} else if (data.downgrade) {
if (!confirm(`${data.error}\n\nInstall the older version anyway?`)) return { ok: false, error: 'Not installed.' };
if (!(await confirm({ title: 'Install the older version anyway?', message: data.error, confirmText: 'Install', cancelText: 'Cancel' }))) {
return { ok: false, error: 'Not installed.' };
}
res = await send(true);
} else {
return { ok: false, error: data.error || 'Installation failed' };
@@ -113,6 +126,7 @@ export async function installVersion(
}
export function MarketplacePage() {
const { dialogProps: confirmProps, confirm } = useConfirmDialog();
const [extensions, setExtensions] = useState<MarketplaceListing[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
@@ -125,7 +139,7 @@ export function MarketplacePage() {
const [directoryUrl, setDirectoryUrl] = useState<string | null>(null);
const [channel, setChannel] = useState<'stable' | 'beta'>('stable');
const [installing, setInstalling] = useState<Set<string>>(new Set());
const [message, setMessage] = useFlash();
const setMessage = useAdminFeedback();
const [error, setError] = useState<string | null>(null);
const [updates, setUpdates] = useState<UpdatesResult | null>(null);
const [updatesLoading, setUpdatesLoading] = useState(true);
@@ -194,7 +208,7 @@ export function MarketplacePage() {
async function install(ext: { slug: string; name: string; type: 'plugin' | 'theme' }, version: string, isUpdate: boolean) {
markInstalling(ext.slug, true);
setMessage(null);
const result = await installVersion({ slug: ext.slug, version, type: ext.type });
const result = await installVersion({ slug: ext.slug, version, type: ext.type }, confirm);
markInstalling(ext.slug, false);
if (!result.ok) {
setMessage({ type: 'error', text: result.error });
@@ -256,7 +270,7 @@ export function MarketplacePage() {
)}
/>
<Flash message={message} onDismiss={() => setMessage(null)} />
<ConfirmDialog {...confirmProps} />
<div className="space-y-10">
{updates && noticeCount > 0 && (
+49 -41
View File
@@ -16,7 +16,7 @@ import { AdminList, AdminListRow } from '@/components/admin/list';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Badge } from '@/components/admin/badge';
import { EmptyState } from '@/components/admin/empty-state';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { useAdminFeedback } from '@/components/admin/feedback';
import { ExtensionIcon } from '@/components/admin/extension-icon';
import { PluginApprovalsPanel } from './plugin-approvals-panel';
import { useExtensionIcons } from './use-extension-icons';
@@ -26,8 +26,9 @@ export function PluginsPage() {
const [plugins, setPlugins] = useState<ExtensionEntry[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [message, setMessage] = useFlash();
const setMessage = useAdminFeedback();
// On/off per plugin waits for Save, like the policy switches below.
const [enabledDraft, setEnabledDraft] = useState<Record<string, boolean>>({});
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [savedPolicy, setSavedPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
@@ -61,16 +62,18 @@ export function PluginsPage() {
const pluginsUploadEnabled = features.pluginsUploadEnabled ?? true;
const requirePluginApproval = features.requirePluginApproval ?? true;
const policyDirty = JSON.stringify(policy.features) !== JSON.stringify(savedPolicy.features);
useAdminUnsavedChanges('plugins', policyDirty);
const isOn = (p: ExtensionEntry) => enabledDraft[p.id] ?? p.enabled;
const switched = plugins.filter((p) => isOn(p) !== p.enabled);
const dirty = policyDirty || switched.length > 0;
useAdminUnsavedChanges('plugins', dirty);
function setFeature(key: 'pluginsEnabled' | 'pluginsUploadEnabled' | 'requirePluginApproval', value: boolean) {
setPolicy((prev) => ({ ...prev, features: { ...prev.features, [key]: value } }));
setMessage(null);
}
async function savePolicy() {
setSavingPolicy(true);
setMessage(null);
/** Stores the policy; returns the error text, or null. */
async function savePolicy(): Promise<string | null> {
try {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
@@ -79,18 +82,34 @@ export function PluginsPage() {
});
if (res.ok) {
setSavedPolicy(policy);
setMessage({ type: 'success', text: 'Plugin policy saved. Users see the change on their next login.' });
} else {
const data = await res.json().catch(() => ({}));
setMessage({ type: 'error', text: data.error || 'Could not save the plugin policy' });
return null;
}
const data = await res.json().catch(() => ({}));
return data.error || 'Could not save the plugin policy';
} catch {
setMessage({ type: 'error', text: 'Could not save the plugin policy' });
} finally {
setSavingPolicy(false);
return 'Could not save the plugin policy';
}
}
async function save() {
setSavingPolicy(true);
setMessage(null);
const errors: string[] = [];
for (const p of switched) {
const error = await setEnabled(p.id, isOn(p));
if (error) errors.push(`${p.name}: ${error}`);
}
setEnabledDraft({});
if (policyDirty) {
const error = await savePolicy();
if (error) errors.push(error);
}
setSavingPolicy(false);
setMessage(errors.length > 0
? { type: 'error', text: `Couldn't save everything. ${errors.join('; ')}` }
: { type: 'success', text: 'Plugins saved. Users see the change on their next login.' });
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
@@ -116,7 +135,8 @@ export function PluginsPage() {
}
}
async function setEnabled(id: string, enabled: boolean): Promise<boolean> {
/** Turns one plugin on or off on the server; returns the error text, or null. */
async function setEnabled(id: string, enabled: boolean): Promise<string | null> {
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -124,30 +144,18 @@ export function PluginsPage() {
});
if (res.ok) {
setPlugins((prev) => prev.map((p) => (p.id === id ? { ...p, enabled } : p)));
return true;
return null;
}
const data = await res.json().catch(() => ({}));
setMessage({ type: 'error', text: data.error || 'Could not change the plugin' });
return false;
return data.error || 'Could not change the plugin';
}
async function setAll(enabled: boolean) {
const targets = plugins.filter((p) => p.enabled !== enabled);
if (targets.length === 0) return;
setBulkBusy(true);
function setAll(enabled: boolean) {
setEnabledDraft(Object.fromEntries(plugins.map((p) => [p.id, enabled])));
setMessage(null);
let failed = 0;
for (const p of targets) {
if (!(await setEnabled(p.id, enabled))) failed++;
}
await fetchPlugins();
setBulkBusy(false);
setMessage(failed === 0
? { type: 'success', text: `${targets.length} plugin${targets.length === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}` }
: { type: 'error', text: `${failed} plugin${failed === 1 ? '' : 's'} could not be ${enabled ? 'enabled' : 'disabled'}` });
}
const enabledCount = plugins.filter((p) => p.enabled).length;
const enabledCount = plugins.filter(isOn).length;
return (
<div>
@@ -176,7 +184,6 @@ export function PluginsPage() {
)}
/>
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="space-y-10">
<PluginApprovalsPanel />
@@ -201,10 +208,10 @@ export function PluginsPage() {
<>
{plugins.length > 1 && (
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === plugins.length} onClick={() => setAll(true)}>
<Button variant="ghost" size="xs" className="gap-1.5 text-xs" disabled={savingPolicy || enabledCount === plugins.length} onClick={() => setAll(true)}>
<Power className="h-3.5 w-3.5" /> Enable all
</Button>
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === 0} onClick={() => setAll(false)}>
<Button variant="ghost" size="xs" className="gap-1.5 text-xs" disabled={savingPolicy || enabledCount === 0} onClick={() => setAll(false)}>
<PowerOff className="h-3.5 w-3.5" /> Disable all
</Button>
</div>
@@ -244,8 +251,9 @@ export function PluginsPage() {
)}
control={(
<ToggleSwitch
checked={plugin.enabled}
onChange={(checked) => { setMessage(null); void setEnabled(plugin.id, checked); }}
checked={isOn(plugin)}
disabled={savingPolicy}
onChange={(checked) => { setMessage(null); setEnabledDraft((prev) => ({ ...prev, [plugin.id]: checked })); }}
ariaLabel={`${plugin.name} enabled`}
/>
)}
@@ -274,11 +282,11 @@ export function PluginsPage() {
</div>
<AdminSaveBar
dirty={policyDirty}
dirty={dirty}
saving={savingPolicy}
onSave={savePolicy}
onDiscard={() => setPolicy(savedPolicy)}
saveLabel="Save policy"
onSave={() => void save()}
onDiscard={() => { setPolicy(savedPolicy); setEnabledDraft({}); }}
saveLabel="Save"
note="Users see the change on their next login"
/>
</div>
+2 -3
View File
@@ -21,7 +21,7 @@ import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Badge } from '@/components/admin/badge';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { useAdminFeedback } from '@/components/admin/feedback';
import { inputClass } from '@/components/admin/config-items';
// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
@@ -98,7 +98,7 @@ export function PolicyPage() {
const [savedPolicy, setSavedPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useFlash();
const setMessage = useAdminFeedback();
const [dirty, setDirty] = useState(false);
useEffect(() => { fetchPolicy(); }, []);
@@ -291,7 +291,6 @@ export function PolicyPage() {
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<Flash message={message} onDismiss={() => setMessage(null)} />
{loading ? (
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
+2 -3
View File
@@ -8,7 +8,7 @@ import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { SettingsSection, SectionNav } from '@/components/settings/settings-section';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { useAdminFeedback } from '@/components/admin/feedback';
import { ConfigTextItem, ConfigToggleItem, ConfigSelectItem, ConfigNote } from '@/components/admin/config-items';
import { JmapServersSection } from './_jmap-servers-section';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
@@ -26,7 +26,7 @@ export function SettingsPage() {
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useFlash();
const setMessage = useAdminFeedback();
useEffect(() => {
fetchConfig();
@@ -111,7 +111,6 @@ export function SettingsPage() {
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="space-y-10">
<SectionNav />
+41 -39
View File
@@ -6,7 +6,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/setting
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { useAdminFeedback } from '@/components/admin/feedback';
import { ConfigNote, inputClass } from '@/components/admin/config-items';
import { adminPageDef } from '@/lib/admin/admin-nav';
import { apiFetch } from '@/lib/browser-navigation';
@@ -45,10 +45,15 @@ export function TelemetryPage() {
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [endpointDraft, setEndpointDraft] = useState('');
const [message, setMessage] = useFlash();
const setMessage = useAdminFeedback();
// The heartbeat switch waits for Save like the endpoint (one page, one
// save bar).
const [consentDraft, setConsentDraft] = useState<'on' | 'off' | null>(null);
const endpointDirty = status !== null && endpointDraft !== status.endpoint;
useAdminUnsavedChanges('telemetry', endpointDirty);
const consentDirty = status !== null && consentDraft !== null && consentDraft !== status.consent;
const dirty = endpointDirty || consentDirty;
useAdminUnsavedChanges('telemetry', dirty);
async function refresh(): Promise<void> {
setLoading(true);
@@ -66,38 +71,35 @@ export function TelemetryPage() {
}
useEffect(() => { void refresh(); }, []);
async function setConsent(consent: 'on' | 'off'): Promise<void> {
setBusy('consent');
setMessage(null);
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-consent', consent }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
setMessage({ type: 'error', text: j.error ?? 'failed' });
}
await refresh();
} finally { setBusy(null); }
/** Sends one change; returns the error text, or null. */
async function post(body: Record<string, unknown>): Promise<string | null> {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (r.ok) return null;
const j = (await r.json().catch(() => ({}))) as { error?: string };
return j.error ?? 'failed';
}
async function saveEndpoint(): Promise<void> {
setBusy('endpoint');
async function save(): Promise<void> {
setBusy('save');
setMessage(null);
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
setMessage({ type: 'error', text: j.error ?? 'failed' });
} else {
setMessage({ type: 'success', text: 'Endpoint saved.' });
const errors: string[] = [];
if (consentDirty && consentDraft) {
const error = await post({ action: 'set-consent', consent: consentDraft });
if (error) errors.push(`Heartbeats: ${error}`);
}
if (endpointDirty) {
const error = await post({ action: 'set-endpoint', endpoint: endpointDraft });
if (error) errors.push(`Endpoint: ${error}`);
}
setConsentDraft(null);
setMessage(errors.length > 0
? { type: 'error', text: `Couldn't save: ${errors.join('; ')}` }
: { type: 'success', text: 'Telemetry settings saved.' });
await refresh();
} finally { setBusy(null); }
}
@@ -134,6 +136,7 @@ export function TelemetryPage() {
const envOverridden = status.consentSource === 'env';
const isOn = status.consent === 'on';
const shownOn = (consentDraft ?? status.consent) === 'on';
const consentDescription = [
status.consent === 'pending' && 'Initialising - no heartbeats sent yet.',
@@ -146,7 +149,6 @@ export function TelemetryPage() {
<div>
{header}
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="space-y-10">
<ConfigNote>
@@ -167,9 +169,9 @@ export function TelemetryPage() {
<SettingsSection id="status" title="Status">
<SettingItem label="Send anonymous heartbeats" description={consentDescription}>
<ToggleSwitch
checked={isOn}
disabled={busy === 'consent' || envOverridden}
onChange={(checked) => void setConsent(checked ? 'on' : 'off')}
checked={shownOn}
disabled={busy === 'save' || envOverridden}
onChange={(checked) => { setConsentDraft(checked ? 'on' : 'off'); setMessage(null); }}
/>
</SettingItem>
<SettingItem label="Last sent">
@@ -240,11 +242,11 @@ export function TelemetryPage() {
</div>
<AdminSaveBar
dirty={endpointDirty}
saving={busy === 'endpoint'}
onSave={() => void saveEndpoint()}
onDiscard={() => setEndpointDraft(status.endpoint)}
saveLabel="Save endpoint"
dirty={dirty}
saving={busy === 'save'}
onSave={() => void save()}
onDiscard={() => { setEndpointDraft(status.endpoint); setConsentDraft(null); }}
saveLabel="Save"
/>
</div>
);
+49 -41
View File
@@ -16,7 +16,7 @@ import { AdminList, AdminListRow } from '@/components/admin/list';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Badge } from '@/components/admin/badge';
import { EmptyState } from '@/components/admin/empty-state';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { useAdminFeedback } from '@/components/admin/feedback';
import { inputClass } from '@/components/admin/config-items';
import { cn } from '@/lib/utils';
import { ExtensionIcon } from '@/components/admin/extension-icon';
@@ -42,8 +42,9 @@ export function ThemesPage() {
const iconFor = useExtensionIcons();
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [message, setMessage] = useFlash();
const setMessage = useAdminFeedback();
// On/off per theme waits for Save, like the policy switches below.
const [enabledDraft, setEnabledDraft] = useState<Record<string, boolean>>({});
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>(() => withThemeDefaults({ ...DEFAULT_POLICY }));
const [savedPolicy, setSavedPolicy] = useState<SettingsPolicy>(() => withThemeDefaults({ ...DEFAULT_POLICY }));
@@ -74,7 +75,10 @@ export function ThemesPage() {
const policyDirty =
JSON.stringify(policy.themePolicy) !== JSON.stringify(savedPolicy.themePolicy) ||
JSON.stringify(policy.features) !== JSON.stringify(savedPolicy.features);
useAdminUnsavedChanges('themes', policyDirty);
const isOn = (t: ExtensionEntry) => enabledDraft[t.id] ?? t.enabled;
const switched = themes.filter((t) => isOn(t) !== t.enabled);
const dirty = policyDirty || switched.length > 0;
useAdminUnsavedChanges('themes', dirty);
function setFeature(key: 'themesEnabled' | 'userThemesEnabled', value: boolean) {
setPolicy(prev => ({ ...prev, features: { ...prev.features, [key]: value } }));
@@ -141,9 +145,8 @@ export function ThemesPage() {
setMessage(null);
}
async function savePolicy() {
setSavingPolicy(true);
setMessage(null);
/** Stores the policy; returns the error text, or null. */
async function savePolicy(): Promise<string | null> {
try {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
@@ -152,18 +155,34 @@ export function ThemesPage() {
});
if (res.ok) {
setSavedPolicy(policy);
setMessage({ type: 'success', text: 'Theme policy saved. Users see the change on their next login.' });
} else {
const data = await res.json().catch(() => ({}));
setMessage({ type: 'error', text: data.error || 'Could not save the theme policy' });
return null;
}
const data = await res.json().catch(() => ({}));
return data.error || 'Could not save the theme policy';
} catch {
setMessage({ type: 'error', text: 'Could not save the theme policy' });
} finally {
setSavingPolicy(false);
return 'Could not save the theme policy';
}
}
async function save() {
setSavingPolicy(true);
setMessage(null);
const errors: string[] = [];
for (const t of switched) {
const error = await setEnabled(t.id, isOn(t));
if (error) errors.push(`${t.name}: ${error}`);
}
setEnabledDraft({});
if (policyDirty) {
const error = await savePolicy();
if (error) errors.push(error);
}
setSavingPolicy(false);
setMessage(errors.length > 0
? { type: 'error', text: `Couldn't save everything. ${errors.join('; ')}` }
: { type: 'success', text: 'Themes saved. Users see the change on their next login.' });
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
@@ -189,7 +208,8 @@ export function ThemesPage() {
}
}
async function setEnabled(id: string, enabled: boolean): Promise<boolean> {
/** Turns one theme on or off on the server; returns the error text, or null. */
async function setEnabled(id: string, enabled: boolean): Promise<string | null> {
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -197,30 +217,18 @@ export function ThemesPage() {
});
if (res.ok) {
setThemes(prev => prev.map(t => (t.id === id ? { ...t, enabled } : t)));
return true;
return null;
}
const data = await res.json().catch(() => ({}));
setMessage({ type: 'error', text: data.error || 'Could not change the theme' });
return false;
return data.error || 'Could not change the theme';
}
async function setAll(enabled: boolean) {
const targets = themes.filter(t => t.enabled !== enabled);
if (targets.length === 0) return;
setBulkBusy(true);
function setAll(enabled: boolean) {
setEnabledDraft(Object.fromEntries(themes.map((t) => [t.id, enabled])));
setMessage(null);
let failed = 0;
for (const t of targets) {
if (!(await setEnabled(t.id, enabled))) failed++;
}
await fetchThemes();
setBulkBusy(false);
setMessage(failed === 0
? { type: 'success', text: `${targets.length} theme${targets.length === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}` }
: { type: 'error', text: `${failed} theme${failed === 1 ? '' : 's'} could not be ${enabled ? 'enabled' : 'disabled'}` });
}
const enabledCount = themes.filter(t => t.enabled).length;
const enabledCount = themes.filter(isOn).length;
const themesEnabled = policy.features.themesEnabled ?? true;
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
const lockColorMode = policy.themePolicy?.lockColorMode === true;
@@ -254,7 +262,6 @@ export function ThemesPage() {
)}
/>
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="space-y-10">
<SettingsSection
@@ -277,10 +284,10 @@ export function ThemesPage() {
<>
{themes.length > 1 && (
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === themes.length} onClick={() => setAll(true)}>
<Button variant="ghost" size="xs" className="gap-1.5 text-xs" disabled={savingPolicy || enabledCount === themes.length} onClick={() => setAll(true)}>
<Power className="h-3.5 w-3.5" /> Enable all
</Button>
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === 0} onClick={() => setAll(false)}>
<Button variant="ghost" size="xs" className="gap-1.5 text-xs" disabled={savingPolicy || enabledCount === 0} onClick={() => setAll(false)}>
<PowerOff className="h-3.5 w-3.5" /> Disable all
</Button>
</div>
@@ -310,8 +317,9 @@ export function ThemesPage() {
)}
control={(
<ToggleSwitch
checked={theme.enabled}
onChange={(checked) => { setMessage(null); void setEnabled(theme.id, checked); }}
checked={isOn(theme)}
disabled={savingPolicy}
onChange={(checked) => { setMessage(null); setEnabledDraft((prev) => ({ ...prev, [theme.id]: checked })); }}
ariaLabel={`${theme.name} enabled`}
/>
)}
@@ -392,11 +400,11 @@ export function ThemesPage() {
</div>
<AdminSaveBar
dirty={policyDirty}
dirty={dirty}
saving={savingPolicy}
onSave={savePolicy}
onDiscard={() => setPolicy(savedPolicy)}
saveLabel="Save policy"
onSave={() => void save()}
onDiscard={() => { setPolicy(savedPolicy); setEnabledDraft({}); }}
saveLabel="Save"
note="Users see the change on their next login"
/>
</div>
+2 -3
View File
@@ -13,7 +13,7 @@ import { SettingsSection, SettingItem } from '@/components/settings/settings-sec
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { Badge, type BadgeTone } from '@/components/admin/badge';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { useAdminFeedback } from '@/components/admin/feedback';
import { adminPageDef } from '@/lib/admin/admin-nav';
import { apiFetch } from '@/lib/browser-navigation';
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
@@ -64,7 +64,7 @@ export function VersionPage() {
const [data, setData] = useState<VersionAdminStatus | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [message, setMessage] = useFlash();
const setMessage = useAdminFeedback();
async function refresh(): Promise<void> {
setLoading(true);
@@ -133,7 +133,6 @@ export function VersionPage() {
<div>
{header}
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="space-y-10">
<SettingsSection id="status" title="Status">
+72 -52
View File
@@ -1,16 +1,42 @@
'use client';
import { useEffect, useId } from 'react';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from '@/components/icons';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { useEffect, useRef } from 'react';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useAdminUnsavedStore, selectHasUnsaved } from '@/stores/admin-unsaved-store';
// Marks the history entry the guard puts on top of a page with unsaved
// edits. It carries a copy of the page entry's own state, so Next's router
// treats it as that page.
const GUARD_KEY = '__adminUnsavedGuard';
function onGuardEntry(): boolean {
const state = window.history.state as Record<string, unknown> | null;
return !!state && state[GUARD_KEY] === true;
}
// Marks the page's own entry under the copy, so Back onto it is told apart
// from a later visit to the same page.
const GUARDED_KEY = '__adminUnsavedGuarded';
/** Puts the copy on top; returns the mark left on the page's entry under it. */
function pushGuardEntry(): string {
const state = (window.history.state as Record<string, unknown> | null) ?? {};
const mark = `${Date.now()}-${Math.random()}`;
window.history.replaceState({ ...state, [GUARDED_KEY]: mark }, '', window.location.href);
window.history.pushState({ ...state, [GUARDED_KEY]: mark, [GUARD_KEY]: true }, '', window.location.href);
return mark;
}
function guardedMark(): unknown {
const state = window.history.state as Record<string, unknown> | null;
return state?.[GUARDED_KEY];
}
/**
* Asks before the admin leaves a section with unsaved edits (#1255). Tab
* buttons and sign out go through requestLeave themselves; links anywhere on
* the page are caught here, and closing or reloading the tab gets the
* browser's own prompt.
* the page are caught here, so are browser Back and the phone's back
* gesture, and closing or reloading the tab gets the browser's own prompt.
*/
export function UnsavedChangesGuard() {
const hasUnsaved = useAdminUnsavedStore(selectHasUnsaved);
@@ -19,22 +45,39 @@ export function UnsavedChangesGuard() {
const confirmLeave = useAdminUnsavedStore((s) => s.confirmLeave);
const cancelLeave = useAdminUnsavedStore((s) => s.cancelLeave);
const isOpen = pendingLeave !== null;
const id = useId();
const dialogRef = useFocusTrap({ isActive: isOpen, restoreFocus: true });
// On document, not the dialog, so Escape means "Stay" wherever focus is.
// Back and the back gesture are a same-document popstate (the pages switch
// through pushState), which can't be cancelled. So while something is
// unsaved a copy of the page's entry sits on top: Back only takes that
// one off, the page stays, and the prompt asks. Stay puts the copy back;
// Leave goes back past the page for real.
// The mark on the page entry the copy was put on top of.
const guard = useRef<string | null>(null);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
cancelLeave();
if (!hasUnsaved || onGuardEntry()) return;
guard.current = pushGuardEntry();
}, [hasUnsaved]);
useEffect(() => {
const onPopState = () => {
// Landing on the page's own entry under the copy: Back took the copy off.
if (onGuardEntry() || !guard.current || guardedMark() !== guard.current) return;
if (!selectHasUnsaved(useAdminUnsavedStore.getState())) {
// A copy left over from edits saved or discarded since: Back goes
// on as asked.
guard.current = null;
window.history.back();
return;
}
guard.current = pushGuardEntry();
useAdminUnsavedStore.getState().requestLeave(() => {
guard.current = null;
window.history.go(-2);
});
};
document.addEventListener('keydown', onKeyDown, true);
return () => document.removeEventListener('keydown', onKeyDown, true);
}, [isOpen, cancelLeave]);
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
useEffect(() => {
if (!hasUnsaved) return;
@@ -76,39 +119,16 @@ export function UnsavedChangesGuard() {
return () => document.removeEventListener('click', onClick, true);
}, [hasUnsaved, requestLeave]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
onMouseDown={(e) => { if (e.target === e.currentTarget) cancelLeave(); }}
>
<div
ref={dialogRef}
role="alertdialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-message`}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6 flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-destructive" />
</div>
<div className="flex-1 min-w-0">
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">
Unsaved changes
</h2>
<p id={`${id}-message`} className="mt-2 text-sm text-muted-foreground">
You have changes that haven&apos;t been saved. If you leave now, they will be lost.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={cancelLeave}>Stay</Button>
<Button variant="destructive" onClick={confirmLeave} className="shadow-sm">Leave</Button>
</div>
</div>
</div>
<ConfirmDialog
isOpen={isOpen}
onClose={cancelLeave}
onConfirm={confirmLeave}
title="Unsaved changes"
message="You have changes that haven't been saved. If you leave now, they will be lost."
cancelText="Stay"
confirmText="Leave"
variant="destructive"
/>
);
}
+2 -3
View File
@@ -6,7 +6,7 @@ import { Loader2 } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { useAdminFeedback } from '@/components/admin/feedback';
import { inputClass } from '@/components/admin/config-items';
import { cn } from '@/lib/utils';
@@ -15,7 +15,7 @@ export default function ChangePasswordPage() {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [message, setMessage] = useFlash();
const setMessage = useAdminFeedback();
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
@@ -61,7 +61,6 @@ export default function ChangePasswordPage() {
<div className="max-w-md">
<AdminPageHeader title="Change password" description="The password for this admin panel. At least 8 characters." back={{ href: '/admin', label: 'Overview' }} />
<Flash message={message} onDismiss={() => setMessage(null)} />
<form onSubmit={handleSubmit} className="space-y-4">
{fields.map((field) => (
@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { NextIntlClientProvider } from 'next-intl';
import de from '@/locales/de/common.json';
import en from '@/locales/en/common.json';
// The setup wizard follows the browser's language like the login page,
// instead of being English only.
vi.unmock('next-intl');
vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: vi.fn(), push: vi.fn() }) }));
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(async () => new Response(JSON.stringify({ state: 'bootstrap', authenticated: false, readOnly: false, partialConfig: null }), {
status: 200, headers: { 'content-type': 'application/json' },
})),
getPathPrefix: () => '',
withBasePath: (p: string) => p,
}));
vi.mock('@/lib/setup/url-token', () => ({ takeSetupTokenFromUrl: () => '' }));
const { default: SetupWizardPage } = await import('../page');
function renderIn(locale: string, messages: Record<string, unknown>) {
return render(
<NextIntlClientProvider locale={locale} messages={messages}>
<SetupWizardPage />
</NextIntlClientProvider>,
);
}
describe('setup wizard language', () => {
it('shows the welcome step in German', async () => {
renderIn('de', de);
expect(await screen.findByText('Einrichtungstoken')).toBeTruthy();
expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('Einrichtung von Bulwark Webmail');
expect(screen.getByRole('button', { name: 'Fortfahren' })).toBeTruthy();
expect(screen.queryByText('Setup token')).toBeNull();
});
it('shows it in English for English', async () => {
renderIn('en', en);
expect(await screen.findByText('Setup token')).toBeTruthy();
expect(screen.getByText('Server')).toBeTruthy();
});
});
+22 -2
View File
@@ -1,5 +1,25 @@
import type { ReactNode } from 'react';
import { getLocale } from 'next-intl/server';
import { IntlProvider } from '@/components/providers/intl-provider';
import { defaultLocale } from '@/i18n/routing';
export default function SetupLayout({ children }: { children: ReactNode }) {
return <div className="min-h-screen bg-background text-foreground">{children}</div>;
/**
* /setup sits outside the [locale] tree, so it loads the message catalog
* itself, like /admin. The locale comes from the request (cookie or
* Accept-Language) and the browser's language takes over on the client,
* as on the login page.
*/
export default async function SetupLayout({ children }: { children: ReactNode }) {
const locale = await getLocale();
let messages;
try {
messages = (await import(`@/locales/${locale}/common.json`)).default;
} catch {
messages = (await import(`@/locales/${defaultLocale}/common.json`)).default;
}
return (
<IntlProvider locale={locale} messages={messages}>
<div className="min-h-screen bg-background text-foreground">{children}</div>
</IntlProvider>
);
}
+212 -198
View File
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,7 @@ vi.mock('@/lib/browser-navigation', () => ({
}));
import { AdminShell } from '../admin-shell';
import { useUpdateStore } from '@/stores/update-store';
function setDesktop(desktop: boolean) {
window.matchMedia = vi.fn((query: string) => ({
@@ -56,4 +57,16 @@ describe('AdminShell', () => {
expect(screen.getByText('Sign out of admin')).toBeTruthy();
expect(screen.getByText('Change password')).toBeTruthy();
});
// The dot next to Version was a span with aria-label, which screen
// readers don't announce.
it('announces a waiting update on the Version entry', () => {
useUpdateStore.setState({
status: { updateAvailable: true, severity: 'minor', current: process.env.NEXT_PUBLIC_APP_VERSION ?? '', latest: '9.9.9' },
startPolling: () => {},
} as never);
render(shell({ authenticated: true, isStalwartAutoLogin: false }));
const dot = screen.getAllByRole('img', { name: 'Update available' })[0];
expect(dot.getAttribute('title')).toBe('Update available');
});
});
@@ -1,31 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Flash } from '../flash-message';
describe('Flash', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('renders nothing without a message', () => {
const { container } = render(<Flash message={null} onDismiss={() => {}} />);
expect(container.firstChild).toBeNull();
});
it('shows an error as an alert until dismissed', () => {
const onDismiss = vi.fn();
render(<Flash message={{ type: 'error', text: 'Save failed' }} onDismiss={onDismiss} />);
expect(screen.getByRole('alert').textContent).toContain('Save failed');
act(() => { vi.advanceTimersByTime(10_000); });
expect(onDismiss).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it('lets a success go away by itself', () => {
const onDismiss = vi.fn();
render(<Flash message={{ type: 'success', text: 'Saved' }} onDismiss={onDismiss} />);
expect(screen.getByRole('status').textContent).toContain('Saved');
act(() => { vi.advanceTimersByTime(6_000); });
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
+6 -1
View File
@@ -181,10 +181,15 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
);
}
if (id === 'version' && hasUpdate) {
// A dot only: role="img" makes screen readers announce its label, which
// they skip on a plain span; the title shows it on hover.
const label = updateImportant ? 'Important update available' : 'Update available';
return (
<span
role="img"
aria-label={label}
title={label}
className={cn('ms-auto h-2 w-2 rounded-full', updateImportant ? 'bg-red-500' : 'bg-amber-500')}
aria-label={updateImportant ? 'Important update available' : 'Update available'}
/>
);
}
+20
View File
@@ -0,0 +1,20 @@
'use client';
import { useCallback } from 'react';
import { toast } from '@/stores/toast-store';
export type AdminFeedback = { type: 'success' | 'error'; text: string } | null;
/**
* Reports the outcome of an admin action in a toast, the same feedback the
* rest of the app uses (R9). A success goes away by itself, an error stays
* longer. `null` says nothing: it used to clear the page banner this
* replaces, and pages still pass it before an action starts.
*/
export function useAdminFeedback(): (feedback: AdminFeedback) => void {
return useCallback((feedback: AdminFeedback) => {
if (!feedback) return;
if (feedback.type === 'success') toast.success(feedback.text);
else toast.error(feedback.text);
}, []);
}
-57
View File
@@ -1,57 +0,0 @@
'use client';
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
import { CheckCircle2, AlertTriangle, X } from '@/components/icons';
import { cn } from '@/lib/utils';
export type FlashMessage = { type: 'success' | 'error'; text: string } | null;
/** Page-level feedback state; the message is shown with `<Flash>`. */
export function useFlash(): [FlashMessage, Dispatch<SetStateAction<FlashMessage>>] {
return useState<FlashMessage>(null);
}
interface FlashProps {
message: FlashMessage;
onDismiss: () => void;
className?: string;
}
/**
* The outcome of the last action, under the page header. A success goes
* away by itself; an error stays until dismissed or the next action.
*/
export function Flash({ message, onDismiss, className }: FlashProps) {
useEffect(() => {
if (message?.type !== 'success') return;
const timer = setTimeout(onDismiss, 6000);
return () => clearTimeout(timer);
}, [message, onDismiss]);
if (!message) return null;
const success = message.type === 'success';
const Icon = success ? CheckCircle2 : AlertTriangle;
return (
<div
role={success ? 'status' : 'alert'}
className={cn(
'mb-6 flex items-start gap-2.5 rounded-md border px-3 py-2.5 text-sm',
success
? 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'border-destructive/30 bg-destructive/10 text-destructive',
className,
)}
>
<Icon className="w-4 h-4 shrink-0 mt-0.5" />
<span className="flex-1 min-w-0 break-words">{message.text}</span>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="shrink-0 -me-1 rounded p-0.5 opacity-70 hover:opacity-100 transition-opacity"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
@@ -102,7 +102,7 @@ describe('filter rule period (dialog)', () => {
fireEvent.click(toggle());
save();
expect(onSave).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledWith('validation_period_empty');
expect(screen.getByRole('alert').textContent).toBe('validation_period_empty');
});
it('will not take a date without its time for an open end', () => {
@@ -114,7 +114,7 @@ describe('filter rule period (dialog)', () => {
Object.defineProperty(endInput(), 'validity', { value: { badInput: true }, configurable: true });
save();
expect(onSave).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledWith('validation_period_invalid');
expect(screen.getByRole('alert').textContent).toBe('validation_period_invalid');
});
it('refuses a year it cannot store, rather than leave that end open', () => {
@@ -124,15 +124,14 @@ describe('filter rule period (dialog)', () => {
fireEvent.change(endInput(), { target: { value: '20266-10-16T18:00' } });
save();
expect(onSave).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledWith('validation_period_invalid');
expect(screen.getByRole('alert').textContent).toBe('validation_period_invalid');
// The last minute of 9999 in New York is year 10000 in UTC already.
toastError.mockClear();
useSettingsStore.setState({ timeZone: 'America/New_York' });
fireEvent.change(endInput(), { target: { value: '9999-12-31T23:59' } });
save();
expect(onSave).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledWith('validation_period_invalid');
expect(screen.getByRole('alert').textContent).toBe('validation_period_invalid');
});
it('offers no year past 9999', () => {
@@ -149,7 +148,7 @@ describe('filter rule period (dialog)', () => {
fireEvent.change(endInput(), { target: { value: '2026-10-16T18:00' } });
save();
expect(onSave).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledWith('validation_period_order');
expect(screen.getByRole('alert').textContent).toBe('validation_period_order');
});
it('shows a saved period in the user time zone', () => {
@@ -35,7 +35,7 @@ describe('FilterRuleModal header names', () => {
fireEvent.click(screen.getByText('save'));
expect(onSave).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith('validation_header_name');
expect(screen.getByRole('alert').textContent).toBe('validation_header_name');
});
it('saves a valid header name, trimmed', () => {
@@ -44,7 +44,7 @@ describe('FilterRuleModal header names', () => {
fireEvent.click(screen.getByText('save'));
expect(toast.error).not.toHaveBeenCalled();
expect(screen.queryByRole('alert')).toBeNull();
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave.mock.calls[0][0].conditions).toEqual([
{ field: 'header', comparator: 'contains', value: 'yes', headerName: 'X-Spam_Flag-2' },
@@ -59,6 +59,42 @@ describe('FilterRuleModal header names', () => {
fireEvent.click(screen.getByText('save'));
expect(onSave).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith('validation_header_name');
expect(screen.getByRole('alert').textContent).toBe('validation_header_name');
});
});
// A new rule starts with "Move to folder" and no folder. Saving it says so
// under the actions and marks the folder field, instead of a toast in a
// corner of the window.
describe('FilterRuleModal incomplete action', () => {
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', sortOrder: 0 },
{ id: 'news', name: 'News', sortOrder: 1 },
] as never;
it('points at the folder field when no folder was chosen', async () => {
vi.mocked(toast.error).mockClear();
const onSave = vi.fn();
render(<FilterRuleModal mailboxes={mailboxes} onSave={onSave} onClose={() => {}} />);
fireEvent.change(screen.getByPlaceholderText('rule_name_placeholder'), { target: { value: 'News' } });
fireEvent.change(screen.getByPlaceholderText('value_placeholder_multi'), { target: { value: 'news@example.com' } });
fireEvent.click(screen.getByRole('button', { name: 'save' }));
expect(onSave).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
expect(screen.getByRole('alert').textContent).toBe('validation_empty_actions');
const folder = screen.getByRole('combobox', { name: 'move_to_folder' });
expect(folder.getAttribute('aria-invalid')).toBe('true');
fireEvent.change(folder, { target: { value: 'news' } });
expect(screen.queryByRole('alert')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'save' }));
expect(onSave.mock.calls[0][0].actions).toEqual([{ type: 'move', mailboxId: 'news', value: 'News' }]);
});
it('starts a changed action without a folder too, like a new row', () => {
render(<FilterRuleModal mailboxes={mailboxes} onSave={vi.fn()} onClose={() => {}} />);
fireEvent.change(screen.getByRole('combobox', { name: 'actions' }), { target: { value: 'copy' } });
expect((screen.getByRole('combobox', { name: 'move_to_folder' }) as HTMLSelectElement).value).toBe('');
});
});
+119 -99
View File
@@ -4,9 +4,10 @@ import { useState, useCallback, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Plus, Trash2 } from "@/components/icons";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { toast } from "@/stores/toast-store";
import { Plus, X } from "@/components/icons";
import { IconButton } from "@/components/ui/icon-button";
import { Dialog } from "@/components/ui/dialog";
import { useDiscardChanges } from "@/components/settings/use-discard-changes";
import type {
FilterRule,
FilterCondition,
@@ -189,7 +190,18 @@ export function FilterRuleModal({
const periodStartRef = useRef<HTMLInputElement>(null);
const periodEndRef = useRef<HTMLInputElement>(null);
const modalRef = useFocusTrap({ isActive: true, onEscape: onClose });
// Unsaved input is dropped only when the user says so (R5): Escape, the
// X and Cancel ask, a click outside is ignored.
const draft = JSON.stringify([
name, matchType, conditions, actions, stopProcessing, includeSpam, applyToExisting, periodOn, periodStart, periodEnd,
]);
const [initialDraft] = useState(draft);
const dirty = draft !== initialDraft;
const { confirmDiscard, discardDialog } = useDiscardChanges();
const requestClose = useCallback(async () => {
if (await confirmDiscard(dirty)) onClose();
}, [confirmDiscard, dirty, onClose]);
const nameRef = useRef<HTMLInputElement>(null);
const { hierarchicalMailboxes, mailboxPathMap } = useMemo(() => {
const tree = buildMailboxTree(mailboxes.filter((mb) => !mb.isShared));
@@ -271,46 +283,50 @@ export function FilterRuleModal({
return retroactiveSupport({ conditions: validConditions, actions: checkActions }).ok;
}, [offerApplyToExisting, validConditions, validActions, periodOn]);
// What keeps the rule from being saved, said under the part it is about
// once Save was pressed, and updated as the user fixes it.
const [showErrors, setShowErrors] = useState(false);
const [halfFilledPeriod, setHalfFilledPeriod] = useState(false);
const activeFrom = periodOn ? fromPeriodInput(periodStart, start?.activeFrom) : undefined;
const activeUntil = periodOn ? fromPeriodInput(periodEnd, start?.activeUntil) : undefined;
const errors = {
name: !name.trim() ? t("validation_empty_name") : null,
conditions: validConditions.length === 0
? t("validation_empty_conditions")
// Stalwart's Sieve compiler refuses any other header name, and with
// it the whole script, so every other rule would stop working too.
: validConditions.some((c) => c.field === "header" && !isValidSieveHeaderName(c.headerName ?? ""))
? t("validation_header_name")
: null,
actions: validActions.length === 0 ? t("validation_empty_actions") : null,
period: !periodOn
? null
// A field the browser holds half filled in (a date without its time)
// reads as empty, and must not pass for an open end of the period.
: activeFrom === null || activeUntil === null || halfFilledPeriod
? t("validation_period_invalid")
: !activeFrom && !activeUntil
? t("validation_period_empty")
: activeFrom && activeUntil && Date.parse(activeUntil) <= Date.parse(activeFrom)
? t("validation_period_order")
: null,
};
const hasErrors = Object.values(errors).some(Boolean);
const shown = (error: string | null) => (showErrors ? error : null);
const handleSave = useCallback(() => {
const halfFilled = periodOn &&
(!!periodStartRef.current?.validity.badInput || !!periodEndRef.current?.validity.badInput);
setHalfFilledPeriod(halfFilled);
if (halfFilled || hasErrors) {
setShowErrors(true);
// Focus goes to the first part that needs fixing.
requestAnimationFrame(() => {
document.querySelector<HTMLElement>('[data-rule-error="true"]')?.focus();
});
return;
}
const trimmedName = name.trim();
if (!trimmedName) {
toast.error(t("validation_empty_name"));
return;
}
if (validConditions.length === 0) {
toast.error(t("validation_empty_conditions"));
return;
}
// Stalwart's Sieve compiler refuses any other header name, and with it
// the whole script, so every other rule would stop working too.
if (validConditions.some((c) => c.field === "header" && !isValidSieveHeaderName(c.headerName ?? ""))) {
toast.error(t("validation_header_name"));
return;
}
if (validActions.length === 0) {
toast.error(t("validation_empty_actions"));
return;
}
const activeFrom = periodOn ? fromPeriodInput(periodStart, start?.activeFrom) : undefined;
const activeUntil = periodOn ? fromPeriodInput(periodEnd, start?.activeUntil) : undefined;
// A field the browser holds half filled in (a date without its time)
// reads as empty, and must not pass for an open end of the period.
const halfFilled = !!periodStartRef.current?.validity.badInput || !!periodEndRef.current?.validity.badInput;
if (activeFrom === null || activeUntil === null || (periodOn && halfFilled)) {
toast.error(t("validation_period_invalid"));
return;
}
if (periodOn && !activeFrom && !activeUntil) {
toast.error(t("validation_period_empty"));
return;
}
if (activeFrom && activeUntil && Date.parse(activeUntil) <= Date.parse(activeFrom)) {
toast.error(t("validation_period_order"));
return;
}
onSave({
id: start?.id || generateUUID(),
@@ -324,10 +340,10 @@ export function FilterRuleModal({
// is off: the settings page merges the result into the stored rule, and
// a field left out would keep its old value there.
includeSpam: includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? true : undefined,
activeFrom,
activeUntil,
activeFrom: activeFrom ?? undefined,
activeUntil: activeUntil ?? undefined,
}, { applyToExisting: applyToExisting && canApplyToExisting });
}, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, periodStart, periodEnd, start, onSave, t, applyToExisting, canApplyToExisting]);
}, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, activeFrom, activeUntil, hasErrors, start, onSave, applyToExisting, canApplyToExisting]);
const visibleSuggestions = suggestions.filter((s) => !usedSuggestions.has(s.id));
@@ -392,11 +408,6 @@ export function FilterRuleModal({
if (updates.type && updates.type !== "forward") {
delete updated.keepCopy;
}
if (updates.type && ACTIONS_WITH_MAILBOX.has(updates.type) && !updated.value) {
const firstMb = hierarchicalMailboxes[0];
updated.value = firstMb ? (mailboxPathMap.get(firstMb.id) || firstMb.name) : "";
if (firstMb) updated.mailboxId = firstMb.id;
}
return updated;
})
);
@@ -411,40 +422,43 @@ export function FilterRuleModal({
"px-2.5 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={isEdit ? t("edit_rule") : t("new_rule")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">
{isEdit ? t("edit_rule") : t("new_rule")}
</h2>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={t("cancel")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-6">
<>
<Dialog
isOpen
onClose={() => void requestClose()}
dirty={dirty}
title={isEdit ? t("edit_rule") : t("new_rule")}
size="lg"
className="max-h-[90vh]"
initialFocus={nameRef}
footer={
<>
<Button variant="outline" onClick={() => void requestClose()}>
{t("cancel")}
</Button>
<Button onClick={handleSave}>
{t("save")}
</Button>
</>
}
>
<div className="pt-4 space-y-6">
<div>
<label className="text-sm font-medium mb-1 block text-foreground">
{t("rule_name")}
</label>
<Input
ref={nameRef}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("rule_name_placeholder")}
maxLength={200}
autoFocus
aria-invalid={!!shown(errors.name)}
aria-describedby={shown(errors.name) ? "rule-name-error" : undefined}
data-rule-error={!!shown(errors.name)}
className={cn(shown(errors.name) && "border-destructive")}
/>
<FieldError id="rule-name-error" text={shown(errors.name)} />
</div>
<div>
@@ -589,15 +603,14 @@ export function FilterRuleModal({
/>
)}
<button
type="button"
<IconButton
label={t("remove_condition")}
onClick={() => removeCondition(index)}
disabled={conditions.length <= 1}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label={t("delete_rule")}
className="text-muted-foreground hover:text-destructive disabled:opacity-30"
>
<Trash2 className="w-4 h-4" />
</button>
<X />
</IconButton>
</div>
))}
</div>
@@ -609,6 +622,7 @@ export function FilterRuleModal({
<Plus className="w-3.5 h-3.5" />
{t("add_condition")}
</button>
<FieldError text={shown(errors.conditions)} />
</div>
<div>
@@ -653,8 +667,10 @@ export function FilterRuleModal({
value: id ? (mailboxPathMap.get(id) ?? "") : "",
});
}}
className={`${selectClass} flex-1 min-w-[140px]`}
className={cn(selectClass, "flex-1 min-w-[140px]", shown(errors.actions) && !selectedId && "border-destructive")}
aria-label={t("move_to_folder")}
aria-invalid={!!shown(errors.actions) && !selectedId}
data-rule-error={!!shown(errors.actions) && !selectedId}
>
<option value="">{t("move_to_folder")}</option>
{missing && <option value={selectedId}>{action.value}</option>}
@@ -711,15 +727,14 @@ export function FilterRuleModal({
</select>
)}
<button
type="button"
<IconButton
label={t("remove_action")}
onClick={() => removeAction(index)}
disabled={actions.length <= 1}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label={t("delete_rule")}
className="text-muted-foreground hover:text-destructive disabled:opacity-30"
>
<Trash2 className="w-4 h-4" />
</button>
<X />
</IconButton>
</div>
))}
</div>
@@ -741,6 +756,7 @@ export function FilterRuleModal({
<Plus className="w-3.5 h-3.5" />
{t("add_action")}
</button>
<FieldError text={shown(errors.actions)} />
</div>
<div className="flex items-center gap-2">
@@ -797,7 +813,7 @@ export function FilterRuleModal({
type="datetime-local"
max={PERIOD_INPUT_MAX}
value={periodStart}
onChange={(e) => setPeriodStart(e.target.value)}
onChange={(e) => { setPeriodStart(e.target.value); setHalfFilledPeriod(false); }}
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
/>
</label>
@@ -808,12 +824,13 @@ export function FilterRuleModal({
type="datetime-local"
max={PERIOD_INPUT_MAX}
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
onChange={(e) => { setPeriodEnd(e.target.value); setHalfFilledPeriod(false); }}
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
/>
</label>
</div>
<p className="text-xs text-muted-foreground">{t("period_hint")}</p>
<FieldError text={shown(errors.period)} />
</div>
)}
</div>
@@ -847,15 +864,18 @@ export function FilterRuleModal({
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
{t("cancel")}
</Button>
<Button onClick={handleSave} disabled={!name.trim()}>
{t("save")}
</Button>
</div>
</div>
</div>
</Dialog>
{discardDialog}
</>
);
}
/** Why the rule can't be saved yet, under the part it is about. */
function FieldError({ id, text }: { id?: string; text: string | null }) {
if (!text) return null;
return (
<p id={id} role="alert" className="mt-1.5 text-xs text-red-600 dark:text-red-400">
{text}
</p>
);
}
+51 -55
View File
@@ -1,10 +1,11 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useRef, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, AlertTriangle, CheckCircle, Loader2 } from "@/components/icons";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { Dialog } from "@/components/ui/dialog";
import { AlertTriangle, CheckCircle, Loader2 } from "@/components/icons";
import { useDiscardChanges } from "@/components/settings/use-discard-changes";
interface SieveEditorModalProps {
content: string;
@@ -28,8 +29,14 @@ export function SieveEditorModal({
} | null>(null);
const [showSaveWarning, setShowSaveWarning] = useState(false);
const modalRef = useFocusTrap({ isActive: true, onEscape: onClose });
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dirty = script !== content;
const { confirmDiscard, discardDialog } = useDiscardChanges();
// Escape, the X, Cancel: an edited script is only dropped when the user
// says so; a click outside is ignored while it is edited.
const requestClose = useCallback(async () => {
if (await confirmDiscard(dirty)) onClose();
}, [confirmDiscard, dirty, onClose]);
const lineCount = script.split("\n").length;
@@ -54,10 +61,6 @@ export function SieveEditorModal({
onSave(script);
}, [script, showSaveWarning, onSave]);
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Tab") {
e.preventDefault();
@@ -72,33 +75,49 @@ export function SieveEditorModal({
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-4xl mx-4 max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={t("cancel")}
<>
<Dialog
isOpen
onClose={() => void requestClose()}
dirty={dirty}
title={t("title")}
size="lg"
className="max-w-4xl max-h-[90vh]"
bodyClassName="flex flex-col"
initialFocus={textareaRef}
footer={
<>
<Button
variant="outline"
onClick={handleValidate}
disabled={isValidating || !script.trim()}
className="me-auto"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 flex-1 overflow-hidden flex flex-col space-y-4">
{isValidating ? (
<>
<Loader2 className="w-4 h-4 me-2 animate-spin" />
{t("validating")}
</>
) : (
t("validate")
)}
</Button>
<Button variant="outline" onClick={() => void requestClose()}>
{t("cancel")}
</Button>
<Button onClick={handleSave} disabled={!script.trim()}>
{showSaveWarning ? t("confirm_save") : t("save")}
</Button>
</>
}
>
<div className="pt-4 flex-1 min-h-0 flex flex-col space-y-4">
<div className="flex items-start gap-2 p-3 rounded-md bg-warning/10 border border-warning/20 text-sm text-warning">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<p>{t("warning")}</p>
</div>
<div className="flex-1 min-h-0 flex border border-border rounded-md overflow-hidden">
<div className="flex-1 min-h-[16rem] h-[50vh] flex border border-border rounded-md overflow-hidden">
<div
className="w-10 flex-shrink-0 bg-muted border-e border-border py-2 text-end pe-2 select-none overflow-hidden"
aria-hidden="true"
@@ -164,31 +183,8 @@ export function SieveEditorModal({
)}
</div>
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
<Button
variant="outline"
onClick={handleValidate}
disabled={isValidating || !script.trim()}
>
{isValidating ? (
<>
<Loader2 className="w-4 h-4 me-2 animate-spin" />
{t("validating")}
</>
) : (
t("validate")
)}
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
{t("cancel")}
</Button>
<Button onClick={handleSave} disabled={!script.trim()}>
{showSaveWarning ? t("confirm_save") : t("save")}
</Button>
</div>
</div>
</div>
</div>
</Dialog>
{discardDialog}
</>
);
}
+16 -2
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -46,9 +46,11 @@ interface IdentityFormProps {
identity?: Identity;
onSave: (data: IdentityFormData) => Promise<void>;
onCancel: () => void;
/** Told whenever the form starts or stops holding unsaved input. */
onDirtyChange?: (dirty: boolean) => void;
}
export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps) {
export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: IdentityFormProps) {
const t = useTranslations('identities.form');
const tValidation = useTranslations('identities.validation_errors');
const tDisplay = useTranslations('identities.display');
@@ -73,6 +75,18 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const dirty =
formData.name !== (identity?.name || '') ||
formData.email !== (identity?.email || '') ||
(formData.textSignature || '') !== (identity?.textSignature || '') ||
(formData.htmlSignature || '') !== (identity?.htmlSignature || '') ||
replyToInput !== (identity?.replyTo?.map(a => a.email).join(', ') || '') ||
bccInput !== (identity?.bcc?.map(a => a.email).join(', ') || '');
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
useEffect(() => () => onDirtyChange?.(false), [onDirtyChange]);
const parseEmailList = (input: string): EmailAddress[] | undefined => {
if (!input.trim()) return undefined;
+74 -100
View File
@@ -2,9 +2,10 @@
import { useEffect, useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { X, Mail, Pencil, Trash2, Plus, AlertTriangle, Star } from '@/components/icons';
import { cn } from '@/lib/utils';
import { Mail, Pencil, Trash2, Plus, AlertTriangle, Star } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { IconButton } from '@/components/ui/icon-button';
import { Dialog } from '@/components/ui/dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { IdentityForm } from './identity-form';
import {
@@ -24,8 +25,8 @@ function useRefreshIdentities() {
}
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { useDiscardChanges } from '@/components/settings/use-discard-changes';
import { toUnicodeEmail } from '@/lib/idn';
import { sortIdentities } from '@/lib/identity-order';
@@ -46,6 +47,7 @@ interface IdentityManagerModalProps {
export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalProps) {
const t = useTranslations('identities');
const tNotif = useTranslations('notifications');
const tCommon = useTranslations('common');
const client = useAuthStore((state) => state.client);
const identities = useIdentityStore((state) => state.identities);
@@ -65,6 +67,13 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [formDirty, setFormDirty] = useState(false);
// Reopened, the dialog starts on the list, not on a form left open.
useEffect(() => {
if (isOpen) return;
setIsCreating(false);
setEditingId(null);
}, [isOpen]);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
// Re-fetch all identities from server and update stores
@@ -91,34 +100,24 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
}
}, [client, syncIdentities]);
// Focus trap with Escape handling
const modalRef = useFocusTrap({
isActive: isOpen,
onEscape: () => {
if (isCreating || editingId) {
setIsCreating(false);
setEditingId(null);
} else {
onClose();
}
},
restoreFocus: true,
});
// Unsaved input in the create or edit form is only dropped once the user
// says so (R5); an outside click is ignored while there is some.
const { confirmDiscard, discardDialog } = useDiscardChanges();
const cancelForm = useCallback(async () => {
if (!(await confirmDiscard(formDirty))) return;
setIsCreating(false);
setEditingId(null);
}, [confirmDiscard, formDirty]);
// Close on click outside (but not when ConfirmDialog is open)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (confirmDialogProps.isOpen) return;
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
// Escape, the X and an outside click close the open form first, then the
// dialog: the innermost layer only.
const requestClose = useCallback(async () => {
if (isCreating || editingId) {
await cancelForm();
return;
}
}, [isOpen, onClose, modalRef, confirmDialogProps.isOpen]);
onClose();
}, [isCreating, editingId, cancelForm, onClose]);
const handleCreate = useCallback(async (data: IdentityFormData) => {
if (!client) return;
@@ -209,46 +208,30 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
toast.success(tNotif('identity_set_primary'));
}, [identities, setPreferredPrimary, syncIdentities, tNotif]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="identity-modal-title"
className={cn(
'bg-background border border-border rounded-lg shadow-xl',
'w-full max-w-3xl max-h-[90vh] overflow-hidden',
'animate-in zoom-in-95 duration-200'
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<Mail className="w-5 h-5 text-muted-foreground" />
<h2 id="identity-modal-title" className="text-lg font-semibold text-foreground">
{t('modal_title')}
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
<>
<Dialog
isOpen={isOpen}
onClose={() => void requestClose()}
dirty={formDirty}
size="lg"
className="max-w-3xl max-h-[90vh]"
title={
<span className="flex items-center gap-3">
<Mail className="w-5 h-5 text-muted-foreground" />
{t('modal_title')}
</span>
}
>
<div className="pt-4">
{/* Create New Form */}
{isCreating && (
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
<h3 className="text-sm font-semibold mb-4">{t('create_new')}</h3>
<IdentityForm
onSave={handleCreate}
onCancel={() => setIsCreating(false)}
onCancel={() => void cancelForm()}
onDirtyChange={setFormDirty}
/>
</div>
)}
@@ -279,7 +262,8 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
<IdentityForm
identity={identity}
onSave={(data) => handleUpdate(identity, data)}
onCancel={() => setEditingId(null)}
onCancel={() => void cancelForm()}
onDirtyChange={setFormDirty}
/>
</div>
) : (
@@ -287,7 +271,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-foreground truncate">
<h3 className="font-semibold text-foreground truncate" title={identity.name} dir="auto">
{identity.name}
</h3>
{identities[0]?.id === identity.id && (
@@ -296,7 +280,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
</span>
)}
</div>
<p className="text-sm text-muted-foreground truncate">
<p className="text-sm text-muted-foreground truncate" title={toUnicodeEmail(identity.email)}>
{toUnicodeEmail(identity.email)}
</p>
@@ -321,49 +305,39 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{identities[0]?.id !== identity.id && identities.length > 1 && (
<Button
variant="ghost"
size="sm"
<IconButton
label={t('set_as_primary')}
onClick={() => handleSetPrimary(identity)}
disabled={!!editingId || isCreating}
title={t('set_as_primary')}
>
<Star className="w-4 h-4" />
</Button>
<Star />
</IconButton>
)}
<Button
variant="ghost"
size="sm"
<IconButton
label={tCommon('edit')}
onClick={() => setEditingId(identity.id)}
disabled={!!editingId || isCreating}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(identity)}
disabled={
!identity.mayDelete ||
!!editingId ||
isCreating ||
deletingId === identity.id
}
className={!identity.mayDelete ? 'opacity-30' : ''}
>
{!identity.mayDelete ? (
<AlertTriangle className="w-4 h-4 text-yellow-500" />
) : (
<Trash2 className="w-4 h-4 text-destructive" />
)}
</Button>
<Pencil />
</IconButton>
{/* Read-only identities show no Delete (R11); the line below says why. */}
{identity.mayDelete && (
<IconButton
label={t('delete_button')}
onClick={() => handleDelete(identity)}
disabled={!!editingId || isCreating || deletingId === identity.id}
className="text-destructive hover:text-destructive"
>
<Trash2 />
</IconButton>
)}
</div>
</div>
{!identity.mayDelete && (
<p className="text-xs text-yellow-600 dark:text-yellow-500 mt-2 flex items-center gap-1">
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{t('cannot_delete')}
</p>
@@ -381,9 +355,9 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
)}
</div>
</div>
</div>
<ConfirmDialog {...confirmDialogProps} />
</div>
</Dialog>
<ConfirmDialog {...confirmDialogProps} />
{discardDialog}
</>
);
}
@@ -0,0 +1,69 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useSettingsStore } from '@/stores/settings-store';
import { AboutDataSettings } from '../about-data-settings';
// Backup & reset: Reset asks in a ConfirmDialog (no button that turns into
// a red "Yes"), and every result is a toast, never a browser alert().
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast }));
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../debug-settings', () => ({ DebugSettings: () => null }));
const initial = useSettingsStore.getState();
let alertSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
toast.success.mockClear();
toast.error.mockClear();
alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
});
afterEach(() => {
useSettingsStore.setState(initial, true);
alertSpy.mockRestore();
});
describe('Backup & reset', () => {
it('resets only after the dialog is confirmed, then says so in a toast', () => {
const resetToDefaults = vi.fn();
useSettingsStore.setState({ resetToDefaults });
render(<AboutDataSettings />);
fireEvent.click(screen.getByRole('button', { name: 'reset_settings.button' }));
expect(resetToDefaults).not.toHaveBeenCalled();
const dialog = within(screen.getByRole('alertdialog'));
expect(dialog.getByText('reset_settings.confirm_title')).toBeTruthy();
fireEvent.click(dialog.getByRole('button', { name: 'reset_settings.button' }));
expect(resetToDefaults).toHaveBeenCalledTimes(1);
expect(toast.success).toHaveBeenCalledWith('reset_settings.done');
expect(alertSpy).not.toHaveBeenCalled();
});
it('reports an import that fails in an error toast', async () => {
useSettingsStore.setState({ importSettings: vi.fn(() => false) });
const { container } = render(<AboutDataSettings />);
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['not json'], 'settings.json', { type: 'application/json' });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('import_error'));
expect(alertSpy).not.toHaveBeenCalled();
});
});
// SET-25: the About card in the settings sidebar is named by what it shows,
// not a hard-coded English "About", and shows the focus ring.
describe('About card', () => {
it('has a visible focus ring and no hard-coded English name', async () => {
const { AboutFooter } = await import('../about-data-settings');
render(<AboutFooter />);
const button = screen.getByTestId('settings-about').querySelector('button') as HTMLButtonElement;
expect(button.getAttribute('aria-label')).toBeNull();
expect(button.className).toContain('focus-visible:ring-2');
expect(button.textContent).toContain('about.title');
});
});
@@ -0,0 +1,77 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AddressBook, ContactCard } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useContactStore } from '@/stores/contact-store';
import { AddressBookManagementSettings } from '../address-book-management-settings';
// Settings > Contacts: an address book can be deleted here as in the
// Contacts sidebar (after a ConfirmDialog), and a category can be deleted,
// with Undo.
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast }));
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const rights = { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true };
const book = (id: string, extra: Partial<AddressBook> = {}) =>
({ id, name: id === 'home' ? 'Home' : 'Club', isDefault: false, isShared: false, myRights: rights, shareWith: null, ...extra }) as unknown as AddressBook;
const contact = (id: string, keywords: Record<string, boolean>) =>
({ id, kind: 'individual', keywords, addressBookIds: { home: true } }) as unknown as ContactCard;
const initial = { auth: useAuthStore.getState(), contacts: useContactStore.getState() };
let removeAddressBook: ReturnType<typeof vi.fn>;
let updateContact: ReturnType<typeof vi.fn>;
beforeEach(() => {
toast.success.mockClear();
removeAddressBook = vi.fn(async () => {});
updateContact = vi.fn(async (_client: unknown, id: string, updates: Partial<ContactCard>) => {
useContactStore.setState((s) => ({ contacts: s.contacts.map((c) => (c.id === id ? { ...c, ...updates } : c)) }));
return true;
});
useAuthStore.setState({ client: { getAccountId: () => 'acc' } } as never);
useContactStore.setState({
addressBooks: [book('home', { isDefault: true }), book('club')],
contacts: [contact('a', { friends: true }), contact('b', { friends: true, work: true }), contact('c', { work: true })],
supportsSync: true,
fetchAddressBooks: vi.fn(async () => {}),
removeAddressBook,
updateContact,
} as never);
});
afterEach(() => {
useAuthStore.setState(initial.auth, true);
useContactStore.setState(initial.contacts, true);
});
describe('address books in Settings', () => {
it('deletes an address book after the confirm dialog, and never the default one', async () => {
render(<AddressBookManagementSettings />);
// Only "Club" can go: "Home" is the default.
const deletes = screen.getAllByRole('button', { name: 'delete' });
expect(deletes).toHaveLength(1);
fireEvent.click(deletes[0]);
const dialog = within(screen.getByRole('alertdialog'));
expect(removeAddressBook).not.toHaveBeenCalled();
fireEvent.click(dialog.getByRole('button', { name: 'delete' }));
await waitFor(() => expect(removeAddressBook).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ id: 'club' })));
});
});
describe('categories in Settings', () => {
it('deletes a category from its contacts, and Undo puts it back on the same ones', async () => {
render(<AddressBookManagementSettings />);
fireEvent.click(screen.getAllByRole('button', { name: 'delete_category' })[0]); // "friends" sorts first
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('category_deleted', expect.anything()));
const keywords = () => Object.fromEntries(useContactStore.getState().contacts.map((c) => [c.id, c.keywords]));
expect(keywords()).toEqual({ a: {}, b: { work: true }, c: { work: true } });
(toast.success.mock.calls[0][1] as { action: { onClick: () => void } }).action.onClick();
await waitFor(() => expect(keywords()).toEqual({ a: { friends: true }, b: { work: true, friends: true }, c: { work: true } }));
});
});
@@ -0,0 +1,73 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { FolderSettings } from '../folder-settings';
import { useEmailStore } from '@/stores/email-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import type { Mailbox } from '@/lib/jmap/types';
// On the phone the counter select and the row's icon buttons left no room
// for the folder name. They sit behind a ⋮ there, which right-click opens
// too, and the name keeps its full text in a tooltip.
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const rights = {
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true,
mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true,
};
function makeMailbox(overrides: Partial<Mailbox>): Mailbox {
return {
id: 'mb', name: 'Folder', sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
myRights: rights, isSubscribed: true, isShared: false, ...overrides,
} as Mailbox;
}
describe('FolderSettings row actions menu', () => {
beforeEach(() => {
useAuthStore.setState({ client: null } as never);
useSettingsStore.setState({ folderCountModes: {} } as never);
useEmailStore.setState({
mailboxes: [makeMailbox({ id: 'projects', name: 'Projects with a long name' })],
fetchMailboxes: vi.fn(),
} as never);
});
it('opens the row actions from a named ⋮ and sets the counter from it', () => {
render(<FolderSettings />);
expect(screen.getByTitle('Projects with a long name')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'actions' }));
const menu = screen.getByRole('menu');
expect(within(menu).getByRole('menuitem', { name: 'create_subfolder' })).toBeTruthy();
expect(within(menu).getByRole('menuitem', { name: 'rename' })).toBeTruthy();
expect(within(menu).getByRole('menuitem', { name: 'delete' })).toBeTruthy();
fireEvent.click(within(menu).getByRole('menuitem', { name: 'counter' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'count_mode_none' }));
expect(useSettingsStore.getState().folderCountModes).toEqual({ projects: 'none' });
});
it('opens the same menu on right-click and starts renaming from it', () => {
render(<FolderSettings />);
fireEvent.contextMenu(screen.getByTitle('Projects with a long name'), { clientX: 20, clientY: 20 });
fireEvent.click(screen.getByRole('menuitem', { name: 'rename' }));
expect((screen.getByDisplayValue('Projects with a long name') as HTMLInputElement).tagName).toBe('INPUT');
});
it('names the expand toggle of a folder with subfolders', () => {
useEmailStore.setState({
mailboxes: [
makeMailbox({ id: 'p', name: 'Parent' }),
makeMailbox({ id: 'c', name: 'Child', parentId: 'p' }),
],
} as never);
render(<FolderSettings />);
const toggle = screen.getByRole('button', { name: 'expand' });
expect(toggle.getAttribute('aria-expanded')).toBe('false');
});
});
@@ -0,0 +1,23 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { EmailAddressesSection } from '../identity-settings';
// SET-19: "Learn more" next to Sub-Addressing opened the identity manager,
// which says nothing about sub-addressing. The row keeps its description;
// only "Manage" opens the identity manager.
vi.mock('@/components/identity/identity-manager-modal', () => ({
IdentityManagerModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? <div role="dialog">identities</div> : null),
}));
describe('Email addresses section', () => {
it('explains sub-addressing without a button that opens the identity manager', () => {
render(<EmailAddressesSection />);
const row = screen.getByText('sub_addressing.label').closest('[data-search-label]') as HTMLElement;
expect(within(row).getByText('sub_addressing.description')).toBeTruthy();
expect(within(row).queryByRole('button')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'manage' }));
expect(screen.getByRole('dialog')).toBeTruthy();
});
});
@@ -390,3 +390,19 @@ describe('KeywordSettings unrecognized tags', () => {
expect(screen.queryByText('unrecognized.none')).not.toBeInTheDocument();
});
});
// SET-20: Escape cancels editing a tag, as it does for folders, calendars
// and address books.
describe('KeywordSettings editing', () => {
it('cancels the edit form on Escape without saving', () => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
render(<KeywordSettings />);
fireEvent.click(screen.getAllByTitle('edit')[0]);
const input = screen.getByDisplayValue(DEFAULT_KEYWORDS[0].label);
fireEvent.change(input, { target: { value: 'Changed' } });
fireEvent.keyDown(input, { key: 'Escape' });
expect(screen.queryByDisplayValue('Changed')).toBeNull();
expect(useSettingsStore.getState().emailKeywords[0].label).toBe(DEFAULT_KEYWORDS[0].label);
});
});
@@ -0,0 +1,40 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { describe, expect, it } from 'vitest';
// R4: no window.confirm, alert() or prompt() in Settings and the admin
// panel. Questions go through ConfirmDialog (the useConfirmDialog hook's
// confirm({ ... }) is fine), results through toasts.
const ROOT = join(__dirname, '..', '..', '..');
const DIRS = ['components/settings', 'components/admin', 'components/filters', 'components/identity', 'app/(main)/admin'];
function sources(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return name === '__tests__' ? [] : sources(path);
return /\.tsx?$/.test(name) ? [path] : [];
});
}
const NATIVE = [
/\bwindow\.(confirm|alert|prompt)\s*\(/,
/(?<![.\w])(alert|prompt)\s*\(/,
// A bare confirm(...) with a message, not the hook's confirm({ ... }).
/(?<![.\w])confirm\s*\((?!\s*\{)/,
];
describe('native browser dialogs in Settings and Admin', () => {
it('are not used', () => {
const found: string[] = [];
for (const dir of DIRS) {
for (const file of sources(join(ROOT, dir))) {
readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
if (line.trim().startsWith('//') || line.trim().startsWith('*')) return;
if (NATIVE.some((re) => re.test(line))) found.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
});
}
}
expect(found).toEqual([]);
});
});
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { FilterRule } from '@/lib/jmap/sieve-types';
import { FilterRuleModal } from '@/components/filters/filter-rule-modal';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useFilterStore } from '@/stores/filter-store';
import { FilterSettings } from '../filter-settings';
// SET-11: icon buttons and switches in Settings have a name that says what
// they do. The rule dialog's remove buttons were both named "Delete Rule";
// the rule switches on the Filters page had none.
vi.mock('@/stores/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const rule: FilterRule = {
id: 'r1', name: 'Newsletters', enabled: true, matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: 'a' }, { field: 'subject', comparator: 'contains', value: 'b' }],
actions: [{ type: 'star' }, { type: 'mark_read' }], stopProcessing: false,
};
const initial = { auth: useAuthStore.getState(), email: useEmailStore.getState(), filter: useFilterStore.getState() };
afterEach(() => {
useAuthStore.setState(initial.auth, true);
useEmailStore.setState(initial.email, true);
useFilterStore.setState(initial.filter, true);
});
function unnamedButtons(root: HTMLElement) {
return Array.from(root.querySelectorAll('button, [role="switch"]')).filter((b) => {
const name = b.getAttribute('aria-label') || b.getAttribute('aria-labelledby') || b.textContent?.trim() || b.getAttribute('title');
return !name;
});
}
describe('names of settings controls', () => {
it('names the remove buttons of a rule for what they remove', () => {
render(<FilterRuleModal rule={rule} mailboxes={[]} onSave={vi.fn()} onClose={vi.fn()} />);
expect(screen.getAllByRole('button', { name: 'remove_condition' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: 'remove_action' })).toHaveLength(2);
expect(screen.queryByRole('button', { name: 'delete_rule' })).toBeNull();
expect(unnamedButtons(screen.getByRole('dialog'))).toEqual([]);
});
it('names each rule switch on the Filters page after its rule', () => {
useAuthStore.setState({ client: { updateSieveScript: vi.fn(), getSieveAccountId: () => 'primary' } as never });
useEmailStore.setState({ fetchMailboxes: vi.fn(async () => {}) as never });
useFilterStore.setState({
rules: [rule], isSupported: true, isLoading: false, isOpaque: false, activeScriptId: 's',
selectedAccountId: 'primary', sieveCapabilities: null, selectAccount: vi.fn(async () => {}),
});
const { container } = render(<FilterSettings />);
expect(screen.getByRole('switch', { name: 'Newsletters' })).toBeTruthy();
expect(unnamedButtons(container)).toEqual([]);
});
});
@@ -0,0 +1,128 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Calendar, Mailbox } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useEmailStore } from '@/stores/email-store';
import { useFilterStore } from '@/stores/filter-store';
import { useSettingsStore } from '@/stores/settings-store';
import { FolderSettings } from '../folder-settings';
import { CalendarManagementSettings } from '../calendar-management-settings';
import { FilterSettings } from '../filter-settings';
// R3: what can't be brought back (a folder, a calendar, its events, a
// subscription, a script typed in the raw editor) is confirmed in one
// ConfirmDialog: "Delete …?", Cancel first, the destructive verb last.
// No inline Delete/Cancel strips.
vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
}));
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const initial = {
auth: useAuthStore.getState(),
calendar: useCalendarStore.getState(),
email: useEmailStore.getState(),
filter: useFilterStore.getState(),
settings: useSettingsStore.getState(),
};
afterEach(() => {
useAuthStore.setState(initial.auth, true);
useCalendarStore.setState(initial.calendar, true);
useEmailStore.setState(initial.email, true);
useFilterStore.setState(initial.filter, true);
useSettingsStore.setState(initial.settings, true);
});
function confirmDialog() {
return within(screen.getByRole('alertdialog'));
}
describe('deleting a folder', () => {
const rights = {
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true,
mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true,
};
let deleteMailbox: ReturnType<typeof vi.fn>;
beforeEach(() => {
deleteMailbox = vi.fn(async () => true);
useAuthStore.setState({ client: {} } as never);
useEmailStore.setState({
mailboxes: [{ id: 'old', name: 'Old stuff', sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, myRights: rights, isSubscribed: true, isShared: false } as Mailbox],
fetchMailboxes: vi.fn(),
deleteMailbox,
} as never);
});
it('asks in a dialog and deletes only on the destructive button', async () => {
render(<FolderSettings />);
fireEvent.click(screen.getAllByRole('button', { name: 'delete' })[0]);
const dialog = confirmDialog();
expect(dialog.getByText('delete_title')).toBeTruthy();
const [cancel, confirm] = dialog.getAllByRole('button');
expect(cancel.textContent).toBe('cancel');
expect(confirm.textContent).toBe('delete');
fireEvent.click(cancel);
expect(deleteMailbox).not.toHaveBeenCalled();
fireEvent.click(screen.getAllByRole('button', { name: 'delete' })[0]);
fireEvent.click(confirmDialog().getAllByRole('button')[1]);
await waitFor(() => expect(deleteMailbox).toHaveBeenCalledWith(expect.anything(), 'old'));
});
});
describe('deleting a calendar and clearing its events', () => {
const work = {
id: 'work', name: 'Work', color: '#3b82f6', isDefault: false, isShared: false, shareWith: null,
myRights: { mayShare: true, mayDelete: true, mayWriteAll: true },
} as unknown as Calendar;
let removeCalendar: ReturnType<typeof vi.fn>;
let clearCalendarEvents: ReturnType<typeof vi.fn>;
beforeEach(() => {
removeCalendar = vi.fn(async () => {});
clearCalendarEvents = vi.fn(async () => 3);
useAuthStore.setState({ client: { getAccountId: () => 'acct-1' }, serverUrl: null, username: null } as never);
useCalendarStore.setState({ calendars: [work], icalSubscriptions: [], removeCalendar, clearCalendarEvents } as never);
});
it('confirms a delete in a dialog, not an inline strip', async () => {
render(<CalendarManagementSettings />);
fireEvent.click(screen.getByTitle('delete'));
expect(confirmDialog().getByText('delete_title')).toBeTruthy();
fireEvent.click(confirmDialog().getByRole('button', { name: 'delete' }));
await waitFor(() => expect(removeCalendar).toHaveBeenCalledWith(expect.anything(), 'work'));
});
it('confirms clearing the events in a dialog', async () => {
render(<CalendarManagementSettings />);
fireEvent.click(screen.getByTitle('clear_events'));
expect(confirmDialog().getByText('clear_title')).toBeTruthy();
fireEvent.click(confirmDialog().getByRole('button', { name: 'clear_events' }));
await waitFor(() => expect(clearCalendarEvents).toHaveBeenCalledWith(expect.anything(), 'work'));
});
});
describe('resetting the raw script to the visual builder', () => {
it('asks in a dialog before throwing the script away', async () => {
const updateSieveScript = vi.fn(async () => {});
useAuthStore.setState({ client: { updateSieveScript, getSieveAccountId: () => 'primary' } as never });
useEmailStore.setState({ fetchMailboxes: vi.fn(async () => {}) as never });
useFilterStore.setState({
rules: [], rawScript: 'keep;', isSupported: true, isLoading: false, isOpaque: true, activeScriptId: 's1',
selectedAccountId: 'primary', sieveCapabilities: null, selectAccount: vi.fn(async () => {}),
});
render(<FilterSettings />);
fireEvent.click(screen.getByRole('button', { name: /reset_to_visual/ }));
expect(useFilterStore.getState().isOpaque).toBe(true);
expect(confirmDialog().getByText('reset_title')).toBeTruthy();
fireEvent.click(confirmDialog().getByRole('button', { name: 'confirm_reset' }));
await waitFor(() => expect(useFilterStore.getState().isOpaque).toBe(false));
});
});
@@ -0,0 +1,90 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { FilterRule } from '@/lib/jmap/sieve-types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useFilterStore } from '@/stores/filter-store';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { FilterSettings } from '../filter-settings';
import { KeywordSettings } from '../keyword-settings';
// R3: a tag or a filter rule can be put back exactly as it was, so Delete
// removes it at once, without a question or an inline Delete/Cancel strip,
// and the toast offers Undo.
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast }));
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
type UndoOptions = { action: { label: string; onClick: () => void } };
const undo = () => (toast.success.mock.calls.at(-1)?.[1] as UndoOptions).action;
const initial = {
auth: useAuthStore.getState(),
email: useEmailStore.getState(),
filter: useFilterStore.getState(),
settings: useSettingsStore.getState(),
};
beforeEach(() => {
toast.success.mockClear();
toast.error.mockClear();
});
afterEach(() => {
useAuthStore.setState(initial.auth, true);
useEmailStore.setState(initial.email, true);
useFilterStore.setState(initial.filter, true);
useSettingsStore.setState(initial.settings, true);
});
describe('deleting a tag', () => {
it('goes at once and Undo puts it back in its place', () => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
render(<KeywordSettings />);
const second = DEFAULT_KEYWORDS[1];
fireEvent.click(screen.getAllByRole('button', { name: 'delete' })[1]);
expect(useSettingsStore.getState().emailKeywords.map((k) => k.id)).not.toContain(second.id);
expect(screen.queryByRole('alertdialog')).toBeNull();
expect(toast.success).toHaveBeenCalledWith('deleted', expect.objectContaining({ action: expect.anything() }));
expect(undo().label).toBe('rule_undo');
undo().onClick();
expect(useSettingsStore.getState().emailKeywords.map((k) => k.id)).toEqual(DEFAULT_KEYWORDS.map((k) => k.id));
});
});
describe('deleting a filter rule', () => {
const rule = (id: string): FilterRule => ({
id, name: `Rule ${id}`, enabled: true, matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: '@' }],
actions: [{ type: 'star' }], stopProcessing: false,
});
it('goes at once, with Undo that saves it back in its place', async () => {
const updateSieveScript = vi.fn(async () => {});
useAuthStore.setState({ client: { updateSieveScript, getSieveAccountId: () => 'primary' } as never });
useEmailStore.setState({ fetchMailboxes: vi.fn(async () => {}) as never });
useFilterStore.setState({
rules: [rule('a'), rule('b'), rule('c')],
isSupported: true, isLoading: false, isOpaque: false, activeScriptId: 'script-1',
selectedAccountId: 'primary', sieveCapabilities: null, selectAccount: vi.fn(async () => {}),
});
render(<FilterSettings />);
fireEvent.click(screen.getAllByRole('button', { name: 'delete_rule' })[1]);
// No inline Delete/Cancel strip: the rule is gone and saved without it.
expect(screen.queryByText('confirm_delete')).toBeNull();
await waitFor(() => expect(updateSieveScript).toHaveBeenCalledTimes(1));
expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['a', 'c']);
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('filters_deleted', expect.anything()));
undo().onClick();
expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['a', 'b', 'c']);
await waitFor(() => expect(updateSieveScript).toHaveBeenCalledTimes(2));
});
});
@@ -0,0 +1,122 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FilterRuleModal } from '@/components/filters/filter-rule-modal';
import { SieveEditorModal } from '@/components/filters/sieve-editor-modal';
import { IdentityManagerModal } from '@/components/identity/identity-manager-modal';
import { useAuthStore } from '@/stores/auth-store';
import { useIdentityStore } from '@/stores/identity-store';
// R5: the identity, filter rule and Sieve editors ask "Discard changes?"
// before dropping what was typed, whatever closes them, and ignore a click
// outside while they hold unsaved input. R4: they render in a portal on
// <body>, so their backdrop covers the whole window.
vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
}));
/** The dimmed backdrop around the open dialog. */
function backdrop(): HTMLElement {
const dialogs = screen.getAllByRole('dialog');
return dialogs[dialogs.length - 1].parentElement as HTMLElement;
}
function clickOutside() {
const el = backdrop();
fireEvent.mouseDown(el);
fireEvent.click(el);
}
function pressEscape() {
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
}
describe('filter rule dialog', () => {
it('closes on Escape without asking while nothing was typed', async () => {
const onClose = vi.fn();
render(<FilterRuleModal mailboxes={[]} onSave={vi.fn()} onClose={onClose} />);
pressEscape();
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(screen.queryByRole('button', { name: 'discard' })).toBeNull();
});
it('ignores a click outside and asks before Escape drops a typed name', async () => {
const onClose = vi.fn();
render(<FilterRuleModal mailboxes={[]} onSave={vi.fn()} onClose={onClose} />);
fireEvent.change(screen.getByPlaceholderText('rule_name_placeholder'), { target: { value: 'Newsletters' } });
clickOutside();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByDisplayValue('Newsletters')).toBeTruthy();
pressEscape();
fireEvent.click(await screen.findByRole('button', { name: 'keep_editing' }));
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByDisplayValue('Newsletters')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'cancel' }));
fireEvent.click(await screen.findByRole('button', { name: 'discard' }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('renders in a portal on body', () => {
const { container } = render(<FilterRuleModal mailboxes={[]} onSave={vi.fn()} onClose={vi.fn()} />);
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(screen.getByRole('dialog').closest('body')).toBe(document.body);
});
});
describe('Sieve editor', () => {
it('asks before Escape drops an edited script, and ignores a click outside', async () => {
const onClose = vi.fn();
render(<SieveEditorModal content="keep;" onSave={vi.fn()} onClose={onClose} onValidate={vi.fn()} />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'keep;\nstop;' } });
clickOutside();
expect(onClose).not.toHaveBeenCalled();
pressEscape();
fireEvent.click(await screen.findByRole('button', { name: 'discard' }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
});
describe('identity dialog', () => {
const initial = { auth: useAuthStore.getState(), identity: useIdentityStore.getState() };
beforeEach(() => {
useAuthStore.setState({ client: null, refreshIdentities: vi.fn(), syncIdentities: vi.fn() } as never);
useIdentityStore.setState({ identities: [] } as never);
});
afterEach(() => {
useAuthStore.setState(initial.auth, true);
useIdentityStore.setState(initial.identity, true);
});
it('keeps a half-filled new identity through a click outside and asks on Escape', async () => {
const onClose = vi.fn();
render(
<div>
<IdentityManagerModal isOpen onClose={onClose} />
</div>,
);
// In a portal on body, not inside the settings page.
expect(screen.getByRole('dialog').parentElement?.parentElement).toBe(document.body);
fireEvent.click(screen.getByRole('button', { name: 'create_new' }));
fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'Work' } });
clickOutside();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByDisplayValue('Work')).toBeTruthy();
// Escape closes the form first, after asking; the dialog stays open.
pressEscape();
fireEvent.click(await screen.findByRole('button', { name: 'discard' }));
await waitFor(() => expect(screen.queryByDisplayValue('Work')).toBeNull());
expect(onClose).not.toHaveBeenCalled();
// Nothing typed any more: Escape closes the dialog.
act(() => { pressEscape(); });
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
});
@@ -0,0 +1,87 @@
import React from 'react';
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// On the phone the tab list is a screen of its own. A link to
// /settings/<tab> (the mail drawer's folder gear, quick-rule "edit in
// settings", the OAuth redirect) must open that page, with the list one Back
// below it.
vi.mock('next-intl', () => ({
useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key),
useMessages: () => ({}),
useLocale: () => 'en',
useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }),
}));
vi.mock('@/hooks/use-media-query', () => ({
useIsDesktop: () => false,
useIsMobile: () => true,
useMediaQuery: () => false,
useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }),
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: false }) }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => <div>appearance pane</div> }));
vi.mock('@/components/settings/organize-settings', () => ({ OrganizeSettings: () => <div>organize pane</div> }));
const { SettingsApp } = await import('../settings-app');
const { useAuthStore } = await import('@/stores/auth-store');
const { usePluginStore } = await import('@/stores/plugin-store');
const { useThemeStore } = await import('@/stores/theme-store');
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
try { localStorage.setItem('settings-active-tab', 'appearance'); } catch { /* ignore */ }
const client = new Proxy({}, {
get: (_t, prop) => (prop === 'then' ? undefined : (prop === 'getAccountId' ? () => 'acc' : () => null)),
});
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: client as never });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('Settings deep links on the phone', () => {
it('opens the page a /settings/<tab> link names, with the list below it in history', () => {
window.history.replaceState(null, '', '/settings/organize/folders');
const push = vi.spyOn(window.history, 'pushState');
render(<SettingsApp linkSegments={['organize', 'folders']} />);
expect(screen.getByText('organize pane')).toBeTruthy();
expect(screen.queryByTestId('settings-tab-appearance')).toBeNull();
// The entry below is the list; the page sits on top under its own URL.
expect(push).toHaveBeenCalledTimes(1);
expect(window.location.pathname).toBe('/settings/organize');
act(() => {
window.history.replaceState(null, '', '/settings');
window.dispatchEvent(new PopStateEvent('popstate'));
});
expect(screen.getByTestId('settings-tab-appearance')).toBeTruthy();
});
it('opens the page a settings-tab-change event names', () => {
window.history.replaceState(null, '', '/settings');
render(<SettingsApp linkSegments={[]} />);
expect(screen.getByTestId('settings-tab-organize')).toBeTruthy();
act(() => {
window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: 'organize' }));
});
expect(screen.getByText('organize pane')).toBeTruthy();
});
it('still opens on the list without a link', () => {
window.history.replaceState(null, '', '/settings');
render(<SettingsApp linkSegments={[]} />);
expect(screen.getByTestId('settings-tab-appearance')).toBeTruthy();
expect(screen.queryByText('appearance pane')).toBeNull();
});
});
@@ -129,3 +129,11 @@ describe('Settings plugin pages', () => {
expect((createSlotInstance.mock.calls[0][0] as { slot: string }).slot).toBe('settings-page');
});
});
// SET-21: every settings page has an h1 with its name on the desktop too.
describe('Settings page heading', () => {
it('names the open page in an h1', () => {
render(<SettingsApp linkSegments={[]} />);
expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('settings.tabs.appearance');
});
});
@@ -0,0 +1,66 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AddressBook, Calendar } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useContactStore } from '@/stores/contact-store';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { KeywordSettings } from '../keyword-settings';
import { CalendarManagementSettings } from '../calendar-management-settings';
import { AddressBookManagementSettings } from '../address-book-management-settings';
// R7: row actions that appear on hover also show on keyboard focus and are
// always visible on a touch screen, where there is no hover.
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const initial = {
auth: useAuthStore.getState(),
calendar: useCalendarStore.getState(),
contacts: useContactStore.getState(),
settings: useSettingsStore.getState(),
};
afterEach(() => {
useAuthStore.setState(initial.auth, true);
useCalendarStore.setState(initial.calendar, true);
useContactStore.setState(initial.contacts, true);
useSettingsStore.setState(initial.settings, true);
});
function expectReachable(button: HTMLElement) {
const group = button.parentElement as HTMLElement;
expect(group.className).toContain('focus-within:opacity-100');
expect(group.className).toContain('pointer-coarse:opacity-100');
}
describe('settings row actions on touch and keyboard', () => {
it('tags', () => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
render(<KeywordSettings />);
expectReachable(screen.getAllByTitle('edit')[0]);
});
it('calendars', () => {
useAuthStore.setState({ client: { getAccountId: () => 'acc' }, serverUrl: null, username: null } as never);
useCalendarStore.setState({
calendars: [{ id: 'c', name: 'Work', color: '#3b82f6', isDefault: false, isShared: false, shareWith: null, myRights: { mayShare: true, mayDelete: true, mayWriteAll: true } } as unknown as Calendar],
icalSubscriptions: [],
} as never);
render(<CalendarManagementSettings />);
expectReachable(screen.getByTitle('edit'));
});
it('address books', () => {
useAuthStore.setState({ client: { getAccountId: () => 'acc' } } as never);
useContactStore.setState({
addressBooks: [{ id: 'b', name: 'Club', isDefault: false, isShared: false, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayDelete: true } } as unknown as AddressBook],
contacts: [],
fetchAddressBooks: vi.fn(async () => {}),
} as never);
render(<AddressBookManagementSettings />);
expectReachable(screen.getByTitle('rename'));
});
});
@@ -0,0 +1,68 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import en from '@/locales/en/common.json';
// SET-5: a search result whose row the page didn't show turned the synced
// Advanced switch on. Only a row known to be advanced may do that now.
vi.mock('next-intl', () => ({
useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key),
useMessages: () => en,
useLocale: () => 'en',
useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }),
}));
vi.mock('@/hooks/use-media-query', () => ({
useIsDesktop: () => true,
useIsMobile: () => false,
useMediaQuery: () => false,
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: false }) }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => null }));
// The page is not drawn here, so no result finds its row.
vi.mock('@/components/settings/message-list-settings', () => ({ MessageListSettings: () => null }));
const { SettingsApp } = await import('../settings-app');
const { useAuthStore } = await import('@/stores/auth-store');
const { usePluginStore } = await import('@/stores/plugin-store');
const { useThemeStore } = await import('@/stores/theme-store');
const { useSettingsStore } = await import('@/stores/settings-store');
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
const client = new Proxy({}, {
get: (_t, prop) => (prop === 'then' ? undefined : (prop === 'getAccountId' ? () => 'acc' : () => null)),
});
useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: client as never });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never);
useSettingsStore.setState({ showAdvancedSettings: false });
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
async function openResult(query: string, label: string) {
render(<SettingsApp linkSegments={[]} />);
fireEvent.change(screen.getByRole('searchbox'), { target: { value: query } });
fireEvent.click(screen.getByRole('button', { name: label }));
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
}
describe('settings search and the Advanced switch', () => {
it('leaves Advanced off for a row that is not an advanced one', async () => {
await openResult('favicons', 'Sender favicons');
expect(useSettingsStore.getState().showAdvancedSettings).toBe(false);
});
it('turns Advanced on for an advanced row it has to reveal', async () => {
await openResult('emails per page', 'Emails per page');
expect(useSettingsStore.getState().showAdvancedSettings).toBe(true);
});
});
@@ -0,0 +1,141 @@
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { NextIntlClientProvider } from 'next-intl';
import en from '@/locales/en/common.json';
// R-SET-5: every settings search result lands on a row of its page. This
// renders every settings page in English, with the Advanced switch on and
// off and in the situations some rows need (touch screen, Mountain View,
// several accounts), and checks that each entry of the search index has a
// row marked with its label, and is advanced exactly when the page says so.
vi.unmock('next-intl');
vi.mock('@/components/plugins/plugin-iframe-slot', () => ({ PluginIframeSlot: () => null }));
const { useSettingsStore } = await import('@/stores/settings-store');
const { useAuthStore } = await import('@/stores/auth-store');
const { useVacationStore } = await import('@/stores/vacation-store');
const { useFilterStore } = await import('@/stores/filter-store');
const { useAccountSecurityStore } = await import('@/stores/account-security-store');
const { useCalendarStore } = await import('@/stores/calendar-store');
const { useContactStore } = await import('@/stores/contact-store');
const { useEmailStore } = await import('@/stores/email-store');
const { useAccountStore } = await import('@/stores/account-store');
const { TourProvider } = await import('@/components/tour/tour-provider');
const { AdvancedSettingsProvider } = await import('../settings-section');
const { tabSearchEntries, getByPath } = await import('@/lib/settings-search');
const pages = {
account: (await import('../account-settings')).AccountSettings,
general: (await import('../general-settings')).GeneralSettings,
notifications: (await import('../notification-settings')).NotificationSettings,
appearance: (await import('../appearance-settings')).AppearanceSettings,
message_list: (await import('../message-list-settings')).MessageListSettings,
reading: (await import('../reading-settings')).ReadingSettings,
composing: (await import('../composing-settings')).ComposingSettings,
vacation: (await import('../vacation-settings')).VacationSettings,
filters: (await import('../filter-settings')).FilterSettings,
organize: (await import('../organize-settings')).OrganizeSettings,
security: (await import('../account-security-settings')).AccountSecuritySettings,
calendar: (await import('../calendar-settings')).CalendarSettings,
calendar2: (await import('../calendar-management-settings')).CalendarManagementSettings,
contacts: (await import('../contacts-settings')).ContactsSettings,
contacts2: (await import('../address-book-management-settings')).AddressBookManagementSettings,
files: (await import('../files-settings')).FilesSettingsComponent,
addons: (await import('../addons-settings')).AddonsSettings,
backup: (await import('../about-data-settings')).AboutDataSettings,
};
const OVERRIDES: Record<string, () => unknown> = {
getAccountId: () => 'acc', getSieveAccountId: () => 'acc', getUsername: () => 'user@example.com',
getSharedAccounts: () => [], getSieveAccounts: () => [], getSieveCapabilities: () => null,
hasAccountCapability: () => false, supportsCalendars: () => true, supportsFiles: () => true, hasDelayedSend: () => true,
};
const CLIENT = new Proxy({}, {
get: (_t, prop) => (prop === 'then' ? undefined : (OVERRIDES[String(prop)] ?? (() => null))),
});
// The settings tabs and the components settings-app.tsx draws for them.
const TAB_PAGES: Record<string, string[]> = {
account: ['account'], general: ['general'], notifications: ['notifications'], appearance: ['appearance'],
message_list: ['message_list'], reading: ['reading'], composing: ['composing'], vacation: ['vacation'],
filters: ['filters'], organize: ['organize'], security: ['security'], calendar: ['calendar', 'calendar2'],
contacts: ['contacts', 'contacts2'], files: ['files'], addons: ['addons'], backup: ['backup'],
};
describe('settings search index', () => {
it('has a row for every entry on its page', { timeout: 120_000 }, () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network')));
useAuthStore.setState({ isAuthenticated: true, authMode: 'basic', client: CLIENT } as never);
useVacationStore.setState({ isSupported: true, isLoading: false, forwardAvailable: true, fetchVacationResponse: vi.fn(async () => {}) } as never);
useFilterStore.setState({ isSupported: true, isLoading: false, rules: [], selectAccount: vi.fn(async () => {}) } as never);
useAccountSecurityStore.setState({ isStalwart: true, isProbing: false, probe: vi.fn(async () => true), fetchAll: vi.fn(), fetchAuthInfo: vi.fn(), fetchPublicKeys: vi.fn(), fetchCryptoInfo: vi.fn() } as never);
useCalendarStore.setState({ calendars: [{ id: 'c', name: 'Work', color: '#3b82f6', isDefault: false, isShared: false, shareWith: null, myRights: { mayShare: true, mayDelete: true, mayWriteAll: true } }], icalSubscriptions: [] } as never);
useContactStore.setState({ addressBooks: [{ id: 'b', name: 'Club', isDefault: true, isShared: false, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayDelete: true } }], contacts: [], fetchAddressBooks: vi.fn(async () => {}) } as never);
useEmailStore.setState({ fetchMailboxes: vi.fn(async () => {}) } as never);
const out: Record<string, Record<string, { on: string[]; off: string[]; error?: string }>> = {};
const realMatchMedia = window.matchMedia;
const variants: Record<string, () => void> = {
base: () => {},
touch: () => {
window.matchMedia = ((q: string) => ({ ...realMatchMedia(q), matches: q.includes('coarse') })) as typeof window.matchMedia;
},
mountainView: () => { useSettingsStore.setState({ interfaceLayout: 'mountain-view' } as never); },
multiAccount: () => {
useAccountStore.setState({ accounts: [
{ id: 'a', label: 'A', email: 'a@example.com', username: 'a@example.com', serverUrl: 'https://x', isDefault: true },
{ id: 'b', label: 'B', email: 'b@example.com', username: 'b@example.com', serverUrl: 'https://x', isDefault: false },
] } as never);
},
};
const settingsInitial = useSettingsStore.getState();
const accountsInitial = useAccountStore.getState();
for (const [variant, apply] of Object.entries(variants)) {
useSettingsStore.setState(settingsInitial, true);
useAccountStore.setState(accountsInitial, true);
window.matchMedia = realMatchMedia;
apply();
out[variant] = {};
for (const [tab, Page] of Object.entries(pages)) {
const labels = (adv: boolean) => {
useSettingsStore.setState({ showAdvancedSettings: adv });
const { container, unmount } = render(
<NextIntlClientProvider locale="en" messages={en}>
<TourProvider>
<AdvancedSettingsProvider>
<Page supportsCalendar />
</AdvancedSettingsProvider>
</TourProvider>
</NextIntlClientProvider>,
);
const found = Array.from(container.querySelectorAll('[data-search-label]')).map((el) => el.getAttribute('data-search-label') ?? '');
unmount();
return found;
};
try {
out[variant][tab] = { on: labels(true), off: labels(false) };
} catch (e) {
out[variant][tab] = { on: [], off: [], error: String(e) };
}
}
}
window.matchMedia = realMatchMedia;
const problems: string[] = [];
for (const [tab, entries] of Object.entries(tabSearchEntries)) {
for (const entry of entries) {
const text = getByPath(en, entry.label) as string;
const variant = out[entry.when ?? 'base'];
const parts = TAB_PAGES[tab].map((page) => variant[page]);
const errors = parts.filter((part) => part.error).map((part) => part.error);
if (errors.length) { problems.push(`${tab}: ${errors.join('; ')}`); continue; }
const withAdvanced = parts.some((part) => part.on.includes(text));
const withoutAdvanced = parts.some((part) => part.off.includes(text));
if (!withAdvanced) problems.push(`${tab}: no row for ${entry.label} ("${text}")`);
else if (!!entry.advanced === withoutAdvanced) {
problems.push(`${tab}: ${entry.label} is ${withoutAdvanced ? 'not ' : ''}an advanced row`);
}
}
}
expect([...new Set(problems)]).toEqual([]);
});
});
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { SettingItem, ToggleSwitch, RadioGroup, Select } from '../settings-section';
import { SettingItem, ToggleSwitch, RadioGroup, Select, SectionNav, SettingsSection } from '../settings-section';
describe('SettingItem controls', () => {
it('names a toggle after the row label and reports its state', () => {
@@ -103,3 +103,19 @@ describe('ToggleSwitch off state (finding 56)', () => {
}
});
});
// SET-17: the jump row read the heading's text, which holds the Advanced
// badge too ("Loading & searchAdvanced").
describe('SectionNav', () => {
it('names each chip after its section, without the Advanced badge', () => {
render(
<div>
<SectionNav />
<SettingsSection id="list" title="Message list"><span /></SettingsSection>
<SettingsSection id="loading" title="Loading & search" advanced><span /></SettingsSection>
</div>
);
const nav = screen.getByTestId('settings-section-nav');
expect(Array.from(nav.querySelectorAll('button')).map((b) => b.textContent)).toEqual(['Message list', 'Loading & search']);
});
});
@@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { SectionNav, Select, SettingItem, SettingsSection, ToggleSwitch } from '../settings-section';
// SET-22: on the phone, settings switches (44x24), jump chips (28px) and
// selects (29px) were smaller than the 44px a finger needs (R1).
const HIT = 'pointer-coarse:after:min-h-11';
describe('settings touch targets', () => {
it('switches keep their size and get a 44px hit area', () => {
render(<SettingItem label="Sync"><ToggleSwitch checked onChange={() => {}} /></SettingItem>);
expect(screen.getByRole('switch').className).toContain(HIT);
});
it('jump chips get a 44px hit area', () => {
render(
<div>
<SectionNav />
<SettingsSection id="a" title="One"><span /></SettingsSection>
<SettingsSection id="b" title="Two"><span /></SettingsSection>
</div>
);
expect(screen.getByRole('button', { name: 'One' }).className).toContain(HIT);
});
it('selects are 44px tall on a touch screen', () => {
render(<Select value="a" onChange={() => {}} ariaLabel="Pick" options={[{ value: 'a', label: 'A' }]} />);
expect(screen.getByRole('combobox').className).toContain('pointer-coarse:min-h-11');
});
});
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { FilterRule } from '@/lib/jmap/sieve-types';
@@ -81,19 +81,24 @@ async function openCard(server: ReturnType<typeof stalwart>) {
useAuthStore.setState({ client: server.client as never });
useVacationStore.setState({ isSupported: true });
render(<VacationSettings />);
await waitFor(() => expect(server.client.getVacationResponse).toHaveBeenCalled());
await waitFor(() => expect(useVacationStore.getState().isLoading).toBe(false));
await screen.findByText('status.label');
}
const forwardSwitch = () => screen.getByRole('switch', { name: 'forward.enabled_label' });
const keepSwitch = () => screen.getByRole('switch', { name: 'forward.keep_label' });
const addressInput = () => screen.getByLabelText('forward.to_label') as HTMLInputElement;
const saveButton = () => screen.getByText('save').closest('button') as HTMLButtonElement;
// The card saves as you go: switches at once, typing once it pauses.
async function save(server: ReturnType<typeof stalwart>) {
fireEvent.click(saveButton());
await waitFor(() => expect(server.client.setVacationResponse).toHaveBeenCalled());
await waitFor(() => expect(toast.success.mock.calls.length + toast.error.mock.calls.length).toBe(1));
const before = server.client.setVacationResponse.mock.calls.length;
await waitFor(() => expect(server.client.setVacationResponse.mock.calls.length).toBeGreaterThan(before), { timeout: 3000 });
await waitFor(() => expect(useVacationStore.getState().isSaving).toBe(false));
await waitFor(() => expect(saved() || toast.error.mock.calls.length > 0).toBe(true));
}
const saved = () => /saved/.test(screen.getByRole('status').textContent ?? '');
/** Long enough for a save that was due to have started. */
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 1100)));
describe('forwarding in the vacation card', () => {
it('is offered only where the server can run it next to the auto-reply', async () => {
@@ -111,7 +116,7 @@ describe('forwarding in the vacation card', () => {
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
await save(server);
expect(toast.success).toHaveBeenCalledWith('vacation_saved');
expect(saved()).toBe(true);
expect(server.client.setVacationResponse.mock.calls[0][0]).not.toHaveProperty('forward');
expect(server.active()).toBe('filters');
const written = server.content('filters');
@@ -135,7 +140,7 @@ describe('forwarding in the vacation card', () => {
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
await save(server);
expect(toast.success).toHaveBeenCalledWith('vacation_saved');
expect(saved()).toBe(true);
expect(server.client.setVacationResponse.mock.calls[0][0]).toMatchObject({ isEnabled: false });
expect(server.scripts.some((s) => s.name === 'vacation' && s.isActive)).toBe(false);
expect(server.active()).toBe('filters');
@@ -152,12 +157,10 @@ describe('forwarding in the vacation card', () => {
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
await save(server);
expect(server.active()).toBe('filters');
toast.success.mockClear();
// Saving again turns Stalwart's own vacation script back on.
fireEvent.change(screen.getByPlaceholderText('message.subject_placeholder'), { target: { value: 'Bis bald' } });
fireEvent.click(saveButton());
await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1));
await save(server);
expect(server.client.setVacationResponse).toHaveBeenCalledTimes(2);
expect(server.active()).toBe('filters');
expect(server.content('filters')).toContain('# Vacation forwarding');
@@ -181,10 +184,11 @@ describe('forwarding in the vacation card', () => {
fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege' } });
expect(screen.getByText('warnings.forward_address')).toBeInTheDocument();
expect(saveButton().disabled).toBe(true);
await settle();
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
expect(screen.queryByText('warnings.forward_address')).toBeNull();
expect(saveButton().disabled).toBe(false);
await save(server);
});
it('does not count an address typed and switched off again as a change', async () => {
@@ -194,7 +198,8 @@ describe('forwarding in the vacation card', () => {
fireEvent.change(addressInput(), { target: { value: 'kollege' } });
fireEvent.click(forwardSwitch());
// Nothing a save would store is different, so there is nothing to save.
expect(saveButton().disabled).toBe(true);
await settle();
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
});
it('warns that a forwarding rule is skipped when a copy is kept and the server allows one redirect', async () => {
@@ -214,17 +219,16 @@ describe('forwarding in the vacation card', () => {
fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
await save(server);
toast.success.mockClear();
// Back as stored after the save, as the server has it now.
expect(useVacationStore.getState().forward).toMatchObject({ enabled: true, to: 'kollege@example.com' });
expect(forwardSwitch().getAttribute('aria-checked')).toBe('true');
expect(addressInput().value).toBe('kollege@example.com');
expect(saveButton().disabled).toBe(true);
await settle();
expect(server.client.setVacationResponse).toHaveBeenCalledTimes(1);
fireEvent.click(forwardSwitch());
fireEvent.click(saveButton());
await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1));
await save(server);
const written = server.content('filters');
expect(written).not.toContain('# Vacation forwarding');
expect(written).not.toContain('redirect');
@@ -244,7 +248,8 @@ describe('forwarding in the vacation card', () => {
expect(forwardSwitch().getAttribute('aria-checked')).toBe('true');
expect(addressInput().value).toBe('kollege@example.com');
expect(keepSwitch().getAttribute('aria-checked')).toBe('true');
expect(saveButton().disabled).toBe(true);
await settle();
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
});
it('says so when the auto-reply was saved but the forwarding was not', async () => {
@@ -256,10 +261,11 @@ describe('forwarding in the vacation card', () => {
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
await save(server);
expect(toast.error).toHaveBeenCalledWith('vacation_filters_save_failed');
expect(toast.success).not.toHaveBeenCalled();
// What was set stays, to be saved again.
expect(saved()).toBe(false);
// What was set stays, and isn't sent again and again until it changes.
expect(forwardSwitch().getAttribute('aria-checked')).toBe('true');
expect(saveButton().disabled).toBe(false);
await settle();
expect(server.client.setVacationResponse).toHaveBeenCalledTimes(1);
});
it('saves only what was changed here, not forwarding another tab changed since', async () => {
@@ -276,7 +282,7 @@ describe('forwarding in the vacation card', () => {
await server.client.updateSieveScript(server.scripts[0].id, stored(false), true, 'b');
fireEvent.change(screen.getByPlaceholderText('message.subject_placeholder'), { target: { value: 'Bis bald' } });
await save(server);
expect(toast.success).toHaveBeenCalledWith('vacation_saved');
expect(saved()).toBe(true);
expect(parseScript(server.content('filters')).vacationForward).toMatchObject({ enabled: false });
expect(server.content('filters')).not.toContain('redirect');
});
@@ -297,14 +303,16 @@ describe('forwarding in the vacation card', () => {
], EXTENSIONS);
await openCard(server);
expect(screen.getByText('warnings.not_running')).toBeInTheDocument();
expect(saveButton().disabled).toBe(false);
// Turned back on only when asked, not by opening the page.
await settle();
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'save' }));
await save(server);
expect(toast.success).toHaveBeenCalledWith('vacation_saved');
expect(saved()).toBe(true);
expect(server.active()).toBe('filters');
expect(server.content('filters')).toContain('# Vacation forwarding');
expect(screen.queryByText('warnings.not_running')).toBeNull();
expect(saveButton().disabled).toBe(true);
});
it('starts over on another account, even where both store the same', async () => {
@@ -312,12 +320,12 @@ describe('forwarding in the vacation card', () => {
await openCard(server);
fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'chef@a.example' } });
expect(saveButton().disabled).toBe(false);
// Neither account has forwarding stored, so no stored value changes.
useAuthStore.setState({ activeAccountId: 'other-login' });
act(() => { useAuthStore.setState({ activeAccountId: 'other-login' }); });
await waitFor(() => expect(forwardSwitch().getAttribute('aria-checked')).toBe('false'));
expect(saveButton().disabled).toBe(true);
await settle();
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
fireEvent.click(forwardSwitch());
expect(addressInput().value).toBe('');
});
@@ -336,12 +344,16 @@ describe('the vacation period in the card', () => {
halfFilledIn(dateField(1), true);
fireEvent.change(dateField(1), { target: { value: '' } });
expect(screen.getByText('warnings.invalid_date')).toBeInTheDocument();
expect(saveButton().disabled).toBe(true);
// Said under the field it is about.
expect(dateField(1).getAttribute('aria-invalid')).toBe('true');
expect(document.getElementById(dateField(1).getAttribute('aria-describedby')!)?.textContent).toBe('warnings.invalid_date');
await settle();
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
halfFilledIn(dateField(1), false);
fireEvent.change(dateField(1), { target: { value: '2026-10-17T18:00' } });
expect(screen.queryByText('warnings.invalid_date')).toBeNull();
expect(saveButton().disabled).toBe(false);
await save(server);
});
it('checks the fields again on save, which a field filled in only half may never have told', async () => {
@@ -349,8 +361,7 @@ describe('the vacation period in the card', () => {
await openCard(server);
fireEvent.change(dateField(1), { target: { value: '' } });
halfFilledIn(dateField(1), true);
fireEvent.click(saveButton());
expect(await screen.findByText('warnings.invalid_date')).toBeInTheDocument();
expect(await screen.findByText('warnings.invalid_date', undefined, { timeout: 3000 })).toBeInTheDocument();
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
});
@@ -360,7 +371,17 @@ describe('the vacation period in the card', () => {
fireEvent.change(dateField(1), { target: { value: '20266-10-16T18:00' } });
expect(dateField(1).value).toBe('20266-10-16T18:00');
expect(screen.getByText('warnings.invalid_date')).toBeInTheDocument();
expect(saveButton().disabled).toBe(true);
await settle();
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
});
it('says an end before the start under the end date and keeps it from the server', async () => {
const server = stalwart([rule('a')]);
await openCard(server);
fireEvent.change(dateField(1), { target: { value: '2026-10-01T09:00' } });
expect(document.getElementById(dateField(1).getAttribute('aria-describedby')!)?.textContent).toBe('warnings.end_before_start');
await settle();
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
});
it('keeps the dates the server has while their fields are left alone', async () => {
@@ -408,7 +429,7 @@ describe('who gets the auto-reply, in the vacation card', () => {
fireEvent.change(audienceSelect(), { target: { value: 'external' } });
await save(server);
expect(toast.success).toHaveBeenCalledWith('vacation_saved');
expect(saved()).toBe(true);
expect(server.active()).toBe('filters');
const written = server.content('filters');
expect(written).toContain([
@@ -438,7 +459,6 @@ describe('who gets the auto-reply, in the vacation card', () => {
await openCard(server);
expect(screen.getByRole('switch', { name: 'status.label' }).getAttribute('aria-checked')).toBe('true');
expect(audienceSelect().value).toBe('internal');
expect(saveButton().disabled).toBe(true);
fireEvent.change(audienceSelect(), { target: { value: 'all' } });
await save(server);
@@ -450,6 +470,44 @@ describe('who gets the auto-reply, in the vacation card', () => {
});
});
describe('saving the out-of-office card', () => {
it('saves the auto-reply switch on change, with no Save button', async () => {
const server = stalwart([rule('a')]);
await openCard(server);
expect(screen.queryByRole('button', { name: 'save' })).toBeNull();
fireEvent.click(screen.getByRole('switch', { name: 'status.label' }));
await save(server);
expect(server.client.setVacationResponse.mock.calls[0][0]).toMatchObject({ isEnabled: true });
expect(useVacationStore.getState().isEnabled).toBe(true);
expect(saved()).toBe(true);
});
it('waits for typing to pause, then saves once', async () => {
const server = stalwart([rule('a')]);
await openCard(server);
const subject = screen.getByPlaceholderText('message.subject_placeholder');
fireEvent.change(subject, { target: { value: 'B' } });
fireEvent.change(subject, { target: { value: 'Bis' } });
fireEvent.change(subject, { target: { value: 'Bis bald' } });
await save(server);
expect(server.client.setVacationResponse).toHaveBeenCalledTimes(1);
expect(server.client.setVacationResponse.mock.calls[0][0]).toMatchObject({ subject: 'Bis bald' });
});
it('saves what was typed when the page is left before the pause', async () => {
const server = stalwart([rule('a')]);
useAuthStore.setState({ client: server.client as never });
useVacationStore.setState({ isSupported: true });
const { unmount } = render(<VacationSettings />);
await screen.findByText('status.label');
fireEvent.change(screen.getByPlaceholderText('message.subject_placeholder'), { target: { value: 'Bis bald' } });
unmount();
await waitFor(() => expect(server.client.setVacationResponse).toHaveBeenCalledTimes(1));
expect(server.client.setVacationResponse.mock.calls[0][0]).toMatchObject({ subject: 'Bis bald' });
});
});
describe('layout of the out-of-office card', () => {
const follows = (a: Node, b: Node) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
+37 -33
View File
@@ -13,6 +13,8 @@ import { cn } from '@/lib/utils';
import { getPathPrefix, withBasePath } from '@/lib/browser-navigation';
import { clearCachedData } from '@/lib/clear-cached-data';
import { SpamSiegeGame } from './spam-siege-game';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from '@/stores/toast-store';
import { DebugSettings } from './debug-settings';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
@@ -83,7 +85,13 @@ export function AboutFooter() {
<div data-testid="settings-about" className="px-3 py-2">
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
<div className="flex items-center gap-3">
<button onClick={handleLogoClick} className="flex items-center gap-3 flex-1 min-w-0 text-start focus:outline-none group/about cursor-pointer" aria-label="About">
{/* Named by what it shows (app name and version), with the focus
ring every other stop in the sidebar has. */}
<button
type="button"
onClick={handleLogoClick}
className="flex items-center gap-3 flex-1 min-w-0 text-start rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group/about cursor-pointer"
>
<img
src={logoLight}
alt={branded ? appName : 'Bulwark'}
@@ -122,7 +130,6 @@ export function AboutFooter() {
*/
export function AboutDataSettings() {
const t = useTranslations('settings.advanced');
const tCommon = useTranslations('common');
const tSettings = useTranslations('settings');
const { settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } =
useSettingsStore();
@@ -157,32 +164,19 @@ export function AboutDataSettings() {
const json = event.target?.result as string;
const success = importSettings(json);
if (success) {
alert(tSettings('import_success'));
toast.success(tSettings('import_success'));
} else {
alert(tSettings('import_error'));
toast.error(tSettings('import_error'));
}
};
reader.readAsText(file);
};
const handleRefreshCache = () => {
if (showRefreshConfirm) {
clearCachedData(); // reloads the page
} else {
setShowRefreshConfirm(true);
setTimeout(() => setShowRefreshConfirm(false), 5000);
}
// The same file can be picked again after fixing it.
e.target.value = '';
};
const handleReset = () => {
if (showResetConfirm) {
resetToDefaults();
setShowResetConfirm(false);
alert(tSettings('save_success'));
} else {
setShowResetConfirm(true);
setTimeout(() => setShowResetConfirm(false), 5000);
}
resetToDefaults();
toast.success(t('reset_settings.done'));
};
return (
@@ -222,26 +216,36 @@ export function AboutDataSettings() {
<SettingsSection id="fix" title={t('sections.fix')} description={t('sections.fix_description')}>
<SettingItem label={t('refresh_cache.label')} description={t('refresh_cache.description')}>
<Button
variant={showRefreshConfirm ? 'default' : 'outline'}
size="sm"
onClick={handleRefreshCache}
>
{showRefreshConfirm ? tCommon('yes') : t('refresh_cache.button')}
<Button variant="outline" size="sm" onClick={() => setShowRefreshConfirm(true)}>
{t('refresh_cache.button')}
</Button>
</SettingItem>
<SettingItem label={t('reset_settings.label')} description={t('reset_settings.description')}>
<Button
variant={showResetConfirm ? 'destructive' : 'outline'}
size="sm"
onClick={handleReset}
>
{showResetConfirm ? tCommon('yes') : t('reset_settings.button')}
<Button variant="outline" size="sm" onClick={() => setShowResetConfirm(true)}>
{t('reset_settings.button')}
</Button>
</SettingItem>
</SettingsSection>
<ConfirmDialog
isOpen={showRefreshConfirm}
onClose={() => setShowRefreshConfirm(false)}
onConfirm={() => clearCachedData() /* reloads the page */}
title={t('refresh_cache.confirm_title')}
message={t('refresh_cache.description')}
confirmText={t('refresh_cache.button')}
/>
<ConfirmDialog
isOpen={showResetConfirm}
onClose={() => setShowResetConfirm(false)}
onConfirm={handleReset}
title={t('reset_settings.confirm_title')}
message={t('reset_settings.description')}
confirmText={t('reset_settings.button')}
variant="destructive"
/>
<DebugSettings />
</div>
);
@@ -11,6 +11,8 @@ import { decryptArchive, encryptArchive, ARCHIVE_MIN_PASSWORD, type ArchiveOwner
import { collectAccounts, fetchArchive, putArchive, removeArchive, restoreArchive } from '@/lib/account-archive-client';
import { toUnicodeEmail } from '@/lib/idn';
import { SettingsSection } from './settings-section';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
const KNOWN_ERRORS = ['mismatch', 'password_length', 'unlock_failed', 'owner_signin_required', 'https_required'];
@@ -35,6 +37,7 @@ export function restoreSummary(t: BackupT, result: Awaited<ReturnType<typeof res
/** Save this browser's account list, encrypted, under the active account; restore it on another device. */
export function AccountArchiveSettings() {
const t = useTranslations('settings.account.backup');
const tCommon = useTranslations('common');
const id = useId();
const username = useAuthStore(s => s.username);
const serverUrl = useAuthStore(s => s.serverUrl);
@@ -48,6 +51,7 @@ export function AccountArchiveSettings() {
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ error: boolean; text: string } | null>(null);
const owner: ArchiveOwner | null = username && serverUrl ? { username, serverUrl } : null;
const { dialogProps: confirmProps, confirm } = useConfirmDialog();
useEffect(() => {
setSaved(null);
@@ -87,8 +91,14 @@ export function AccountArchiveSettings() {
if (!envelope) { setSaved(false); throw new Error('storage_failed'); }
return restoreSummary(t, await restoreArchive(await decryptArchive(owner, envelope, password), rememberMeEnabled));
});
const remove = () => {
if (!window.confirm(t('delete_confirm'))) return;
const remove = async () => {
const ok = await confirm({
title: t('delete_confirm'),
message: t('delete_message'),
confirmText: tCommon('delete'),
variant: 'destructive',
});
if (!ok) return;
void run(async () => { await removeArchive(owner); setSaved(false); return t('deleted'); });
};
@@ -150,6 +160,7 @@ export function AccountArchiveSettings() {
{message.text}
</p>
)}
<ConfirmDialog {...confirmProps} />
</SettingsSection>
);
}
@@ -19,9 +19,13 @@ import { sanitizeI18nHtml } from '@/lib/email-sanitization';
import { IS_LITE } from '@/lib/lite';
import { useConfig } from '@/hooks/use-config';
import { totpIssuer } from '@/lib/totp-issuer';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { IconButton } from '@/components/ui/icon-button';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
function PasswordChangeSection() {
const t = useTranslations('settings.security');
const tLogin = useTranslations('login');
const { changePassword, isSaving, otpEnabled } = useAccountSecurityStore();
const [currentPassword, setCurrentPassword] = useState('');
const [otpCode, setOtpCode] = useState('');
@@ -62,7 +66,7 @@ function PasswordChangeSection() {
<div className="space-y-4">
<div className="flex items-center gap-2 mb-2">
<Key className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('password.title')}</h4>
<h4 data-search-label={t('password.title')} className="text-sm font-medium text-foreground">{t('password.title')}</h4>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
@@ -76,13 +80,14 @@ function PasswordChangeSection() {
autoComplete="current-password"
className="pe-10"
/>
<button
type="button"
<IconButton
label={showCurrent ? tLogin('hide_password') : tLogin('show_password')}
aria-pressed={showCurrent}
onClick={() => setShowCurrent(!showCurrent)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
className="absolute end-1 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showCurrent ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
{showCurrent ? <EyeOff /> : <Eye />}
</IconButton>
</div>
</div>
{otpEnabled && (
@@ -110,13 +115,14 @@ function PasswordChangeSection() {
autoComplete="new-password"
className="pe-10"
/>
<button
type="button"
<IconButton
label={showNew ? tLogin('hide_password') : tLogin('show_password')}
aria-pressed={showNew}
onClick={() => setShowNew(!showNew)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
className="absolute end-1 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showNew ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
{showNew ? <EyeOff /> : <Eye />}
</IconButton>
</div>
</div>
<div>
@@ -390,7 +396,7 @@ function parseIpList(raw: string): string[] {
.filter(Boolean);
}
function CredentialRow({ entry, onRemove, isSaving }: { entry: AppPasswordInfo | ApiKeyInfo; onRemove: (id: string) => void; isSaving: boolean }) {
function CredentialRow({ entry, onRemove, isSaving, deleteLabel }: { entry: AppPasswordInfo | ApiKeyInfo; onRemove: (id: string) => void; isSaving: boolean; deleteLabel: string }) {
return (
<div className="flex items-start justify-between py-2 px-3 bg-muted/50 rounded-md gap-2">
<div className="flex flex-col min-w-0 flex-1">
@@ -414,15 +420,14 @@ function CredentialRow({ entry, onRemove, isSaving }: { entry: AppPasswordInfo |
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
<IconButton
label={deleteLabel}
onClick={() => onRemove(entry.id)}
disabled={isSaving}
className="text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="w-3 h-3" />
</Button>
<Trash2 />
</IconButton>
</div>
);
}
@@ -437,6 +442,7 @@ interface CredentialSectionProps {
function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRemove }: CredentialSectionProps) {
const t = useTranslations('settings.security');
const tCommon = useTranslations('common');
const tk = (key: string) => t(`${i18nNamespace}.${key}`);
const { isSaving, isLoadingAuth } = useAccountSecurityStore();
const [showAdd, setShowAdd] = useState(false);
@@ -467,7 +473,18 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
}
};
const { dialogProps: confirmProps, confirm } = useConfirmDialog();
// A credential can't be brought back, so it asks first (R3).
const handleRemove = async (id: string) => {
const entry = entries.find((e) => e.id === id);
const ok = await confirm({
title: tk('delete_title'),
message: t(`${i18nNamespace}.delete_message`, { name: entry?.description || id }),
confirmText: tCommon('delete'),
variant: 'destructive',
});
if (!ok) return;
try {
await onRemove(id);
toast.success(tk('removed'));
@@ -489,7 +506,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
<div className="space-y-2">
<div className="flex items-center gap-2 mb-2">
<Icon className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
<h4 data-search-label={tk('title')} className="text-sm font-medium text-foreground">{tk('title')}</h4>
</div>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
@@ -501,7 +518,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
<h4 data-search-label={tk('title')} className="text-sm font-medium text-foreground">{tk('title')}</h4>
</div>
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="w-3 h-3 me-1" />
@@ -568,12 +585,13 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
{entries.length > 0 ? (
<div className="space-y-1">
{entries.map((entry) => (
<CredentialRow key={entry.id} entry={entry} onRemove={handleRemove} isSaving={isSaving} />
<CredentialRow key={entry.id} entry={entry} onRemove={handleRemove} isSaving={isSaving} deleteLabel={tCommon('delete')} />
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">{tk('none')}</p>
)}
<ConfirmDialog {...confirmProps} />
</div>
);
}
@@ -606,6 +624,7 @@ function ApiKeysSection() {
function PublicKeysSection() {
const t = useTranslations('settings.security');
const tCommon = useTranslations('common');
const tk = (key: string) => t(`public_keys.${key}`);
const {
publicKeys,
@@ -645,7 +664,17 @@ function PublicKeysSection() {
}
};
const { dialogProps: confirmProps, confirm } = useConfirmDialog();
const handleRemove = async (id: string) => {
const key = publicKeys.find((k) => k.id === id);
const ok = await confirm({
title: tk('delete_title'),
message: t('public_keys.delete_message', { name: key?.description || id }),
confirmText: tCommon('delete'),
variant: 'destructive',
});
if (!ok) return;
try {
await removePublicKey(id);
toast.success(tk('removed'));
@@ -659,7 +688,14 @@ function PublicKeysSection() {
encryptionConfig.type !== 'Disabled' && encryptionConfig.publicKeyId === keyId;
if (isCurrentlyActiveKey) {
// Disable encryption
// Turning it off changes how new mail is stored, so it asks first.
const ok = await confirm({
title: t('encryption.disable_title'),
message: t('encryption.disable_message'),
confirmText: t('encryption.disable_button'),
variant: 'destructive',
});
if (!ok) return;
try {
await updateEncryptionAtRest({ type: 'Disabled' });
toast.success(t('encryption.disabled_success'));
@@ -698,7 +734,7 @@ function PublicKeysSection() {
<div className="space-y-2">
<div className="flex items-center gap-2 mb-2">
<Lock className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
<h4 data-search-label={tk('title')} className="text-sm font-medium text-foreground">{tk('title')}</h4>
</div>
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
@@ -710,7 +746,7 @@ function PublicKeysSection() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Lock className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('encryption.section_title')}</h4>
<h4 data-search-label={t('encryption.section_title')} className="text-sm font-medium text-foreground">{t('encryption.section_title')}</h4>
</div>
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="w-3 h-3 me-1" />
@@ -836,30 +872,27 @@ function PublicKeysSection() {
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
<IconButton
label={isEncryptedWithThisKey ? t('encryption.disable_tooltip') : t('encryption.enable_tooltip')}
onClick={() => handleToggleEncryption(key.id)}
disabled={isSaving}
title={isEncryptedWithThisKey ? t('encryption.disable_tooltip') : t('encryption.enable_tooltip')}
className={cn(
isEncryptedWithThisKey
? 'text-green-600 hover:text-green-700 dark:text-green-400'
: 'text-muted-foreground hover:text-foreground'
)}
>
{isEncryptedWithThisKey ? <Lock className="w-4 h-4" /> : <Unlock className="w-4 h-4" />}
</Button>
{isEncryptedWithThisKey ? <Lock /> : <Unlock />}
</IconButton>
<Button
variant="ghost"
size="sm"
<IconButton
label={tCommon('delete')}
onClick={() => handleRemove(key.id)}
disabled={isSaving || isEncryptedWithThisKey}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3" />
</Button>
<Trash2 />
</IconButton>
</div>
</div>
);
@@ -868,6 +901,7 @@ function PublicKeysSection() {
) : (
<p className="text-xs text-muted-foreground italic">{tk('none')}</p>
)}
<ConfirmDialog {...confirmProps} />
</div>
);
}
@@ -890,7 +924,7 @@ function EmailClientSection() {
<div className="space-y-3">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('email_client.title')}</h4>
<h4 data-search-label={t('email_client.title')} className="text-sm font-medium text-foreground">{t('email_client.title')}</h4>
</div>
<p className="text-xs text-muted-foreground">{t('email_client.description')}</p>
<div className="p-3 bg-muted/70 dark:bg-muted/40 rounded-md space-y-2">
@@ -1288,7 +1322,7 @@ export function LinkDeviceSection() {
<p className="sr-only" aria-live="polite">{announcement}</p>
<div className="flex items-center gap-2">
<QrCode className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('link_device.title')}</h4>
<h4 data-search-label={t('link_device.title')} className="text-sm font-medium text-foreground">{t('link_device.title')}</h4>
</div>
<p className="text-xs text-muted-foreground">{t('link_device.description')}</p>
@@ -1517,7 +1551,7 @@ export function AccountSecuritySettings() {
<div>
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-muted-foreground" />
<h4 className="text-sm font-medium text-foreground">{t('totp.section_title')}</h4>
<h4 data-search-label={t('totp.section_title')} className="text-sm font-medium text-foreground">{t('totp.section_title')}</h4>
</div>
<TotpSection />
</div>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Book, BookPlus, Pencil, Share2, Star, Tag, Users } from "@/components/icons";
import { Book, BookPlus, Pencil, Share2, Star, Tag, Trash2, Users } from "@/components/icons";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
@@ -12,6 +12,8 @@ import { cn } from "@/lib/utils";
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "./share-collection-dialog";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
function AddressBookEditRow({
initial,
@@ -74,7 +76,9 @@ export function AddressBookManagementSettings() {
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, setDefaultAddressBook, renameKeyword } = useContactStore();
const tNotifications = useTranslations("notifications");
const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, setDefaultAddressBook, removeAddressBook, renameKeyword, updateContact, updateLocalContact } = useContactStore();
const { dialogProps: confirmProps, confirm } = useConfirmDialog();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
@@ -128,6 +132,65 @@ export function AddressBookManagementSettings() {
}
};
// An address book takes its contacts with it, so it asks first (R3), as
// the Contacts sidebar does.
const handleDelete = async (book: AddressBook) => {
if (!client) return;
const ok = await confirm({
title: t("delete"),
message: t("confirm_delete", { name: book.name }),
confirmText: t("delete"),
variant: "destructive",
});
if (!ok) return;
setIsLoading(true);
try {
await removeAddressBook(client, book);
toast.success(t("deleted"));
} catch {
toast.error(t("delete_failed"));
} finally {
setIsLoading(false);
}
};
// Sets a category on or off for each of these contacts.
const setCategory = async (keyword: string, ids: string[], on: boolean) => {
for (const id of ids) {
const contact = useContactStore.getState().contacts.find((c) => c.id === id);
if (!contact) continue;
const { [keyword]: _removed, ...rest } = contact.keywords || {};
const keywords: Record<string, boolean> = on ? { ...rest, [keyword]: true } : rest;
const saved = supportsSync && client
? await updateContact(client, id, { keywords })
: await updateLocalContact(id, { keywords });
if (!saved) throw new Error("not saved");
}
};
// Deleting a category only takes it off the contacts: no confirm, and
// Undo puts it back on the same ones (R3).
const handleDeleteCategory = async (keyword: string) => {
const ids = contacts.filter((c) => c.kind !== "group" && c.keywords?.[keyword]).map((c) => c.id);
setIsLoading(true);
try {
await setCategory(keyword, ids, false);
} catch {
toast.error(tSettings("category_delete_failed"));
return;
} finally {
setIsLoading(false);
}
toast.success(tSettings("category_deleted"), {
action: {
label: tNotifications("rule_undo"),
onClick: () => {
setCategory(keyword, ids, true).catch(() => toast.error(tSettings("category_delete_failed")));
},
},
});
};
// Group: personal first, then by shared account
const personal = addressBooks.filter((b) => !b.isShared);
const sharedGroups = new Map<string, { accountName: string; books: AddressBook[] }>();
@@ -156,6 +219,9 @@ export function AddressBookManagementSettings() {
// The default only applies to books the account owns, and re-setting the
// current default is a no-op, so hide it in both cases.
const canSetDefault = !!client && !book.isShared && !book.isDefault;
// The default book is where new contacts go, so it stays; a shared one
// only when its owner lets this account delete it.
const canDelete = !!client && !book.isDefault && book.myRights?.mayDelete !== false;
return (
<div
@@ -186,7 +252,7 @@ export function AddressBookManagementSettings() {
</span>
);
})()}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
{canRename && (
<button
type="button"
@@ -218,6 +284,18 @@ export function AddressBookManagementSettings() {
<Users className="w-3.5 h-3.5" />
</button>
)}
{canDelete && (
<button
type="button"
onClick={() => void handleDelete(book)}
disabled={isLoading}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
title={t("delete")}
aria-label={t("delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
@@ -313,7 +391,7 @@ export function AddressBookManagementSettings() {
<span className="text-sm font-medium truncate block">{keyword}</span>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{count}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingKeyword(keyword)}
@@ -322,6 +400,16 @@ export function AddressBookManagementSettings() {
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => void handleDeleteCategory(keyword)}
disabled={isLoading}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
title={tSettings("delete_category")}
aria-label={tSettings("delete_category")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
@@ -343,6 +431,8 @@ export function AddressBookManagementSettings() {
onSubmit={(name) => void handleCreate(name)}
/>
<ConfirmDialog {...confirmProps} />
{sharingId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingId);
if (!book) return null;
@@ -25,6 +25,7 @@ import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-c
import { IS_LITE } from '@/lib/lite';
import { shareRecipientNames, useShareRecipientLabels } from '@/hooks/use-share-recipient-labels';
import { calendarAccountKey, moveCalendar } from '@/lib/calendar-sort';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
function CalendarColorPicker({
value,
@@ -280,6 +281,7 @@ export function CalendarManagementSettings() {
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
const tImport = useTranslations('calendar.import');
const tSub = useTranslations('calendar.subscription');
const tConfirm = useTranslations('settings.calendar_management');
const timeFormat = useSettingsStore((s) => s.timeFormat);
const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors);
const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor);
@@ -594,54 +596,6 @@ export function CalendarManagementSettings() {
);
}
if (deletingId === cal.id) {
return (
<div key={cal.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{t('confirm_delete', { name: cal.name })}
</p>
<button
onClick={() => handleDelete(cal.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
>
{t('delete')}
</button>
<button
onClick={() => setDeletingId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
}
if (clearingId === cal.id) {
return (
<div key={cal.id} className="flex items-center gap-3 py-2.5 px-3 bg-amber-500/5 rounded-md border border-amber-500/20">
<Eraser className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{t('confirm_clear', { name: cal.name })}
</p>
<button
onClick={() => handleClear(cal.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-amber-600 text-white rounded-md hover:bg-amber-700 disabled:opacity-50"
>
{t('clear_events')}
</button>
<button
onClick={() => setClearingId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
}
return (
<div
key={cal.id}
@@ -772,7 +726,7 @@ export function CalendarManagementSettings() {
</div>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingId(cal.id)}
@@ -844,6 +798,7 @@ export function CalendarManagementSettings() {
</button>
<button
type="button"
data-search-label={tImport('title')}
onClick={() => setShowImportModal(true)}
className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
>
@@ -854,6 +809,7 @@ export function CalendarManagementSettings() {
{!IS_LITE && (
<button
type="button"
data-search-label={tSub('title')}
onClick={() => setShowSubscriptionModal(true)}
className="flex items-center gap-2 py-2.5 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md border border-dashed border-border transition-colors"
>
@@ -873,30 +829,6 @@ export function CalendarManagementSettings() {
{tSub('section_title')}
</h4>
{icalSubscriptions.map((sub) => {
if (deletingSubId === sub.id) {
return (
<div key={sub.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{tSub('confirm_delete', { name: sub.name })}
</p>
<button
onClick={() => handleDeleteSubscription(sub.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
>
{t('delete')}
</button>
<button
onClick={() => setDeletingSubId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
}
return (
<div
key={sub.id}
@@ -923,7 +855,7 @@ export function CalendarManagementSettings() {
</span>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<button
type="button"
onClick={() => setEditingSubscription(sub)}
@@ -956,6 +888,35 @@ export function CalendarManagementSettings() {
</div>
)}
{/* Not reversible, so each asks first (R3). */}
<ConfirmDialog
isOpen={!!deletingId}
onClose={() => setDeletingId(null)}
onConfirm={() => { if (deletingId) void handleDelete(deletingId); }}
title={tConfirm('delete_title')}
message={t('confirm_delete', { name: calendars.find((cal) => cal.id === deletingId)?.name ?? '' })}
confirmText={t('delete')}
variant="destructive"
/>
<ConfirmDialog
isOpen={!!clearingId}
onClose={() => setClearingId(null)}
onConfirm={() => { if (clearingId) void handleClear(clearingId); }}
title={tConfirm('clear_title')}
message={t('confirm_clear', { name: calendars.find((cal) => cal.id === clearingId)?.name ?? '' })}
confirmText={t('clear_events')}
variant="destructive"
/>
<ConfirmDialog
isOpen={!!deletingSubId}
onClose={() => setDeletingSubId(null)}
onConfirm={() => { if (deletingSubId) void handleDeleteSubscription(deletingSubId); }}
title={tConfirm('unsubscribe_title')}
message={tSub('confirm_delete', { name: icalSubscriptions.find((sub) => sub.id === deletingSubId)?.name ?? '' })}
confirmText={tSub('unsubscribe')}
variant="destructive"
/>
{showImportModal && client && (
<ICalImportModal
calendars={calendars}
+1 -1
View File
@@ -16,7 +16,7 @@ export function DebugSettings() {
return (
<SettingsSection id="debug" advanced title={t('debug_mode.label')} description={t('debug_mode.description')}>
<SettingItem label={t('debug_mode.label')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}>
<SettingItem label={t('debug_mode.toggle')} description={t('debug_mode.description')} locked={isSettingLocked('debugMode')}>
<ToggleSwitch checked={debugMode} onChange={(checked) => updateSetting('debugMode', checked)} />
</SettingItem>
+61 -75
View File
@@ -4,6 +4,9 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { SettingsSection, ToggleSwitch } from "./settings-section";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { FilterRuleModal } from "@/components/filters/filter-rule-modal";
import { SieveEditorModal } from "@/components/filters/sieve-editor-modal";
import { useFilterStore } from "@/stores/filter-store";
@@ -21,7 +24,7 @@ import { useManagedAccountStore } from "@/stores/managed-account-store";
import {
Plus,
GripVertical,
X,
Trash2,
Code,
AlertTriangle,
Loader2,
@@ -288,8 +291,6 @@ export function FilterSettings() {
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
const [showRuleModal, setShowRuleModal] = useState(false);
const [showSieveEditor, setShowSieveEditor] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const draggedIndexRef = useRef<number | null>(null);
@@ -336,22 +337,38 @@ export function FilterSettings() {
[client, toggleRule, saveFilters, tNotifications]
);
// A rule can be put back as it was, so it goes at once with an Undo (R3).
const handleDelete = useCallback(
async (ruleId: string) => {
const deletedRule = rules.find((r) => r.id === ruleId);
const before = useFilterStore.getState().rules;
const deletedRule = before.find((r) => r.id === ruleId);
if (!deletedRule) return;
deleteRule(ruleId);
setDeleteConfirmId(null);
if (client) {
try {
await saveFilters(client);
toast.success(tNotifications("filters_deleted"));
} catch {
if (deletedRule) addRule(deletedRule);
toast.error(tNotifications("filters_save_failed"));
}
if (!client) return;
const putBack = () => useFilterStore.setState({ rules: before });
try {
await saveFilters(client);
} catch {
putBack();
toast.error(tNotifications("filters_save_failed"));
return;
}
toast.success(tNotifications("filters_deleted"), {
action: {
label: tNotifications("rule_undo"),
onClick: () => {
if (useFilterStore.getState().rules.some((r) => r.id === ruleId)) return;
const current = useFilterStore.getState().rules;
putBack();
saveFilters(client).catch(() => {
useFilterStore.setState({ rules: current });
toast.error(tNotifications("filters_save_failed"));
});
},
},
});
},
[client, rules, deleteRule, addRule, saveFilters, tNotifications]
[client, deleteRule, saveFilters, tNotifications]
);
const handleSaveRule = useCallback(
@@ -394,13 +411,17 @@ export function FilterSettings() {
[client, setRawScript, saveFilters, tNotifications]
);
const { dialogProps: confirmProps, confirm } = useConfirmDialog();
// The script typed in the raw editor is thrown away, so it asks first (R3).
const handleResetToVisual = useCallback(async () => {
if (!showResetConfirm) {
setShowResetConfirm(true);
return;
}
const ok = await confirm({
title: t("reset_title"),
message: t("reset_warning"),
confirmText: t("confirm_reset"),
variant: "destructive",
});
if (!ok) return;
resetToVisualBuilder();
setShowResetConfirm(false);
if (client) {
try {
await saveFilters(client);
@@ -409,7 +430,7 @@ export function FilterSettings() {
toast.error(tNotifications("filters_save_failed"));
}
}
}, [showResetConfirm, resetToVisualBuilder, client, saveFilters, tNotifications]);
}, [confirm, t, resetToVisualBuilder, client, saveFilters, tNotifications]);
const handleValidate = useCallback(
async (content: string) => {
@@ -519,34 +540,14 @@ export function FilterSettings() {
>
{t("open_sieve_editor")}
</button>
{showResetConfirm ? (
<span className="flex items-center gap-2">
<span className="text-red-600 dark:text-red-400">{t("reset_warning")}</span>
<button
type="button"
onClick={handleResetToVisual}
className="text-red-600 dark:text-red-400 hover:underline font-medium"
>
{t("confirm_reset")}
</button>
<button
type="button"
onClick={() => setShowResetConfirm(false)}
className="text-muted-foreground hover:underline"
>
{t("cancel")}
</button>
</span>
) : (
<button
type="button"
onClick={handleResetToVisual}
className="text-red-600 dark:text-red-400 hover:underline font-medium flex items-center gap-1"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("reset_to_visual")}
</button>
)}
<button
type="button"
onClick={() => void handleResetToVisual()}
className="text-red-600 dark:text-red-400 hover:underline font-medium flex items-center gap-1"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("reset_to_visual")}
</button>
</div>
</div>
</div>
@@ -671,6 +672,7 @@ export function FilterSettings() {
<ToggleSwitch
checked={rule.enabled}
onChange={() => handleToggle(rule.id)}
ariaLabel={rule.name}
/>
</div>
@@ -700,33 +702,13 @@ export function FilterSettings() {
)}
</div>
{deleteConfirmId === rule.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(rule.id)}
>
{t("confirm_delete")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmId(null)}
>
{t("cancel")}
</Button>
</div>
) : (
<button
type="button"
onClick={() => setDeleteConfirmId(rule.id)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
aria-label={t("delete_rule")}
>
<X className="w-4 h-4" />
</button>
)}
<IconButton
label={t("delete_rule")}
onClick={() => void handleDelete(rule.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 />
</IconButton>
</div>
);
})}
@@ -740,6 +722,7 @@ export function FilterSettings() {
<Button
variant="outline"
size="sm"
data-search-label={t("add_rule")}
onClick={() => {
setEditingRule(undefined);
setShowRuleModal(true);
@@ -752,6 +735,7 @@ export function FilterSettings() {
<Button
variant="outline"
size="sm"
data-search-label={t("raw_editor")}
onClick={() => setShowSieveEditor(true)}
>
<Code className="w-4 h-4 me-1" />
@@ -772,6 +756,7 @@ export function FilterSettings() {
<ToggleSwitch
checked={expandedFilterView}
onChange={(v) => updateSetting("expandedFilterView", v)}
ariaLabel={t("expanded_view")}
/>
</div>
)}
@@ -802,6 +787,7 @@ export function FilterSettings() {
onValidate={handleValidate}
/>
)}
<ConfirmDialog {...confirmProps} />
</div>
);
}
+115 -65
View File
@@ -14,8 +14,14 @@ import {
Star, Heart, Bookmark, Tag, Flag, Briefcase, Users,
Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail,
AlertTriangle, NotebookPen, CalendarClock, BellOff, CheckSquare, Square,
MoreVertical, Palette, ListOrdered,
type AppIcon,
} from '@/components/icons';
import { IconButton } from '@/components/ui/icon-button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from '@/components/ui/context-menu';
import { useContextMenu } from '@/hooks/use-context-menu';
import { cn, buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils';
import { jmapErrorType } from '@/lib/jmap/jmap-error';
import {
@@ -181,7 +187,6 @@ export function FolderSettings() {
const [newFolderName, setNewFolderName] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
const [iconPickerId, setIconPickerId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
@@ -201,6 +206,9 @@ export function FolderSettings() {
const [moveTarget, setMoveTarget] = useState('');
const [isMoving, setIsMoving] = useState(false);
// The row's actions: the ⋮ on the phone and right-click everywhere.
const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu<MailboxNode>();
const ownMailboxes = mailboxes.filter(mb => !mb.isShared);
const folderTree = buildMailboxTree(ownMailboxes);
@@ -443,12 +451,21 @@ export function FolderSettings() {
}
};
const handleDelete = async (mailboxId: string) => {
const { dialogProps: confirmProps, confirm } = useConfirmDialog();
// Not reversible, so it asks first (R3).
const handleDelete = async (mailbox: { id: string; name: string }) => {
if (!client) return;
const ok = await confirm({
title: t('delete_title'),
message: t('confirm_delete', { name: mailbox.name }),
confirmText: t('delete'),
variant: 'destructive',
});
if (!ok) return;
setIsLoading(true);
try {
if (!(await deleteMailbox(client, mailboxId))) return;
setDeletingId(null);
if (!(await deleteMailbox(client, mailbox.id))) return;
toast.success(t('folder_deleted'));
} catch (err: unknown) {
switch (jmapErrorType(err)) {
@@ -502,7 +519,7 @@ export function FolderSettings() {
return (
<div
className="flex items-center gap-2 mt-1 p-2.5 bg-muted/30 rounded-md border border-border"
style={{ marginLeft: depth * 16 }}
style={{ marginInlineStart: depth * 16 }}
>
<FolderPlus className="w-4 h-4 text-primary flex-shrink-0" />
<div className="flex-1 flex flex-col gap-1">
@@ -563,7 +580,7 @@ export function FolderSettings() {
if (editingId === mb.id) {
return (
<div key={mb.id}>
<div className="flex items-center gap-2 py-2 px-3" style={{ paddingLeft: 12 + depth * 16 }}>
<div className="flex items-center gap-2 py-2 px-3" style={{ paddingInlineStart: 12 + depth * 16 }}>
<Icon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<input
type="text"
@@ -597,41 +614,20 @@ export function FolderSettings() {
);
}
if (deletingId === mb.id) {
return (
<div key={mb.id}>
<div className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20" style={{ marginLeft: depth * 16 }}>
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{t('confirm_delete', { name: mb.name })}
</p>
<button
onClick={() => handleDelete(mb.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
>
{t('delete')}
</button>
<button
onClick={() => setDeletingId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
</div>
);
}
return (
<div key={mb.id}>
<SortableFolderRow id={mb.id} title={t('reorder')}>
<div
className={cn(
"flex items-center justify-between py-2 px-3 rounded-md hover:bg-muted/50",
isSelected && "bg-muted",
"flex items-center justify-between gap-1 py-2 px-3 rounded-md hover:bg-muted/50",
isSelected && "bg-selection",
)}
style={{ paddingLeft: 12 + depth * 16 }}
style={{ paddingInlineStart: 12 + depth * 16 }}
onContextMenu={(e) => {
if ((e.target as HTMLElement).closest('input, select')) return;
openContextMenu(e, mb);
}}
>
<div className="flex items-center gap-2.5 min-w-0">
{mb.myRights?.mayRename ? (
@@ -659,15 +655,17 @@ export function FolderSettings() {
)}
{/* Expand/collapse toggle for folders with children */}
{hasChildren ? (
<button
<IconButton
label={isExpanded ? t('collapse', { name: mb.name }) : t('expand', { name: mb.name })}
aria-expanded={isExpanded}
onClick={() => toggleExpanded(mb.id)}
className="p-0.5 text-muted-foreground hover:text-foreground rounded transition-colors flex-shrink-0"
className="h-5 w-5 text-muted-foreground flex-shrink-0"
>
{isExpanded
? <ChevronDown className="w-3.5 h-3.5" />
: <ChevronRight className="w-3.5 h-3.5" />
}
</button>
</IconButton>
) : (
<span className="w-4.5 flex-shrink-0" />
)}
@@ -707,7 +705,7 @@ export function FolderSettings() {
/>
)}
</div>
<span className="text-sm text-foreground truncate">{mb.name}</span>
<span className="text-sm text-foreground truncate" title={mb.name} dir="auto">{mb.name}</span>
{mb.role && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0">
{t(`role_${mb.role}`)}
@@ -719,48 +717,49 @@ export function FolderSettings() {
</span>
)}
</div>
<div className="flex items-center gap-0.5">
{/* Phone: the counter and the row actions don't fit next to the
name, so they sit behind a ⋮ (right-click opens it too). */}
<IconButton
label={t('actions', { name: mb.name })}
className="sm:hidden flex-shrink-0 text-muted-foreground"
onClick={(e) => openContextMenuFrom(e.currentTarget, mb, 'end')}
>
<MoreVertical />
</IconButton>
<div className="hidden sm:flex items-center gap-0.5 flex-shrink-0">
<Select
value={folderCountModes?.[mb.id] ?? ''}
onChange={(value) => {
const { [mb.id]: _, ...rest } = folderCountModes ?? {};
updateSetting('folderCountModes', isFolderCountMode(value) ? { ...rest, [mb.id]: value } : rest);
}}
onChange={(value) => setCountMode(mb.id, value)}
ariaLabel={t('count_mode', { name: mb.name })}
className="px-1.5 py-0.5 text-xs max-w-[9rem]"
options={[
{ value: '', label: t('count_mode_default') },
{ value: 'unread', label: t('count_mode_unread') },
{ value: 'all', label: t('count_mode_all') },
{ value: 'none', label: t('count_mode_none') },
]}
options={countModeOptions}
/>
{mb.myRights?.mayCreateChild && (
<button
<IconButton
label={t('create_subfolder')}
onClick={() => startCreateSubfolder(mb.id)}
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
title={t('create_subfolder')}
className="text-muted-foreground"
>
<FolderPlus className="w-3.5 h-3.5" />
</button>
<FolderPlus />
</IconButton>
)}
{mb.myRights?.mayRename && (
<button
<IconButton
label={t('rename')}
onClick={() => startEdit(mb)}
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
title={t('rename')}
className="text-muted-foreground"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<Pencil />
</IconButton>
)}
{mb.myRights?.mayDelete && !mb.role && (
<button
onClick={() => setDeletingId(mb.id)}
className="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
title={t('delete')}
<IconButton
label={t('delete')}
onClick={() => void handleDelete(mb)}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<Trash2 />
</IconButton>
)}
</div>
</div>
@@ -771,8 +770,59 @@ export function FolderSettings() {
);
};
const countModeOptions = [
{ value: '', label: t('count_mode_default') },
{ value: 'unread', label: t('count_mode_unread') },
{ value: 'all', label: t('count_mode_all') },
{ value: 'none', label: t('count_mode_none') },
];
const setCountMode = (id: string, value: string) => {
const { [id]: _, ...rest } = folderCountModes ?? {};
updateSetting('folderCountModes', isFolderCountMode(value) ? { ...rest, [id]: value } : rest);
};
const renderRowMenu = () => {
const mb = contextMenu.data;
if (!mb) return null;
const current = folderCountModes?.[mb.id] ?? '';
const run = (fn: () => void) => () => { closeContextMenu(); fn(); };
const canDelete = mb.myRights?.mayDelete && !mb.role;
return (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
{mb.myRights?.mayCreateChild && (
<ContextMenuItem icon={FolderPlus} label={t('create_subfolder')} onClick={run(() => startCreateSubfolder(mb.id))} />
)}
{mb.myRights?.mayRename && (
<ContextMenuItem icon={Pencil} label={t('rename')} onClick={run(() => startEdit(mb))} />
)}
{folderIconsAllowed && (
<ContextMenuItem icon={Palette} label={t('change_icon')} onClick={run(() => setIconPickerId(mb.id))} />
)}
<ContextMenuSubMenu icon={ListOrdered} label={t('counter')}>
{countModeOptions.map((option) => (
<ContextMenuItem
key={option.value}
icon={current === option.value ? Check : undefined}
label={option.label}
onClick={run(() => setCountMode(mb.id, option.value))}
/>
))}
</ContextMenuSubMenu>
{canDelete && (
<>
<ContextMenuSeparator />
<ContextMenuItem icon={Trash2} label={t('delete')} destructive onClick={run(() => void handleDelete(mb))} />
</>
)}
</ContextMenu>
);
};
return (
<div className="space-y-8">
{renderRowMenu()}
<ConfirmDialog {...confirmProps} />
{/* Folder List - primary section */}
<SettingsSection id="folders" title={t('folder_list')} description={t('folder_list_description')}>
{selectedCount > 0 && (
+3 -4
View File
@@ -52,14 +52,13 @@ export function EmailAddressesSection() {
</div>
</SettingItem>
{/* Sub-Addressing Info */}
{/* Sub-Addressing Info: the description says it all. Its "Learn
more" opened the identity manager, which says nothing about it. */}
<SettingItem
label={t('sub_addressing.label')}
description={t('sub_addressing.description')}
>
<Button variant="outline" size="sm" onClick={() => setShowModal(true)}>
{t('sub_addressing.learn_more')}
</Button>
{null}
</SettingItem>
<SettingItem
+28 -2
View File
@@ -134,7 +134,7 @@ function KeywordRow({
ariaLabel={t("visibility_field")}
className="text-xs py-1"
/>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 transition-opacity">
<button
type="button"
onClick={onEdit}
@@ -243,7 +243,15 @@ function KeywordEditForm({
placeholder={t("label_placeholder")}
autoFocus
maxLength={30}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
// Like folders, calendars and address books: Escape cancels.
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onCancel();
}
}}
/>
{nestedTags && normalizedId.length > 0 && (
<p className="text-xs text-muted-foreground font-mono mt-1 break-all">
@@ -523,6 +531,7 @@ function UnrecognizedKeywords() {
export function KeywordSettings() {
const t = useTranslations("settings.keywords");
const tNotifications = useTranslations("notifications");
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
useSettingsStore();
const { client } = useAuthStore();
@@ -634,8 +643,25 @@ export function KeywordSettings() {
setEditingId(null);
};
// A tag can be put back as it was, so it goes at once with an Undo (R3).
const handleDelete = (id: string) => {
const before = useSettingsStore.getState().emailKeywords;
const index = before.findIndex((k) => k.id === id);
const removed = before[index];
if (!removed) return;
removeKeyword(id);
toast.success(t("deleted"), {
action: {
label: tNotifications("rule_undo"),
onClick: () => {
const now = useSettingsStore.getState().emailKeywords;
if (now.some((k) => k.id === id)) return;
const restored = [...now];
restored.splice(Math.min(index, restored.length), 0, removed);
reorderKeywords(restored);
},
},
});
};
const handleVisibilityChange = (id: string, visibility: KeywordVisibility) => {
+87 -62
View File
@@ -28,6 +28,7 @@ import {
type AppIcon,
} from '@/components/icons';
import { Button } from '@/components/ui/button';
import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { AppTopBannerSlot } from '@/components/plugins/app-top-banner-slot';
@@ -76,16 +77,18 @@ import { productName } from '@/lib/product-name';
import { usePolicyStore } from '@/stores/policy-store';
import { cn } from '@/lib/utils';
import {
type SettingsSearchEntry,
type SettingsSearchTab,
type SubResult,
collectSubResults,
flattenStrings,
withAppName,
getByPath,
matchesAllWords,
settingsSubResults,
tabKeywords,
tabSearchPaths,
tabSearchEntries,
archiveActionSearchPaths,
} from '@/lib/settings-search';
import { useAccountStore } from '@/stores/account-store';
import { useMediaQuery } from '@/hooks/use-media-query';
import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments';
import { useDocumentTitle } from '@/hooks/use-document-title';
import { MvTopBar } from "@/components/layout/mv-top-bar";
@@ -193,6 +196,10 @@ function migrateTarget(raw: string, section: string | null = null): { tab: strin
return { tab, section: section ?? (raw in LEGACY_SECTION_MAP ? LEGACY_SECTION_MAP[raw] : null) };
}
function hasOneShotDeepLink(): boolean {
try { return !!sessionStorage.getItem('settings-deep-link-tab'); } catch { return false; }
}
function readPersistedTab(): SettingsTabId {
try {
// One-shot deep link from the sidebar section gears (Folders / Tags).
@@ -276,9 +283,15 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const target = targetFromDeepLink(linkSegments);
return target?.section ? { tab: target.tab, id: target.section } : null;
});
// On the phone the tab list is a screen of its own: a link or event that
// names a page opens that page, with the list one Back below it.
const [mobileShowContent, setMobileShowContent] = useState(
() => targetFromDeepLink(linkSegments) !== null || hasOneShotDeepLink(),
);
const goTo = useCallback((target: SettingsTarget) => {
setActiveTab(target.tab);
setPendingSection(target.section ? { tab: target.tab, id: target.section } : null);
setMobileShowContent(true);
}, []);
// Active plugins that expose a `settings-section` slot — each becomes its own
// Settings menu entry. Referentially stable per registry mutation, so it is
@@ -318,9 +331,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
if (target) goTo(target);
});
}, [isEmbedded, goTo]);
const [mobileShowContent, setMobileShowContent] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null);
const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string; advanced?: boolean } | null>(null);
const isDesktop = useIsDesktop();
const messages = useMessages() as Record<string, unknown>;
@@ -337,30 +349,39 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const clearManagedAccount = useManagedAccountStore((s) => s.clear);
// Build a per-tab haystack for fulltext search and a list of sub-results
// (individual settings) per tab. Sub-results come from translation entries
// that have a `label`/`title` field, plus dynamic content (installed
// (individual settings) per tab: the rows each page draws (see
// lib/settings-search.ts), plus dynamic content (installed
// plugins/themes/sidebar apps).
const touchScreen = useMediaQuery('(pointer: coarse)');
const mountainViewLayout = useSettingsStore((s) => s.interfaceLayout === 'mountain-view');
const multiAccount = useAccountStore((s) => s.accounts.length > 0);
const { tabSearchHaystacks, tabSubResults } = useMemo(() => {
const haystacks: Partial<Record<Tab, string>> = {};
const subs: Partial<Record<Tab, SubResult[]>> = {};
const tabIds = Object.keys(tabSearchPaths) as Tab[];
for (const tabId of tabIds) {
const strings: string[] = [tabId.replace(/_/g, ' '), tabKeywords[tabId] ?? ''];
const list: SubResult[] = [];
for (const path of tabSearchPaths[tabId]) {
if (!archiveActionEnabled && archiveActionSearchPaths.has(path)) continue;
const node = withAppName(getByPath(messages, path), appName);
flattenStrings(node, strings);
collectSubResults(node, list);
}
// Dedupe sub-results by label
// Only rows the page draws right now, so every result lands on one.
const shown = (entry: SettingsSearchEntry) => {
if (!archiveActionEnabled && archiveActionSearchPaths.has(entry.label)) return false;
if (entry.when === 'touch') return touchScreen;
if (entry.when === 'mountainView') return mountainViewLayout;
if (entry.when === 'multiAccount') return multiAccount;
return true;
};
for (const tabId of Object.keys(tabSearchEntries) as Tab[]) {
const tabName = getByPath(messages, `settings.tabs.${tabId}`);
const list = settingsSubResults(messages, tabId, appName, shown)
// A section titled like its page is the page itself.
.filter((r) => r.label !== tabName);
const seen = new Set<string>();
subs[tabId] = list.filter((r) => {
if (seen.has(r.label)) return false;
seen.add(r.label);
return true;
});
haystacks[tabId] = strings.join(' ').toLowerCase();
haystacks[tabId] = [
tabId.replace(/_/g, ' '),
tabKeywords[tabId] ?? '',
...list.flatMap((r) => [r.label, r.description ?? '']),
].join(' ').toLowerCase();
}
if (installedPlugins.length) {
const haystackText = installedPlugins.map((p) => {
@@ -405,7 +426,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
];
}
return { tabSearchHaystacks: haystacks, tabSubResults: subs };
}, [messages, installedPlugins, installedThemes, sidebarAppsList, archiveActionEnabled, appName]);
}, [messages, installedPlugins, installedThemes, sidebarAppsList, archiveActionEnabled, appName, touchScreen, mountainViewLayout, multiAccount]);
// Sidebar resize state
const [settingsSidebarWidth, setSettingsSidebarWidth] = useState(() => {
@@ -490,7 +511,19 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
if (isDesktop) return;
if (typeof window === 'undefined') return;
if (!mobileShowContent) return;
// The first hydration pass reports a phone layout on every screen.
if (!isEmbedded && window.matchMedia('(min-width: 1024px)').matches) return;
// A page opened straight from a link has no list below it yet: turn the
// entry it arrived on into the list, then push the page on top. Inside
// the Pro shell /pro owns the address bar, so its URL stays.
if (!isEmbedded) {
const listPath = appPath(buildSettingsPath(null));
const here = window.location.pathname;
if (here !== listPath && here.startsWith(`${listPath}/`)) {
window.history.replaceState(window.history.state, '', listPath);
}
}
window.history.pushState({ __settingsSubmenu: true }, '');
const handlePop = () => {
@@ -498,7 +531,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
};
window.addEventListener('popstate', handlePop);
return () => window.removeEventListener('popstate', handlePop);
}, [isDesktop, mobileShowContent]);
}, [isDesktop, isEmbedded, mobileShowContent]);
// After clicking a search sub-result, scroll the matching setting into view
// and add a temporary highlight class. Some tabs fetch data and render
@@ -536,8 +569,9 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
if (cancelled) return;
const el = document.querySelector<HTMLElement>(selector);
if (!el) {
// The row may be an advanced one the switch hides: reveal, then keep looking.
if (Date.now() > deadline - 1600 && !useSettingsStore.getState().showAdvancedSettings) {
// An advanced row the switch hides: reveal it, then keep looking.
// Only for a row known to be advanced; any other stays as it is.
if (pendingHighlight.advanced && !useSettingsStore.getState().showAdvancedSettings) {
useSettingsStore.getState().updateSetting('showAdvancedSettings', true);
}
if (Date.now() < deadline) {
@@ -700,21 +734,21 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
}))
.filter((g) => g.items.length > 0);
// Every word of the query counts, in any order, like the admin search.
const trimmedQuery = searchQuery.trim().toLowerCase();
const matchesQuery = (tab: TabDef) => {
if (!trimmedQuery) return true;
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
return tabSearchHaystacks[tab.id as Tab]?.includes(trimmedQuery) ?? false;
return matchesAllWords(`${tab.label} ${tabSearchHaystacks[tab.id as Tab] ?? ''}`, trimmedQuery);
};
const subResultsForTab = (tabId: SettingsTabId): SubResult[] => {
if (!trimmedQuery) return [];
const list = tabSubResults[tabId as Tab] ?? [];
// Rows whose name matches come before rows matched by their description.
const byName = (r: SubResult) => (matchesAllWords(r.label, trimmedQuery) ? 0 : 1);
return list
.filter((r) =>
r.label.toLowerCase().includes(trimmedQuery) ||
(r.description?.toLowerCase().includes(trimmedQuery) ?? false)
)
.filter((r) => matchesAllWords(`${r.label} ${r.description ?? ''}`, trimmedQuery))
.sort((a, b) => byName(a) - byName(b))
.slice(0, 6);
};
@@ -749,7 +783,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const handleSubResultSelect = (tabId: SettingsTabId, sub: SubResult) => {
handleTabSelect(tabId);
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId, advanced: sub.advanced });
};
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
@@ -836,14 +870,9 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => window.history.back()}
className="h-10 w-10"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<IconButton label={t('back_to_settings')} size="icon-lg" onClick={() => window.history.back()}>
<ArrowLeft />
</IconButton>
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
<PhoneHeaderActions />
</div>
@@ -870,14 +899,9 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
<div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => router.push('/')}
className="h-10 w-10"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<IconButton label={t('back_to_mail')} size="icon-lg" onClick={() => router.push('/')}>
<ArrowLeft />
</IconButton>
<div className="flex items-center gap-2">
<SettingsIcon className="w-5 h-5 text-muted-foreground" />
<h1 className="font-semibold text-lg">{t('title')}</h1>
@@ -898,14 +922,13 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
aria-label={t('search_placeholder')}
/>
{searchQuery && (
<button
type="button"
<IconButton
label={t('search_clear')}
onClick={() => setSearchQuery('')}
className="absolute end-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t('search_clear')}
className="absolute end-1 top-1/2 -translate-y-1/2 h-7 w-7 rounded-full text-muted-foreground"
>
<X className="w-4 h-4" />
</button>
<X />
</IconButton>
)}
</div>
</div>
@@ -948,9 +971,9 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
<button
key={`${tab.id}:${sub.label}`}
onClick={() => handleSubResultSelect(tab.id, sub)}
className="w-full flex items-center ps-12 pe-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-start"
className="w-full flex items-center ps-12 pe-5 py-2 pointer-coarse:min-h-11 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-start"
>
<span className="truncate">{sub.label}</span>
<span className="truncate" title={sub.label}>{sub.label}</span>
</button>
))}
</div>
@@ -1060,14 +1083,13 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
aria-label={t('search_placeholder')}
/>
{searchQuery && (
<button
type="button"
<IconButton
label={t('search_clear')}
onClick={() => setSearchQuery('')}
className="absolute end-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t('search_clear')}
className="absolute end-1 top-1/2 -translate-y-1/2 h-7 w-7 rounded-full text-muted-foreground"
>
<X className="w-4 h-4" />
</button>
<X />
</IconButton>
)}
</div>
</div>
@@ -1116,7 +1138,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
onClick={() => handleSubResultSelect(tab.id, sub)}
className="w-full text-start ps-9 pe-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
>
<span className="truncate block">{sub.label}</span>
<span className="truncate block" title={sub.label}>{sub.label}</span>
</button>
))}
</div>
@@ -1144,6 +1166,9 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
<div ref={attachContentScroll} className="flex-1 overflow-y-auto">
<div className={effectiveActiveTab.startsWith(PLUGIN_PAGE_PREFIX) ? "px-6 py-6" : "max-w-3xl mx-auto px-6 py-6"}>
{/* The page's heading for screen readers; the sidebar shows which
page is open (the phone header has a visible one). */}
<h1 className="sr-only">{activeTabLabel}</h1>
{renderTabContent()}
</div>
</div>
+13 -6
View File
@@ -4,6 +4,7 @@ import { ReactNode, createContext, useContext, useEffect, useId, useState, useCa
import { useTranslations } from 'next-intl';
import { Lock } from '@/components/icons';
import { cn } from '@/lib/utils';
import { Button, TOUCH_HIT_AREA } from '@/components/ui/button';
import { useSettingsStore } from '@/stores/settings-store';
/**
@@ -117,7 +118,9 @@ export function SectionNav() {
if (!host) return;
const collect = () => {
const list = Array.from(host.querySelectorAll<HTMLElement>('[data-settings-section]'))
.map((el) => ({ id: el.dataset.settingsSection ?? '', title: el.querySelector('h3')?.textContent?.trim() ?? '' }))
// The section's own title, not the heading's text, which also holds
// the Advanced badge ("Loading & searchAdvanced").
.map((el) => ({ id: el.dataset.settingsSection ?? '', title: (el.dataset.searchLabel ?? el.querySelector('h3')?.textContent ?? '').trim() }))
.filter((entry) => entry.id && entry.title);
setSections((prev) => (
prev.length === list.length && prev.every((p, i) => p.id === list[i].id && p.title === list[i].title) ? prev : list
@@ -132,14 +135,16 @@ export function SectionNav() {
return (
<div ref={ref} hidden={sections.length < 2} data-testid="settings-section-nav" className="flex flex-wrap gap-1.5 mb-6">
{sections.map((section) => (
<button
<Button
key={section.id}
type="button"
variant="ghost"
size="xs"
onClick={() => { if (ref.current?.parentElement) scrollToSettingsSection(section.id, ref.current.parentElement); }}
className="px-3 py-1.5 text-xs rounded-md bg-muted hover:bg-accent hover:text-accent-foreground text-foreground transition-colors duration-150"
className="px-3 text-xs font-normal bg-muted text-foreground"
>
{section.title}
</button>
</Button>
))}
</div>
);
@@ -255,7 +260,9 @@ export function ToggleSwitch({ checked, onChange, disabled, ariaLabel, testId }:
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-150',
'inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-150',
// 44px to hit on a touch screen, drawn the same size (R1).
TOUCH_HIT_AREA,
// Off: an outlined track and a grey knob, 3:1 against the page in
// both schemes (a light track with a white knob was ~1.1:1). The
// ring is inset, so the on state keeps its size and look.
@@ -323,7 +330,7 @@ export function Select({ value, onChange, options, disabled, className, ariaLabe
aria-labelledby={ariaLabel ? undefined : labelledBy}
dir="auto"
className={cn(
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150",
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 pointer-coarse:min-h-11",
disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer hover:border-muted-foreground",
className
)}
+9 -10
View File
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Plus, Pencil, Trash2, ExternalLink, PanelRight, GripVertical, Lock } from "@/components/icons";
import { iconForName } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { Input } from "@/components/ui/input";
import { SettingsSection, SettingItem, ToggleSwitch } from "./settings-section";
import { IconPicker } from "@/components/layout/icon-picker";
@@ -341,18 +342,16 @@ export function SidebarAppsSettings() {
)}>
{app.openMode === "inline" ? tApps("inline_badge") : tApps("tab_badge")}
</span>
<button
onClick={() => setEditingApp(app.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
<IconButton label={tApps("edit_app")} onClick={() => setEditingApp(app.id)} className="text-muted-foreground">
<Pencil />
</IconButton>
<IconButton
label={tApps("delete")}
onClick={() => handleDelete(app)}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<Trash2 />
</IconButton>
</div>
);
})}
@@ -0,0 +1,37 @@
"use client";
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
/**
* The R5 question for an editor in Settings: closing it with unsaved input
* asks "Discard changes?" with Keep editing / Discard. Render `discardDialog`
* next to the editor.
*
* ```tsx
* const { confirmDiscard, discardDialog } = useDiscardChanges();
* const close = async () => { if (await confirmDiscard(dirty)) onClose(); };
* ```
*/
export function useDiscardChanges() {
const t = useTranslations("settings");
const { dialogProps, confirm } = useConfirmDialog();
const confirmDiscard = useCallback(
async (dirty: boolean) => {
if (!dirty) return true;
return confirm({
title: t("discard_changes"),
message: t("unsaved_changes"),
confirmText: t("discard"),
cancelText: t("keep_editing"),
variant: "destructive",
});
},
[confirm, t]
);
return { confirmDiscard, discardDialog: <ConfirmDialog {...dialogProps} /> };
}
+177 -112
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import { Button } from '@/components/ui/button';
@@ -15,11 +15,17 @@ import { ownDomains } from '@/lib/sieve/vacation-audience';
import { sanitizeEmailHtml } from '@/lib/email-sanitization';
import { htmlToPlainText } from '@/lib/html-to-text';
import { isValidEmail } from '@/lib/validation';
import { Loader2, AlertTriangle, Eye, EyeOff } from '@/components/icons';
import { Loader2, AlertTriangle, Eye, EyeOff, Check } from '@/components/icons';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
const STALWART_VACATION_LIMITS = { subject: 511, body: 2047 };
// The page saves as you go, like every settings page: switches and menus at
// once, typing once it pauses.
const SAVE_AT_ONCE = 0;
const SAVE_AFTER_TYPING = 800;
function byteLength(value: string): number {
return new TextEncoder().encode(value).length;
}
@@ -51,6 +57,9 @@ function utcToLocalDatetime(utcIso: string): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const DATE_FIELD =
'px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground';
// The latest moment the date fields offer: a later year has no room in the
// four digits a stored date has.
const DATE_INPUT_MAX = '9999-12-31T23:59';
@@ -123,7 +132,6 @@ export function VacationSettings() {
const [htmlEnabled, setHtmlEnabled] = useState(!!htmlBody);
const [localHtmlBody, setLocalHtmlBody] = useState(htmlBody || '');
const [showPreview, setShowPreview] = useState(false);
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
const [forwardEnabled, setForwardEnabled] = useState(forward?.enabled ?? false);
const [forwardTo, setForwardTo] = useState(forward?.to ?? '');
const [forwardKeep, setForwardKeep] = useState(forward?.keepCopy ?? false);
@@ -138,29 +146,60 @@ export function VacationSettings() {
}
}, [client, isSupported, managedAccountId, fetchVacationResponse]);
// The fields take what the server has when it loads and on an account
// switch. What comes back from a save made here is what was sent: the
// fields keep what was typed while it ran. Only what the user changed is
// saved, never a render that hasn't caught up with a load yet.
const touched = useRef(false);
const lastSent = useRef<{ reply: string; forward: boolean } | null>(null);
const syncedAccount = useRef<string | null>(null);
const storedReply = JSON.stringify([isEnabled, fromDate, toDate, subject, textBody, htmlBody]);
useEffect(() => {
setLocalEnabled(isEnabled);
setFromInput(fromDate ? utcToLocalDatetime(fromDate) : '');
setToInput(toDate ? utcToLocalDatetime(toDate) : '');
setHalfFilled({ from: false, to: false });
setLocalSubject(subject);
setLocalTextBody(textBody);
setHtmlEnabled(!!htmlBody);
setLocalHtmlBody(htmlBody || '');
}, [accountKey, isEnabled, fromDate, toDate, subject, textBody, htmlBody]);
if (isLoading) return;
const newAccount = syncedAccount.current !== accountKey;
syncedAccount.current = accountKey;
const sent = newAccount ? null : lastSent.current;
if (newAccount) lastSent.current = null;
const stored = useVacationStore.getState();
if (!sent || sent.reply !== storedReply) {
touched.current = false;
setLocalEnabled(stored.isEnabled);
setFromInput(stored.fromDate ? utcToLocalDatetime(stored.fromDate) : '');
setToInput(stored.toDate ? utcToLocalDatetime(stored.toDate) : '');
setHalfFilled({ from: false, to: false });
setLocalSubject(stored.subject);
setLocalTextBody(stored.textBody);
setHtmlEnabled(!!stored.htmlBody);
setLocalHtmlBody(stored.htmlBody || '');
}
// Forwarding a save didn't send takes what the server has now; one it
// sent stays as set here, also when its filters part failed and it
// waits to be saved again.
if (!sent || sent.reply !== storedReply || !sent.forward) {
setForwardEnabled(stored.forward?.enabled ?? false);
setForwardTo(stored.forward?.to ?? '');
setForwardKeep(stored.forward?.keepCopy ?? false);
}
setAudienceOnly(stored.audience?.only ?? 'all');
}, [accountKey, isLoading, storedReply, forward, audience]);
useEffect(() => {
setForwardEnabled(forward?.enabled ?? false);
setForwardTo(forward?.to ?? '');
setForwardKeep(forward?.keepCopy ?? false);
}, [accountKey, forward]);
useEffect(() => {
setAudienceOnly(audience?.only ?? 'all');
}, [accountKey, audience]);
// How long the next save waits, set by the control that changed.
const saveDelay = useRef(SAVE_AT_ONCE);
const changeNow = <T,>(set: (value: T) => void) => (value: T) => {
touched.current = true;
saveDelay.current = SAVE_AT_ONCE;
set(value);
};
const changeTyped = <T,>(set: (value: T) => void) => (value: T) => {
touched.current = true;
saveDelay.current = SAVE_AFTER_TYPING;
set(value);
};
// Reads a date field as the browser holds it, half filled in or not.
const readDateField = (field: 'from' | 'to', input: HTMLInputElement) => {
touched.current = true;
saveDelay.current = SAVE_AFTER_TYPING;
(field === 'from' ? setFromInput : setToInput)(input.value);
const half = input.validity.badInput;
setHalfFilled((current) => (current[field] === half ? current : { ...current, [field]: half }));
@@ -223,53 +262,24 @@ export function VacationSettings() {
};
}, [sizeLimits, localSubject, payload]);
const validate = useCallback(() => {
const warnings: string[] = [];
// Checked as you type, under the field they are about.
const endBeforeStart = !!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate));
const fromError = localFromDate === null || halfFilled.from ? t('warnings.invalid_date') : null;
const toError = localToDate === null || halfFilled.to
? t('warnings.invalid_date')
: endBeforeStart ? t('warnings.end_before_start') : null;
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
// Only a start the user just set: a stored one passes while they are away.
const fromWarning = !fromError && localFromDate && localFromDate !== fromDate && new Date(localFromDate) < todayStart
? t('warnings.start_in_past')
: null;
const hasHtmlContent = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
const bodyWarning = oversize.body && sizeLimits
? t('warnings.body_too_long', { max: sizeLimits.body })
: localEnabled && !localTextBody.trim() && !hasHtmlContent ? t('warnings.empty_body') : null;
if (datesUnusable) {
warnings.push(t('warnings.invalid_date'));
}
if (localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate)) {
warnings.push(t('warnings.end_before_start'));
}
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
if (localFromDate && new Date(localFromDate) < todayStart) {
warnings.push(t('warnings.start_in_past'));
}
const hasHtmlContent = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
if (localEnabled && !localTextBody.trim() && !hasHtmlContent) {
warnings.push(t('warnings.empty_body'));
}
if (oversize.subject && sizeLimits) {
warnings.push(t('warnings.subject_too_long', { max: sizeLimits.subject }));
}
if (oversize.body && sizeLimits) {
warnings.push(t('warnings.body_too_long', { max: sizeLimits.body }));
}
if (forwardInvalid) {
warnings.push(t('warnings.forward_address'));
}
if (forwardOverLimit) {
warnings.push(tFilters('forward_limit', { count: forwardLimit }));
}
if (notRunning) {
warnings.push(t('warnings.not_running'));
}
setValidationWarnings(warnings);
return warnings;
}, [datesUnusable, localFromDate, localToDate, localEnabled, localTextBody, htmlEnabled, localHtmlBody, oversize, sizeLimits, forwardInvalid, forwardOverLimit, forwardLimit, notRunning, t, tFilters]);
useEffect(() => {
validate();
}, [validate]);
const hasChanges =
const userChanges =
localEnabled !== isEnabled ||
(localFromDate || null) !== (fromDate || null) ||
(localToDate || null) !== (toDate || null) ||
@@ -277,15 +287,22 @@ export function VacationSettings() {
localTextBody !== textBody ||
(htmlEnabled ? localHtmlBody : '') !== (htmlBody || '') ||
forwardChanged ||
audienceChanged ||
// Saving sets right what is stored but does not run.
notRunning;
audienceChanged;
const hasBlockingError =
datesUnusable ||
!!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate)) ||
oversize.subject || oversize.body || forwardInvalid;
// What a save would store, to tell one change from the next.
const snapshot = JSON.stringify([
localEnabled, localFromDate, localToDate, localSubject, payload, forwardSettings, audienceSettings,
]);
// A save that failed or was stopped isn't tried again until something changes.
const [refusedSnapshot, setRefusedSnapshot] = useState<string | null>(null);
const [savedOnce, setSavedOnce] = useState(false);
const savingSnapshot = useRef<string | null>(null);
const handleSave = async () => {
if (!client) return;
// Half filled in without a single event: only the field itself knows.
@@ -294,10 +311,17 @@ export function VacationSettings() {
setHalfFilled(half);
return;
}
validate();
if (hasBlockingError) return;
const { sanitizedHtml, textBody } = payload;
const sent = snapshot;
savingSnapshot.current = sent;
lastSent.current = {
reply: JSON.stringify([
localEnabled, localFromDate || null, localToDate || null, localSubject, textBody, sanitizedHtml,
]),
forward: forwardChanged,
};
try {
const saved = await updateVacationResponse(client, {
@@ -312,15 +336,39 @@ export function VacationSettings() {
}, managedAccountId ?? undefined);
// false: a plugin stopped the change and is expected to have said why.
if (saved) toast.success(tNotifications('vacation_saved'));
if (saved) setSavedOnce(true);
else setRefusedSnapshot(sent);
} catch (error) {
console.error('Failed to save vacation response:', error);
setRefusedSnapshot(sent);
toast.error(tNotifications(
error instanceof VacationFiltersError ? 'vacation_filters_save_failed' : 'vacation_save_failed',
));
} finally {
savingSnapshot.current = null;
}
};
// The latest save, and whether one is waiting, for the timer and for
// leaving the page.
const saveRef = useRef(handleSave);
saveRef.current = handleSave;
const canAutoSave = !!client && userChanges && !hasBlockingError && snapshot !== refusedSnapshot;
const waitingRef = useRef(false);
waitingRef.current = canAutoSave && savingSnapshot.current !== snapshot;
// Leaving the page (another settings page, another app) saves what is
// still waiting for its timer.
useEffect(() => () => {
if (waitingRef.current && touched.current) void saveRef.current();
}, []);
useEffect(() => {
if (!canAutoSave || isSaving || !touched.current) return;
const timer = setTimeout(() => { void saveRef.current(); }, saveDelay.current);
return () => clearTimeout(timer);
}, [canAutoSave, isSaving, snapshot]);
if (!isSupported) {
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -354,6 +402,22 @@ export function VacationSettings() {
return (
<div className="space-y-6">
<div role="status" aria-live="polite" className="flex justify-end items-center gap-1.5 min-h-4 -mb-4 text-xs text-muted-foreground">
{isSaving ? (
<><Loader2 className="w-3.5 h-3.5 animate-spin" />{t('saving')}</>
) : savedOnce && !userChanges ? (
<><Check className="w-3.5 h-3.5" />{t('saved')}</>
) : null}
</div>
{notRunning && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-md bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">{t('warnings.not_running')}</span>
<Button size="xs" variant="outline" onClick={() => void handleSave()} disabled={isSaving || hasBlockingError}>
{t('save')}
</Button>
</div>
)}
{/* The period is the absence: the auto-reply and forwarding both keep to it. */}
<SettingsSection title={t('title')} description={t('description')}>
<SettingItem
@@ -367,10 +431,13 @@ export function VacationSettings() {
value={fromInput}
onChange={(e) => readDateField('from', e.currentTarget)}
onBlur={(e) => readDateField('from', e.currentTarget)}
aria-invalid={localFromDate === null || halfFilled.from}
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
aria-invalid={!!fromError}
aria-describedby={fromError ? 'vacation-from-error' : undefined}
className={cn(DATE_FIELD, fromError && 'border-destructive hover:border-destructive')}
/>
</SettingItem>
<FieldMessage id="vacation-from-error" text={fromError} />
<FieldMessage text={fromWarning} warning />
<SettingItem
label={t('date_range.end')}
description={t('date_range.end_description')}
@@ -382,17 +449,19 @@ export function VacationSettings() {
value={toInput}
onChange={(e) => readDateField('to', e.currentTarget)}
onBlur={(e) => readDateField('to', e.currentTarget)}
aria-invalid={localToDate === null || halfFilled.to}
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
aria-invalid={!!toError}
aria-describedby={toError ? 'vacation-to-error' : undefined}
className={cn(DATE_FIELD, toError && 'border-destructive hover:border-destructive')}
/>
</SettingItem>
<FieldMessage id="vacation-to-error" text={toError} />
</SettingsSection>
<SettingsSection id="reply" title={t('auto_reply.title')} description={t('status.description')}>
<SettingItem label={t('status.label')}>
<StatusSwitch
checked={localEnabled}
onChange={setLocalEnabled}
onChange={changeNow(setLocalEnabled)}
activeLabel={t('status.active')}
inactiveLabel={t('status.inactive')}
/>
@@ -404,7 +473,7 @@ export function VacationSettings() {
>
<Select
value={audienceOnly}
onChange={(value) => setAudienceOnly(value as 'all' | 'internal' | 'external')}
onChange={(value) => changeNow(setAudienceOnly)(value as 'all' | 'internal' | 'external')}
options={[
{ value: 'all', label: t('audience.all') },
{ value: 'internal', label: t('audience.internal') },
@@ -420,11 +489,13 @@ export function VacationSettings() {
<input
type="text"
value={localSubject}
onChange={(e) => setLocalSubject(e.target.value)}
onChange={(e) => changeTyped(setLocalSubject)(e.target.value)}
placeholder={t('message.subject_placeholder')}
aria-invalid={oversize.subject}
className="w-64 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
/>
</SettingItem>
<FieldMessage text={oversize.subject && sizeLimits ? t('warnings.subject_too_long', { max: sizeLimits.subject }) : null} />
<div className="py-3">
<label htmlFor="vacation-body" className="text-sm font-medium text-foreground block mb-1">
{t('message.body_label')}
@@ -435,24 +506,25 @@ export function VacationSettings() {
<textarea
id="vacation-body"
value={localTextBody}
onChange={(e) => setLocalTextBody(e.target.value)}
onChange={(e) => changeTyped(setLocalTextBody)(e.target.value)}
placeholder={t('message.body_placeholder')}
rows={6}
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground resize-y"
/>
<FieldMessage text={bodyWarning} warning={!oversize.body} />
</div>
<SettingItem
label={t('message.html_label')}
description={t('message.html_description')}
>
<ToggleSwitch checked={htmlEnabled} onChange={setHtmlEnabled} />
<ToggleSwitch checked={htmlEnabled} onChange={changeNow(setHtmlEnabled)} />
</SettingItem>
{htmlEnabled && (
<div className="pb-3">
<div className="rounded-md border border-border overflow-hidden">
<RichTextEditor
content={localHtmlBody}
onChange={setLocalHtmlBody}
onChange={changeTyped(setLocalHtmlBody)}
placeholder={t('message.html_placeholder')}
/>
</div>
@@ -507,7 +579,7 @@ export function VacationSettings() {
<SettingItem label={t('forward.enabled_label')}>
<StatusSwitch
checked={forwardEnabled}
onChange={setForwardEnabled}
onChange={changeNow(setForwardEnabled)}
activeLabel={t('status.active')}
inactiveLabel={t('status.inactive')}
/>
@@ -518,47 +590,40 @@ export function VacationSettings() {
<input
type="email"
value={forwardTo}
onChange={(e) => setForwardTo(e.target.value)}
onChange={(e) => changeTyped(setForwardTo)(e.target.value)}
placeholder={t('forward.to_placeholder')}
aria-label={t('forward.to_label')}
aria-invalid={forwardInvalid}
className="w-64 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
className="w-64 max-w-full px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
/>
</SettingItem>
<FieldMessage text={forwardInvalid ? t('warnings.forward_address') : null} />
<SettingItem label={t('forward.keep_label')} description={t('forward.keep_description')}>
<ToggleSwitch checked={forwardKeep} onChange={setForwardKeep} />
<ToggleSwitch checked={forwardKeep} onChange={changeNow(setForwardKeep)} />
</SettingItem>
<FieldMessage text={forwardOverLimit ? tFilters('forward_limit', { count: forwardLimit }) : null} warning />
</>
)}
</SettingsSection>
)}
{validationWarnings.length > 0 && (
<div className="space-y-2">
{validationWarnings.map((warning, i) => (
<div key={i} className="flex items-start gap-2 text-sm text-warning">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
)}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={isSaving || !hasChanges || hasBlockingError}
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 me-2 animate-spin" />
{t('saving')}
</>
) : (
t('save')
)}
</Button>
</div>
</div>
);
}
/** A line under a field: an error that stops the save, or a warning. */
function FieldMessage({ id, text, warning = false }: { id?: string; text: string | null; warning?: boolean }) {
if (!text) return null;
return (
<p
id={id}
className={cn(
'flex items-start gap-1.5 -mt-1 pb-2 text-xs',
warning ? 'text-amber-600 dark:text-amber-400' : 'text-red-600 dark:text-red-400',
)}
>
<AlertTriangle className="w-3.5 h-3.5 mt-px flex-shrink-0" />
<span>{text}</span>
</p>
);
}
+67 -127
View File
@@ -2,154 +2,94 @@ import { describe, it, expect } from 'vitest';
import en from '@/locales/en/common.json';
import {
type SettingsSearchTab,
type SubResult,
collectSubResults,
flattenStrings,
descriptionKeyFor,
getByPath,
tabSearchPaths,
matchesAllWords,
tabKeywords,
settingsSubResults,
tabSearchEntries,
withAppName,
} from '../settings-search';
function subResultsFor(tab: SettingsSearchTab): SubResult[] {
const list: SubResult[] = [];
for (const path of tabSearchPaths[tab]) {
collectSubResults(getByPath(en, path), list);
}
return list;
}
function haystackFor(tab: SettingsSearchTab): string {
const strings: string[] = [];
for (const path of tabSearchPaths[tab]) {
flattenStrings(getByPath(en, path), strings);
}
return strings.join(' ').toLowerCase();
}
function labelsFor(tab: SettingsSearchTab): string[] {
return subResultsFor(tab).map((r) => r.label);
}
describe('collectSubResults', () => {
it('emits flat `key` / `key_desc` string pairs as a labelled sub-result', () => {
const list: SubResult[] = [];
collectSubResults(
{
title: 'Calendar settings',
free_scroll: 'Free scrolling',
free_scroll_desc: 'Scroll continuously',
hover_preview_off: 'Disabled',
},
list
);
expect(list).toEqual([
{ label: 'Calendar settings', description: undefined },
{ label: 'Free scrolling', description: 'Scroll continuously' },
]);
});
it('still emits label/description objects and *_label keys', () => {
const list: SubResult[] = [];
collectSubResults(
{
name_label: 'Name',
nested: { label: 'Theme', description: 'Pick one', dark: 'Dark' },
},
list
);
expect(list).toEqual([
{ label: 'Name' },
{ label: 'Theme', description: 'Pick one' },
]);
});
});
const labelsFor = (tab: SettingsSearchTab) => settingsSubResults(en, tab, 'Bulwark').map((r) => r.label);
const resultsFor = (tab: SettingsSearchTab, query: string) =>
settingsSubResults(en, tab, 'Bulwark').filter((r) => matchesAllWords(`${r.label} ${r.description ?? ''}`, query));
describe('settings search index (English messages)', () => {
it('every tab path resolves to a translation subtree', () => {
const missing: string[] = [];
for (const paths of Object.values(tabSearchPaths)) {
for (const path of paths) {
if (getByPath(en, path) === undefined) missing.push(path);
it('every entry names a label that exists, without a raw placeholder', () => {
const broken: string[] = [];
for (const entries of Object.values(tabSearchEntries)) {
for (const entry of entries) {
const label = getByPath(en, entry.label);
if (typeof label !== 'string' || /\{(?!appName\})/.test(label)) broken.push(entry.label);
}
}
expect(missing).toEqual([]);
expect(broken).toEqual([]);
});
it('lists the calendar toggles as sub-results', () => {
const labels = labelsFor('calendar');
expect(labels).toContain('Free scrolling');
expect(labels).toContain('Show week numbers');
expect(labels).toContain('Organize invitations as');
expect(labels).toContain('Parse email invitations');
expect(labelsFor('notifications')).toContain('Unread Count on Tab and App Icon');
expect(labelsFor('notifications')).not.toContain('Parse email invitations');
expect(labels).toContain('Contact birthday calendar');
// Option values are not settings of their own.
expect(labels).not.toContain('Disabled');
it('finds a description next to its label', () => {
expect(descriptionKeyFor('settings.email_behavior.send_delay.label', en)).toBe('settings.email_behavior.send_delay.description');
expect(descriptionKeyFor('settings.contacts.group_by_letter_label', en)).toBe('settings.contacts.group_by_letter_description');
expect(descriptionKeyFor('calendar.settings.calendar_free_scroll', en)).toBe('calendar.settings.calendar_free_scroll_desc');
});
it('finds the newer mail settings on their tabs', () => {
it('lists each setting under the page that draws it', () => {
expect(labelsFor('calendar')).toContain('Free scrolling');
expect(labelsFor('calendar')).toContain('Show week numbers');
expect(labelsFor('notifications')).toContain('Unread count on the tab and app icon');
expect(labelsFor('message_list')).toContain('Sender favicons');
expect(labelsFor('message_list')).toContain('Clear search when switching folders');
expect(labelsFor('message_list')).toContain('Swipe right action (mobile)');
expect(labelsFor('message_list')).toContain('Message list order');
expect(labelsFor('reading')).toContain('Trusted Senders');
expect(labelsFor('reading')).toContain('Email (.eml) filename');
expect(labelsFor('reading')).not.toContain('Delete Action');
expect(labelsFor('reading')).toContain('Trusted senders');
expect(labelsFor('composing')).toContain('Undo send / send delay');
expect(labelsFor('composing')).toContain('Request read receipts by default');
expect(labelsFor('composing')).toContain('Email Templates');
expect(labelsFor('composing')).toContain('Quick Reply');
expect(labelsFor('composing')).toContain('Placeholder warning');
expect(labelsFor('composing')).not.toContain('Sub-Address Delimiter');
expect(labelsFor('appearance')).toContain('Pro Interface (Experimental)');
expect(labelsFor('account')).toContain('Tell accounts apart');
expect(labelsFor('account')).toContain('Your Identities');
expect(labelsFor('appearance')).toContain('Themes');
expect(labelsFor('general')).toContain('Time zone');
expect(labelsFor('general')).toContain('Email links');
expect(labelsFor('organize')).toContain('Nested Tags');
expect(haystackFor('organize')).toContain('standard folder roles');
expect(labelsFor('organize')).toContain('Nested tags');
});
it('matches the calendar tab for a free-scroll query', () => {
expect(haystackFor('calendar')).toContain('free scrolling');
expect(haystackFor('general')).toContain('time zone');
// SET-5: whole namespaces were indexed, so results led to pages without
// the row (Sender Favicons under Backup & reset, Phone under Contacts,
// Default Mail Program under Composing) or showed "Level {n} criterion".
it('does not list settings a page does not draw', () => {
expect(labelsFor('backup')).not.toContain('Sender favicons');
expect(labelsFor('backup')).not.toContain('Keyboard shortcuts');
expect(labelsFor('contacts')).not.toContain('Phone');
expect(labelsFor('composing')).not.toContain('Default Mail Program');
for (const tab of Object.keys(tabSearchEntries) as SettingsSearchTab[]) {
expect(labelsFor(tab).filter((l) => l.includes('{'))).toEqual([]);
}
});
// SET-6: these found nothing, or not the right row.
it('finds the settings people searched for', () => {
expect(resultsFor('message_list', 'inbox type').map((r) => r.label)).toEqual(['Inbox type']);
expect(resultsFor('calendar', 'import calendar').map((r) => r.label)).toContain('Import calendar');
expect(resultsFor('composing', 'mention').map((r) => r.label).slice(0, 2)).toEqual(['Mention recipients with @', 'Contacts in @ mentions']);
expect(resultsFor('security', 'two-factor').map((r) => r.label)).toContain('Two-factor authentication');
expect(resultsFor('filters', 'new rule').map((r) => r.label)).toEqual(['New rule']);
// "add rule" finds the Filters page itself.
expect(matchesAllWords(tabKeywords.filters, 'add rule')).toBe(true);
});
it('matches every word of a query, in any order', () => {
const text = 'Signature Position Where to insert your signature in replies';
expect(matchesAllWords(text, 'replies signature')).toBe(true);
expect(matchesAllWords(text, 'signature replies')).toBe(true);
expect(matchesAllWords(text, 'signature forwarding')).toBe(false);
expect(resultsFor('composing', 'reply signature').map((r) => r.label)).toContain('Signature position');
});
it('marks the rows the Advanced switch hides', () => {
const advanced = settingsSubResults(en, 'message_list', 'Bulwark').filter((r) => r.advanced).map((r) => r.label);
expect(advanced).toContain('Emails per page');
expect(advanced).not.toContain('Sender favicons');
});
});
describe('{appName} in the index (#1314)', () => {
const index = (appName: string) => {
const list: SubResult[] = [];
const strings: string[] = [];
for (const path of tabSearchPaths.general) {
const node = withAppName(getByPath(en, path), appName);
collectSubResults(node, list);
flattenStrings(node, strings);
}
return { list, haystack: strings.join(' ').toLowerCase() };
};
it('never keeps the raw placeholder', () => {
for (const tab of Object.keys(tabSearchPaths) as SettingsSearchTab[]) {
for (const path of tabSearchPaths[tab]) {
expect(JSON.stringify(withAppName(getByPath(en, path), 'Bulwark')) ?? '').not.toContain('{appName}');
}
}
});
it('finds Default apps by "bulwark" on a stock install', () => {
const { list, haystack } = index('Bulwark');
expect(haystack).toContain('bulwark');
expect(list.some((r) => r.description?.includes('Bulwark'))).toBe(true);
});
it('finds Default apps by the configured name', () => {
const { list, haystack } = index('Rath Mail');
expect(haystack).toContain('rath mail');
const links: string[] = [];
flattenStrings(withAppName(getByPath(en, 'protocol_handlers'), 'Rath Mail'), links);
expect(links.join(' ').toLowerCase()).not.toContain('bulwark');
expect(list.some((r) => r.description?.includes('Rath Mail'))).toBe(true);
it('fills in the configured name', () => {
const results = settingsSubResults(en, 'general', 'Rath Mail');
const text = results.map((r) => `${r.label} ${r.description ?? ''}`).join(' ');
expect(text).not.toContain('{appName}');
expect(text).toContain('Rath Mail');
});
it('leaves other values alone', () => {
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import en from '@/locales/en/common.json';
import { getByPath } from '../settings-search';
// SET-23 / R10: one verb per action ("New …" creates, "Save" saves) and
// sentence case for labels in Settings.
const text = (key: string) => getByPath(en, key) as string;
// Words that keep their capital inside a sentence-case label.
const PROPER = /^(ASCII-armored|JMAP|Sieve|HTML|PGP|S\/MIME|TOTP|API|IPs|ASCII|BCC|Pro|Junk|Trash|Bulwark|Webmail|Siege|Spam|ID|URL|OAuth\/OIDC\)|\(OAuth\/OIDC\)|Mountain|View|Cc|To|ALL|ANY|Z|→|&)$/;
function titleCaseWords(value: string): string[] {
return value.split(/\s+/).slice(1).filter((w) => /^[A-Z]/.test(w) && !PROPER.test(w.replace(/[(),.]/g, '')));
}
describe('settings wording', () => {
it('names creating "New …" and saving "Save"', () => {
expect(text('settings.keywords.add_keyword')).toBe('New tag');
expect(text('settings.folders.create_folder')).toBe('New folder');
expect(text('settings.folders.create_subfolder')).toBe('New subfolder');
expect(text('settings.filters.add_rule')).toBe('New rule');
expect(text('identities.create_new')).toBe('New identity');
expect(text('settings.vacation.save')).toBe('Save');
expect(text('settings.filters.save')).toBe('Save');
expect(text('identities.form.save')).toBe('Save');
});
it('uses sentence case for labels and titles', () => {
const offenders: string[] = [];
const walk = (node: unknown, path: string) => {
if (typeof node === 'string') {
const last = path.split('.').pop() ?? '';
if (!/(^|_)(label|title)$/.test(last) || node.includes('{')) return;
if (path.startsWith('settings.files.')) return; // moved by the Files work
if (titleCaseWords(node).length > 0) offenders.push(`${path}: ${node}`);
return;
}
if (node && typeof node === 'object') {
for (const [key, value] of Object.entries(node)) walk(value, path ? `${path}.${key}` : key);
}
};
walk((en as Record<string, unknown>).settings, 'settings');
walk((en as Record<string, unknown>).identities, 'identities');
expect(offenders).toEqual([]);
});
it('does not repeat the debug section title as its only row', () => {
expect(text('settings.advanced.debug_mode.toggle')).not.toBe(text('settings.advanced.debug_mode.label'));
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { flatFieldsSkin } from '../theme-flat-fields';
// Flat fields gives every field the control border with an unlayered rule,
// which also beat the red border of a field holding an error. A field that
// says it is invalid keeps the error colour.
function rulesFor(selectorPart: string) {
return flatFieldsSkin.split('}').filter((block) => block.includes(selectorPart));
}
describe('Flat fields error borders', () => {
it('colours an invalid field with the destructive colour, after the control-border rule', () => {
const controlRule = flatFieldsSkin.indexOf('border-color: var(--color-input)');
const errorRule = rulesFor('[aria-invalid="true"]').find((block) => block.includes('border-color: var(--color-destructive)'));
expect(errorRule).toBeTruthy();
expect(flatFieldsSkin.indexOf(errorRule!)).toBeGreaterThan(controlRule);
// Same selector list plus the error condition, so it wins on specificity too.
expect(errorRule).toContain(':is(input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea, .border-input):is(');
expect(errorRule).toContain('.border-destructive');
});
it('no longer claims the global border rule outranks utilities', () => {
expect(flatFieldsSkin).not.toContain('rule outranks utilities');
expect(flatFieldsSkin).not.toContain('rule wins over\n utilities');
});
});
+17 -21
View File
@@ -15,7 +15,7 @@ import { usePolicyStore } from '@/stores/policy-store';
import { fetchConfig, type ConfigData } from '@/hooks/use-config';
import { productName } from '@/lib/product-name';
import {
collectSubResults, getByPath, tabKeywords, tabSearchPaths, withAppName, type SettingsSearchTab, type SubResult,
getByPath, settingsSubResults, tabKeywords, tabSearchEntries, type SettingsSearchTab,
} from '@/lib/settings-search';
import type { AppSettingsView, SettingsSearchResult } from '../plugin-types';
@@ -80,29 +80,25 @@ export async function doAppSearchSettings(query: unknown, options?: unknown): Pr
const scored: { score: number; order: number; result: SettingsSearchResult }[] = [];
let order = 0;
for (const tab of Object.keys(tabSearchPaths) as SettingsSearchTab[]) {
for (const tab of Object.keys(tabSearchEntries) as SettingsSearchTab[]) {
if (!pages.has(tab)) continue;
const page = pageName(local, tab);
const pageHaystack = `${page} ${pageName(english, tab)} ${tab.replace(/_/g, ' ')} ${tabKeywords[tab] ?? ''}`.toLowerCase();
for (const path of tabSearchPaths[tab]) {
// The merged catalogue has the English one's shape, so both walks line up.
const mine: SubResult[] = [];
const theirs: SubResult[] = [];
collectSubResults(withAppName(getByPath(local, path), appName), mine);
collectSubResults(withAppName(getByPath(english, path), appName), theirs);
mine.forEach((entry, i) => {
const en = theirs[i];
const text = `${entry.label} ${entry.description ?? ''} ${en?.label ?? ''} ${en?.description ?? ''}`.toLowerCase();
let score = 0;
for (const word of words) {
if (text.includes(word)) score += 2;
else if (pageHaystack.includes(word)) score += 1;
}
if (score > 0) {
scored.push({ score, order: order++, result: { pageId: tab, page, label: entry.label, ...(entry.description ? { description: entry.description } : {}) } });
}
});
}
// The merged catalogue has the English one's shape, so both lists line up.
const mine = settingsSubResults(local, tab, appName);
const theirs = settingsSubResults(english, tab, appName);
mine.forEach((entry, i) => {
const en = theirs[i];
const text = `${entry.label} ${entry.description ?? ''} ${en?.label ?? ''} ${en?.description ?? ''}`.toLowerCase();
let score = 0;
for (const word of words) {
if (text.includes(word)) score += 2;
else if (pageHaystack.includes(word)) score += 1;
}
if (score > 0) {
scored.push({ score, order: order++, result: { pageId: tab, page, label: entry.label, ...(entry.description ? { description: entry.description } : {}) } });
}
});
}
scored.sort((a, b) => b.score - a.score || a.order - b.order);
const seen = new Set<string>();
+382 -238
View File
@@ -1,238 +1,382 @@
// Settings search index helpers: which translation subtrees each settings tab
// renders, extra keywords per tab, and the walker that turns those subtrees
// into clickable sub-results. Kept free of React so it can be unit-tested.
export type SettingsSearchTab =
| 'account'
| 'general'
| 'notifications'
| 'appearance'
| 'message_list'
| 'reading'
| 'composing'
| 'vacation'
| 'filters'
| 'organize'
| 'security'
| 'calendar'
| 'contacts'
| 'files'
| 'addons'
| 'backup';
type Tab = SettingsSearchTab;
// Translation paths per tab. Tabs that share a namespace (email_behavior,
// appearance) explicitly list the subkeys they actually render so sub-results
// are attributed to the correct tab. Tabs with their own namespace just point
// at the namespace root.
export const tabSearchPaths: Record<Tab, string[]> = {
account: [
'settings.account.name_label',
'settings.security.display_name',
'settings.account.username_label',
'settings.account.account_type_label',
'settings.account.auth_method_label',
'settings.account.email',
'settings.account.server',
'settings.account.storage',
'settings.identities',
'settings.email_behavior.sub_address_delimiter',
'settings.account.accounts',
'settings.account.backup',
'settings.appearance.unified_mailbox',
'settings.appearance.cross_unread',
'settings.appearance.cross_starred',
'settings.appearance.cross_all',
'settings.appearance.all_mail',
'settings.appearance.account_marker',
'settings.appearance.show_shared_mail_accounts',
'settings.account.shared_accounts',
],
general: ['settings.language_region', 'protocol_handlers', 'tour.restart_title', 'tour.show_on_new_devices_title'],
notifications: ['settings.notifications', 'settings.appearance.favicon_unread_badge'],
appearance: [
'settings.appearance.theme',
'settings.themes',
'settings.appearance.font_size',
'settings.appearance.list_density',
'settings.appearance.animations',
'settings.appearance.interface_layout',
'settings.email_behavior.mail_layout',
'settings.appearance.toolbar_position',
'settings.appearance.toolbar_labels',
'settings.appearance.colorful_sidebar_icons',
'settings.appearance.show_folder_total_count',
'settings.appearance.hide_account_switcher',
'settings.appearance.show_rail_account_list',
'settings.appearance.pro_interface',
],
message_list: [
'settings.email_behavior.group_conversations',
'settings.email_behavior.show_preview',
'settings.email_behavior.list_columns',
'settings.appearance.message_list_order',
'settings.appearance.tint_list_rows',
'settings.email_behavior.show_verification_codes',
'settings.advanced.sender_favicons',
'settings.advanced.show_avatars_in_junk',
'settings.email_behavior.hover_actions',
'settings.email_behavior.swipe_right_action',
'settings.email_behavior.swipe_left_action',
'settings.email_behavior.delete_action',
'settings.email_behavior.archive_mode',
'settings.email_behavior.confirm_bulk_delete',
'settings.email_behavior.permanently_delete_junk',
'settings.email_behavior.emails_per_page',
'settings.email_behavior.clear_search_on_folder_change',
],
reading: [
'settings.email_behavior.mark_read',
'settings.email_behavior.return_to_list_after_action',
'settings.email_behavior.message_spacing',
'settings.email_behavior.plain_text_font',
'settings.email_behavior.always_light_mode',
'settings.email_behavior.external_content',
'settings.email_behavior.trusted_senders',
'settings.email_behavior.attachment_position',
'settings.email_behavior.attachment_click_action',
'settings.email_behavior.attachment_image_previews',
'settings.email_behavior.hide_inline_image_attachments',
'settings.downloads',
],
composing: [
'settings.templates',
'settings.email_behavior.signatures_link',
'settings.email_behavior.attachment_reminder',
'settings.email_behavior.quick_reply',
'settings.email_behavior.auto_select_reply_identity',
'settings.email_behavior.reply_identity_match',
'settings.email_behavior.plain_text_mode',
'settings.email_behavior.rtl_editing',
'settings.email_behavior.default_mail_program',
'settings.email_behavior.empty_subject_warning',
'settings.email_behavior.placeholder_warning',
'settings.email_behavior.send_delay',
'settings.email_behavior.signature_position',
'settings.email_behavior.signature_separator',
'settings.email_behavior.request_read_receipt',
'settings.email_behavior.read_receipt_response',
],
vacation: ['settings.vacation'],
filters: ['settings.filters'],
organize: ['settings.folders', 'settings.keywords'],
security: ['settings.security'],
calendar: ['calendar.settings', 'calendar.management'],
contacts: ['settings.contacts', 'contacts'],
files: ['settings.files'],
addons: ['settings.sidebar_apps', 'sidebar_apps', 'plugins.settings'],
backup: ['settings.advanced'],
};
// Extra English keywords per tab so common search terms hit even when the
// translation doesn't contain the literal word.
/** Settings shown only while the admin has the Archive action on (#1257). */
export const archiveActionSearchPaths: ReadonlySet<string> = new Set(['settings.email_behavior.archive_mode']);
export const tabKeywords: Record<Tab, string> = {
account: 'profile email user signin signout reorder rearrange drag dropdown switcher multi-account identities from address signature plus addressing unified mailbox shared group',
general: 'language locale region timezone date time format mailto webcal links default app protocol handler tour onboarding',
notifications: 'sound alert push badge favicon tab icon unread count background relay device',
appearance: 'theme dark light font size accent color animation density custom theme css skin toolbar sidebar account switcher icons rail layout reading pane',
message_list: 'inbox list preview thread conversation grouping sort order columns archive delete swipe hover favicon avatar tag color',
reading: 'mark read attachment open monospace mono font plain text images remote content block sender privacy tracking dark light download filename template eml save export',
composing: 'editor signature plain text reply forward draft compose template snippet quick reply mention undo send receipt attachment reminder',
vacation: 'auto reply away out of office holiday responder',
filters: 'sieve rules block junk forward',
organize: 'folders mailbox subscribe roles tags labels keywords colors nested',
security: 'password 2fa two-factor passkey app password mfa api key token phone mobile app pair qr thunderbird outlook imap client encryption pgp smime',
calendar: 'event schedule appointment meeting timezone invitation ics tasks birthday working hours',
contacts: 'address book contact',
files: 'attachments cloud drive storage upload',
addons: 'apps webview iframe plugins extensions addons sidebar',
backup: 'export import storage quota privacy backup reset sync cache about version debug logs developer console diagnostic',
};
// Some settings texts name the app with {appName} (#1314). The index keeps
// them filled in, so the search matches the name people see and a result
// never carries the raw placeholder.
export function withAppName(node: unknown, appName: string): unknown {
if (typeof node === 'string') return node.split('{appName}').join(appName);
if (Array.isArray(node)) return node.map((item) => withAppName(item, appName));
if (node && typeof node === 'object') {
return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, withAppName(value, appName)]));
}
return node;
}
export function flattenStrings(node: unknown, sink: string[]): void {
if (typeof node === 'string') {
sink.push(node);
return;
}
if (Array.isArray(node)) {
for (const item of node) flattenStrings(item, sink);
return;
}
if (node && typeof node === 'object') {
for (const value of Object.values(node)) flattenStrings(value, sink);
}
}
export interface SubResult {
label: string;
description?: string;
// For plugin setting fields: the id of the plugin whose card needs to be
// expanded before the field becomes visible in the DOM.
pluginId?: string;
}
// Walk a translation subtree and emit sub-results for renderable settings.
// Picks up:
// - bare string leaves (when a tab path points directly at a flat label)
// - objects with a `label` or `title` field (the standard pattern)
// - flat `*_label` string keys at any object level (e.g. `name_label`)
// - flat `foo` / `foo_desc` string pairs (the calendar.settings pattern)
export function collectSubResults(node: unknown, sink: SubResult[]): void {
if (typeof node === 'string') {
sink.push({ label: node });
return;
}
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
const obj = node as Record<string, unknown>;
const label = typeof obj.label === 'string' ? obj.label : (typeof obj.title === 'string' ? obj.title : undefined);
if (label) {
sink.push({
label,
description: typeof obj.description === 'string' ? obj.description : undefined,
});
}
for (const [key, value] of Object.entries(obj)) {
if (typeof value !== 'string' || key === 'label' || key === 'title') continue;
if (key.endsWith('_label')) {
sink.push({ label: value });
continue;
}
const desc = obj[`${key}_desc`];
if (typeof desc === 'string') {
sink.push({ label: value, description: desc });
}
}
for (const value of Object.values(obj)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
collectSubResults(value, sink);
}
}
}
export function getByPath(obj: unknown, path: string): unknown {
let cur: unknown = obj;
for (const key of path.split('.')) {
if (cur && typeof cur === 'object' && key in (cur as Record<string, unknown>)) {
cur = (cur as Record<string, unknown>)[key];
} else {
return undefined;
}
}
return cur;
}
// Settings search index helpers: which translation subtrees each settings tab
// renders, extra keywords per tab, and the walker that turns those subtrees
// into clickable sub-results. Kept free of React so it can be unit-tested.
export type SettingsSearchTab =
| 'account'
| 'general'
| 'notifications'
| 'appearance'
| 'message_list'
| 'reading'
| 'composing'
| 'vacation'
| 'filters'
| 'organize'
| 'security'
| 'calendar'
| 'contacts'
| 'files'
| 'addons'
| 'backup';
type Tab = SettingsSearchTab;
/** A row a settings page draws only in some situations. */
export type SettingsSearchCondition =
/** A touch screen (pointer: coarse), e.g. the swipe actions. */
| 'touch'
/** The Mountain View interface layout. */
| 'mountainView'
/** More than one account signed in. */
| 'multiAccount';
export interface SettingsSearchEntry {
/**
* Translation key of the label the page draws for the setting. The page
* marks that row with the same text in `data-search-label`, so a result
* always lands on its row (a test renders every page and checks).
*/
label: string;
/** The row is hidden until the Advanced switch is on. */
advanced?: true;
/** The page draws the row only then. */
when?: SettingsSearchCondition;
}
// The settings each page draws, as the search lists them. Only labels a page
// renders belong here: settings-search-anchors.test.tsx renders every page
// and fails for an entry without its row. A description is found next to
// the label (see descriptionKeyFor) and searched too.
export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
account: [
{ label: 'settings.account.name_label' },
{ label: 'settings.account.email.label' },
{ label: 'settings.account.auth_method_label' },
{ label: 'settings.account.server.label', advanced: true },
{ label: 'settings.account.sections.addresses' },
{ label: 'settings.identities.identities_count.label' },
{ label: 'settings.identities.sub_addressing.label' },
{ label: 'settings.email_behavior.sub_address_delimiter.label', advanced: true },
{ label: 'settings.account.accounts.title', when: 'multiAccount' },
],
general: [
{ label: 'settings.language_region.title' },
{ label: 'settings.language_region.language.label' },
{ label: 'settings.language_region.date_format.label' },
{ label: 'settings.language_region.date_locale.label', advanced: true },
{ label: 'settings.language_region.time_format.label' },
{ label: 'settings.language_region.time_zone.label' },
{ label: 'settings.language_region.first_day.label' },
{ label: 'protocol_handlers.title' },
{ label: 'protocol_handlers.mailto_label' },
{ label: 'protocol_handlers.webcal_label' },
{ label: 'protocol_handlers.protocol_open_mode_label', advanced: true },
{ label: 'tour.settings_section' },
{ label: 'tour.restart_title' },
{ label: 'tour.show_on_new_devices_title', advanced: true },
],
notifications: [
{ label: 'settings.notifications.email.title' },
{ label: 'settings.notifications.email.enabled' },
{ label: 'settings.notifications.email.sound' },
{ label: 'settings.appearance.favicon_unread_badge.label' },
{ label: 'settings.notifications.calendar.title' },
{ label: 'settings.notifications.calendar.enabled' },
{ label: 'settings.notifications.sound_selection.title' },
{ label: 'settings.notifications.sound_selection.choose' },
{ label: 'settings.notifications.push.title' },
{ label: 'settings.notifications.push.relay_label', advanced: true },
{ label: 'settings.notifications.push.devices_title', advanced: true },
],
appearance: [
{ label: 'settings.appearance.theme.label' },
{ label: 'settings.themes.title' },
{ label: 'settings.appearance.sections.text' },
{ label: 'settings.appearance.font_size.label' },
{ label: 'settings.appearance.list_density.label' },
{ label: 'settings.appearance.animations.label' },
{ label: 'settings.appearance.sections.layout' },
{ label: 'settings.appearance.interface_layout.label' },
{ label: 'settings.email_behavior.mail_layout.label' },
{ label: 'settings.appearance.toolbar_position.label' },
{ label: 'settings.appearance.toolbar_labels.label' },
{ label: 'settings.appearance.sections.sidebar' },
{ label: 'settings.appearance.colorful_sidebar_icons.label' },
{ label: 'settings.appearance.show_folder_total_count.label' },
{ label: 'settings.appearance.sections.experimental', advanced: true },
{ label: 'settings.appearance.pro_interface.label', advanced: true },
{ label: 'settings.appearance.hide_account_switcher.label', when: 'multiAccount' },
{ label: 'settings.appearance.show_rail_account_list.label', when: 'multiAccount' },
],
message_list: [
{ label: 'settings.email_behavior.group_conversations.label' },
{ label: 'settings.email_behavior.show_preview.label' },
{ label: 'settings.appearance.message_list_order.label' },
{ label: 'settings.appearance.message_list_order.scope.label' },
{ label: 'settings.appearance.tint_list_rows.label' },
{ label: 'settings.email_behavior.show_verification_codes.label' },
{ label: 'settings.message_list.sections.senders' },
{ label: 'settings.advanced.sender_favicons.label' },
{ label: 'settings.advanced.sender_favicons.refresh_label' },
{ label: 'settings.advanced.show_avatars_in_junk.label', advanced: true },
{ label: 'settings.message_list.sections.actions' },
{ label: 'settings.email_behavior.hover_actions.label' },
{ label: 'settings.message_list.sections.delete' },
{ label: 'settings.email_behavior.delete_action.label' },
{ label: 'settings.email_behavior.archive_mode.label' },
{ label: 'settings.email_behavior.confirm_bulk_delete.label' },
{ label: 'settings.email_behavior.permanently_delete_junk.label', advanced: true },
{ label: 'settings.message_list.sections.loading', advanced: true },
{ label: 'settings.email_behavior.emails_per_page.label', advanced: true },
{ label: 'settings.email_behavior.clear_search_on_folder_change.label', advanced: true },
{ label: 'settings.email_behavior.swipe_right_action.label', when: 'touch' },
{ label: 'settings.email_behavior.swipe_left_action.label', when: 'touch' },
{ label: 'settings.email_behavior.inbox_type.label', when: 'mountainView' },
],
reading: [
{ label: 'settings.email_behavior.mark_read.label' },
{ label: 'settings.email_behavior.return_to_list_after_action.label' },
{ label: 'settings.reading_page.sections.display' },
{ label: 'settings.email_behavior.message_spacing.label' },
{ label: 'settings.email_behavior.plain_text_font.label' },
{ label: 'settings.email_behavior.always_light_mode.label' },
{ label: 'settings.reading_page.sections.images' },
{ label: 'settings.email_behavior.external_content.label' },
{ label: 'settings.email_behavior.trusted_senders.label' },
{ label: 'settings.email_behavior.trusted_senders.use_address_book_label' },
{ label: 'settings.reading_page.sections.attachments' },
{ label: 'settings.email_behavior.attachment_position.label' },
{ label: 'settings.email_behavior.attachment_click_action.label' },
{ label: 'settings.email_behavior.attachment_image_previews.label' },
{ label: 'settings.email_behavior.hide_inline_image_attachments.label' },
{ label: 'settings.downloads.title', advanced: true },
{ label: 'settings.downloads.email_template.label', advanced: true },
{ label: 'settings.downloads.attachment_template.label', advanced: true },
{ label: 'settings.downloads.bundle_template.label', advanced: true },
{ label: 'settings.downloads.spaces.label', advanced: true },
{ label: 'settings.downloads.lowercase.label', advanced: true },
{ label: 'settings.downloads.strip_diacritics.label', advanced: true },
{ label: 'settings.downloads.collapse_separators.label', advanced: true },
{ label: 'settings.downloads.after_export.label', advanced: true },
],
composing: [
{ label: 'settings.email_behavior.plain_text_mode.label' },
{ label: 'settings.email_behavior.recipient_mentions.label' },
{ label: 'settings.email_behavior.mention_adds_recipient.label' },
{ label: 'settings.email_behavior.rtl_editing.label', advanced: true },
{ label: 'settings.composing_page.sections.replying' },
{ label: 'settings.email_behavior.quick_reply.label' },
{ label: 'settings.email_behavior.auto_select_reply_identity.label' },
{ label: 'settings.email_behavior.signature_position.label' },
{ label: 'settings.email_behavior.signature_separator.label', advanced: true },
{ label: 'settings.composing_page.sections.sending' },
{ label: 'settings.email_behavior.send_delay.label' },
{ label: 'settings.email_behavior.attachment_reminder.label' },
{ label: 'settings.email_behavior.empty_subject_warning.label' },
{ label: 'settings.email_behavior.placeholder_warning.label' },
{ label: 'settings.composing_page.sections.receipts' },
{ label: 'settings.email_behavior.request_read_receipt.label' },
{ label: 'settings.email_behavior.read_receipt_response.label' },
{ label: 'settings.templates.title' },
{ label: 'settings.templates.export_import', advanced: true },
{ label: 'settings.email_behavior.signatures_link.label' },
],
vacation: [
{ label: 'settings.vacation.date_range.start' },
{ label: 'settings.vacation.date_range.end' },
{ label: 'settings.vacation.auto_reply.title' },
{ label: 'settings.vacation.status.label' },
{ label: 'settings.vacation.message.subject_label' },
{ label: 'settings.vacation.message.html_label' },
{ label: 'settings.vacation.forward.title' },
{ label: 'settings.vacation.forward.enabled_label' },
],
filters: [
{ label: 'settings.filters.title' },
{ label: 'settings.filters.add_rule' },
{ label: 'settings.filters.raw_editor' },
],
organize: [
{ label: 'settings.folders.folder_list' },
{ label: 'settings.folders.standard_roles', advanced: true },
{ label: 'settings.folders.role_inbox', advanced: true },
{ label: 'settings.folders.role_drafts', advanced: true },
{ label: 'settings.folders.role_sent', advanced: true },
{ label: 'settings.folders.role_trash', advanced: true },
{ label: 'settings.folders.role_junk', advanced: true },
{ label: 'settings.folders.role_archive', advanced: true },
{ label: 'settings.keywords.title' },
{ label: 'settings.keywords.nesting.label' },
],
security: [
{ label: 'settings.security.title' },
{ label: 'settings.security.totp.label' },
{ label: 'settings.security.sections.devices' },
{ label: 'settings.security.sections.encryption', advanced: true },
{ label: 'settings.security.password.title' },
{ label: 'settings.security.totp.section_title' },
{ label: 'settings.security.app_passwords.title' },
{ label: 'settings.security.api_keys.title', advanced: true },
{ label: 'settings.security.encryption.section_title', advanced: true },
{ label: 'settings.security.email_client.title' },
{ label: 'settings.security.link_device.title' },
],
calendar: [
{ label: 'calendar.settings.title' },
{ label: 'calendar.settings.default_view' },
{ label: 'calendar.settings.invitation_parsing' },
{ label: 'calendar.settings.show_time_in_month_view' },
{ label: 'calendar.settings.show_week_numbers' },
{ label: 'calendar.settings.limit_hours' },
{ label: 'calendar.settings.visible_hours' },
{ label: 'calendar.settings.hide_non_working_days' },
{ label: 'calendar.settings.calendar_free_scroll' },
{ label: 'calendar.settings.hover_preview' },
{ label: 'calendar.settings.show_birthday_calendar' },
{ label: 'calendar.settings.enable_tasks' },
{ label: 'calendar.management.title' },
{ label: 'calendar.import.title' },
{ label: 'calendar.subscription.title' },
],
contacts: [
{ label: 'settings.contacts.group_by_letter_label' },
{ label: 'settings.contacts.sort_by_last_name_label' },
{ label: 'settings.contacts.import_label' },
{ label: 'settings.contacts.export_label' },
{ label: 'settings.contacts.manage_title' },
{ label: 'settings.contacts.categories_title' },
],
files: [
{ label: 'settings.files.display.title' },
{ label: 'settings.files.default_view.label' },
{ label: 'settings.files.default_sort.label' },
{ label: 'settings.files.sort_direction.label' },
{ label: 'settings.files.icons.title' },
{ label: 'settings.files.show_icons.label' },
{ label: 'settings.files.colored_icons.label' },
{ label: 'settings.files.show_thumbnails.label' },
{ label: 'settings.files.behavior.title', advanced: true },
{ label: 'settings.files.show_hidden.label', advanced: true },
],
addons: [
{ label: 'settings.sidebar_apps.title' },
{ label: 'settings.sidebar_apps.keep_loaded', advanced: true },
],
backup: [
{ label: 'settings.advanced.sections.backup' },
{ label: 'settings.advanced.export_settings.label' },
{ label: 'settings.advanced.import_settings.label' },
{ label: 'settings.advanced.sections.fix' },
{ label: 'settings.advanced.refresh_cache.label' },
{ label: 'settings.advanced.reset_settings.label' },
{ label: 'settings.advanced.debug_mode.label', advanced: true },
],
};
// Extra English keywords per tab so common search terms hit even when the
// translation doesn't contain the literal word.
/** Settings shown only while the admin has the Archive action on (#1257). */
export const archiveActionSearchPaths: ReadonlySet<string> = new Set(['settings.email_behavior.archive_mode.label']);
export const tabKeywords: Record<Tab, string> = {
account: 'profile email user signin signout reorder rearrange drag dropdown switcher multi-account identities from address signature plus addressing unified mailbox shared group',
general: 'language locale region timezone date time format mailto webcal links default app protocol handler tour onboarding',
notifications: 'sound alert push badge favicon tab icon unread count background relay device',
appearance: 'theme dark light font size accent color animation density custom theme css skin toolbar sidebar account switcher icons rail layout reading pane',
message_list: 'inbox list preview thread conversation grouping sort order columns archive delete swipe hover favicon avatar tag color',
reading: 'mark read attachment open monospace mono font plain text images remote content block sender privacy tracking dark light download filename template eml save export',
composing: 'editor signature plain text reply forward draft compose template snippet quick reply mention undo send receipt attachment reminder',
vacation: 'auto reply away out of office holiday responder',
filters: 'sieve rules add create new block junk forward',
organize: 'folders mailbox subscribe roles tags labels keywords colors nested',
security: 'password 2fa two-factor passkey app password mfa api key token phone mobile app pair qr thunderbird outlook imap client encryption pgp smime',
calendar: 'event schedule appointment meeting timezone invitation ics tasks birthday working hours',
contacts: 'address book contact',
files: 'attachments cloud drive storage upload',
addons: 'apps webview iframe plugins extensions addons sidebar',
backup: 'export import storage quota privacy backup reset sync cache about version debug logs developer console diagnostic',
};
// Some settings texts name the app with {appName} (#1314). The index keeps
// them filled in, so the search matches the name people see and a result
// never carries the raw placeholder.
export function withAppName(node: unknown, appName: string): unknown {
if (typeof node === 'string') return node.split('{appName}').join(appName);
if (Array.isArray(node)) return node.map((item) => withAppName(item, appName));
if (node && typeof node === 'object') {
return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, withAppName(value, appName)]));
}
return node;
}
export interface SubResult {
label: string;
description?: string;
// For plugin setting fields: the id of the plugin whose card needs to be
// expanded before the field becomes visible in the DOM.
pluginId?: string;
/** The row is hidden until the Advanced switch is on. */
advanced?: boolean;
}
/**
* The key of the text a page shows under a label: `x.label` and `x.title`
* have `x.description`, `x_label` has `x_description`, and a flat `x` (the
* calendar settings) has `x_desc`.
*/
export function descriptionKeyFor(label: string, messages: unknown): string | undefined {
const candidates = [
label.replace(/\.(label|title)$/, '.description'),
label.replace(/_(label|title)$/, '_description'),
`${label}_desc`,
`${label}_description`,
].filter((key) => key !== label);
return candidates.find((key) => typeof getByPath(messages, key) === 'string');
}
/**
* The search results a page offers, in the language of `messages`:
* each entry's label and description, with the app's name filled in.
* `shown` leaves out entries the page doesn't draw right now.
*/
export function settingsSubResults(
messages: unknown,
tab: SettingsSearchTab,
appName: string,
shown: (entry: SettingsSearchEntry) => boolean = () => true,
): SubResult[] {
const list: SubResult[] = [];
for (const entry of tabSearchEntries[tab]) {
if (!shown(entry)) continue;
const label = getByPath(messages, entry.label);
if (typeof label !== 'string') continue;
const descriptionKey = descriptionKeyFor(entry.label, messages);
const description = descriptionKey ? (getByPath(messages, descriptionKey) as string) : undefined;
list.push({
label: withAppName(label, appName) as string,
...(description ? { description: withAppName(description, appName) as string } : {}),
...(entry.advanced ? { advanced: true } : {}),
});
}
return list;
}
/** Every word of the query is somewhere in the text, in any order. */
export function matchesAllWords(text: string, query: string): boolean {
const haystack = text.toLowerCase();
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
return words.length > 0 && words.every((word) => haystack.includes(word));
}
export function getByPath(obj: unknown, path: string): unknown {
let cur: unknown = obj;
for (const key of path.split('.')) {
if (cur && typeof cur === 'object' && key in (cur as Record<string, unknown>)) {
cur = (cur as Record<string, unknown>)[key];
} else {
return undefined;
}
}
return cur;
}
+13 -6
View File
@@ -234,9 +234,9 @@ body[data-theme-skin="builtin-flat-fields"] .flex-col.h-full.bg-secondary {
background-color: var(--color-background);
}
/* ── Selection is grey plus a 3px brand edge. The folder row asks for
border-primary, but the global \`* { border-color }\` rule wins over
utilities, so the edge colour is set here. ── */
/* ── Selection is grey plus a 3px brand edge: the theme widens the folder
row's start border and colours it, the rows that aren't selected keep a
transparent one so the names don't shift. ── */
body[data-theme-skin="builtin-flat-fields"] [data-tour="sidebar"] [data-testid="folder-row"] {
border-inline-start-color: transparent;
}
@@ -303,13 +303,20 @@ body[data-theme-skin="builtin-flat-fields"] [role="switch"][aria-checked="false"
}
/* Things you type into or press carry the control border (3:1 on every
ground). The app asks for border-input, but the global \`* { border-color }\`
rule outranks utilities, so it is set here. Borderless fields are
unaffected because they have no border width. */
ground), also the fields that ask for border-border. This rule is not in
a layer, so it outranks the border utilities (the app's own
\`* { border-color }\` default sits in @layer base and doesn't). Borderless
fields are unaffected because they have no border width. */
body[data-theme-skin="builtin-flat-fields"] :is(input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea, .border-input) {
border-color: var(--color-input);
}
/* ...except a field that holds an error: it keeps the error colour its
page gives it (aria-invalid, border-destructive or a red border). */
body[data-theme-skin="builtin-flat-fields"] :is(input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea, .border-input):is([aria-invalid="true"], .border-destructive, [class*="border-red-"]) {
border-color: var(--color-destructive);
}
body[data-theme-skin="builtin-flat-fields"] :is(input[type="checkbox"], input[type="radio"]) {
accent-color: var(--color-primary);
}
+255 -6
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "الإعدادات",
"back_to_mail": "العودة إلى البريد",
"back_to_settings": "العودة إلى الإعدادات",
"save_success": "تم حفظ الإعدادات بنجاح",
"import_success": "تم استيراد الإعدادات بنجاح",
"import_error": "فشل استيراد الإعدادات",
@@ -1348,6 +1349,7 @@
"id_exists": "معرّف الوسم هذا موجود بالفعل",
"edit": "تعديل الوسم",
"delete": "حذف الوسم",
"deleted": "تم حذف الوسم",
"save": "حفظ",
"add": "إضافة",
"cancel": "إلغاء",
@@ -1852,6 +1854,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "لن تتمكن الأجهزة الأخرى من استعادة الحسابات منها بعد الآن.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1957,6 +1960,8 @@
"removed": "تمت إزالة كلمة مرور التطبيق",
"add_error": "فشل إنشاء كلمة مرور التطبيق",
"remove_error": "فشلت إزالة كلمة مرور التطبيق",
"delete_title": "حذف كلمة مرور التطبيق؟",
"delete_message": "لن تتمكن التطبيقات التي تسجّل الدخول باستخدام {name} من تسجيل الدخول بعد الآن.",
"none": "لا توجد كلمات مرور تطبيقات مُعدَّة"
},
"api_keys": {
@@ -1969,6 +1974,8 @@
"removed": "تمت إزالة مفتاح API",
"add_error": "فشل إنشاء مفتاح API",
"remove_error": "فشلت إزالة مفتاح API",
"delete_title": "حذف مفتاح API؟",
"delete_message": "ستفقد البرامج النصية والتكاملات التي تستخدم {name} الوصول.",
"none": "لا توجد مفاتيح API مُعدَّة"
},
"encryption": {
@@ -1988,6 +1995,9 @@
"configure_title": "تفعيل التشفير باستخدام هذا المفتاح",
"enable_button": "تفعيل",
"disable_tooltip": "تعطيل التشفير",
"disable_title": "إيقاف تشفير البيانات المخزنة؟",
"disable_message": "سيُخزَّن البريد الجديد بدون تشفير من الآن فصاعدًا.",
"disable_button": "إيقاف",
"enabled_success": "تم تفعيل التشفير أثناء التخزين"
},
"email_client": {
@@ -2036,7 +2046,14 @@
"name_placeholder": "مثال: مفتاحي",
"key_label": "المفتاح العام (S/MIME أو PGP بترميز ASCII)",
"key_placeholder": "الصق مفتاحك العام هنا",
"none": "لا توجد مفاتيح عامة مُعدّة"
"none": "لا توجد مفاتيح عامة مُعدّة",
"title": "المفاتيح العامة",
"added": "تمت إضافة المفتاح العام",
"removed": "تم حذف المفتاح العام",
"add_error": "تعذّرت إضافة المفتاح العام",
"remove_error": "تعذّر حذف المفتاح العام",
"delete_title": "حذف المفتاح العام؟",
"delete_message": "ستتم إزالة {name} من الخادم."
},
"sections": {
"devices": "تطبيقات وأجهزة أخرى",
@@ -2096,6 +2113,7 @@
},
"save": "حفظ التغييرات",
"saving": "جارٍ الحفظ...",
"saved": "تم الحفظ",
"warnings": {
"end_before_start": "يجب أن يكون تاريخ الانتهاء بعد تاريخ البدء",
"invalid_date": "أدخل تاريخ بدء وانتهاء صالحين مع الوقت، أو اترك الحقل فارغًا",
@@ -2162,12 +2180,17 @@
"new_folder_name": "اسم المجلد",
"rename": "إعادة تسمية",
"change_icon": "تغيير الأيقونة",
"actions": "إجراءات {name}",
"expand": "توسيع {name}",
"collapse": "طي {name}",
"counter": "العداد",
"count_mode": "العداد لـ {name}",
"count_mode_default": "العداد: افتراضي",
"count_mode_unread": "غير المقروءة فقط",
"count_mode_all": "غير المقروءة والإجمالي",
"count_mode_none": "بدون عداد",
"delete": "حذف",
"delete_title": "حذف المجلد؟",
"confirm_delete": "هل أنت متأكد أنك تريد حذف \"{name}\"؟ ستُنقل الرسائل في هذا المجلد إلى المهملات.",
"create": "إنشاء",
"cancel": "إلغاء",
@@ -2187,7 +2210,8 @@
"advanced": {
"debug_mode": {
"label": "وضع التصحيح",
"description": "تفعيل التسجيل المفصّل لاستكشاف الأخطاء وإصلاحها"
"description": "تفعيل التسجيل المفصّل لاستكشاف الأخطاء وإصلاحها",
"toggle": "تسجيل مفصّل"
},
"debug_categories": {
"description": "اختر الفئات التي تريد تسجيلها. عطّل الفئات التي لا تحتاجها لتقليل ضجيج وحدة التحكم.",
@@ -2232,12 +2256,15 @@
"refresh_cache": {
"label": "تحديث البيانات المخزنة مؤقتًا",
"description": "إعادة تحميل جهات الاتصال والتقويمات والمجلدات من الخادم. يحافظ على حساباتك وجلساتك — يصلح العرض القديم أو الخاطئ دون تسجيل الخروج.",
"button": "تحديث"
"button": "تحديث",
"confirm_title": "تحديث البيانات المخزنة مؤقتًا؟"
},
"reset_settings": {
"label": "إعادة تعيين الإعدادات",
"description": "استعادة جميع الإعدادات إلى القيم الافتراضية",
"button": "إعادة التعيين للافتراضي"
"button": "إعادة التعيين للافتراضي",
"confirm_title": "إعادة تعيين كل الإعدادات؟",
"done": "تمت إعادة تعيين الإعدادات"
},
"export_settings": {
"label": "تصدير الإعدادات",
@@ -2312,7 +2339,15 @@
"no_address_books": "لم يتم العثور على دفاتر عناوين",
"categories_title": "الفئات",
"categories_description": "إعادة تسمية فئات جهات الاتصال",
"no_categories": "لم يتم العثور على فئات"
"no_categories": "لم يتم العثور على فئات",
"delete_category": "حذف الفئة",
"category_deleted": "تم حذف الفئة",
"category_delete_failed": "تعذّر حذف الفئة"
},
"calendar_management": {
"delete_title": "حذف التقويم؟",
"clear_title": "مسح كل الأحداث؟",
"unsubscribe_title": "إلغاء الاشتراك في هذا التقويم؟"
},
"downloads": {
"title": "التنزيلات",
@@ -2378,7 +2413,7 @@
"validate": "تحقق",
"validation_success": "النص صالح",
"validation_error": "النص يحتوي على أخطاء",
"save": "حفظ القواعد",
"save": "حفظ",
"saving": "جارٍ الحفظ...",
"saved": "تم حفظ المرشحات بنجاح",
"save_failed": "فشل حفظ المرشحات",
@@ -2392,6 +2427,8 @@
"add_condition": "إضافة شرط",
"actions": "الإجراءات",
"add_action": "إضافة إجراء",
"remove_condition": "إزالة الشرط",
"remove_action": "إزالة الإجراء",
"stop_processing": "إيقاف معالجة القواعد التالية",
"include_spam": "نقل الرسائل المصنفة كرسائل مزعجة أيضًا",
"attachment_type_placeholder": "مثال: pdf, doc, jpg",
@@ -2468,6 +2505,7 @@
"match_type": "نوع المطابقة",
"reset_to_visual": "إعادة التعيين إلى أداة الإنشاء المرئي",
"reset_warning": "سيؤدي هذا إلى تجاهل النص الحالي والبدء من جديد.",
"reset_title": "إعادة التعيين إلى المحرر المرئي؟",
"confirm_reset": "إعادة التعيين",
"validation_empty_name": "اسم القاعدة مطلوب",
"validation_empty_conditions": "مطلوب شرط واحد على الأقل بقيمة",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "إعداد Bulwark Webmail",
"subtitle": "اضبط نسخة البريد الإلكتروني على الويب من المتصفح.",
"loading": "جارٍ التحميل…",
"back": "رجوع",
"next": "التالي",
"saving": "جارٍ الحفظ…",
"show": "إظهار",
"hide": "إخفاء",
"remove": "إزالة",
"on": "تشغيل",
"off": "إيقاف",
"dismiss": "إغلاق",
"unknown_error": "خطأ غير معروف",
"steps": {
"welcome": "مرحبًا",
"server": "الخادم",
"auth": "تسجيل الدخول",
"security": "الأمان",
"logging": "السجلات",
"branding": "الهوية البصرية",
"review": "المراجعة"
},
"read_only": {
"title": "الإعدادات للقراءة فقط",
"body": "وحدة تخزين الإعدادات مثبتة للقراءة فقط أو تم تعيين {variable}. أعد تثبيتها مع إذن الكتابة أو أزل هذا المتغير، ثم أعد تشغيل الحاوية."
},
"done": {
"title": "كل شيء جاهز",
"body": "تم إعداد {appName} وهو جاهز للاستخدام.",
"admin_sign_in": "تسجيل الدخول إلى لوحة الإدارة",
"webmail_login": "فتح تسجيل الدخول إلى البريد",
"redirecting": "جارٍ نقلك إلى لوحة الإدارة…"
},
"already": {
"title": "اكتمل الإعداد بالفعل",
"body": "تم إعداد {appName}. سجّل الدخول للمتابعة."
},
"insecure": {
"title": "أنت تشغّل الإعداد عبر HTTP غير مشفر",
"body": "سيُرسل رمز الإعداد وكلمة مرور المسؤول اللذان تدخلهما هنا نصًا واضحًا. استخدم HTTPS إن أمكن: أنهِ TLS في الحاوية أو في وكيل عكسي أمامها.",
"try_https": "تجربة HTTPS",
"continue_http": "المتابعة عبر HTTP"
},
"errors": {
"token_expired": "رمز الإعداد هذا لم يعد صالحًا. أعد تشغيل الحاوية للحصول على رمز جديد من السجلات.",
"session_expired": "انتهت جلسة الإعداد. الصق رمز الإعداد مرة أخرى للمتابعة.",
"token_required": "الصق رمز الإعداد المعروض في سجلات الحاوية للمتابعة.",
"already_finished": "اكتمل الإعداد بالفعل. أعد تحميل الصفحة لتسجيل الدخول.",
"token_rejected": "تم رفض الرمز (HTTP {status})",
"step_failed": "تعذّر حفظ هذه الخطوة (HTTP {status})",
"upload_failed": "فشل الرفع (HTTP {status})",
"finish_failed": "تعذّر إكمال الإعداد (HTTP {status})"
},
"welcome": {
"title": "مرحبًا",
"body": "الصق رمز الإعداد المعروض في سجلات الحاوية للمتابعة. تنتهي صلاحية الرمز بعد ساعة واحدة.",
"token_label": "رمز الإعداد",
"token_placeholder": "رمز سداسي عشري بطول 32 بايت",
"verifying": "جارٍ التحقق…",
"continue": "متابعة"
},
"server": {
"title": "الخادم",
"subtitle": "مكان بريدك.",
"app_name": "اسم التطبيق",
"url_label": "عنوان URL لخادم JMAP",
"url_hint": "الخادم الافتراضي الذي يتصل به المستخدمون. مثال: https://mail.example.com",
"test": "اختبار",
"testing": "جارٍ الاختبار…",
"probe_ok": "تم الاتصال: يبدو أنه خادم JMAP.",
"probe_not_jmap": "وصلنا إلى الخادم، لكنه لا يبدو نقطة نهاية JMAP.",
"probe_invalid": "عنوان URL هذا غير صالح. يجب أن يبدأ بـ http:// أو https://.",
"probe_unreachable": "تعذّر الاتصال بهذا العنوان. تحقق من عنوان URL ومن أن الخادم متصل.",
"not_jmap_hint": "قد يحدث هذا عندما يوجّه وكيل عكسي JMAP بشكل منفصل على النطاق نفسه. وإلا فعادةً يكون عنوان URL خاطئًا.",
"not_jmap_confirm": "أنا متأكد من أن عنوان URL صحيح، تابع على أي حال.",
"http_title": "يستخدم عنوان URL هذا HTTP غير مشفر.",
"http_body": "ستنتقل كلمات المرور ومحتوى الرسائل دون تشفير بين المستخدمين وخادمك. استخدم {https} في بيئة الإنتاج: أنهِ TLS على خادم البريد أو في وكيل عكسي أمامه.",
"local_title": "عنوان URL هذا يعمل محليًا فقط.",
"local_body": "يُجلب البريد مباشرةً من متصفح المستخدم، لذا يجب أن يكون عنوان JMAP متاحًا من كل مكان يسجّل منه المستخدمون الدخول، لا من هذا الجهاز أو الشبكة المحلية فقط. استخدم اسم مضيف عامًا (مثل {example}) في بيئة الإنتاج.",
"additional_title": "خوادم JMAP إضافية",
"additional_hint": "اختياري. اعرض عدة خوادم في قائمة تسجيل الدخول، وهذا مفيد لمن يشغّل عدة نسخ من Stalwart.",
"additional_show": "إظهار ({count})",
"add": "إضافة",
"row_title": "الخادم رقم {index}",
"id_label": "المعرّف",
"id_hint": "اسم قصير فريد، مثل eu-1",
"label_label": "التسمية",
"label_hint": "تظهر للمستخدمين",
"row_url": "URL",
"domains_label": "النطاقات",
"domains_hint": "مفصولة بفواصل. تُستخدم لاختيار هذا الخادم حسب نطاق البريد عند تسجيل الدخول.",
"add_server": "إضافة خادم",
"auto_pick": "اختيار الخادم حسب نطاق البريد",
"auto_pick_hint": "عندما يكتب المستخدم بريده، يُختار الخادم المطابق من القائمة أعلاه.",
"row_id_required": "الخادم رقم {index}: المعرّف مطلوب",
"row_id_invalid": "الخادم رقم {index}: يمكن أن يحتوي المعرّف على حروف وأرقام و- و_ فقط، ويجب أن يبدأ بحرف أو رقم",
"row_id_duplicate": "الخادم رقم {index}: المعرّف «{id}» مكرر",
"row_url_required": "الخادم رقم {index}: عنوان URL مطلوب",
"row_url_scheme": "الخادم رقم {index}: يجب أن يبدأ عنوان URL بـ http:// أو https://",
"stalwart": "تفعيل ميزات Stalwart الخاصة",
"stalwart_hint": "يضيف تغيير كلمة المرور وإدارة عوامل تصفية Sieve. يمكن تفعيله بأمان على الخوادم الأخرى أيضًا."
},
"auth": {
"title": "المصادقة",
"subtitle": "تسجيل الدخول بكلمة المرور متاح دائمًا. OAuth/OIDC اختياري.",
"oauth": "تفعيل OAuth2 / OpenID Connect",
"oauth_only": "OAuth فقط (إخفاء نموذج كلمة المرور)",
"client_id": "معرّف عميل OAuth",
"client_secret": "سر عميل OAuth",
"client_secret_hint": "اتركه فارغًا للعملاء العامين الذين يستخدمون PKCE فقط.",
"client_secret_placeholder": "الصق السر",
"issuer": "عنوان URL لمُصدِر OAuth",
"issuer_hint": "لموفّري الهوية الخارجيين (Keycloak وAuthentik وEntra ID وغيرها)."
},
"security": {
"title": "الأمان والجلسات",
"subtitle": "يتيح سر الجلسة خيار «تذكرني» ومزامنة الإعدادات.",
"generated": "تم إنشاء سر الجلسة",
"customize": "تخصيص",
"generated_hint": "أُنشئ لك سر بطول 32 بايت. غيّره فقط إن كان لديك سبب محدد.",
"secret_label": "سر الجلسة",
"secret_hint": "الصق قيمتك الخاصة أو أنشئ قيمة جديدة.",
"regenerate": "إنشاء من جديد",
"sync": "مزامنة إعدادات المستخدمين بين الأجهزة",
"sync_hint": "يخزّن تفضيلات المستخدمين على الخادم مشفرة بسر الجلسة.",
"telemetry": "إرسال إحصاءات استخدام مجهولة للمساعدة في تحسين Bulwark",
"telemetry_hint": "متوقف افتراضيًا. إشارة مجهولة واحدة يوميًا تتضمن الإصدار والمنصة والميزات المفعّلة؛ ولا تتضمن أبدًا عناوين البريد أو أسماء المضيفين أو عناوين IP. يمكنك تغيير ذلك في أي وقت من إعدادات الإدارة."
},
"logging": {
"title": "السجلات",
"subtitle": "تنسيق سجلات التطبيق ومستوى تفاصيلها.",
"format": "التنسيق",
"format_text": "text: ملوّن وسهل القراءة",
"format_json": "json: منظَّم، لتجميع السجلات",
"level": "المستوى",
"level_info": "info (موصى به)"
},
"branding": {
"title": "الهوية البصرية",
"subtitle": "كل الحقول اختيارية. ارفع ملفًا أو الصق عنوان URL؛ وتُستخدم القيم الافتراضية لكل ما تتجاوزه.",
"company": "اسم الشركة أو المؤسسة",
"favicon": "أيقونة الموقع",
"favicon_hint": "أيقونة علامة تبويب المتصفح. يُنصح بـ SVG.",
"login_light": "شعار تسجيل الدخول (الوضع الفاتح)",
"login_light_hint": "يظهر في صفحة تسجيل الدخول على الخلفيات الفاتحة.",
"login_dark": "شعار تسجيل الدخول (الوضع الداكن)",
"login_dark_hint": "يظهر في صفحة تسجيل الدخول على الخلفيات الداكنة.",
"sidebar_light": "شعار الشريط الجانبي (الوضع الفاتح)",
"sidebar_light_hint": "يظهر بعد تسجيل الدخول. اتركه فارغًا إن لم تُرد شعارًا.",
"sidebar_dark": "شعار الشريط الجانبي (الوضع الداكن)",
"sidebar_dark_hint": "نسخة الوضع الداكن من شعار الشريط الجانبي.",
"website": "عنوان URL للموقع",
"imprint": "عنوان URL لبيانات الناشر",
"privacy": "عنوان URL لسياسة الخصوصية",
"drop": "انقر أو أفلت هنا",
"uploading": "جارٍ الرفع…",
"uploaded": "ملف مرفوع",
"formats": "SVG أو PNG أو JPEG أو WebP أو ICO · بحد أقصى 2 ميغابايت",
"use_url": "استخدام عنوان URL",
"hide_url": "إخفاء عنوان URL",
"url_placeholder": "https://… أو /branding/file.svg"
},
"review": {
"title": "أوشكت على الانتهاء",
"subtitle": "راجع إعداداتك، واختر كلمة مرور للمسؤول، ثم طبّقها.",
"server": "الخادم",
"app_name": "اسم التطبيق",
"jmap_server": "خادم JMAP",
"additional": "خوادم إضافية",
"additional_count": "العدد المُعدّ: {count}",
"additional_auto_pick": "العدد المُعدّ: {count} · الاختيار حسب النطاق",
"stalwart": "ميزات Stalwart",
"auth": "المصادقة",
"method": "الطريقة",
"oauth_only": "OAuth فقط",
"password_oauth": "كلمة المرور + OAuth",
"password_only": "كلمة المرور فقط",
"client_id": "معرّف عميل OAuth",
"security": "الأمان",
"secret": "سر الجلسة",
"configured": "مُعدّ",
"not_set": "غير معيّن",
"remember_me": "تذكرني",
"available": "متاح",
"disabled": "معطّل",
"sync": "مزامنة الإعدادات",
"needs_secret": "يتطلب سر جلسة",
"telemetry": "القياس عن بُعد المجهول",
"logging": "السجلات",
"format": "التنسيق",
"level": "المستوى",
"branding": "الهوية البصرية",
"customizations": "التخصيصات",
"custom_assets": "تم إعداد ملفات مخصصة",
"defaults": "القيم الافتراضية",
"company": "اسم الشركة",
"password_title": "اختر كلمة مرور للمسؤول",
"password_hint": "ستستخدمها لتسجيل الدخول عبر {path}. 8 أحرف على الأقل.",
"new_password": "كلمة المرور الجديدة",
"confirm": "التأكيد",
"too_short": "8 أحرف على الأقل.",
"mismatch": "كلمتا المرور غير متطابقتين.",
"looks_good": "يبدو جيدًا.",
"advanced": "متقدم",
"lock": "قفل الإعدادات بعد الإعداد",
"lock_hint": "يُنشئ ملف علامة. بعد ذلك، ثبّت وحدة تخزين الإعدادات للقراءة فقط وسيرفض التطبيق أي تغييرات أخرى على الإعدادات. تبقى سجلات التدقيق وحالة تسجيل الدخول قابلة للكتابة في وحدة تخزين الحالة.",
"applying": "جارٍ التطبيق…",
"finish": "تطبيق وإنهاء"
}
},
"errors": {
"page_error_title": "حدث خطأ ما",
"page_error_description": "واجهنا خطأ غير متوقع. يرجى المحاولة مرة أخرى أو العودة إلى الصفحة الرئيسية.",
+255 -6
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Configuració",
"back_to_mail": "Torna al correu",
"back_to_settings": "Torna als paràmetres",
"save_success": "Configuració desada correctament",
"import_success": "Configuració importada correctament",
"import_error": "No s'ha pogut importar la configuració",
@@ -1348,6 +1349,7 @@
"id_exists": "Aquest ID d'etiqueta ja existeix",
"edit": "Edita l'etiqueta",
"delete": "Suprimeix l'etiqueta",
"deleted": "Etiqueta suprimida",
"save": "Desa",
"add": "Afegeix",
"cancel": "Cancel·la",
@@ -1852,6 +1854,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Els altres dispositius ja no en podran restaurar els comptes.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1957,6 +1960,8 @@
"removed": "Contrasenya d'aplicació eliminada",
"add_error": "No s'ha pogut crear la contrasenya d'aplicació",
"remove_error": "No s'ha pogut eliminar la contrasenya d'aplicació",
"delete_title": "Voleu suprimir la contrasenya d'aplicació?",
"delete_message": "Les aplicacions que inicien la sessió amb {name} ja no ho podran fer.",
"none": "No hi ha cap contrasenya d'aplicació configurada"
},
"api_keys": {
@@ -1969,6 +1974,8 @@
"removed": "Clau API eliminada",
"add_error": "No s'ha pogut crear la clau API",
"remove_error": "No s'ha pogut eliminar la clau API",
"delete_title": "Voleu suprimir la clau d'API?",
"delete_message": "Els scripts i les integracions que fan servir {name} perden l'accés.",
"none": "No hi ha cap clau API configurada"
},
"encryption": {
@@ -1988,6 +1995,9 @@
"configure_title": "Activa el xifratge amb aquesta clau",
"enable_button": "Activa",
"disable_tooltip": "Desactiva el xifratge",
"disable_title": "Voleu desactivar el xifratge en repòs?",
"disable_message": "A partir d'ara, el correu nou es desa sense xifrar.",
"disable_button": "Desactiva",
"enabled_success": "Xifratge en repòs activat"
},
"email_client": {
@@ -2036,7 +2046,14 @@
"name_placeholder": "p. ex. La meva clau",
"key_label": "Clau pública (S/MIME o PGP en ASCII armor)",
"key_placeholder": "Enganxeu aquí la vostra clau pública",
"none": "No hi ha cap clau pública configurada"
"none": "No hi ha cap clau pública configurada",
"title": "Claus públiques",
"added": "Clau pública afegida",
"removed": "Clau pública suprimida",
"add_error": "No s'ha pogut afegir la clau pública",
"remove_error": "No s'ha pogut suprimir la clau pública",
"delete_title": "Voleu suprimir la clau pública?",
"delete_message": "{name} es treu del servidor."
},
"sections": {
"devices": "Altres aplicacions i dispositius",
@@ -2096,6 +2113,7 @@
},
"save": "Desa els canvis",
"saving": "Desant...",
"saved": "Desat",
"warnings": {
"end_before_start": "La data de finalització ha de ser posterior a la data d'inici",
"invalid_date": "Introdueix una data d'inici i de finalització vàlides amb l'hora, o deixa el camp buit",
@@ -2162,12 +2180,17 @@
"new_folder_name": "Nom de la carpeta",
"rename": "Canvia el nom",
"change_icon": "Canvia la icona",
"actions": "Accions per a {name}",
"expand": "Desplega {name}",
"collapse": "Plega {name}",
"counter": "Comptador",
"count_mode": "Comptador per a {name}",
"count_mode_default": "Comptador: per defecte",
"count_mode_unread": "Només no llegits",
"count_mode_all": "No llegits i total",
"count_mode_none": "Sense comptador",
"delete": "Suprimeix",
"delete_title": "Voleu suprimir la carpeta?",
"confirm_delete": "Segur que voleu suprimir «{name}»? Els correus d'aquesta carpeta es mouran a la paperera.",
"create": "Crea",
"cancel": "Cancel·la",
@@ -2187,7 +2210,8 @@
"advanced": {
"debug_mode": {
"label": "Mode de depuració",
"description": "Activa el registre detallat per a la resolució de problemes"
"description": "Activa el registre detallat per a la resolució de problemes",
"toggle": "Registre detallat"
},
"debug_categories": {
"description": "Seleccioneu quines categories registrar. Desactiveu les que no necessiteu per reduir el soroll a la consola.",
@@ -2232,12 +2256,15 @@
"refresh_cache": {
"label": "Actualitza les dades emmagatzemades",
"description": "Torna a carregar els contactes, els calendaris i les carpetes des del servidor. Manté els comptes i les sessions — soluciona una vista obsoleta o incorrecta sense tancar la sessió.",
"button": "Actualitza"
"button": "Actualitza",
"confirm_title": "Voleu actualitzar les dades de la memòria cau?"
},
"reset_settings": {
"label": "Restableix la configuració",
"description": "Restaura tota la configuració als valors per defecte",
"button": "Restableix als valors per defecte"
"button": "Restableix als valors per defecte",
"confirm_title": "Voleu restablir tots els paràmetres?",
"done": "Paràmetres restablerts"
},
"export_settings": {
"label": "Exporta la configuració",
@@ -2312,7 +2339,15 @@
"no_address_books": "No s'ha trobat cap llibreta d'adreces",
"categories_title": "Categories",
"categories_description": "Canvieu el nom de les categories de contactes",
"no_categories": "No s'ha trobat cap categoria"
"no_categories": "No s'ha trobat cap categoria",
"delete_category": "Suprimeix la categoria",
"category_deleted": "Categoria suprimida",
"category_delete_failed": "No s'ha pogut suprimir la categoria"
},
"calendar_management": {
"delete_title": "Voleu suprimir el calendari?",
"clear_title": "Voleu esborrar tots els esdeveniments?",
"unsubscribe_title": "Voleu cancel·lar la subscripció a aquest calendari?"
},
"downloads": {
"title": "Baixades",
@@ -2378,7 +2413,7 @@
"validate": "Valida",
"validation_success": "L'script és vàlid",
"validation_error": "L'script té errors",
"save": "Desa les regles",
"save": "Desa",
"saving": "Desant...",
"saved": "Filtres desats correctament",
"save_failed": "No s'han pogut desar els filtres",
@@ -2392,6 +2427,8 @@
"add_condition": "Afegeix una condició",
"actions": "Accions",
"add_action": "Afegeix una acció",
"remove_condition": "Treu la condició",
"remove_action": "Treu l'acció",
"stop_processing": "Atura el processament de les regles següents",
"include_spam": "Mou també els missatges marcats com a correu brossa",
"attachment_type_placeholder": "p. ex. pdf, doc, jpg",
@@ -2468,6 +2505,7 @@
"match_type": "Tipus de coincidència",
"reset_to_visual": "Torna al constructor visual",
"reset_warning": "Això descartarà l'script actual i començarà de nou.",
"reset_title": "Voleu tornar a l'editor visual?",
"confirm_reset": "Reinicia",
"validation_empty_name": "El nom de la regla és obligatori",
"validation_empty_conditions": "Cal com a mínim una condició amb un valor",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Configuració de Bulwark Webmail",
"subtitle": "Configureu la vostra instància de correu web des del navegador.",
"loading": "S’està carregant…",
"back": "Enrere",
"next": "Següent",
"saving": "S’està desant…",
"show": "Mostra",
"hide": "Amaga",
"remove": "Treu",
"on": "Activat",
"off": "Desactivat",
"dismiss": "Tanca",
"unknown_error": "Error desconegut",
"steps": {
"welcome": "Benvinguda",
"server": "Servidor",
"auth": "Inici de sessió",
"security": "Seguretat",
"logging": "Registres",
"branding": "Marca",
"review": "Revisió"
},
"read_only": {
"title": "La configuració és només de lectura",
"body": "El volum de configuració està muntat només de lectura o la variable {variable} està definida. Torneu-lo a muntar amb escriptura o elimineu la variable i reinicieu el contenidor."
},
"done": {
"title": "Tot a punt",
"body": "{appName} està configurat i a punt per fer servir.",
"admin_sign_in": "Inicia la sessió al tauler d’administració",
"webmail_login": "Obre l’inici de sessió del correu web",
"redirecting": "Us portem al tauler d’administració…"
},
"already": {
"title": "La configuració ja s’ha completat",
"body": "{appName} està configurat. Inicieu la sessió per continuar."
},
"insecure": {
"title": "Esteu executant la configuració per HTTP sense xifrar",
"body": "El testimoni de configuració i la contrasenya d’administrador que introduïu aquí viatjaran en text pla. Feu servir HTTPS sempre que pugueu: acabeu el TLS al contenidor o en un servidor intermediari invers al davant.",
"try_https": "Prova HTTPS",
"continue_http": "Continua per HTTP"
},
"errors": {
"token_expired": "Aquest testimoni de configuració ja no és vàlid. Reinicieu el contenidor per obtenir-ne un de nou als registres.",
"session_expired": "La sessió de configuració ha caducat. Torneu a enganxar el testimoni de configuració per continuar.",
"token_required": "Enganxeu el testimoni de configuració que apareix als registres del contenidor per continuar.",
"already_finished": "La configuració ja ha acabat. Recarregueu per iniciar la sessió.",
"token_rejected": "Testimoni rebutjat (HTTP {status})",
"step_failed": "No s’ha pogut desar aquest pas (HTTP {status})",
"upload_failed": "No s’ha pogut pujar (HTTP {status})",
"finish_failed": "No s’ha pogut acabar la configuració (HTTP {status})"
},
"welcome": {
"title": "Benvinguda",
"body": "Enganxeu el testimoni de configuració que apareix als registres del contenidor per continuar. El testimoni caduca al cap d’1 hora.",
"token_label": "Testimoni de configuració",
"token_placeholder": "Testimoni hexadecimal de 32 bytes",
"verifying": "S’està verificant…",
"continue": "Continua"
},
"server": {
"title": "Servidor",
"subtitle": "On és el vostre correu.",
"app_name": "Nom de l’aplicació",
"url_label": "URL del servidor JMAP",
"url_hint": "El servidor predeterminat al qual es connecten els usuaris. Exemple: https://mail.example.com",
"test": "Prova",
"testing": "S’està provant…",
"probe_ok": "Connectat: sembla un servidor JMAP.",
"probe_not_jmap": "El servidor respon, però no sembla un punt d’accés JMAP.",
"probe_invalid": "Aquest URL no és vàlid. Ha de començar per http:// o https://.",
"probe_unreachable": "No s’ha pogut connectar a aquesta adreça. Comproveu l’URL i que el servidor estigui en línia.",
"not_jmap_hint": "Pot passar quan un servidor intermediari invers encamina JMAP per separat al mateix domini. Si no, normalment l’URL és incorrecte.",
"not_jmap_confirm": "Estic segur que l’URL és correcte, continua igualment.",
"http_title": "Aquest URL fa servir HTTP sense xifrar.",
"http_body": "Les contrasenyes i el contingut dels correus viatjaran sense xifrar entre els usuaris i el vostre servidor. En producció feu servir {https}: acabeu el TLS al servidor de correu o en un servidor intermediari invers al davant.",
"local_title": "Aquest URL només funciona en local.",
"local_body": "El correu s’obté directament des del navegador de l’usuari, per tant l’URL de JMAP ha de ser accessible des de qualsevol lloc on els usuaris iniciïn la sessió, no només des d’aquest ordinador o xarxa local. En producció feu servir un nom d’amfitrió públic (p. ex. {example}).",
"additional_title": "Servidors JMAP addicionals",
"additional_hint": "Opcional. Oferiu diversos servidors a la llista d’inici de sessió, útil per a proveïdors amb diverses instàncies de Stalwart.",
"additional_show": "Mostra ({count})",
"add": "Afegeix",
"row_title": "Servidor núm. {index}",
"id_label": "ID",
"id_hint": "Identificador únic, p. ex. eu-1",
"label_label": "Etiqueta",
"label_hint": "Visible per als usuaris",
"row_url": "URL",
"domains_label": "Dominis",
"domains_hint": "Separats per comes. Serveixen per triar aquest servidor segons el domini del correu en iniciar la sessió.",
"add_server": "Afegeix un servidor",
"auto_pick": "Tria el servidor segons el domini del correu",
"auto_pick_hint": "Quan un usuari escriu el seu correu, se selecciona el servidor corresponent de la llista de dalt.",
"row_id_required": "Servidor núm. {index}: l’ID és obligatori",
"row_id_invalid": "Servidor núm. {index}: l’ID només pot tenir lletres, xifres, - i _, i ha de començar per una lletra o una xifra",
"row_id_duplicate": "Servidor núm. {index}: l’ID «{id}» està repetit",
"row_url_required": "Servidor núm. {index}: l’URL és obligatori",
"row_url_scheme": "Servidor núm. {index}: l’URL ha de començar per http:// o https://",
"stalwart": "Activa les funcions pròpies de Stalwart",
"stalwart_hint": "Afegeix el canvi de contrasenya i la gestió de filtres Sieve. Es pot activar sense risc en altres servidors."
},
"auth": {
"title": "Autenticació",
"subtitle": "L’inici de sessió amb contrasenya sempre està disponible. OAuth/OIDC és opcional.",
"oauth": "Activa OAuth2 / OpenID Connect",
"oauth_only": "Només OAuth (amaga el formulari de contrasenya)",
"client_id": "ID de client OAuth",
"client_secret": "Secret de client OAuth",
"client_secret_hint": "Deixeu-ho en blanc per a clients públics que només fan servir PKCE.",
"client_secret_placeholder": "Enganxeu el secret",
"issuer": "URL de l’emissor OAuth",
"issuer_hint": "Per a proveïdors d’identitat externs (Keycloak, Authentik, Entra ID, etc.)."
},
"security": {
"title": "Seguretat i sessions",
"subtitle": "El secret de sessió permet «Recorda’m» i la sincronització de paràmetres.",
"generated": "Secret de sessió generat",
"customize": "Personalitza",
"generated_hint": "S’ha creat un secret de 32 bytes. Canvieu-lo només si teniu un motiu concret.",
"secret_label": "Secret de sessió",
"secret_hint": "Enganxeu el vostre propi valor o genereu-ne un de nou.",
"regenerate": "Torna a generar",
"sync": "Sincronitza els paràmetres dels usuaris entre dispositius",
"sync_hint": "Desa les preferències dels usuaris al servidor, xifrades amb el secret de sessió.",
"telemetry": "Envia estadístiques d’ús anònimes per ajudar a millorar Bulwark",
"telemetry_hint": "Desactivat per defecte. Un senyal anònim al dia amb la versió, la plataforma i les funcions activades; mai adreces de correu, noms d’amfitrió ni IP. Ho podeu canviar quan vulgueu als paràmetres d’administració."
},
"logging": {
"title": "Registres",
"subtitle": "Format i nivell de detall dels registres de l’aplicació.",
"format": "Format",
"format_text": "text: amb colors, fàcil de llegir",
"format_json": "json: estructurat, per a l’agregació de registres",
"level": "Nivell",
"level_info": "info (recomanat)"
},
"branding": {
"title": "Marca",
"subtitle": "Tots els camps són opcionals. Pugeu un fitxer o enganxeu un URL; per al que ometeu es fan servir els valors predeterminats.",
"company": "Nom de l’empresa o organització",
"favicon": "Favicon",
"favicon_hint": "Icona de la pestanya del navegador. Es recomana SVG.",
"login_light": "Logotip d’inici de sessió (mode clar)",
"login_light_hint": "Es mostra a la pàgina d’inici de sessió, sobre fons clar.",
"login_dark": "Logotip d’inici de sessió (mode fosc)",
"login_dark_hint": "Es mostra a la pàgina d’inici de sessió, sobre fons fosc.",
"sidebar_light": "Logotip de la barra lateral (mode clar)",
"sidebar_light_hint": "Es mostra després d’iniciar la sessió. Deixeu-ho en blanc per no mostrar-ne cap.",
"sidebar_dark": "Logotip de la barra lateral (mode fosc)",
"sidebar_dark_hint": "Versió per al mode fosc del logotip de la barra lateral.",
"website": "URL del lloc web",
"imprint": "URL de l’avís legal",
"privacy": "URL de la política de privadesa",
"drop": "Feu clic o deixeu anar aquí",
"uploading": "S’està pujant…",
"uploaded": "Fitxer pujat",
"formats": "SVG, PNG, JPEG, WebP o ICO · màx. 2 MB",
"use_url": "Fes servir un URL",
"hide_url": "Amaga l’URL",
"url_placeholder": "https://… o /branding/fitxer.svg"
},
"review": {
"title": "Gairebé fet",
"subtitle": "Reviseu els paràmetres, trieu una contrasenya d’administrador i apliqueu-los.",
"server": "Servidor",
"app_name": "Nom de l’aplicació",
"jmap_server": "Servidor JMAP",
"additional": "Servidors addicionals",
"additional_count": "{count} configurats",
"additional_auto_pick": "{count} configurats · triats per domini",
"stalwart": "Funcions de Stalwart",
"auth": "Autenticació",
"method": "Mètode",
"oauth_only": "Només OAuth",
"password_oauth": "Contrasenya + OAuth",
"password_only": "Només contrasenya",
"client_id": "ID de client OAuth",
"security": "Seguretat",
"secret": "Secret de sessió",
"configured": "Configurat",
"not_set": "Sense definir",
"remember_me": "Recorda’m",
"available": "Disponible",
"disabled": "Desactivat",
"sync": "Sincronització de paràmetres",
"needs_secret": "Necessita un secret de sessió",
"telemetry": "Telemetria anònima",
"logging": "Registres",
"format": "Format",
"level": "Nivell",
"branding": "Marca",
"customizations": "Personalitzacions",
"custom_assets": "Fitxers propis configurats",
"defaults": "Valors predeterminats",
"company": "Nom de l’empresa",
"password_title": "Trieu una contrasenya d’administrador",
"password_hint": "La fareu servir per iniciar la sessió a {path}. Mínim 8 caràcters.",
"new_password": "Contrasenya nova",
"confirm": "Confirma",
"too_short": "Almenys 8 caràcters.",
"mismatch": "Les contrasenyes no coincideixen.",
"looks_good": "Tot correcte.",
"advanced": "Avançat",
"lock": "Bloqueja la configuració després de la instal·lació",
"lock_hint": "Crea un fitxer marcador. Després, munteu el volum de configuració només de lectura i l’aplicació rebutjarà més canvis de configuració. Els registres d’auditoria i l’estat d’inici de sessió continuen sent editables al volum d’estat.",
"applying": "S’està aplicant…",
"finish": "Aplica i acaba"
}
},
"errors": {
"page_error_title": "S'ha produït un error",
"page_error_description": "S'ha produït un error inesperat. Torneu-ho a provar o torneu a la pàgina d'inici.",
+255 -6
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Nastavení",
"back_to_mail": "Zpět na poštu",
"back_to_settings": "Zpět do nastavení",
"save_success": "Nastavení bylo uloženo",
"import_success": "Nastavení bylo importováno",
"import_error": "Import nastavení selhal",
@@ -1345,6 +1346,7 @@
"id_exists": "Toto ID štítku již existuje",
"edit": "Upravit štítek",
"delete": "Odstranit štítek",
"deleted": "Štítek byl odstraněn",
"save": "Uložit",
"add": "Přidat",
"cancel": "Zrušit",
@@ -1849,6 +1851,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Jiná zařízení z něj už účty neobnoví.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1950,6 +1953,8 @@
"removed": "Heslo aplikace smazáno",
"add_error": "Chyba při vytváření hesla aplikace",
"remove_error": "Chyba při mazání hesla aplikace",
"delete_title": "Odstranit heslo aplikace?",
"delete_message": "Aplikace, které se přihlašují pomocí {name}, se už nepřihlásí.",
"none": "Žádná hesla aplikací"
},
"api_keys": {
@@ -1962,6 +1967,8 @@
"removed": "API klíč smazán",
"add_error": "Chyba při vytváření API klíče",
"remove_error": "Chyba při mazání API klíče",
"delete_title": "Odstranit klíč API?",
"delete_message": "Skripty a integrace, které používají {name}, ztratí přístup.",
"none": "Žádné API klíče"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "Zapnout šifrování tímto klíčem",
"enable_button": "Zapnout",
"disable_tooltip": "Vypnout šifrování",
"disable_title": "Vypnout šifrování uložených dat?",
"disable_message": "Nová pošta se od teď ukládá bez šifrování.",
"disable_button": "Vypnout",
"enabled_success": "Šifrování dat v klidu zapnuto"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "např. Můj klíč",
"key_label": "Veřejný klíč (S/MIME nebo PGP ve formátu ASCII)",
"key_placeholder": "Sem vložte svůj veřejný klíč",
"none": "Nejsou nastaveny žádné veřejné klíče"
"none": "Nejsou nastaveny žádné veřejné klíče",
"title": "Veřejné klíče",
"added": "Veřejný klíč byl přidán",
"removed": "Veřejný klíč byl odstraněn",
"add_error": "Veřejný klíč se nepodařilo přidat",
"remove_error": "Veřejný klíč se nepodařilo odstranit",
"delete_title": "Odstranit veřejný klíč?",
"delete_message": "{name} bude odebrán ze serveru."
},
"sections": {
"devices": "Další aplikace a zařízení",
@@ -2089,6 +2106,7 @@
},
"save": "Uložit změny",
"saving": "Ukládání...",
"saved": "Uloženo",
"warnings": {
"end_before_start": "Datum ukončení musí být po datu zahájení",
"invalid_date": "Zadejte platné datum zahájení a ukončení včetně času, nebo pole nechte prázdné",
@@ -2155,12 +2173,17 @@
"new_folder_name": "Název složky",
"rename": "Přejmenovat",
"change_icon": "Změnit ikonu",
"actions": "Akce pro {name}",
"expand": "Rozbalit {name}",
"collapse": "Sbalit {name}",
"counter": "Počítadlo",
"count_mode": "Počítadlo pro {name}",
"count_mode_default": "Počítadlo: výchozí",
"count_mode_unread": "Jen nepřečtené",
"count_mode_all": "Nepřečtené a celkem",
"count_mode_none": "Bez počítadla",
"delete": "Odstranit",
"delete_title": "Odstranit složku?",
"confirm_delete": "Opravdu chcete odstranit složku „{name}“? Zprávy v této složce budou přesunuty do Koše.",
"create": "Vytvořit",
"cancel": "Zrušit",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "Režim ladění",
"description": "Povolit podrobné protokolování pro řešení problémů"
"description": "Povolit podrobné protokolování pro řešení problémů",
"toggle": "Podrobné protokolování"
},
"debug_categories": {
"description": "Vyberte kategorie pro protokolování. Vypněte kategorie, které nepotřebujete, pro snížení množství záznamů v konzoli.",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Obnovit data z mezipaměti",
"description": "Znovu načíst kontakty, kalendáře a složky ze serveru. Účty a přihlášení zůstanou zachovány – opraví zastaralé nebo chybné zobrazení bez odhlášení.",
"button": "Obnovit"
"button": "Obnovit",
"confirm_title": "Obnovit data v mezipaměti?"
},
"reset_settings": {
"label": "Resetovat nastavení",
"description": "Obnovit všechna nastavení na výchozí hodnoty",
"button": "Obnovit výchozí"
"button": "Obnovit výchozí",
"confirm_title": "Obnovit všechna nastavení?",
"done": "Nastavení byla obnovena"
},
"export_settings": {
"label": "Exportovat nastavení",
@@ -2305,7 +2332,15 @@
"no_address_books": "Nenalezeny žádné adresáře",
"categories_title": "Kategorie",
"categories_description": "Přejmenovat kategorie kontaktů",
"no_categories": "Nenalezeny žádné kategorie"
"no_categories": "Nenalezeny žádné kategorie",
"delete_category": "Odstranit kategorii",
"category_deleted": "Kategorie byla odstraněna",
"category_delete_failed": "Kategorii se nepodařilo odstranit"
},
"calendar_management": {
"delete_title": "Odstranit kalendář?",
"clear_title": "Vymazat všechny události?",
"unsubscribe_title": "Zrušit odběr tohoto kalendáře?"
},
"downloads": {
"title": "Stažené",
@@ -2371,7 +2406,7 @@
"validate": "Ověřit",
"validation_success": "Skript je platný",
"validation_error": "Skript obsahuje chyby",
"save": "Uložit pravidla",
"save": "Uložit",
"saving": "Ukládání...",
"saved": "Filtry byly uloženy",
"save_failed": "Uložení filtrů selhalo",
@@ -2385,6 +2420,8 @@
"add_condition": "Přidat podmínku",
"actions": "Akce",
"add_action": "Přidat akci",
"remove_condition": "Odebrat podmínku",
"remove_action": "Odebrat akci",
"stop_processing": "Zastavit zpracování dalších pravidel",
"include_spam": "Přesouvat i zprávy označené jako spam",
"attachment_type_placeholder": "např. pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "Typ shody",
"reset_to_visual": "Resetovat na vizuálního průvodce",
"reset_warning": "Tímto zahodíte aktuální skript a začnete znovu.",
"reset_title": "Obnovit vizuální editor?",
"confirm_reset": "Resetovat",
"validation_empty_name": "Název pravidla je vyžadován",
"validation_empty_conditions": "Je vyžadována alespoň jedna podmínka s hodnotou",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Nastavení Bulwark Webmail",
"subtitle": "Nastavte svou instanci webmailu v prohlížeči.",
"loading": "Načítání…",
"back": "Zpět",
"next": "Další",
"saving": "Ukládání…",
"show": "Zobrazit",
"hide": "Skrýt",
"remove": "Odebrat",
"on": "Zapnuto",
"off": "Vypnuto",
"dismiss": "Zavřít",
"unknown_error": "Neznámá chyba",
"steps": {
"welcome": "Vítejte",
"server": "Server",
"auth": "Přihlášení",
"security": "Zabezpečení",
"logging": "Protokol",
"branding": "Vzhled značky",
"review": "Kontrola"
},
"read_only": {
"title": "Konfigurace je jen pro čtení",
"body": "Svazek s konfigurací je připojen jen pro čtení nebo je nastavena proměnná {variable}. Připojte ho pro zápis nebo proměnnou odstraňte a restartujte kontejner."
},
"done": {
"title": "Vše je připraveno",
"body": "{appName} je nastaven a připraven k použití.",
"admin_sign_in": "Přihlásit se do administrace",
"webmail_login": "Otevřít přihlášení k webmailu",
"redirecting": "Přesměrování do administrace…"
},
"already": {
"title": "Nastavení je už dokončeno",
"body": "{appName} je nastaven. Pokračujte přihlášením."
},
"insecure": {
"title": "Nastavení spouštíte přes nešifrované HTTP",
"body": "Token pro nastavení a heslo správce, které zde zadáte, se přenesou jako prostý text. Pokud to jde, použijte HTTPS: ukončete TLS v kontejneru nebo v reverzní proxy před ním.",
"try_https": "Zkusit HTTPS",
"continue_http": "Pokračovat přes HTTP"
},
"errors": {
"token_expired": "Tento token pro nastavení už neplatí. Restartujte kontejner a v protokolech najdete nový.",
"session_expired": "Relace nastavení vypršela. Pokračujte opětovným vložením tokenu.",
"token_required": "Pokračujte vložením tokenu pro nastavení z protokolů kontejneru.",
"already_finished": "Nastavení je už dokončeno. Pro přihlášení stránku obnovte.",
"token_rejected": "Token odmítnut (HTTP {status})",
"step_failed": "Tento krok se nepodařilo uložit (HTTP {status})",
"upload_failed": "Nahrání se nezdařilo (HTTP {status})",
"finish_failed": "Nastavení se nepodařilo dokončit (HTTP {status})"
},
"welcome": {
"title": "Vítejte",
"body": "Pokračujte vložením tokenu pro nastavení z protokolů kontejneru. Token vyprší po 1 hodině.",
"token_label": "Token pro nastavení",
"token_placeholder": "32bajtový hexadecimální token",
"verifying": "Ověřování…",
"continue": "Pokračovat"
},
"server": {
"title": "Server",
"subtitle": "Kde je vaše pošta.",
"app_name": "Název aplikace",
"url_label": "URL serveru JMAP",
"url_hint": "Výchozí server, ke kterému se uživatelé připojují. Příklad: https://mail.example.com",
"test": "Otestovat",
"testing": "Testování…",
"probe_ok": "Připojeno: vypadá to na server JMAP.",
"probe_not_jmap": "Server odpovídá, ale nevypadá jako koncový bod JMAP.",
"probe_invalid": "Tato URL není platná. Musí začínat http:// nebo https://.",
"probe_unreachable": "K této adrese se nepodařilo připojit. Zkontrolujte URL a zda je server online.",
"not_jmap_hint": "To se stává, když reverzní proxy směruje JMAP na stejné doméně zvlášť. Jinak je URL obvykle chybná.",
"not_jmap_confirm": "Jsem si jistý, že URL je správná, přesto pokračovat.",
"http_title": "Tato URL používá nešifrované HTTP.",
"http_body": "Hesla a obsah e-mailů se budou mezi uživateli a vaším serverem přenášet nešifrovaně. V provozu použijte {https}: ukončete TLS na poštovním serveru nebo v reverzní proxy před ním.",
"local_title": "Tato URL funguje jen lokálně.",
"local_body": "Pošta se načítá přímo v prohlížeči uživatele, takže URL JMAP musí být dostupná odkudkoli, kde se uživatelé přihlašují, nejen z tohoto počítače nebo místní sítě. V provozu použijte veřejný název hostitele (např. {example}).",
"additional_title": "Další servery JMAP",
"additional_hint": "Volitelné. Nabídněte při přihlášení více serverů, užitečné pro hostingy s několika instancemi Stalwart.",
"additional_show": "Zobrazit ({count})",
"add": "Přidat",
"row_title": "Server č. {index}",
"id_label": "ID",
"id_hint": "Jedinečný identifikátor, např. eu-1",
"label_label": "Popisek",
"label_hint": "Zobrazí se uživatelům",
"row_url": "URL",
"domains_label": "Domény",
"domains_hint": "Oddělené čárkami. Podle domény e-mailu se při přihlášení zvolí tento server.",
"add_server": "Přidat server",
"auto_pick": "Volit server podle domény e-mailu",
"auto_pick_hint": "Když uživatel zadá svůj e-mail, zvolí se odpovídající server ze seznamu výše.",
"row_id_required": "Server č. {index}: ID je povinné",
"row_id_invalid": "Server č. {index}: ID smí obsahovat jen písmena, číslice, - a _ a musí začínat písmenem nebo číslicí",
"row_id_duplicate": "Server č. {index}: ID „{id}“ se opakuje",
"row_url_required": "Server č. {index}: URL je povinná",
"row_url_scheme": "Server č. {index}: URL musí začínat http:// nebo https://",
"stalwart": "Zapnout funkce specifické pro Stalwart",
"stalwart_hint": "Přidá změnu hesla a správu filtrů Sieve. Bezpečně lze zapnout i u jiných serverů."
},
"auth": {
"title": "Ověřování",
"subtitle": "Přihlášení heslem je vždy k dispozici. OAuth/OIDC je volitelné.",
"oauth": "Zapnout OAuth2 / OpenID Connect",
"oauth_only": "Jen OAuth (skrýt formulář hesla)",
"client_id": "ID klienta OAuth",
"client_secret": "Tajný klíč klienta OAuth",
"client_secret_hint": "U veřejných klientů, kteří používají jen PKCE, nechte prázdné.",
"client_secret_placeholder": "Vložte tajný klíč",
"issuer": "URL vydavatele OAuth",
"issuer_hint": "Pro externí poskytovatele identit (Keycloak, Authentik, Entra ID atd.)."
},
"security": {
"title": "Zabezpečení a relace",
"subtitle": "Tajný klíč relace umožní „Zapamatovat si mě“ a synchronizaci nastavení.",
"generated": "Tajný klíč relace vygenerován",
"customize": "Upravit",
"generated_hint": "Byl pro vás vytvořen 32bajtový tajný klíč. Měňte ho jen z konkrétního důvodu.",
"secret_label": "Tajný klíč relace",
"secret_hint": "Vložte vlastní hodnotu nebo vygenerujte novou.",
"regenerate": "Vygenerovat znovu",
"sync": "Synchronizovat nastavení uživatelů mezi zařízeními",
"sync_hint": "Ukládá předvolby uživatelů na server, šifrované tajným klíčem relace.",
"telemetry": "Odesílat anonymní statistiky používání pro zlepšení Bulwarku",
"telemetry_hint": "Ve výchozím stavu vypnuto. Jeden anonymní signál denně s verzí, platformou a zapnutými funkcemi; nikdy e-mailové adresy, názvy hostitelů ani IP adresy. Kdykoli to můžete změnit v nastavení administrace."
},
"logging": {
"title": "Protokol",
"subtitle": "Formát a podrobnost protokolů aplikace.",
"format": "Formát",
"format_text": "text: barevný, čitelný",
"format_json": "json: strukturovaný, pro sběr protokolů",
"level": "Úroveň",
"level_info": "info (doporučeno)"
},
"branding": {
"title": "Vzhled značky",
"subtitle": "Všechna pole jsou volitelná. Nahrajte soubor nebo vložte URL; pro vše, co vynecháte, platí výchozí hodnoty.",
"company": "Název firmy nebo organizace",
"favicon": "Favicon",
"favicon_hint": "Ikona karty prohlížeče. Doporučeno SVG.",
"login_light": "Logo přihlášení (světlý režim)",
"login_light_hint": "Na přihlašovací stránce, na světlém pozadí.",
"login_dark": "Logo přihlášení (tmavý režim)",
"login_dark_hint": "Na přihlašovací stránce, na tmavém pozadí.",
"sidebar_light": "Logo postranního panelu (světlý režim)",
"sidebar_light_hint": "Zobrazí se po přihlášení. Nechte prázdné pro žádné.",
"sidebar_dark": "Logo postranního panelu (tmavý režim)",
"sidebar_dark_hint": "Verze loga postranního panelu pro tmavý režim.",
"website": "URL webu",
"imprint": "URL tiráže",
"privacy": "URL zásad ochrany osobních údajů",
"drop": "Klikněte nebo přetáhněte",
"uploading": "Nahrávání…",
"uploaded": "Nahraný soubor",
"formats": "SVG, PNG, JPEG, WebP nebo ICO · max. 2 MB",
"use_url": "Použít URL",
"hide_url": "Skrýt URL",
"url_placeholder": "https://… nebo /branding/soubor.svg"
},
"review": {
"title": "Skoro hotovo",
"subtitle": "Zkontrolujte nastavení, zvolte heslo správce a použijte je.",
"server": "Server",
"app_name": "Název aplikace",
"jmap_server": "Server JMAP",
"additional": "Další servery",
"additional_count": "Nastaveno: {count}",
"additional_auto_pick": "Nastaveno: {count} · volba podle domény",
"stalwart": "Funkce Stalwart",
"auth": "Ověřování",
"method": "Metoda",
"oauth_only": "Jen OAuth",
"password_oauth": "Heslo + OAuth",
"password_only": "Jen heslo",
"client_id": "ID klienta OAuth",
"security": "Zabezpečení",
"secret": "Tajný klíč relace",
"configured": "Nastaveno",
"not_set": "Nenastaveno",
"remember_me": "Zapamatovat si mě",
"available": "K dispozici",
"disabled": "Vypnuto",
"sync": "Synchronizace nastavení",
"needs_secret": "Vyžaduje tajný klíč relace",
"telemetry": "Anonymní telemetrie",
"logging": "Protokol",
"format": "Formát",
"level": "Úroveň",
"branding": "Vzhled značky",
"customizations": "Úpravy",
"custom_assets": "Vlastní soubory nastaveny",
"defaults": "Výchozí hodnoty",
"company": "Název firmy",
"password_title": "Zvolte heslo správce",
"password_hint": "Budete se jím přihlašovat na {path}. Nejméně 8 znaků.",
"new_password": "Nové heslo",
"confirm": "Potvrdit",
"too_short": "Nejméně 8 znaků.",
"mismatch": "Hesla se neshodují.",
"looks_good": "V pořádku.",
"advanced": "Pokročilé",
"lock": "Po nastavení zamknout konfiguraci",
"lock_hint": "Vytvoří značkovací soubor. Poté připojte svazek s konfigurací jen pro čtení a aplikace odmítne další zápisy do konfigurace. Auditní protokoly a stav přihlášení zůstanou zapisovatelné ve svazku se stavem.",
"applying": "Používání…",
"finish": "Použít a dokončit"
}
},
"errors": {
"page_error_title": "Něco se pokazilo",
"page_error_description": "Došlo k neočekávané chybě. Zkuste to znovu nebo se vraťte na domovskou stránku.",
+255 -6
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Indstillinger",
"back_to_mail": "Tilbage til mail",
"back_to_settings": "Tilbage til indstillinger",
"save_success": "Indstillinger gemt succesfuldt",
"import_success": "Indstillinger importeret succesfuldt",
"import_error": "Kunne ikke importere indstillinger",
@@ -1348,6 +1349,7 @@
"id_exists": "Dette tag-ID findes allerede",
"edit": "Redigér tag",
"delete": "Slet tag",
"deleted": "Tag slettet",
"save": "Gem",
"add": "Tilføj",
"cancel": "Annuller",
@@ -1852,6 +1854,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Andre enheder kan ikke længere gendanne kontiene fra den.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1953,6 +1956,8 @@
"removed": "App-adgangskode fjernet",
"add_error": "Kunne ikke oprette app-adgangskode",
"remove_error": "Kunne ikke fjerne app-adgangskode",
"delete_title": "Slet app-adgangskoden?",
"delete_message": "Apps, der logger ind med {name}, kan ikke længere logge ind.",
"none": "Ingen app-adgangskoder konfigureret"
},
"api_keys": {
@@ -1965,6 +1970,8 @@
"removed": "API-nøgle fjernet",
"add_error": "Kunne ikke oprette API-nøgle",
"remove_error": "Kunne ikke fjerne API-nøgle",
"delete_title": "Slet API-nøglen?",
"delete_message": "Scripts og integrationer, der bruger {name}, mister adgangen.",
"none": "Ingen API-nøgler konfigureret"
},
"encryption": {
@@ -1984,6 +1991,9 @@
"configure_title": "Aktivér kryptering med denne nøgle",
"enable_button": "Aktivér",
"disable_tooltip": "Deaktivér kryptering",
"disable_title": "Slå kryptering af lagrede data fra?",
"disable_message": "Ny post gemmes fra nu af uden kryptering.",
"disable_button": "Slå fra",
"enabled_success": "Kryptering af lagrede data aktiveret"
},
"email_client": {
@@ -2032,7 +2042,14 @@
"name_placeholder": "f.eks. Min nøgle",
"key_label": "Offentlig nøgle (S/MIME eller PGP i ASCII-format)",
"key_placeholder": "Indsæt din offentlige nøgle her",
"none": "Ingen offentlige nøgler konfigureret"
"none": "Ingen offentlige nøgler konfigureret",
"title": "Offentlige nøgler",
"added": "Offentlig nøgle tilføjet",
"removed": "Offentlig nøgle slettet",
"add_error": "Den offentlige nøgle kunne ikke tilføjes",
"remove_error": "Den offentlige nøgle kunne ikke slettes",
"delete_title": "Slet den offentlige nøgle?",
"delete_message": "{name} fjernes fra serveren."
},
"sections": {
"devices": "Andre apps og enheder",
@@ -2092,6 +2109,7 @@
},
"save": "Gem ændringer",
"saving": "Gemmer...",
"saved": "Gemt",
"warnings": {
"end_before_start": "Slutdato skal være efter startdato",
"invalid_date": "Angiv en gyldig start- og slutdato med klokkeslæt, eller lad feltet stå tomt",
@@ -2158,12 +2176,17 @@
"new_folder_name": "Mappenavn",
"rename": "Omdøb",
"change_icon": "Skift ikon",
"actions": "Handlinger for {name}",
"expand": "Fold {name} ud",
"collapse": "Fold {name} sammen",
"counter": "Tæller",
"count_mode": "Tæller for {name}",
"count_mode_default": "Tæller: standard",
"count_mode_unread": "Kun ulæste",
"count_mode_all": "Ulæste og i alt",
"count_mode_none": "Ingen tæller",
"delete": "Slet",
"delete_title": "Slet mappen?",
"confirm_delete": "Er du sikker på, at du vil slette \"{name}\"? E-mails i denne mappe flyttes til papirkurven.",
"create": "Opret",
"cancel": "Annuller",
@@ -2183,7 +2206,8 @@
"advanced": {
"debug_mode": {
"label": "Debug-tilstand",
"description": "Aktivér detaljeret logning til fejlfinding"
"description": "Aktivér detaljeret logning til fejlfinding",
"toggle": "Detaljeret logning"
},
"debug_categories": {
"description": "Vælg hvilke kategorier der skal logges. Deaktivér kategorier du ikke har brug for at reducere konsolstøj.",
@@ -2228,12 +2252,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "Opdater cachelagrede data?"
},
"reset_settings": {
"label": "Nulstil indstillinger",
"description": "Gendan alle indstillinger til standardværdier",
"button": "Nulstil til standard"
"button": "Nulstil til standard",
"confirm_title": "Nulstil alle indstillinger?",
"done": "Indstillinger nulstillet"
},
"export_settings": {
"label": "Eksportér indstillinger",
@@ -2308,7 +2335,15 @@
"no_address_books": "Ingen adressebøger fundet",
"categories_title": "Kategorier",
"categories_description": "Omdøb kontaktkategorier",
"no_categories": "Ingen kategorier fundet"
"no_categories": "Ingen kategorier fundet",
"delete_category": "Slet kategori",
"category_deleted": "Kategori slettet",
"category_delete_failed": "Kategorien kunne ikke slettes"
},
"calendar_management": {
"delete_title": "Slet kalenderen?",
"clear_title": "Ryd alle begivenheder?",
"unsubscribe_title": "Afmeld denne kalender?"
},
"downloads": {
"title": "Downloads",
@@ -2374,7 +2409,7 @@
"validate": "Valider",
"validation_success": "Scriptet er gyldigt",
"validation_error": "Scriptet har fejl",
"save": "Gem regler",
"save": "Gem",
"saving": "Gemmer...",
"saved": "Filtre gemt succesfuldt",
"save_failed": "Kunne ikke gemme filtre",
@@ -2388,6 +2423,8 @@
"add_condition": "Tilføj betingelse",
"actions": "Handlinger",
"add_action": "Tilføj handling",
"remove_condition": "Fjern betingelse",
"remove_action": "Fjern handling",
"stop_processing": "Stop behandling af efterfølgende regler",
"include_spam": "Flyt også beskeder, der er markeret som spam",
"attachment_type_placeholder": "f.eks. pdf, doc, jpg",
@@ -2464,6 +2501,7 @@
"match_type": "Match-type",
"reset_to_visual": "Nulstil til visuel builder",
"reset_warning": "Dette vil kassere det nuværende script og starte forfra.",
"reset_title": "Nulstil til den visuelle editor?",
"confirm_reset": "Nulstil",
"validation_empty_name": "Regelnavn er påkrævet",
"validation_empty_conditions": "Mindst én betingelse med en værdi er påkrævet",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Opsætning af Bulwark Webmail",
"subtitle": "Opsæt din webmailinstans i browseren.",
"loading": "Indlæser…",
"back": "Tilbage",
"next": "Næste",
"saving": "Gemmer…",
"show": "Vis",
"hide": "Skjul",
"remove": "Fjern",
"on": "Til",
"off": "Fra",
"dismiss": "Luk",
"unknown_error": "Ukendt fejl",
"steps": {
"welcome": "Velkommen",
"server": "Server",
"auth": "Login",
"security": "Sikkerhed",
"logging": "Logning",
"branding": "Branding",
"review": "Gennemse"
},
"read_only": {
"title": "Konfigurationen er skrivebeskyttet",
"body": "Konfigurationsvolumen er monteret skrivebeskyttet, eller {variable} er sat. Montér den med skriveadgang eller fjern variablen, og genstart containeren."
},
"done": {
"title": "Alt er klar",
"body": "{appName} er sat op og klar til brug.",
"admin_sign_in": "Log ind på administrationspanelet",
"webmail_login": "Åbn webmail-login",
"redirecting": "Du sendes til administrationspanelet…"
},
"already": {
"title": "Opsætningen er allerede fuldført",
"body": "{appName} er sat op. Log ind for at fortsætte."
},
"insecure": {
"title": "Du kører opsætningen over ukrypteret HTTP",
"body": "Opsætningstokenet og den administratoradgangskode, du indtaster her, sendes i klartekst. Brug HTTPS, hvis det er muligt: afslut TLS i containeren eller i en reverse proxy foran den.",
"try_https": "Prøv HTTPS",
"continue_http": "Fortsæt over HTTP"
},
"errors": {
"token_expired": "Opsætningstokenet er ikke længere gyldigt. Genstart containeren for at få et nyt fra loggen.",
"session_expired": "Din opsætningssession er udløbet. Indsæt opsætningstokenet igen for at fortsætte.",
"token_required": "Indsæt opsætningstokenet fra containerens log for at fortsætte.",
"already_finished": "Opsætningen er allerede fuldført. Genindlæs for at logge ind.",
"token_rejected": "Token afvist (HTTP {status})",
"step_failed": "Kunne ikke gemme dette trin (HTTP {status})",
"upload_failed": "Upload mislykkedes (HTTP {status})",
"finish_failed": "Kunne ikke fuldføre opsætningen (HTTP {status})"
},
"welcome": {
"title": "Velkommen",
"body": "Indsæt opsætningstokenet fra containerens log for at fortsætte. Tokenet udløber efter 1 time.",
"token_label": "Opsætningstoken",
"token_placeholder": "32-byte hex-token",
"verifying": "Bekræfter…",
"continue": "Fortsæt"
},
"server": {
"title": "Server",
"subtitle": "Hvor din mail ligger.",
"app_name": "Programmets navn",
"url_label": "JMAP-serverens URL",
"url_hint": "Den standardserver, brugerne forbinder til. Eksempel: https://mail.example.com",
"test": "Test",
"testing": "Tester…",
"probe_ok": "Forbundet: det ligner en JMAP-server.",
"probe_not_jmap": "Serveren svarer, men ligner ikke et JMAP-endepunkt.",
"probe_invalid": "URL’en er ugyldig. Den skal starte med http:// eller https://.",
"probe_unreachable": "Kunne ikke oprette forbindelse til adressen. Tjek URL’en, og at serveren er online.",
"not_jmap_hint": "Det kan ske, når en reverse proxy sender JMAP videre separat på samme domæne. Ellers er URL’en som regel forkert.",
"not_jmap_confirm": "Jeg er sikker på, at URL’en er rigtig, fortsæt alligevel.",
"http_title": "Denne URL bruger ukrypteret HTTP.",
"http_body": "Adgangskoder og mailindhold sendes ukrypteret mellem brugerne og din server. Brug {https} i produktion: afslut TLS på mailserveren eller i en reverse proxy foran den.",
"local_title": "Denne URL virker kun lokalt.",
"local_body": "Mail hentes direkte fra brugerens browser, så JMAP-URL’en skal kunne nås alle steder, hvor brugerne logger ind, ikke kun fra denne maskine eller det lokale netværk. Brug et offentligt værtsnavn (f.eks. {example}) i produktion.",
"additional_title": "Flere JMAP-servere",
"additional_hint": "Valgfrit. Vis flere servere i listen ved login, nyttigt for udbydere med flere Stalwart-instanser.",
"additional_show": "Vis ({count})",
"add": "Tilføj",
"row_title": "Server nr. {index}",
"id_label": "ID",
"id_hint": "Entydigt kortnavn, f.eks. eu-1",
"label_label": "Navn",
"label_hint": "Vises for brugerne",
"row_url": "URL",
"domains_label": "Domæner",
"domains_hint": "Kommaseparerede. Bruges til at vælge denne server ud fra maildomænet ved login.",
"add_server": "Tilføj server",
"auto_pick": "Vælg server ud fra maildomænet",
"auto_pick_hint": "Når en bruger skriver sin mailadresse, vælges den tilsvarende server fra listen ovenfor.",
"row_id_required": "Server nr. {index}: ID er påkrævet",
"row_id_invalid": "Server nr. {index}: ID må kun indeholde bogstaver, tal, - og _ og skal starte med et bogstav eller tal",
"row_id_duplicate": "Server nr. {index}: ID’et “{id}” bruges to gange",
"row_url_required": "Server nr. {index}: URL’en er påkrævet",
"row_url_scheme": "Server nr. {index}: URL’en skal starte med http:// eller https://",
"stalwart": "Slå Stalwart-specifikke funktioner til",
"stalwart_hint": "Tilføjer ændring af adgangskode og håndtering af Sieve-filtre. Kan uden risiko slås til på andre servere."
},
"auth": {
"title": "Godkendelse",
"subtitle": "Login med adgangskode er altid muligt. OAuth/OIDC er valgfrit.",
"oauth": "Slå OAuth2 / OpenID Connect til",
"oauth_only": "Kun OAuth (skjul adgangskodeformularen)",
"client_id": "OAuth-klient-ID",
"client_secret": "OAuth-klienthemmelighed",
"client_secret_hint": "Lad stå tomt for offentlige klienter, der kun bruger PKCE.",
"client_secret_placeholder": "Indsæt hemmeligheden",
"issuer": "OAuth-udsteder-URL",
"issuer_hint": "Til eksterne identitetsudbydere (Keycloak, Authentik, Entra ID osv.)."
},
"security": {
"title": "Sikkerhed og sessioner",
"subtitle": "Sessionshemmeligheden gør “Husk mig” og synkronisering af indstillinger mulig.",
"generated": "Sessionshemmelighed oprettet",
"customize": "Tilpas",
"generated_hint": "Der er oprettet en hemmelighed på 32 byte til dig. Skift den kun, hvis du har en særlig grund.",
"secret_label": "Sessionshemmelighed",
"secret_hint": "Indsæt din egen værdi, eller opret en ny.",
"regenerate": "Opret ny",
"sync": "Synkroniser brugerindstillinger på tværs af enheder",
"sync_hint": "Gemmer brugernes præferencer på serveren, krypteret med sessionshemmeligheden.",
"telemetry": "Send anonyme brugsstatistikker for at forbedre Bulwark",
"telemetry_hint": "Fra som standard. Ét anonymt signal om dagen med version, platform og hvilke funktioner der er slået til; aldrig mailadresser, værtsnavne eller IP-adresser. Du kan altid ændre det i administrationsindstillingerne."
},
"logging": {
"title": "Logning",
"subtitle": "Format og detaljegrad for programmets logfiler.",
"format": "Format",
"format_text": "text: i farver, letlæseligt",
"format_json": "json: struktureret, til logopsamling",
"level": "Niveau",
"level_info": "info (anbefalet)"
},
"branding": {
"title": "Branding",
"subtitle": "Alle felter er valgfrie. Upload en fil eller indsæt en URL; standardværdier bruges til alt, du springer over.",
"company": "Firmaets eller organisationens navn",
"favicon": "Favicon",
"favicon_hint": "Ikon på browserfanen. SVG anbefales.",
"login_light": "Login-logo (lys tilstand)",
"login_light_hint": "Vises på loginsiden på lys baggrund.",
"login_dark": "Login-logo (mørk tilstand)",
"login_dark_hint": "Vises på loginsiden på mørk baggrund.",
"sidebar_light": "Sidepanelets logo (lys tilstand)",
"sidebar_light_hint": "Vises efter login. Lad stå tomt for intet logo.",
"sidebar_dark": "Sidepanelets logo (mørk tilstand)",
"sidebar_dark_hint": "Udgave af sidepanelets logo til mørk tilstand.",
"website": "Webstedets URL",
"imprint": "URL til kolofon",
"privacy": "URL til privatlivspolitik",
"drop": "Klik eller slip her",
"uploading": "Uploader…",
"uploaded": "Uploadet fil",
"formats": "SVG, PNG, JPEG, WebP eller ICO · maks. 2 MB",
"use_url": "Brug URL",
"hide_url": "Skjul URL",
"url_placeholder": "https://… eller /branding/fil.svg"
},
"review": {
"title": "Næsten færdig",
"subtitle": "Gennemse dine indstillinger, vælg en administratoradgangskode, og anvend.",
"server": "Server",
"app_name": "Appens navn",
"jmap_server": "JMAP-server",
"additional": "Flere servere",
"additional_count": "{count} opsat",
"additional_auto_pick": "{count} opsat · valgt ud fra domæne",
"stalwart": "Stalwart-funktioner",
"auth": "Godkendelse",
"method": "Metode",
"oauth_only": "Kun OAuth",
"password_oauth": "Adgangskode + OAuth",
"password_only": "Kun adgangskode",
"client_id": "OAuth-klient-ID",
"security": "Sikkerhed",
"secret": "Sessionshemmelighed",
"configured": "Opsat",
"not_set": "Ikke angivet",
"remember_me": "Husk mig",
"available": "Tilgængelig",
"disabled": "Deaktiveret",
"sync": "Synkronisering af indstillinger",
"needs_secret": "Kræver en sessionshemmelighed",
"telemetry": "Anonym telemetri",
"logging": "Logning",
"format": "Format",
"level": "Niveau",
"branding": "Branding",
"customizations": "Tilpasninger",
"custom_assets": "Egne filer opsat",
"defaults": "Standardværdier",
"company": "Firmanavn",
"password_title": "Vælg en administratoradgangskode",
"password_hint": "Du bruger den til at logge ind på {path}. Mindst 8 tegn.",
"new_password": "Ny adgangskode",
"confirm": "Bekræft",
"too_short": "Mindst 8 tegn.",
"mismatch": "Adgangskoderne er ikke ens.",
"looks_good": "Ser godt ud.",
"advanced": "Avanceret",
"lock": "Lås konfigurationen efter opsætningen",
"lock_hint": "Opretter en markørfil. Montér derefter konfigurationsvolumen skrivebeskyttet, så afviser appen yderligere ændringer af konfigurationen. Revisionslogfiler og loginstatus kan stadig skrives i tilstandsvolumen.",
"applying": "Anvender…",
"finish": "Anvend og afslut"
}
},
"errors": {
"page_error_title": "Noget gik galt",
"page_error_description": "Vi stødte på en uventet fejl. Prøv igen eller gå tilbage til startsiden.",
+254 -5
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Einstellungen",
"back_to_mail": "Zurück zu E-Mails",
"back_to_settings": "Zurück zu den Einstellungen",
"save_success": "Einstellungen erfolgreich gespeichert",
"import_success": "Einstellungen erfolgreich importiert",
"import_error": "Import der Einstellungen fehlgeschlagen",
@@ -1345,6 +1346,7 @@
"id_exists": "Diese Label-ID existiert bereits",
"edit": "Label bearbeiten",
"delete": "Label löschen",
"deleted": "Label gelöscht",
"save": "Speichern",
"add": "Hinzufügen",
"cancel": "Abbrechen",
@@ -1849,6 +1851,7 @@
"delete": "Gespeicherte Liste löschen",
"cancel": "Abbrechen",
"delete_confirm": "Die gespeicherte Kontoliste vom Server löschen?",
"delete_message": "Andere Geräte können die Konten danach nicht mehr daraus wiederherstellen.",
"password": "Sicherungspasswort",
"password_hint": "Mindestens 10 Zeichen. Es kann nicht wiederhergestellt werden: Wenn Sie es vergessen, speichern Sie die Liste erneut mit einem neuen.",
"restore_hint": "Das Passwort, das beim Speichern der Liste gewählt wurde.",
@@ -1944,6 +1947,8 @@
"removed": "App-Passwort entfernt",
"add_error": "App-Passwort konnte nicht erstellt werden",
"remove_error": "App-Passwort konnte nicht entfernt werden",
"delete_title": "App-Passwort löschen?",
"delete_message": "Apps, die sich mit {name} anmelden, können sich danach nicht mehr anmelden.",
"none": "Keine App-Passwörter konfiguriert",
"done": "Fertig",
"expires_label": "Läuft ab (optional)",
@@ -1962,6 +1967,8 @@
"removed": "API-Schlüssel entfernt",
"add_error": "API-Schlüssel konnte nicht erstellt werden",
"remove_error": "API-Schlüssel konnte nicht entfernt werden",
"delete_title": "API-Schlüssel löschen?",
"delete_message": "Skripte und Integrationen, die {name} verwenden, verlieren den Zugriff.",
"none": "Keine API-Schlüssel konfiguriert"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "Verschlüsselung mit diesem Schlüssel aktivieren",
"enable_button": "Aktivieren",
"disable_tooltip": "Verschlüsselung deaktivieren",
"disable_title": "Verschlüsselung ruhender Daten ausschalten?",
"disable_message": "Neue E-Mails werden ab jetzt unverschlüsselt gespeichert.",
"disable_button": "Ausschalten",
"enabled_success": "Verschlüsselung im Ruhezustand aktiviert"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "z.B. Mein Schlüssel",
"key_label": "Öffentlicher Schlüssel (S/MIME oder PGP im ASCII-Format)",
"key_placeholder": "Fügen Sie hier Ihren öffentlichen Schlüssel ein",
"none": "Keine öffentlichen Schlüssel konfiguriert"
"none": "Keine öffentlichen Schlüssel konfiguriert",
"title": "Öffentliche Schlüssel",
"added": "Öffentlicher Schlüssel hinzugefügt",
"removed": "Öffentlicher Schlüssel gelöscht",
"add_error": "Öffentlicher Schlüssel konnte nicht hinzugefügt werden",
"remove_error": "Öffentlicher Schlüssel konnte nicht gelöscht werden",
"delete_title": "Öffentlichen Schlüssel löschen?",
"delete_message": "{name} wird vom Server entfernt."
},
"sections": {
"devices": "Andere Apps und Geräte",
@@ -2089,6 +2106,7 @@
},
"save": "Änderungen speichern",
"saving": "Speichern...",
"saved": "Gespeichert",
"warnings": {
"end_before_start": "Das Enddatum muss nach dem Startdatum liegen",
"invalid_date": "Bitte ein gültiges Start- und Enddatum mit Uhrzeit angeben oder das Feld leer lassen",
@@ -2155,12 +2173,17 @@
"new_folder_name": "Ordnername",
"rename": "Umbenennen",
"change_icon": "Symbol ändern",
"actions": "Aktionen für {name}",
"expand": "{name} aufklappen",
"collapse": "{name} zuklappen",
"counter": "Zähler",
"count_mode": "Zähler für {name}",
"count_mode_default": "Zähler: Standard",
"count_mode_unread": "Nur ungelesene",
"count_mode_all": "Ungelesene und gesamt",
"count_mode_none": "Kein Zähler",
"delete": "Löschen",
"delete_title": "Ordner löschen?",
"confirm_delete": "Möchten Sie \"{name}\" wirklich löschen? E-Mails in diesem Ordner werden in den Papierkorb verschoben.",
"create": "Erstellen",
"cancel": "Abbrechen",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "Debug-Modus",
"description": "Detaillierte Protokollierung zur Fehlerbehebung aktivieren"
"description": "Detaillierte Protokollierung zur Fehlerbehebung aktivieren",
"toggle": "Ausführliche Protokollierung"
},
"debug_categories": {
"description": "Wählen Sie aus, welche Kategorien protokolliert werden sollen. Deaktivieren Sie nicht benötigte Kategorien, um die Konsolenausgabe zu reduzieren.",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Zwischengespeicherte Daten aktualisieren",
"description": "Kontakte, Kalender und Ordner vom Server neu laden. Konten und Sitzungen bleiben erhalten; veraltete oder fehlerhafte Ansichten werden korrigiert, ohne dass Sie sich abmelden müssen.",
"button": "Aktualisieren"
"button": "Aktualisieren",
"confirm_title": "Zwischengespeicherte Daten aktualisieren?"
},
"reset_settings": {
"label": "Einstellungen zurücksetzen",
"description": "Alle Einstellungen auf Standardwerte zurücksetzen",
"button": "Auf Standard zurücksetzen"
"button": "Auf Standard zurücksetzen",
"confirm_title": "Alle Einstellungen zurücksetzen?",
"done": "Einstellungen zurückgesetzt"
},
"export_settings": {
"label": "Einstellungen exportieren",
@@ -2302,11 +2329,19 @@
"categories_title": "Kategorien",
"categories_description": "Kontaktkategorien umbenennen",
"no_categories": "Keine Kategorien gefunden",
"delete_category": "Kategorie löschen",
"category_deleted": "Kategorie gelöscht",
"category_delete_failed": "Kategorie konnte nicht gelöscht werden",
"group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen",
"group_by_letter_label": "Nach Anfangsbuchstaben gruppieren",
"sort_by_last_name_label": "Nach Nachname sortieren",
"sort_by_last_name_description": "Kontaktliste nach Nachnamen ordnen, damit Familienmitglieder zusammenstehen"
},
"calendar_management": {
"delete_title": "Kalender löschen?",
"clear_title": "Alle Termine entfernen?",
"unsubscribe_title": "Dieses Kalenderabo beenden?"
},
"downloads": {
"title": "Downloads",
"description": "Legen Sie fest, wie heruntergeladene E-Mails und Anhänge benannt werden.",
@@ -2371,7 +2406,7 @@
"validate": "Validieren",
"validation_success": "Das Skript ist gültig",
"validation_error": "Das Skript enthält Fehler",
"save": "Regeln speichern",
"save": "Speichern",
"saving": "Wird gespeichert...",
"saved": "Filter erfolgreich gespeichert",
"save_failed": "Fehler beim Speichern der Filter",
@@ -2385,6 +2420,8 @@
"add_condition": "Bedingung hinzufügen",
"actions": "Aktionen",
"add_action": "Aktion hinzufügen",
"remove_condition": "Bedingung entfernen",
"remove_action": "Aktion entfernen",
"stop_processing": "Verarbeitung nachfolgender Regeln stoppen",
"include_spam": "Auch als Spam markierte Nachrichten verschieben",
"attachment_type_placeholder": "z.B. pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "Übereinstimmungstyp",
"reset_to_visual": "Zum visuellen Builder zurücksetzen",
"reset_warning": "Dies verwirft das aktuelle Skript und beginnt von vorne.",
"reset_title": "Auf den visuellen Editor zurücksetzen?",
"confirm_reset": "Zurücksetzen",
"validation_empty_name": "Regelname ist erforderlich",
"validation_empty_conditions": "Mindestens eine Bedingung mit einem Wert ist erforderlich",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Einrichtung von Bulwark Webmail",
"subtitle": "Richten Sie Ihre Webmail-Instanz im Browser ein.",
"loading": "Wird geladen…",
"back": "Zurück",
"next": "Weiter",
"saving": "Wird gespeichert…",
"show": "Anzeigen",
"hide": "Ausblenden",
"remove": "Entfernen",
"on": "An",
"off": "Aus",
"dismiss": "Schließen",
"unknown_error": "Unbekannter Fehler",
"steps": {
"welcome": "Willkommen",
"server": "Server",
"auth": "Anmeldung",
"security": "Sicherheit",
"logging": "Protokoll",
"branding": "Branding",
"review": "Prüfen"
},
"read_only": {
"title": "Die Konfiguration ist schreibgeschützt",
"body": "Das Konfigurations-Volume ist schreibgeschützt eingebunden oder {variable} ist gesetzt. Binden Sie es beschreibbar ein oder entfernen Sie die Variable und starten Sie den Container neu."
},
"done": {
"title": "Alles bereit",
"body": "{appName} ist eingerichtet und einsatzbereit.",
"admin_sign_in": "Im Admin-Dashboard anmelden",
"webmail_login": "Webmail-Anmeldung öffnen",
"redirecting": "Sie werden zum Admin-Dashboard weitergeleitet…"
},
"already": {
"title": "Die Einrichtung ist bereits abgeschlossen",
"body": "{appName} ist eingerichtet. Melden Sie sich an, um fortzufahren."
},
"insecure": {
"title": "Sie führen die Einrichtung über unverschlüsseltes HTTP aus",
"body": "Das Einrichtungstoken und das Admin-Passwort, die Sie hier eingeben, werden im Klartext übertragen. Verwenden Sie nach Möglichkeit HTTPS: Terminieren Sie TLS im Container oder in einem vorgeschalteten Reverse Proxy.",
"try_https": "HTTPS versuchen",
"continue_http": "Über HTTP fortfahren"
},
"errors": {
"token_expired": "Dieses Einrichtungstoken ist nicht mehr gültig. Starten Sie den Container neu, um ein neues aus den Logs zu erhalten.",
"session_expired": "Ihre Einrichtungssitzung ist abgelaufen. Fügen Sie das Einrichtungstoken erneut ein, um fortzufahren.",
"token_required": "Fügen Sie das Einrichtungstoken aus den Container-Logs ein, um fortzufahren.",
"already_finished": "Die Einrichtung ist bereits abgeschlossen. Laden Sie die Seite neu, um sich anzumelden.",
"token_rejected": "Token abgelehnt (HTTP {status})",
"step_failed": "Dieser Schritt konnte nicht gespeichert werden (HTTP {status})",
"upload_failed": "Hochladen fehlgeschlagen (HTTP {status})",
"finish_failed": "Die Einrichtung konnte nicht abgeschlossen werden (HTTP {status})"
},
"welcome": {
"title": "Willkommen",
"body": "Fügen Sie das Einrichtungstoken aus den Container-Logs ein, um fortzufahren. Das Token läuft nach einer Stunde ab.",
"token_label": "Einrichtungstoken",
"token_placeholder": "32-Byte-Hex-Token",
"verifying": "Wird geprüft…",
"continue": "Fortfahren"
},
"server": {
"title": "Server",
"subtitle": "Wo Ihre E-Mails liegen.",
"app_name": "Name der Anwendung",
"url_label": "JMAP-Server-URL",
"url_hint": "Der Standardserver, mit dem sich Benutzer verbinden. Beispiel: https://mail.example.com",
"test": "Testen",
"testing": "Wird getestet…",
"probe_ok": "Verbunden: Das sieht nach einem JMAP-Server aus.",
"probe_not_jmap": "Der Server ist erreichbar, scheint aber kein JMAP-Endpunkt zu sein.",
"probe_invalid": "Diese URL ist ungültig. Sie muss mit http:// oder https:// beginnen.",
"probe_unreachable": "Keine Verbindung zu dieser Adresse. Prüfen Sie die URL und ob der Server erreichbar ist.",
"not_jmap_hint": "Das kann vorkommen, wenn ein Reverse Proxy JMAP auf derselben Domain gesondert weiterleitet. Sonst ist die URL meist falsch.",
"not_jmap_confirm": "Ich bin sicher, dass die URL stimmt, trotzdem fortfahren.",
"http_title": "Diese URL verwendet unverschlüsseltes HTTP.",
"http_body": "Passwörter und E-Mail-Inhalte werden unverschlüsselt zwischen den Benutzern und Ihrem Server übertragen. Verwenden Sie im Betrieb {https}: Terminieren Sie TLS auf dem Mailserver oder in einem vorgeschalteten Reverse Proxy.",
"local_title": "Diese URL ist nur lokal erreichbar.",
"local_body": "E-Mails werden direkt vom Browser der Benutzer abgerufen, daher muss die JMAP-URL von überall erreichbar sein, wo sich Benutzer anmelden, nicht nur von diesem Rechner oder LAN. Verwenden Sie im Betrieb einen öffentlichen Hostnamen (z. B. {example}).",
"additional_title": "Weitere JMAP-Server",
"additional_hint": "Optional. Mehrere Server in der Auswahl bei der Anmeldung anbieten, nützlich für Hosts mit mehreren Stalwart-Instanzen.",
"additional_show": "Anzeigen ({count})",
"add": "Hinzufügen",
"row_title": "Server #{index}",
"id_label": "ID",
"id_hint": "Eindeutiger Kurzname, z. B. eu-1",
"label_label": "Bezeichnung",
"label_hint": "Für Benutzer sichtbar",
"row_url": "URL",
"domains_label": "Domains",
"domains_hint": "Durch Kommas getrennt. Damit wird dieser Server bei der Anmeldung anhand der E-Mail-Domain gewählt.",
"add_server": "Server hinzufügen",
"auto_pick": "Server anhand der E-Mail-Domain wählen",
"auto_pick_hint": "Wenn ein Benutzer seine E-Mail-Adresse eingibt, wird der passende Server aus der Liste oben gewählt.",
"row_id_required": "Server #{index}: Die ID ist erforderlich",
"row_id_invalid": "Server #{index}: Die ID darf nur Buchstaben, Ziffern, - und _ enthalten und muss mit einem Buchstaben oder einer Ziffer beginnen",
"row_id_duplicate": "Server #{index}: Die ID „{id}“ kommt doppelt vor",
"row_url_required": "Server #{index}: Die URL ist erforderlich",
"row_url_scheme": "Server #{index}: Die URL muss mit http:// oder https:// beginnen",
"stalwart": "Stalwart-spezifische Funktionen aktivieren",
"stalwart_hint": "Fügt Passwortänderung und die Verwaltung von Sieve-Filtern hinzu. Kann auch bei anderen Servern bedenkenlos aktiviert werden."
},
"auth": {
"title": "Anmeldung",
"subtitle": "Anmeldung mit Passwort ist immer möglich. OAuth/OIDC ist optional.",
"oauth": "OAuth2 / OpenID Connect aktivieren",
"oauth_only": "Nur OAuth (Passwortformular ausblenden)",
"client_id": "OAuth-Client-ID",
"client_secret": "OAuth-Client-Secret",
"client_secret_hint": "Für öffentliche Clients, die nur PKCE nutzen, leer lassen.",
"client_secret_placeholder": "Secret einfügen",
"issuer": "OAuth-Issuer-URL",
"issuer_hint": "Für externe Identitätsanbieter (Keycloak, Authentik, Entra ID usw.)."
},
"security": {
"title": "Sicherheit und Sitzungen",
"subtitle": "Das Sitzungsgeheimnis ermöglicht „Angemeldet bleiben“ und die Synchronisierung der Einstellungen.",
"generated": "Sitzungsgeheimnis erzeugt",
"customize": "Anpassen",
"generated_hint": "Ein 32-Byte-Geheimnis wurde für Sie erzeugt. Ändern Sie es nur, wenn Sie einen bestimmten Grund haben.",
"secret_label": "Sitzungsgeheimnis",
"secret_hint": "Fügen Sie einen eigenen Wert ein oder erzeugen Sie einen neuen.",
"regenerate": "Neu erzeugen",
"sync": "Benutzereinstellungen geräteübergreifend synchronisieren",
"sync_hint": "Speichert die Einstellungen der Benutzer auf dem Server, verschlüsselt mit dem Sitzungsgeheimnis.",
"telemetry": "Anonyme Nutzungsstatistiken senden, um Bulwark zu verbessern",
"telemetry_hint": "Standardmäßig aus. Ein anonymes Lebenszeichen pro Tag mit Version, Plattform und den aktivierten Funktionen; niemals E-Mail-Adressen, Hostnamen oder IP-Adressen. Sie können das jederzeit in den Admin-Einstellungen ändern."
},
"logging": {
"title": "Protokoll",
"subtitle": "Format und Ausführlichkeit der Anwendungsprotokolle.",
"format": "Format",
"format_text": "text: farbig, gut lesbar",
"format_json": "json: strukturiert, für die Protokollauswertung",
"level": "Stufe",
"level_info": "info (empfohlen)"
},
"branding": {
"title": "Branding",
"subtitle": "Alle Felder sind optional. Laden Sie eine Datei hoch oder fügen Sie eine URL ein; für alles, was Sie auslassen, gelten die Standardwerte.",
"company": "Name des Unternehmens oder der Organisation",
"favicon": "Favicon",
"favicon_hint": "Symbol im Browser-Tab. SVG empfohlen.",
"login_light": "Logo der Anmeldung (heller Modus)",
"login_light_hint": "Auf der Anmeldeseite, auf hellem Hintergrund.",
"login_dark": "Logo der Anmeldung (dunkler Modus)",
"login_dark_hint": "Auf der Anmeldeseite, auf dunklem Hintergrund.",
"sidebar_light": "Logo der Seitenleiste (heller Modus)",
"sidebar_light_hint": "Nach der Anmeldung sichtbar. Leer lassen für keins.",
"sidebar_dark": "Logo der Seitenleiste (dunkler Modus)",
"sidebar_dark_hint": "Version des Seitenleisten-Logos für den dunklen Modus.",
"website": "Website-URL",
"imprint": "Impressum-URL",
"privacy": "URL der Datenschutzerklärung",
"drop": "Klicken oder ablegen",
"uploading": "Wird hochgeladen…",
"uploaded": "Hochgeladene Datei",
"formats": "SVG, PNG, JPEG, WebP oder ICO · max. 2 MB",
"use_url": "URL verwenden",
"hide_url": "URL ausblenden",
"url_placeholder": "https://… oder /branding/datei.svg"
},
"review": {
"title": "Fast geschafft",
"subtitle": "Prüfen Sie Ihre Einstellungen, wählen Sie ein Admin-Passwort und übernehmen Sie.",
"server": "Server",
"app_name": "Name der App",
"jmap_server": "JMAP-Server",
"additional": "Weitere Server",
"additional_count": "{count} eingerichtet",
"additional_auto_pick": "{count} eingerichtet · Auswahl nach Domain",
"stalwart": "Stalwart-Funktionen",
"auth": "Anmeldung",
"method": "Methode",
"oauth_only": "Nur OAuth",
"password_oauth": "Passwort + OAuth",
"password_only": "Nur Passwort",
"client_id": "OAuth-Client-ID",
"security": "Sicherheit",
"secret": "Sitzungsgeheimnis",
"configured": "Eingerichtet",
"not_set": "Nicht gesetzt",
"remember_me": "Angemeldet bleiben",
"available": "Verfügbar",
"disabled": "Deaktiviert",
"sync": "Synchronisierung der Einstellungen",
"needs_secret": "Benötigt ein Sitzungsgeheimnis",
"telemetry": "Anonyme Telemetrie",
"logging": "Protokoll",
"format": "Format",
"level": "Stufe",
"branding": "Branding",
"customizations": "Anpassungen",
"custom_assets": "Eigene Dateien eingerichtet",
"defaults": "Standardwerte",
"company": "Name des Unternehmens",
"password_title": "Admin-Passwort wählen",
"password_hint": "Damit melden Sie sich unter {path} an. Mindestens 8 Zeichen.",
"new_password": "Neues Passwort",
"confirm": "Bestätigen",
"too_short": "Mindestens 8 Zeichen.",
"mismatch": "Die Passwörter stimmen nicht überein.",
"looks_good": "Sieht gut aus.",
"advanced": "Erweitert",
"lock": "Konfiguration nach der Einrichtung sperren",
"lock_hint": "Legt eine Markierungsdatei an. Binden Sie das Konfigurations-Volume nach der Einrichtung schreibgeschützt ein; die App lehnt dann weitere Änderungen der Konfiguration ab. Audit-Logs und Anmeldestatus bleiben im State-Volume beschreibbar.",
"applying": "Wird übernommen…",
"finish": "Übernehmen und abschließen"
}
},
"errors": {
"page_error_title": "Etwas ist schiefgelaufen",
"page_error_description": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut oder kehren Sie zur Startseite zurück.",
+436 -187
View File
File diff suppressed because it is too large Load Diff
+254 -5
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Configuración",
"back_to_mail": "Volver al Correo",
"back_to_settings": "Volver a los ajustes",
"save_success": "Configuración guardada exitosamente",
"import_success": "Configuración importada exitosamente",
"import_error": "Error al importar la configuración",
@@ -1345,6 +1346,7 @@
"id_exists": "Este ID de etiqueta ya existe",
"edit": "Editar etiqueta",
"delete": "Eliminar etiqueta",
"deleted": "Etiqueta eliminada",
"save": "Guardar",
"add": "Añadir",
"cancel": "Cancelar",
@@ -1849,6 +1851,7 @@
"delete": "Eliminar lista guardada",
"cancel": "Cancelar",
"delete_confirm": "¿Eliminar del servidor la lista de cuentas guardada?",
"delete_message": "Los demás dispositivos ya no podrán restaurar las cuentas desde ella.",
"password": "Contraseña de la copia",
"password_hint": "Al menos 10 caracteres. No se puede recuperar: si la olvidas, vuelve a guardar la lista con una nueva.",
"restore_hint": "La contraseña elegida al guardar la lista.",
@@ -1944,6 +1947,8 @@
"removed": "Contraseña de aplicación eliminada",
"add_error": "No se pudo crear la contraseña de aplicación",
"remove_error": "No se pudo eliminar la contraseña de aplicación",
"delete_title": "¿Eliminar la contraseña de aplicación?",
"delete_message": "Las aplicaciones que inician sesión con {name} ya no podrán hacerlo.",
"none": "No hay contraseñas de aplicación configuradas",
"done": "Hecho",
"expires_label": "Caduca (opcional)",
@@ -1962,6 +1967,8 @@
"removed": "Clave API eliminada",
"add_error": "No se pudo crear la clave API",
"remove_error": "No se pudo eliminar la clave API",
"delete_title": "¿Eliminar la clave de API?",
"delete_message": "Los scripts e integraciones que usan {name} pierden el acceso.",
"none": "No hay claves API configuradas"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "Habilitar el cifrado con esta clave",
"enable_button": "Habilitar",
"disable_tooltip": "Deshabilitar el cifrado",
"disable_title": "¿Desactivar el cifrado en reposo?",
"disable_message": "A partir de ahora, el correo nuevo se guarda sin cifrar.",
"disable_button": "Desactivar",
"enabled_success": "Cifrado en reposo habilitado"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "ej. Mi clave",
"key_label": "Clave pública (S/MIME o PGP en formato ASCII)",
"key_placeholder": "Pega aquí tu clave pública",
"none": "No hay claves públicas configuradas"
"none": "No hay claves públicas configuradas",
"title": "Claves públicas",
"added": "Clave pública añadida",
"removed": "Clave pública eliminada",
"add_error": "No se pudo añadir la clave pública",
"remove_error": "No se pudo eliminar la clave pública",
"delete_title": "¿Eliminar la clave pública?",
"delete_message": "{name} se quita del servidor."
},
"sections": {
"devices": "Otras aplicaciones y dispositivos",
@@ -2089,6 +2106,7 @@
},
"save": "Guardar cambios",
"saving": "Guardando...",
"saved": "Guardado",
"warnings": {
"end_before_start": "La fecha de fin debe ser posterior a la fecha de inicio",
"invalid_date": "Introduce una fecha de inicio y de fin válidas con hora o deja el campo vacío",
@@ -2155,12 +2173,17 @@
"new_folder_name": "Nombre de la carpeta",
"rename": "Renombrar",
"change_icon": "Cambiar icono",
"actions": "Acciones para {name}",
"expand": "Expandir {name}",
"collapse": "Contraer {name}",
"counter": "Contador",
"count_mode": "Contador de {name}",
"count_mode_default": "Contador: predeterminado",
"count_mode_unread": "Solo no leídos",
"count_mode_all": "No leídos y total",
"count_mode_none": "Sin contador",
"delete": "Eliminar",
"delete_title": "¿Eliminar la carpeta?",
"confirm_delete": "¿Está seguro de que desea eliminar \"{name}\"? Los correos de esta carpeta se moverán a la Papelera.",
"create": "Crear",
"cancel": "Cancelar",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "Modo de Depuración",
"description": "Habilitar registro detallado para solución de problemas"
"description": "Habilitar registro detallado para solución de problemas",
"toggle": "Registro detallado"
},
"debug_categories": {
"description": "Selecciona qué categorías registrar. Desactiva las categorías que no necesites para reducir el ruido en la consola.",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "¿Actualizar los datos en caché?"
},
"reset_settings": {
"label": "Restablecer Configuración",
"description": "Restaurar toda la configuración a los valores predeterminados",
"button": "Restablecer a Predeterminados"
"button": "Restablecer a Predeterminados",
"confirm_title": "¿Restablecer todos los ajustes?",
"done": "Ajustes restablecidos"
},
"export_settings": {
"label": "Exportar Configuración",
@@ -2302,11 +2329,19 @@
"categories_title": "Categorías",
"categories_description": "Renombrar categorías de contactos",
"no_categories": "No se encontraron categorías",
"delete_category": "Eliminar categoría",
"category_deleted": "Categoría eliminada",
"category_delete_failed": "No se pudo eliminar la categoría",
"group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos",
"group_by_letter_label": "Agrupar por primera letra",
"sort_by_last_name_label": "Ordenar por apellido",
"sort_by_last_name_description": "Ordenar la lista de contactos por apellido para que los miembros de una familia aparezcan juntos"
},
"calendar_management": {
"delete_title": "¿Eliminar el calendario?",
"clear_title": "¿Borrar todos los eventos?",
"unsubscribe_title": "¿Cancelar la suscripción a este calendario?"
},
"downloads": {
"title": "Descargas",
"description": "Personaliza cómo se nombran los correos y archivos adjuntos descargados.",
@@ -2371,7 +2406,7 @@
"validate": "Validar",
"validation_success": "El script es válido",
"validation_error": "El script tiene errores",
"save": "Guardar reglas",
"save": "Guardar",
"saving": "Guardando...",
"saved": "Filtros guardados correctamente",
"save_failed": "Error al guardar los filtros",
@@ -2385,6 +2420,8 @@
"add_condition": "Agregar condición",
"actions": "Acciones",
"add_action": "Agregar acción",
"remove_condition": "Quitar condición",
"remove_action": "Quitar acción",
"stop_processing": "Detener el procesamiento de reglas posteriores",
"include_spam": "Mover también los mensajes marcados como spam",
"attachment_type_placeholder": "p. ej. pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "Tipo de coincidencia",
"reset_to_visual": "Restablecer al constructor visual",
"reset_warning": "Esto descartará el script actual y comenzará de nuevo.",
"reset_title": "¿Restablecer al editor visual?",
"confirm_reset": "Restablecer",
"validation_empty_name": "El nombre de la regla es obligatorio",
"validation_empty_conditions": "Se requiere al menos una condición con un valor",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Configuración de Bulwark Webmail",
"subtitle": "Configura tu instancia de webmail desde el navegador.",
"loading": "Cargando…",
"back": "Atrás",
"next": "Siguiente",
"saving": "Guardando…",
"show": "Mostrar",
"hide": "Ocultar",
"remove": "Quitar",
"on": "Activado",
"off": "Desactivado",
"dismiss": "Cerrar",
"unknown_error": "Error desconocido",
"steps": {
"welcome": "Bienvenida",
"server": "Servidor",
"auth": "Acceso",
"security": "Seguridad",
"logging": "Registros",
"branding": "Marca",
"review": "Revisión"
},
"read_only": {
"title": "La configuración es de solo lectura",
"body": "El volumen de configuración está montado como solo lectura o la variable {variable} está definida. Móntalo con escritura o quita esa variable y reinicia el contenedor."
},
"done": {
"title": "Todo listo",
"body": "{appName} está configurado y listo para usar.",
"admin_sign_in": "Iniciar sesión en el panel de administración",
"webmail_login": "Abrir el inicio de sesión del webmail",
"redirecting": "Te llevamos al panel de administración…"
},
"already": {
"title": "La configuración ya está completa",
"body": "{appName} está configurado. Inicia sesión para continuar."
},
"insecure": {
"title": "Estás ejecutando la configuración por HTTP sin cifrar",
"body": "El token de configuración y la contraseña de administrador que introduzcas aquí viajarán en texto plano. Usa HTTPS siempre que puedas: termina TLS en el contenedor o en un proxy inverso delante de él.",
"try_https": "Probar HTTPS",
"continue_http": "Continuar por HTTP"
},
"errors": {
"token_expired": "Ese token de configuración ya no es válido. Reinicia el contenedor para obtener uno nuevo en los registros.",
"session_expired": "Tu sesión de configuración ha caducado. Pega de nuevo el token de configuración para continuar.",
"token_required": "Pega el token de configuración que aparece en los registros del contenedor para continuar.",
"already_finished": "La configuración ya ha terminado. Recarga para iniciar sesión.",
"token_rejected": "Token rechazado (HTTP {status})",
"step_failed": "No se pudo guardar este paso (HTTP {status})",
"upload_failed": "Error al subir (HTTP {status})",
"finish_failed": "No se pudo terminar la configuración (HTTP {status})"
},
"welcome": {
"title": "Bienvenida",
"body": "Pega el token de configuración que aparece en los registros del contenedor para continuar. El token caduca al cabo de 1 hora.",
"token_label": "Token de configuración",
"token_placeholder": "Token hexadecimal de 32 bytes",
"verifying": "Verificando…",
"continue": "Continuar"
},
"server": {
"title": "Servidor",
"subtitle": "Dónde está tu correo.",
"app_name": "Nombre de la aplicación",
"url_label": "URL del servidor JMAP",
"url_hint": "El servidor predeterminado al que se conectan los usuarios. Ejemplo: https://mail.example.com",
"test": "Probar",
"testing": "Probando…",
"probe_ok": "Conectado: parece un servidor JMAP.",
"probe_not_jmap": "Hemos llegado al servidor, pero no parece un punto de acceso JMAP.",
"probe_invalid": "Esa URL no es válida. Debe empezar por http:// o https://.",
"probe_unreachable": "No se pudo conectar a esa dirección. Comprueba la URL y que el servidor esté en línea.",
"not_jmap_hint": "Puede ocurrir cuando un proxy inverso enruta JMAP por separado en el mismo dominio. Si no, normalmente la URL es incorrecta.",
"not_jmap_confirm": "Estoy seguro de que la URL es correcta, continuar de todos modos.",
"http_title": "Esta URL usa HTTP sin cifrar.",
"http_body": "Las contraseñas y el contenido de los correos viajarán sin cifrar entre los usuarios y tu servidor. Usa {https} en producción: termina TLS en el servidor de correo o en un proxy inverso delante de él.",
"local_title": "Esta URL solo funciona en local.",
"local_body": "El correo se obtiene directamente desde el navegador del usuario, así que la URL de JMAP debe ser accesible desde cualquier lugar donde inicien sesión los usuarios, no solo desde este equipo o esta red local. Usa un nombre de host público (p. ej., {example}) en producción.",
"additional_title": "Servidores JMAP adicionales",
"additional_hint": "Opcional. Ofrece varios servidores en la lista de inicio de sesión, útil para proveedores con varias instancias de Stalwart.",
"additional_show": "Mostrar ({count})",
"add": "Añadir",
"row_title": "Servidor n.º {index}",
"id_label": "ID",
"id_hint": "Identificador único, p. ej., eu-1",
"label_label": "Etiqueta",
"label_hint": "Visible para los usuarios",
"row_url": "URL",
"domains_label": "Dominios",
"domains_hint": "Separados por comas. Sirven para elegir este servidor según el dominio del correo al iniciar sesión.",
"add_server": "Añadir servidor",
"auto_pick": "Elegir el servidor según el dominio del correo",
"auto_pick_hint": "Cuando un usuario escribe su correo, se selecciona el servidor correspondiente de la lista de arriba.",
"row_id_required": "Servidor n.º {index}: el ID es obligatorio",
"row_id_invalid": "Servidor n.º {index}: el ID solo puede tener letras, números, - y _, y debe empezar por una letra o un número",
"row_id_duplicate": "Servidor n.º {index}: el ID «{id}» está repetido",
"row_url_required": "Servidor n.º {index}: la URL es obligatoria",
"row_url_scheme": "Servidor n.º {index}: la URL debe empezar por http:// o https://",
"stalwart": "Activar las funciones propias de Stalwart",
"stalwart_hint": "Añade el cambio de contraseña y la gestión de filtros Sieve. Se puede activar sin problema en otros servidores."
},
"auth": {
"title": "Autenticación",
"subtitle": "El inicio de sesión con contraseña siempre está disponible. OAuth/OIDC es opcional.",
"oauth": "Activar OAuth2 / OpenID Connect",
"oauth_only": "Solo OAuth (ocultar el formulario de contraseña)",
"client_id": "ID de cliente OAuth",
"client_secret": "Secreto de cliente OAuth",
"client_secret_hint": "Déjalo vacío para clientes públicos que solo usan PKCE.",
"client_secret_placeholder": "Pega el secreto",
"issuer": "URL del emisor OAuth",
"issuer_hint": "Para proveedores de identidad externos (Keycloak, Authentik, Entra ID, etc.)."
},
"security": {
"title": "Seguridad y sesiones",
"subtitle": "El secreto de sesión activa «Recordarme» y la sincronización de ajustes.",
"generated": "Secreto de sesión generado",
"customize": "Personalizar",
"generated_hint": "Se ha creado un secreto de 32 bytes. Cámbialo solo si tienes un motivo concreto.",
"secret_label": "Secreto de sesión",
"secret_hint": "Pega tu propio valor o genera uno nuevo.",
"regenerate": "Regenerar",
"sync": "Sincronizar los ajustes de los usuarios entre dispositivos",
"sync_hint": "Guarda las preferencias de los usuarios en el servidor, cifradas con el secreto de sesión.",
"telemetry": "Enviar estadísticas de uso anónimas para mejorar Bulwark",
"telemetry_hint": "Desactivado de forma predeterminada. Una señal anónima al día con la versión, la plataforma y las funciones activadas; nunca direcciones de correo, nombres de host ni IP. Puedes cambiarlo cuando quieras en los ajustes de administración."
},
"logging": {
"title": "Registros",
"subtitle": "Formato y nivel de detalle de los registros de la aplicación.",
"format": "Formato",
"format_text": "text: con colores, fácil de leer",
"format_json": "json: estructurado, para agregación de registros",
"level": "Nivel",
"level_info": "info (recomendado)"
},
"branding": {
"title": "Marca",
"subtitle": "Todos los campos son opcionales. Sube un archivo o pega una URL; para lo que dejes vacío se usan los valores predeterminados.",
"company": "Nombre de la empresa u organización",
"favicon": "Favicon",
"favicon_hint": "Icono de la pestaña del navegador. Se recomienda SVG.",
"login_light": "Logotipo de inicio de sesión (modo claro)",
"login_light_hint": "Se muestra en la página de inicio de sesión, sobre fondo claro.",
"login_dark": "Logotipo de inicio de sesión (modo oscuro)",
"login_dark_hint": "Se muestra en la página de inicio de sesión, sobre fondo oscuro.",
"sidebar_light": "Logotipo de la barra lateral (modo claro)",
"sidebar_light_hint": "Se muestra tras iniciar sesión. Déjalo vacío para no mostrar ninguno.",
"sidebar_dark": "Logotipo de la barra lateral (modo oscuro)",
"sidebar_dark_hint": "Versión para el modo oscuro del logotipo de la barra lateral.",
"website": "URL del sitio web",
"imprint": "URL del aviso legal",
"privacy": "URL de la política de privacidad",
"drop": "Haz clic o suelta aquí",
"uploading": "Subiendo…",
"uploaded": "Archivo subido",
"formats": "SVG, PNG, JPEG, WebP o ICO · máx. 2 MB",
"use_url": "Usar URL",
"hide_url": "Ocultar URL",
"url_placeholder": "https://… o /branding/archivo.svg"
},
"review": {
"title": "Casi listo",
"subtitle": "Revisa tus ajustes, elige una contraseña de administrador y aplica.",
"server": "Servidor",
"app_name": "Nombre de la aplicación",
"jmap_server": "Servidor JMAP",
"additional": "Servidores adicionales",
"additional_count": "{count} configurados",
"additional_auto_pick": "{count} configurados · elegidos por dominio",
"stalwart": "Funciones de Stalwart",
"auth": "Autenticación",
"method": "Método",
"oauth_only": "Solo OAuth",
"password_oauth": "Contraseña + OAuth",
"password_only": "Solo contraseña",
"client_id": "ID de cliente OAuth",
"security": "Seguridad",
"secret": "Secreto de sesión",
"configured": "Configurado",
"not_set": "Sin definir",
"remember_me": "Recordarme",
"available": "Disponible",
"disabled": "Desactivado",
"sync": "Sincronización de ajustes",
"needs_secret": "Necesita un secreto de sesión",
"telemetry": "Telemetría anónima",
"logging": "Registros",
"format": "Formato",
"level": "Nivel",
"branding": "Marca",
"customizations": "Personalizaciones",
"custom_assets": "Archivos propios configurados",
"defaults": "Valores predeterminados",
"company": "Nombre de la empresa",
"password_title": "Elige una contraseña de administrador",
"password_hint": "La usarás para iniciar sesión en {path}. Mínimo 8 caracteres.",
"new_password": "Nueva contraseña",
"confirm": "Confirmar",
"too_short": "Al menos 8 caracteres.",
"mismatch": "Las contraseñas no coinciden.",
"looks_good": "Todo bien.",
"advanced": "Avanzado",
"lock": "Bloquear la configuración después de la instalación",
"lock_hint": "Crea un archivo marcador. Después, monta el volumen de configuración como solo lectura y la aplicación rechazará más cambios de configuración. Los registros de auditoría y el estado de inicio de sesión siguen siendo editables en el volumen de estado.",
"applying": "Aplicando…",
"finish": "Aplicar y terminar"
}
},
"errors": {
"page_error_title": "Algo salió mal",
"page_error_description": "Encontramos un error inesperado. Por favor, inténtelo de nuevo o regrese a la página principal.",
+255 -6
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Seaded",
"back_to_mail": "Tagasi postkasti",
"back_to_settings": "Tagasi seadete juurde",
"save_success": "Seaded salvestatud",
"import_success": "Seaded imporditud",
"import_error": "Seadete importimine ebaõnnestus",
@@ -1348,6 +1349,7 @@
"id_exists": "See sildi ID on juba kasutusel",
"edit": "Muuda silti",
"delete": "Kustuta silt",
"deleted": "Silt kustutatud",
"save": "Salvesta",
"add": "Lisa",
"cancel": "Tühista",
@@ -1852,6 +1854,7 @@
"delete": "Kustuta salvestatud nimekiri",
"cancel": "Tühista",
"delete_confirm": "Kas kustutada salvestatud kontode nimekiri serverist?",
"delete_message": "Teised seadmed ei saa enam kontosid sellest taastada.",
"password": "Varukoopia parool",
"password_hint": "Vähemalt 10 märki. Seda ei saa taastada: kui unustad, salvesta nimekiri uuesti uue parooliga.",
"restore_hint": "Parool, mille valisid nimekirja salvestamisel.",
@@ -1957,6 +1960,8 @@
"removed": "Rakenduse parool eemaldatud",
"add_error": "Rakenduse parooli loomine ebaõnnestus",
"remove_error": "Rakenduse parooli eemaldamine ebaõnnestus",
"delete_title": "Kustutada rakenduse parool?",
"delete_message": "Rakendused, mis logivad sisse parooliga {name}, ei saa enam sisse logida.",
"none": "Ühtegi rakenduse parooli pole seadistatud"
},
"api_keys": {
@@ -1969,6 +1974,8 @@
"removed": "API võti eemaldatud",
"add_error": "API võtme loomine ebaõnnestus",
"remove_error": "API võtme eemaldamine ebaõnnestus",
"delete_title": "Kustutada API-võti?",
"delete_message": "Skriptid ja liidestused, mis kasutavad võtit {name}, kaotavad ligipääsu.",
"none": "Ühtegi API võtit pole seadistatud"
},
"encryption": {
@@ -1988,6 +1995,9 @@
"configure_title": "Luba krüpteerimine selle võtmega",
"enable_button": "Luba",
"disable_tooltip": "Keela krüpteerimine",
"disable_title": "Lülitada salvestatud andmete krüpteerimine välja?",
"disable_message": "Uued kirjad salvestatakse nüüdsest krüpteerimata.",
"disable_button": "Lülita välja",
"enabled_success": "Salvestatud andmete krüpteerimine lubatud"
},
"public_keys": {
@@ -1995,7 +2005,14 @@
"name_placeholder": "nt Minu võti",
"key_label": "Avalik võti (S/MIME või PGP ASCII-armored)",
"key_placeholder": "Kleebi oma avalik võti siia",
"none": "Ühtegi avalikku võtit pole seadistatud"
"none": "Ühtegi avalikku võtit pole seadistatud",
"title": "Avalikud võtmed",
"added": "Avalik võti lisatud",
"removed": "Avalik võti kustutatud",
"add_error": "Avalikku võtit ei õnnestunud lisada",
"remove_error": "Avalikku võtit ei õnnestunud kustutada",
"delete_title": "Kustutada avalik võti?",
"delete_message": "{name} eemaldatakse serverist."
},
"email_client": {
"title": "E-posti kliendi seadistus",
@@ -2096,6 +2113,7 @@
},
"save": "Salvesta muudatused",
"saving": "Salvestamine...",
"saved": "Salvestatud",
"warnings": {
"end_before_start": "Lõppkuupäev peab olema hilisem kui alguskuupäev",
"invalid_date": "Sisesta kehtiv algus- ja lõppkuupäev koos kellaajaga või jäta väli tühjaks",
@@ -2162,12 +2180,17 @@
"new_folder_name": "Kausta nimi",
"rename": "Nimeta ümber",
"change_icon": "Muuda ikooni",
"actions": "Toimingud: {name}",
"expand": "Laienda: {name}",
"collapse": "Ahenda: {name}",
"counter": "Loendur",
"count_mode": "Kausta {name} loendur",
"count_mode_default": "Loendur: vaikimisi",
"count_mode_unread": "Ainult lugemata",
"count_mode_all": "Lugemata ja kokku",
"count_mode_none": "Loendurit pole",
"delete": "Kustuta",
"delete_title": "Kustutada kaust?",
"confirm_delete": "Kas oled kindel, et soovid kustutada kausta \"{name}\"? Selles kaustas olevad kirjad liigutatakse prügikasti.",
"create": "Loo",
"cancel": "Tühista",
@@ -2187,7 +2210,8 @@
"advanced": {
"debug_mode": {
"label": "Silumisrežiim",
"description": "Luba tõrkeotsinguks üksikasjalik logimine"
"description": "Luba tõrkeotsinguks üksikasjalik logimine",
"toggle": "Üksikasjalik logimine"
},
"debug_categories": {
"description": "Vali, milliseid kategooriaid logida. Lülita välja need, mida ei vaja, et vähendada konsooli müra.",
@@ -2232,12 +2256,15 @@
"refresh_cache": {
"label": "Värskenda vahemällu salvestatud andmeid",
"description": "Laadi kontaktid, kalendrid ja kaustad serverist uuesti. Kontod ja seansid jäävad alles — parandab aegunud või vale kuva ilma välja logimata.",
"button": "Värskenda"
"button": "Värskenda",
"confirm_title": "Värskendada vahemälu andmed?"
},
"reset_settings": {
"label": "Lähtesta seaded",
"description": "Taasta kõik seaded vaikeväärtustele",
"button": "Lähtesta vaikeväärtustele"
"button": "Lähtesta vaikeväärtustele",
"confirm_title": "Lähtestada kõik seaded?",
"done": "Seaded lähtestatud"
},
"export_settings": {
"label": "Ekspordi seaded",
@@ -2312,7 +2339,15 @@
"no_address_books": "Aadressiraamatuid ei leitud",
"categories_title": "Kategooriad",
"categories_description": "Nimeta kontaktikategooriad ümber",
"no_categories": "Kategooriaid ei leitud"
"no_categories": "Kategooriaid ei leitud",
"delete_category": "Kustuta kategooria",
"category_deleted": "Kategooria kustutatud",
"category_delete_failed": "Kategooriat ei õnnestunud kustutada"
},
"calendar_management": {
"delete_title": "Kustutada kalender?",
"clear_title": "Tühjendada kõik sündmused?",
"unsubscribe_title": "Loobuda selle kalendri tellimusest?"
},
"downloads": {
"title": "Allalaadimised",
@@ -2378,7 +2413,7 @@
"validate": "Valideeri",
"validation_success": "Skript on kehtiv",
"validation_error": "Skriptis on vigu",
"save": "Salvesta reeglid",
"save": "Salvesta",
"saving": "Salvestamine...",
"saved": "Filtrid salvestatud edukalt",
"save_failed": "Filtrite salvestamine ebaõnnestus",
@@ -2392,6 +2427,8 @@
"add_condition": "Lisa tingimus",
"actions": "Tegevused",
"add_action": "Lisa tegevus",
"remove_condition": "Eemalda tingimus",
"remove_action": "Eemalda toiming",
"stop_processing": "Peata järgmiste reeglite töötlemine",
"include_spam": "Liiguta ka rämpspostiks märgitud kirjad",
"attachment_type_placeholder": "nt pdf, doc, jpg",
@@ -2468,6 +2505,7 @@
"match_type": "Vastavuse tüüp",
"reset_to_visual": "Lähtesta visuaalsele koostajale",
"reset_warning": "See hülgab praeguse skripti ja alustab uuesti.",
"reset_title": "Lähtestada visuaalsele redaktorile?",
"confirm_reset": "Lähtesta",
"validation_empty_name": "Reegli nimi on kohustuslik",
"validation_empty_conditions": "Vajalik on vähemalt üks väärtusega tingimus",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Bulwark Webmaili seadistamine",
"subtitle": "Seadista oma veebipostiteenus brauseris.",
"loading": "Laadimine…",
"back": "Tagasi",
"next": "Edasi",
"saving": "Salvestamine…",
"show": "Näita",
"hide": "Peida",
"remove": "Eemalda",
"on": "Sees",
"off": "Väljas",
"dismiss": "Sulge",
"unknown_error": "Tundmatu viga",
"steps": {
"welcome": "Tere tulemast",
"server": "Server",
"auth": "Sisselogimine",
"security": "Turvalisus",
"logging": "Logimine",
"branding": "Kujundus",
"review": "Ülevaade"
},
"read_only": {
"title": "Seadistus on kirjutuskaitstud",
"body": "Seadistuse köide on ühendatud kirjutuskaitstuna või on määratud {variable}. Ühenda see kirjutusõigusega või eemalda muutuja ja käivita konteiner uuesti."
},
"done": {
"title": "Kõik on valmis",
"body": "{appName} on seadistatud ja kasutusvalmis.",
"admin_sign_in": "Logi sisse halduspaneeli",
"webmail_login": "Ava veebiposti sisselogimine",
"redirecting": "Suuname sind halduspaneeli…"
},
"already": {
"title": "Seadistamine on juba lõpetatud",
"body": "{appName} on seadistatud. Jätkamiseks logi sisse."
},
"insecure": {
"title": "Käivitad seadistamise krüpteerimata HTTP kaudu",
"body": "Siin sisestatud seadistustõend ja administraatori parool liiguvad avatekstina. Kasuta võimalusel HTTPS-i: lõpeta TLS konteineris või selle ees olevas pöördpuhverserveris.",
"try_https": "Proovi HTTPS-i",
"continue_http": "Jätka HTTP kaudu"
},
"errors": {
"token_expired": "See seadistustõend ei kehti enam. Taaskäivita konteiner, et saada logidest uus.",
"session_expired": "Seadistamise seanss aegus. Jätkamiseks kleebi seadistustõend uuesti.",
"token_required": "Jätkamiseks kleebi konteineri logides näidatud seadistustõend.",
"already_finished": "Seadistamine on juba lõpetatud. Sisselogimiseks laadi leht uuesti.",
"token_rejected": "Tõend lükati tagasi (HTTP {status})",
"step_failed": "Seda sammu ei õnnestunud salvestada (HTTP {status})",
"upload_failed": "Üleslaadimine ebaõnnestus (HTTP {status})",
"finish_failed": "Seadistamist ei õnnestunud lõpetada (HTTP {status})"
},
"welcome": {
"title": "Tere tulemast",
"body": "Jätkamiseks kleebi konteineri logides näidatud seadistustõend. Tõend aegub 1 tunni pärast.",
"token_label": "Seadistustõend",
"token_placeholder": "32-baidine kuueteistkümnendtõend",
"verifying": "Kontrollimine…",
"continue": "Jätka"
},
"server": {
"title": "Server",
"subtitle": "Kus su post asub.",
"app_name": "Rakenduse nimi",
"url_label": "JMAP-serveri URL",
"url_hint": "Vaikeserver, millega kasutajad ühenduvad. Näide: https://mail.example.com",
"test": "Testi",
"testing": "Testimine…",
"probe_ok": "Ühendatud: see paistab olevat JMAP-server.",
"probe_not_jmap": "Server vastab, aga ei paista olevat JMAP-i otspunkt.",
"probe_invalid": "See URL ei sobi. See peab algama http:// või https://.",
"probe_unreachable": "Selle aadressiga ei õnnestunud ühenduda. Kontrolli URL-i ja seda, et server on võrgus.",
"not_jmap_hint": "See võib juhtuda, kui pöördpuhverserver suunab JMAP-i samal domeenil eraldi. Muidu on URL tavaliselt vale.",
"not_jmap_confirm": "Olen kindel, et URL on õige, jätka ikkagi.",
"http_title": "See URL kasutab krüpteerimata HTTP-d.",
"http_body": "Paroolid ja kirjade sisu liiguvad kasutajate ja sinu serveri vahel krüpteerimata. Kasuta tootmises {https}: lõpeta TLS meiliserveris või selle ees olevas pöördpuhverserveris.",
"local_title": "See URL töötab ainult kohalikult.",
"local_body": "Post laaditakse otse kasutaja brauserist, seega peab JMAP-i URL olema kättesaadav kõikjal, kus kasutajad sisse logivad, mitte ainult sellest arvutist või kohtvõrgust. Kasuta tootmises avalikku hostinime (nt {example}).",
"additional_title": "Täiendavad JMAP-serverid",
"additional_hint": "Valikuline. Paku sisselogimisel mitut serverit, kasulik mitme Stalwarti eksemplariga teenusepakkujale.",
"additional_show": "Näita ({count})",
"add": "Lisa",
"row_title": "Server nr {index}",
"id_label": "ID",
"id_hint": "Ainulaadne lühinimi, nt eu-1",
"label_label": "Silt",
"label_hint": "Kasutajatele nähtav",
"row_url": "URL",
"domains_label": "Domeenid",
"domains_hint": "Komadega eraldatud. Nende järgi valitakse sisselogimisel e-posti domeeni alusel see server.",
"add_server": "Lisa server",
"auto_pick": "Vali server e-posti domeeni järgi",
"auto_pick_hint": "Kui kasutaja sisestab oma e-posti aadressi, valitakse ülaltoodud loendist sobiv server.",
"row_id_required": "Server nr {index}: ID on kohustuslik",
"row_id_invalid": "Server nr {index}: ID võib sisaldada ainult tähti, numbreid, - ja _ ning peab algama tähe või numbriga",
"row_id_duplicate": "Server nr {index}: ID „{id}“ on topelt",
"row_url_required": "Server nr {index}: URL on kohustuslik",
"row_url_scheme": "Server nr {index}: URL peab algama http:// või https://",
"stalwart": "Luba Stalwarti-põhised funktsioonid",
"stalwart_hint": "Lisab parooli muutmise ja Sieve-filtrite haldamise. Võib ohutult lubada ka teistel serveritel."
},
"auth": {
"title": "Autentimine",
"subtitle": "Parooliga sisselogimine on alati saadaval. OAuth/OIDC on valikuline.",
"oauth": "Luba OAuth2 / OpenID Connect",
"oauth_only": "Ainult OAuth (peida paroolivorm)",
"client_id": "OAuth-i kliendi ID",
"client_secret": "OAuth-i kliendi saladus",
"client_secret_hint": "Jäta tühjaks avalike klientide puhul, mis kasutavad ainult PKCE-d.",
"client_secret_placeholder": "Kleebi saladus",
"issuer": "OAuth-i väljastaja URL",
"issuer_hint": "Väliste identiteedipakkujate jaoks (Keycloak, Authentik, Entra ID jt)."
},
"security": {
"title": "Turvalisus ja seansid",
"subtitle": "Seansi saladus võimaldab valikut „Jäta mind meelde“ ja seadete sünkroonimist.",
"generated": "Seansi saladus loodi",
"customize": "Kohanda",
"generated_hint": "Sinu jaoks loodi 32-baidine saladus. Muuda seda ainult kindla põhjusega.",
"secret_label": "Seansi saladus",
"secret_hint": "Kleebi oma väärtus või loo uus.",
"regenerate": "Loo uus",
"sync": "Sünkrooni kasutajate seaded seadmete vahel",
"sync_hint": "Salvestab kasutajate eelistused serverisse, krüpteerituna seansi saladusega.",
"telemetry": "Saada Bulwarki parandamiseks anonüümset kasutusstatistikat",
"telemetry_hint": "Vaikimisi väljas. Üks anonüümne signaal päevas versiooni, platvormi ja sisse lülitatud funktsioonidega; mitte kunagi e-posti aadresse, hostinimesid ega IP-aadresse. Saad seda igal ajal muuta haldusseadetes."
},
"logging": {
"title": "Logimine",
"subtitle": "Rakenduse logide vorming ja üksikasjalikkus.",
"format": "Vorming",
"format_text": "text: värviline, loetav",
"format_json": "json: struktureeritud, logide koondamiseks",
"level": "Tase",
"level_info": "info (soovitatav)"
},
"branding": {
"title": "Kujundus",
"subtitle": "Kõik väljad on valikulised. Laadi fail üles või kleebi URL; vahele jäetu puhul kasutatakse vaikeväärtusi.",
"company": "Ettevõtte või organisatsiooni nimi",
"favicon": "Favicon",
"favicon_hint": "Brauseri vahekaardi ikoon. Soovitatav SVG.",
"login_light": "Sisselogimise logo (hele režiim)",
"login_light_hint": "Sisselogimislehel, heledal taustal.",
"login_dark": "Sisselogimise logo (tume režiim)",
"login_dark_hint": "Sisselogimislehel, tumedal taustal.",
"sidebar_light": "Külgriba logo (hele režiim)",
"sidebar_light_hint": "Kuvatakse pärast sisselogimist. Jäta tühjaks, kui logo pole vaja.",
"sidebar_dark": "Külgriba logo (tume režiim)",
"sidebar_dark_hint": "Külgriba logo tume versioon.",
"website": "Veebisaidi URL",
"imprint": "Impressumi URL",
"privacy": "Privaatsuspoliitika URL",
"drop": "Klõpsa või lohista siia",
"uploading": "Üleslaadimine…",
"uploaded": "Üles laaditud fail",
"formats": "SVG, PNG, JPEG, WebP või ICO · kuni 2 MB",
"use_url": "Kasuta URL-i",
"hide_url": "Peida URL",
"url_placeholder": "https://… või /branding/fail.svg"
},
"review": {
"title": "Peaaegu valmis",
"subtitle": "Vaata seaded üle, vali administraatori parool ja rakenda.",
"server": "Server",
"app_name": "Rakenduse nimi",
"jmap_server": "JMAP-server",
"additional": "Täiendavad serverid",
"additional_count": "Seadistatud: {count}",
"additional_auto_pick": "Seadistatud: {count} · valik domeeni järgi",
"stalwart": "Stalwarti funktsioonid",
"auth": "Autentimine",
"method": "Meetod",
"oauth_only": "Ainult OAuth",
"password_oauth": "Parool + OAuth",
"password_only": "Ainult parool",
"client_id": "OAuth-i kliendi ID",
"security": "Turvalisus",
"secret": "Seansi saladus",
"configured": "Seadistatud",
"not_set": "Määramata",
"remember_me": "Jäta mind meelde",
"available": "Saadaval",
"disabled": "Keelatud",
"sync": "Seadete sünkroonimine",
"needs_secret": "Vajab seansi saladust",
"telemetry": "Anonüümne telemeetria",
"logging": "Logimine",
"format": "Vorming",
"level": "Tase",
"branding": "Kujundus",
"customizations": "Kohandused",
"custom_assets": "Omad failid seadistatud",
"defaults": "Vaikeväärtused",
"company": "Ettevõtte nimi",
"password_title": "Vali administraatori parool",
"password_hint": "Selle abil logid sisse aadressil {path}. Vähemalt 8 märki.",
"new_password": "Uus parool",
"confirm": "Kinnita",
"too_short": "Vähemalt 8 märki.",
"mismatch": "Paroolid ei ühti.",
"looks_good": "Korras.",
"advanced": "Täpsemad",
"lock": "Lukusta seadistus pärast paigaldamist",
"lock_hint": "Loob märgistusfaili. Seejärel ühenda seadistuse köide kirjutuskaitstuna ja rakendus keeldub edasistest seadistuse muudatustest. Auditilogid ja sisselogimise olek jäävad oleku köites kirjutatavaks.",
"applying": "Rakendamine…",
"finish": "Rakenda ja lõpeta"
}
},
"errors": {
"page_error_title": "Midagi läks valesti",
"page_error_description": "Ilmnes ootamatu viga. Proovi uuesti või mine tagasi avalehele.",
+255 -6
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "تنظیمات",
"back_to_mail": "بازگشت به ایمیل",
"back_to_settings": "بازگشت به تنظیمات",
"save_success": "تنظیمات با موفقیت ذخیره شد",
"import_success": "تنظیمات با موفقیت وارد شد",
"import_error": "وارد کردن تنظیمات ناموفق بود",
@@ -1348,6 +1349,7 @@
"id_exists": "این شناسه قبلاً وجود دارد",
"edit": "ویرایش برچسب",
"delete": "حذف برچسب",
"deleted": "برچسب حذف شد",
"save": "ذخیره",
"add": "افزودن",
"cancel": "انصراف",
@@ -1852,6 +1854,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "دستگاه‌های دیگر دیگر نمی‌توانند حساب‌ها را از آن بازیابی کنند.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1957,6 +1960,8 @@
"removed": "حذف شد",
"add_error": "خطای ایجاد",
"remove_error": "خطای حذف",
"delete_title": "گذرواژه برنامه حذف شود؟",
"delete_message": "برنامه‌هایی که با {name} وارد می‌شوند دیگر نمی‌توانند وارد شوند.",
"none": "پیکربندی نشده"
},
"api_keys": {
@@ -1969,6 +1974,8 @@
"removed": "حذف شد",
"add_error": "خطای ایجاد",
"remove_error": "خطای حذف",
"delete_title": "کلید API حذف شود؟",
"delete_message": "اسکریپت‌ها و یکپارچه‌سازی‌هایی که از {name} استفاده می‌کنند دسترسی را از دست می‌دهند.",
"none": "پیکربندی نشده"
},
"encryption": {
@@ -1988,6 +1995,9 @@
"configure_title": "فعال‌سازی رمزنگاری با این کلید",
"enable_button": "فعال‌سازی",
"disable_tooltip": "غیرفعال‌سازی رمزنگاری",
"disable_title": "رمزگذاری داده‌های ذخیره‌شده خاموش شود؟",
"disable_message": "از این پس نامه‌های جدید بدون رمزگذاری ذخیره می‌شوند.",
"disable_button": "خاموش کردن",
"enabled_success": "فعال شد"
},
"email_client": {
@@ -2036,7 +2046,14 @@
"name_placeholder": "مثال: کلید من",
"key_label": "کلید عمومی (S/MIME یا PGP با قالب ASCII)",
"key_placeholder": "کلید عمومی خود را اینجا بچسبانید",
"none": "هیچ کلید عمومی‌ای تنظیم نشده است"
"none": "هیچ کلید عمومی‌ای تنظیم نشده است",
"title": "کلیدهای عمومی",
"added": "کلید عمومی افزوده شد",
"removed": "کلید عمومی حذف شد",
"add_error": "افزودن کلید عمومی ممکن نشد",
"remove_error": "حذف کلید عمومی ممکن نشد",
"delete_title": "کلید عمومی حذف شود؟",
"delete_message": "{name} از سرور حذف می‌شود."
},
"sections": {
"devices": "برنامه‌ها و دستگاه‌های دیگر",
@@ -2096,6 +2113,7 @@
},
"save": "ذخیره تغییرات",
"saving": "در حال ذخیره...",
"saved": "ذخیره شد",
"warnings": {
"end_before_start": "تاریخ پایان باید بعد از تاریخ شروع باشد",
"invalid_date": "تاریخ شروع و پایان معتبر را همراه با ساعت وارد کنید یا فیلد را خالی بگذارید",
@@ -2162,12 +2180,17 @@
"new_folder_name": "نام پوشه",
"rename": "تغییر نام",
"change_icon": "تغییر آیکون",
"actions": "کارها برای {name}",
"expand": "باز کردن {name}",
"collapse": "بستن {name}",
"counter": "شمارنده",
"count_mode": "شمارنده برای {name}",
"count_mode_default": "شمارنده: پیش‌فرض",
"count_mode_unread": "فقط خوانده‌نشده",
"count_mode_all": "خوانده‌نشده و کل",
"count_mode_none": "بدون شمارنده",
"delete": "حذف",
"delete_title": "پوشه حذف شود؟",
"confirm_delete": "آیا از حذف \"{name}\" اطمینان دارید؟ ایمیل‌های این پوشه به زباله‌دان منتقل می‌شوند.",
"create": "ایجاد",
"cancel": "انصراف",
@@ -2187,7 +2210,8 @@
"advanced": {
"debug_mode": {
"label": "حالت اشکال‌زدایی",
"description": "فعال کردن ثبت جزئیات برای عیب‌یابی"
"description": "فعال کردن ثبت جزئیات برای عیب‌یابی",
"toggle": "ثبت گزارش با جزئیات"
},
"debug_categories": {
"description": "انتخاب کنید کدام دسته‌بندی‌ها ثبت شوند. دسته‌بندی‌های غیرضروری را برای کاهش نویز کنسول غیرفعال کنید.",
@@ -2232,12 +2256,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "داده‌های ذخیره‌شده موقت تازه شوند؟"
},
"reset_settings": {
"label": "بازنشانی تنظیمات",
"description": "بازگردانی همه تنظیمات به مقادیر پیش‌فرض",
"button": "بازنشانی به پیش‌فرض"
"button": "بازنشانی به پیش‌فرض",
"confirm_title": "همه تنظیمات بازنشانی شوند؟",
"done": "تنظیمات بازنشانی شد"
},
"export_settings": {
"label": "خروجی تنظیمات",
@@ -2312,7 +2339,15 @@
"no_address_books": "دفترچه آدرسی یافت نشد",
"categories_title": "دسته‌بندی‌ها",
"categories_description": "تغییر نام دسته‌بندی‌های مخاطبین",
"no_categories": "دسته‌بندی یافت نشد"
"no_categories": "دسته‌بندی یافت نشد",
"delete_category": "حذف دسته",
"category_deleted": "دسته حذف شد",
"category_delete_failed": "حذف دسته ممکن نشد"
},
"calendar_management": {
"delete_title": "تقویم حذف شود؟",
"clear_title": "همه رویدادها پاک شوند؟",
"unsubscribe_title": "اشتراک این تقویم لغو شود؟"
},
"downloads": {
"title": "دانلودها",
@@ -2378,7 +2413,7 @@
"validate": "اعتبارسنجی",
"validation_success": "اسکریپت معتبر است",
"validation_error": "اسکریپت خطا دارد",
"save": "ذخیره قوانین",
"save": "ذخیره",
"saving": "در حال ذخیره...",
"saved": "فیلترها با موفقیت ذخیره شدند",
"save_failed": "ذخیره فیلترها ناموفق بود",
@@ -2392,6 +2427,8 @@
"add_condition": "افزودن شرط",
"actions": "عملیات‌ها",
"add_action": "افزودن عملیات",
"remove_condition": "حذف شرط",
"remove_action": "حذف کنش",
"stop_processing": "توقف پردازش قوانین بعدی",
"include_spam": "پیام‌های علامت‌خورده به‌عنوان هرزنامه هم منتقل شوند",
"attachment_type_placeholder": "مثال: pdf, doc, jpg",
@@ -2468,6 +2505,7 @@
"match_type": "نوع مطابقت",
"reset_to_visual": "بازنشانی به ویرایشگر بصری",
"reset_warning": "این اسکریپت فعلی را دور می‌اندازد و از نو شروع می‌کند.",
"reset_title": "به سازنده تصویری بازنشانی شود؟",
"confirm_reset": "بازنشانی",
"validation_empty_name": "نام قانون الزامی است",
"validation_empty_conditions": "حداقل یک شرط با مقدار الزامی است",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "راه‌اندازی Bulwark Webmail",
"subtitle": "نمونهٔ وب‌میل خود را از مرورگر پیکربندی کنید.",
"loading": "در حال بارگذاری…",
"back": "بازگشت",
"next": "بعدی",
"saving": "در حال ذخیره…",
"show": "نمایش",
"hide": "پنهان کردن",
"remove": "حذف",
"on": "روشن",
"off": "خاموش",
"dismiss": "بستن",
"unknown_error": "خطای ناشناخته",
"steps": {
"welcome": "خوش آمدید",
"server": "سرور",
"auth": "ورود",
"security": "امنیت",
"logging": "گزارش‌ها",
"branding": "هویت بصری",
"review": "بازبینی"
},
"read_only": {
"title": "پیکربندی فقط‌خواندنی است",
"body": "حجم پیکربندی فقط‌خواندنی متصل شده یا {variable} تنظیم شده است. آن را با اجازهٔ نوشتن متصل کنید یا آن متغیر را بردارید و سپس کانتینر را دوباره راه‌اندازی کنید."
},
"done": {
"title": "همه‌چیز آماده است",
"body": "{appName} پیکربندی شده و آمادهٔ استفاده است.",
"admin_sign_in": "ورود به داشبورد مدیریت",
"webmail_login": "باز کردن صفحهٔ ورود وب‌میل",
"redirecting": "در حال انتقال به داشبورد مدیریت…"
},
"already": {
"title": "راه‌اندازی قبلاً کامل شده است",
"body": "{appName} پیکربندی شده است. برای ادامه وارد شوید."
},
"insecure": {
"title": "راه‌اندازی را روی HTTP رمزنگاری‌نشده اجرا می‌کنید",
"body": "توکن راه‌اندازی و گذرواژهٔ مدیری که اینجا وارد می‌کنید به‌صورت متن ساده ارسال می‌شوند. در صورت امکان از HTTPS استفاده کنید: TLS را در کانتینر یا در یک پراکسی معکوس جلوی آن خاتمه دهید.",
"try_https": "امتحان HTTPS",
"continue_http": "ادامه با HTTP"
},
"errors": {
"token_expired": "این توکن راه‌اندازی دیگر معتبر نیست. کانتینر را دوباره راه‌اندازی کنید تا توکن تازه‌ای در گزارش‌ها بگیرید.",
"session_expired": "نشست راه‌اندازی منقضی شده است. برای ادامه، توکن راه‌اندازی را دوباره بچسبانید.",
"token_required": "برای ادامه، توکن راه‌اندازی نمایش‌داده‌شده در گزارش‌های کانتینر را بچسبانید.",
"already_finished": "راه‌اندازی قبلاً تمام شده است. برای ورود، صفحه را دوباره بارگذاری کنید.",
"token_rejected": "توکن رد شد (HTTP {status})",
"step_failed": "ذخیرهٔ این مرحله ممکن نشد (HTTP {status})",
"upload_failed": "بارگذاری ناموفق بود (HTTP {status})",
"finish_failed": "تکمیل راه‌اندازی ممکن نشد (HTTP {status})"
},
"welcome": {
"title": "خوش آمدید",
"body": "برای ادامه، توکن راه‌اندازی نمایش‌داده‌شده در گزارش‌های کانتینر را بچسبانید. توکن پس از ۱ ساعت منقضی می‌شود.",
"token_label": "توکن راه‌اندازی",
"token_placeholder": "توکن هگزادسیمال ۳۲ بایتی",
"verifying": "در حال بررسی…",
"continue": "ادامه"
},
"server": {
"title": "سرور",
"subtitle": "جایی که نامه‌های شما هستند.",
"app_name": "نام برنامه",
"url_label": "نشانی URL سرور JMAP",
"url_hint": "سرور پیش‌فرضی که کاربران به آن وصل می‌شوند. نمونه: https://mail.example.com",
"test": "آزمایش",
"testing": "در حال آزمایش…",
"probe_ok": "متصل شد: به نظر می‌رسد سرور JMAP است.",
"probe_not_jmap": "به سرور رسیدیم، اما به نظر نمی‌رسد نقطهٔ پایانی JMAP باشد.",
"probe_invalid": "این نشانی معتبر نیست. باید با http:// یا https:// شروع شود.",
"probe_unreachable": "اتصال به این نشانی ممکن نشد. نشانی و روشن بودن سرور را بررسی کنید.",
"not_jmap_hint": "این وقتی پیش می‌آید که یک پراکسی معکوس JMAP را روی همان دامنه جداگانه مسیریابی کند. وگرنه معمولاً نشانی اشتباه است.",
"not_jmap_confirm": "مطمئنم این نشانی درست است، در هر حال ادامه بده.",
"http_title": "این نشانی از HTTP رمزنگاری‌نشده استفاده می‌کند.",
"http_body": "گذرواژه‌ها و محتوای نامه‌ها بدون رمزنگاری بین کاربران و سرور شما جابه‌جا می‌شوند. در محیط تولید از {https} استفاده کنید: TLS را روی سرور نامه یا در یک پراکسی معکوس جلوی آن خاتمه دهید.",
"local_title": "این نشانی فقط به‌صورت محلی کار می‌کند.",
"local_body": "نامه‌ها مستقیماً از مرورگر کاربر دریافت می‌شوند، پس نشانی JMAP باید از هر جایی که کاربران وارد می‌شوند در دسترس باشد، نه فقط از این رایانه یا شبکهٔ محلی. در محیط تولید از نام میزبان عمومی (مثلاً {example}) استفاده کنید.",
"additional_title": "سرورهای JMAP بیشتر",
"additional_hint": "اختیاری. چند سرور را در فهرست ورود نشان دهید؛ برای میزبان‌هایی با چند نمونهٔ Stalwart مفید است.",
"additional_show": "نمایش ({count})",
"add": "افزودن",
"row_title": "سرور شمارهٔ {index}",
"id_label": "شناسه",
"id_hint": "نام کوتاه یکتا، مثلاً eu-1",
"label_label": "برچسب",
"label_hint": "برای کاربران نمایش داده می‌شود",
"row_url": "URL",
"domains_label": "دامنه‌ها",
"domains_hint": "با کاما جدا کنید. برای انتخاب این سرور بر اساس دامنهٔ رایانامه هنگام ورود به کار می‌روند.",
"add_server": "افزودن سرور",
"auto_pick": "انتخاب سرور بر اساس دامنهٔ رایانامه",
"auto_pick_hint": "وقتی کاربر رایانامهٔ خود را وارد می‌کند، سرور مطابق از فهرست بالا انتخاب می‌شود.",
"row_id_required": "سرور شمارهٔ {index}: شناسه لازم است",
"row_id_invalid": "سرور شمارهٔ {index}: شناسه فقط می‌تواند حرف، رقم، - و _ داشته باشد و باید با حرف یا رقم شروع شود",
"row_id_duplicate": "سرور شمارهٔ {index}: شناسهٔ «{id}» تکراری است",
"row_url_required": "سرور شمارهٔ {index}: نشانی URL لازم است",
"row_url_scheme": "سرور شمارهٔ {index}: نشانی URL باید با http:// یا https:// شروع شود",
"stalwart": "فعال کردن قابلیت‌های ویژهٔ Stalwart",
"stalwart_hint": "تغییر گذرواژه و مدیریت پالایه‌های Sieve را اضافه می‌کند. روی سرورهای دیگر هم بی‌خطر است."
},
"auth": {
"title": "احراز هویت",
"subtitle": "ورود با گذرواژه همیشه در دسترس است. OAuth/OIDC اختیاری است.",
"oauth": "فعال کردن OAuth2 / OpenID Connect",
"oauth_only": "فقط OAuth (پنهان کردن فرم گذرواژه)",
"client_id": "شناسهٔ کلاینت OAuth",
"client_secret": "رمز کلاینت OAuth",
"client_secret_hint": "برای کلاینت‌های عمومی که فقط از PKCE استفاده می‌کنند خالی بگذارید.",
"client_secret_placeholder": "رمز را بچسبانید",
"issuer": "نشانی صادرکنندهٔ OAuth",
"issuer_hint": "برای ارائه‌دهندگان هویت بیرونی (Keycloak، Authentik، Entra ID و غیره)."
},
"security": {
"title": "امنیت و نشست‌ها",
"subtitle": "رمز نشست امکان «مرا به خاطر بسپار» و همگام‌سازی تنظیمات را فراهم می‌کند.",
"generated": "رمز نشست ساخته شد",
"customize": "سفارشی‌سازی",
"generated_hint": "یک رمز ۳۲ بایتی برایتان ساخته شد. فقط اگر دلیل مشخصی دارید آن را تغییر دهید.",
"secret_label": "رمز نشست",
"secret_hint": "مقدار خودتان را بچسبانید یا مقدار تازه بسازید.",
"regenerate": "ساخت دوباره",
"sync": "همگام‌سازی تنظیمات کاربران بین دستگاه‌ها",
"sync_hint": "ترجیحات کاربران را روی سرور ذخیره می‌کند، رمزنگاری‌شده با رمز نشست.",
"telemetry": "ارسال آمار استفادهٔ ناشناس برای بهبود Bulwark",
"telemetry_hint": "به‌طور پیش‌فرض خاموش است. روزی یک سیگنال ناشناس با نسخه، سکو و قابلیت‌های فعال؛ هرگز نشانی رایانامه، نام میزبان یا IP. هر زمان بخواهید می‌توانید این را در تنظیمات مدیریت تغییر دهید."
},
"logging": {
"title": "گزارش‌ها",
"subtitle": "قالب و میزان جزئیات گزارش‌های برنامه.",
"format": "قالب",
"format_text": "text: رنگی و خوانا",
"format_json": "json: ساختاریافته، برای تجمیع گزارش‌ها",
"level": "سطح",
"level_info": "info (توصیه‌شده)"
},
"branding": {
"title": "هویت بصری",
"subtitle": "همهٔ فیلدها اختیاری‌اند. پرونده‌ای بارگذاری کنید یا نشانی بچسبانید؛ برای هرچه رد کنید مقادیر پیش‌فرض به کار می‌روند.",
"company": "نام شرکت یا سازمان",
"favicon": "نماد وب‌سایت",
"favicon_hint": "نماد زبانهٔ مرورگر. SVG توصیه می‌شود.",
"login_light": "نشان ورود (حالت روشن)",
"login_light_hint": "در صفحهٔ ورود، روی پس‌زمینهٔ روشن.",
"login_dark": "نشان ورود (حالت تیره)",
"login_dark_hint": "در صفحهٔ ورود، روی پس‌زمینهٔ تیره.",
"sidebar_light": "نشان نوار کناری (حالت روشن)",
"sidebar_light_hint": "پس از ورود نمایش داده می‌شود. برای هیچ، خالی بگذارید.",
"sidebar_dark": "نشان نوار کناری (حالت تیره)",
"sidebar_dark_hint": "نسخهٔ حالت تیرهٔ نشان نوار کناری.",
"website": "نشانی وب‌سایت",
"imprint": "نشانی صفحهٔ اطلاعات حقوقی",
"privacy": "نشانی سیاست حریم خصوصی",
"drop": "کلیک کنید یا اینجا رها کنید",
"uploading": "در حال بارگذاری…",
"uploaded": "پروندهٔ بارگذاری‌شده",
"formats": "SVG، PNG، JPEG، WebP یا ICO · حداکثر ۲ مگابایت",
"use_url": "استفاده از نشانی",
"hide_url": "پنهان کردن نشانی",
"url_placeholder": "https://… یا /branding/file.svg"
},
"review": {
"title": "تقریباً تمام شد",
"subtitle": "تنظیمات را بازبینی کنید، گذرواژهٔ مدیر را انتخاب کنید و اعمال کنید.",
"server": "سرور",
"app_name": "نام برنامه",
"jmap_server": "سرور JMAP",
"additional": "سرورهای بیشتر",
"additional_count": "{count} پیکربندی شده",
"additional_auto_pick": "{count} پیکربندی شده · انتخاب بر اساس دامنه",
"stalwart": "قابلیت‌های Stalwart",
"auth": "احراز هویت",
"method": "روش",
"oauth_only": "فقط OAuth",
"password_oauth": "گذرواژه + OAuth",
"password_only": "فقط گذرواژه",
"client_id": "شناسهٔ کلاینت OAuth",
"security": "امنیت",
"secret": "رمز نشست",
"configured": "پیکربندی شده",
"not_set": "تنظیم نشده",
"remember_me": "مرا به خاطر بسپار",
"available": "در دسترس",
"disabled": "غیرفعال",
"sync": "همگام‌سازی تنظیمات",
"needs_secret": "به رمز نشست نیاز دارد",
"telemetry": "تله‌متری ناشناس",
"logging": "گزارش‌ها",
"format": "قالب",
"level": "سطح",
"branding": "هویت بصری",
"customizations": "سفارشی‌سازی‌ها",
"custom_assets": "پرونده‌های سفارشی پیکربندی شده",
"defaults": "مقادیر پیش‌فرض",
"company": "نام شرکت",
"password_title": "گذرواژهٔ مدیر را انتخاب کنید",
"password_hint": "با آن در {path} وارد می‌شوید. دست‌کم ۸ نویسه.",
"new_password": "گذرواژهٔ جدید",
"confirm": "تأیید",
"too_short": "دست‌کم ۸ نویسه.",
"mismatch": "گذرواژه‌ها یکسان نیستند.",
"looks_good": "خوب است.",
"advanced": "پیشرفته",
"lock": "قفل کردن پیکربندی پس از راه‌اندازی",
"lock_hint": "یک پروندهٔ نشانگر می‌سازد. پس از آن، حجم پیکربندی را فقط‌خواندنی متصل کنید تا برنامه نوشتن بیشتر در پیکربندی را رد کند. گزارش‌های ممیزی و وضعیت ورود در حجم وضعیت قابل نوشتن می‌مانند.",
"applying": "در حال اعمال…",
"finish": "اعمال و پایان"
}
},
"errors": {
"page_error_title": "مشکلی پیش آمد",
"page_error_description": "با خطای غیرمنتظره‌ای مواجه شدیم. لطفاً دوباره تلاش کنید.",
+254 -5
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Paramètres",
"back_to_mail": "Retour aux emails",
"back_to_settings": "Retour aux paramètres",
"save_success": "Paramètres enregistrés avec succès",
"import_success": "Paramètres importés avec succès",
"import_error": "Échec de l'importation des paramètres",
@@ -1345,6 +1346,7 @@
"id_exists": "Cet ID d'étiquette existe déjà",
"edit": "Éditer l'étiquette",
"delete": "Supprimer l'étiquette",
"deleted": "Étiquette supprimée",
"save": "Enregistrer",
"add": "Ajouter",
"cancel": "Annuler",
@@ -1849,6 +1851,7 @@
"delete": "Supprimer la liste enregistrée",
"cancel": "Annuler",
"delete_confirm": "Supprimer du serveur la liste de comptes enregistrée ?",
"delete_message": "Les autres appareils ne pourront plus en restaurer les comptes.",
"password": "Mot de passe de la sauvegarde",
"password_hint": "Au moins 10 caractères. Il ne peut pas être récupéré : si vous l'oubliez, enregistrez à nouveau la liste avec un nouveau mot de passe.",
"restore_hint": "Le mot de passe choisi lors de l'enregistrement de la liste.",
@@ -1944,6 +1947,8 @@
"removed": "Mot de passe d'application supprimé",
"add_error": "Impossible de créer le mot de passe d'application",
"remove_error": "Impossible de supprimer le mot de passe d'application",
"delete_title": "Supprimer le mot de passe d'application ?",
"delete_message": "Les applications qui se connectent avec {name} ne pourront plus se connecter.",
"none": "Aucun mot de passe d'application configuré",
"done": "Terminé",
"expires_label": "Expire (facultatif)",
@@ -1962,6 +1967,8 @@
"removed": "Clé API supprimée",
"add_error": "Échec de la création de la clé API",
"remove_error": "Échec de la suppression de la clé API",
"delete_title": "Supprimer la clé d'API ?",
"delete_message": "Les scripts et intégrations qui utilisent {name} perdent l'accès.",
"none": "Aucune clé API configurée"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "Activer le chiffrement avec cette clé",
"enable_button": "Activer",
"disable_tooltip": "Désactiver le chiffrement",
"disable_title": "Désactiver le chiffrement au repos ?",
"disable_message": "Les nouveaux e-mails seront désormais stockés sans chiffrement.",
"disable_button": "Désactiver",
"enabled_success": "Chiffrement au repos activé"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "ex. Ma clé",
"key_label": "Clé publique (S/MIME ou PGP au format ASCII)",
"key_placeholder": "Collez votre clé publique ici",
"none": "Aucune clé publique configurée"
"none": "Aucune clé publique configurée",
"title": "Clés publiques",
"added": "Clé publique ajoutée",
"removed": "Clé publique supprimée",
"add_error": "Impossible d'ajouter la clé publique",
"remove_error": "Impossible de supprimer la clé publique",
"delete_title": "Supprimer la clé publique ?",
"delete_message": "{name} est retirée du serveur."
},
"sections": {
"devices": "Autres applications et appareils",
@@ -2089,6 +2106,7 @@
},
"save": "Enregistrer les modifications",
"saving": "Enregistrement...",
"saved": "Enregistré",
"warnings": {
"end_before_start": "La date de fin doit être postérieure à la date de début",
"invalid_date": "Indiquez une date de début et de fin valides avec l'heure, ou laissez le champ vide",
@@ -2155,12 +2173,17 @@
"new_folder_name": "Nom du dossier",
"rename": "Renommer",
"change_icon": "Changer l'icône",
"actions": "Actions pour {name}",
"expand": "Développer {name}",
"collapse": "Réduire {name}",
"counter": "Compteur",
"count_mode": "Compteur pour {name}",
"count_mode_default": "Compteur : par défaut",
"count_mode_unread": "Non lus uniquement",
"count_mode_all": "Non lus et total",
"count_mode_none": "Aucun compteur",
"delete": "Supprimer",
"delete_title": "Supprimer le dossier ?",
"confirm_delete": "Êtes-vous sûr de vouloir supprimer \"{name}\" ? Les e-mails de ce dossier seront déplacés dans la Corbeille.",
"create": "Créer",
"cancel": "Annuler",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "Mode débogage",
"description": "Activer la journalisation détaillée pour le dépannage"
"description": "Activer la journalisation détaillée pour le dépannage",
"toggle": "Journalisation détaillée"
},
"debug_categories": {
"description": "Sélectionnez les catégories à journaliser. Désactivez celles dont vous n'avez pas besoin pour réduire le bruit dans la console.",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Actualiser les données en cache",
"description": "Actualiser les contacts, calendriers et dossiers depuis le serveur. Vos comptes et sessions sont conservés. Corrige un affichage obsolète ou incorrect sans vous déconnecter.",
"button": "Actualiser"
"button": "Actualiser",
"confirm_title": "Actualiser les données en cache ?"
},
"reset_settings": {
"label": "Réinitialiser les paramètres",
"description": "Restaurer tous les paramètres par défaut",
"button": "Réinitialiser"
"button": "Réinitialiser",
"confirm_title": "Réinitialiser tous les paramètres ?",
"done": "Paramètres réinitialisés"
},
"export_settings": {
"label": "Exporter les paramètres",
@@ -2302,11 +2329,19 @@
"categories_title": "Catégories",
"categories_description": "Renommer les catégories de contacts",
"no_categories": "Aucune catégorie trouvée",
"delete_category": "Supprimer la catégorie",
"category_deleted": "Catégorie supprimée",
"category_delete_failed": "Impossible de supprimer la catégorie",
"group_by_letter_description": "Afficher des en-têtes alphabétiques dans la liste de contacts",
"group_by_letter_label": "Grouper par première lettre",
"sort_by_last_name_label": "Trier par nom de famille",
"sort_by_last_name_description": "Classer la liste des contacts par nom de famille pour regrouper les membres d'une même famille"
},
"calendar_management": {
"delete_title": "Supprimer le calendrier ?",
"clear_title": "Effacer tous les événements ?",
"unsubscribe_title": "Se désabonner de ce calendrier ?"
},
"downloads": {
"title": "Téléchargements",
"description": "Personnalisez la façon dont les e-mails et pièces jointes téléchargés sont nommés.",
@@ -2371,7 +2406,7 @@
"validate": "Valider",
"validation_success": "Le script est valide",
"validation_error": "Le script contient des erreurs",
"save": "Enregistrer les règles",
"save": "Enregistrer",
"saving": "Enregistrement...",
"saved": "Filtres enregistrés avec succès",
"save_failed": "Échec de l'enregistrement des filtres",
@@ -2385,6 +2420,8 @@
"add_condition": "Ajouter une condition",
"actions": "Actions",
"add_action": "Ajouter une action",
"remove_condition": "Retirer la condition",
"remove_action": "Retirer l'action",
"stop_processing": "Arrêter le traitement des règles suivantes",
"include_spam": "Déplacer aussi les messages marqués comme spam",
"attachment_type_placeholder": "p. ex. pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "Type de correspondance",
"reset_to_visual": "Revenir au constructeur visuel",
"reset_warning": "Cela supprimera le script actuel et recommencera à zéro.",
"reset_title": "Revenir à l'éditeur visuel ?",
"confirm_reset": "Réinitialiser",
"validation_empty_name": "Le nom de la règle est requis",
"validation_empty_conditions": "Au moins une condition avec une valeur est requise",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Configuration de Bulwark Webmail",
"subtitle": "Configurez votre instance de webmail depuis le navigateur.",
"loading": "Chargement…",
"back": "Retour",
"next": "Suivant",
"saving": "Enregistrement…",
"show": "Afficher",
"hide": "Masquer",
"remove": "Retirer",
"on": "Activé",
"off": "Désactivé",
"dismiss": "Fermer",
"unknown_error": "Erreur inconnue",
"steps": {
"welcome": "Bienvenue",
"server": "Serveur",
"auth": "Connexion",
"security": "Sécurité",
"logging": "Journaux",
"branding": "Image de marque",
"review": "Vérification"
},
"read_only": {
"title": "La configuration est en lecture seule",
"body": "Le volume de configuration est monté en lecture seule ou la variable {variable} est définie. Remontez-le en lecture-écriture ou supprimez cette variable, puis redémarrez le conteneur."
},
"done": {
"title": "Tout est prêt",
"body": "{appName} est configuré et prêt à l’emploi.",
"admin_sign_in": "Se connecter au tableau de bord d’administration",
"webmail_login": "Ouvrir la connexion au webmail",
"redirecting": "Redirection vers le tableau de bord d’administration…"
},
"already": {
"title": "La configuration est déjà terminée",
"body": "{appName} est configuré. Connectez-vous pour continuer."
},
"insecure": {
"title": "Vous lancez la configuration en HTTP non chiffré",
"body": "Le jeton de configuration et le mot de passe administrateur saisis ici circuleront en clair. Utilisez HTTPS si possible : terminez TLS sur le conteneur ou sur un proxy inverse placé devant.",
"try_https": "Essayer HTTPS",
"continue_http": "Continuer en HTTP"
},
"errors": {
"token_expired": "Ce jeton de configuration n’est plus valide. Redémarrez le conteneur pour en obtenir un nouveau dans les journaux.",
"session_expired": "Votre session de configuration a expiré. Collez à nouveau le jeton de configuration pour continuer.",
"token_required": "Collez le jeton de configuration affiché dans les journaux du conteneur pour continuer.",
"already_finished": "La configuration est déjà terminée. Rechargez la page pour vous connecter.",
"token_rejected": "Jeton refusé (HTTP {status})",
"step_failed": "Impossible d’enregistrer cette étape (HTTP {status})",
"upload_failed": "Échec de l’envoi (HTTP {status})",
"finish_failed": "Impossible de terminer la configuration (HTTP {status})"
},
"welcome": {
"title": "Bienvenue",
"body": "Collez le jeton de configuration affiché dans les journaux du conteneur pour continuer. Le jeton expire au bout d’une heure.",
"token_label": "Jeton de configuration",
"token_placeholder": "Jeton hexadécimal de 32 octets",
"verifying": "Vérification…",
"continue": "Continuer"
},
"server": {
"title": "Serveur",
"subtitle": "Là où se trouvent vos e-mails.",
"app_name": "Nom de l’application",
"url_label": "URL du serveur JMAP",
"url_hint": "Le serveur par défaut auquel les utilisateurs se connectent. Exemple : https://mail.example.com",
"test": "Tester",
"testing": "Test en cours…",
"probe_ok": "Connecté : cela ressemble à un serveur JMAP.",
"probe_not_jmap": "Le serveur répond, mais ne semble pas être un point d’accès JMAP.",
"probe_invalid": "Cette URL n’est pas valide. Elle doit commencer par http:// ou https://.",
"probe_unreachable": "Impossible de se connecter à cette adresse. Vérifiez l’URL et que le serveur est en ligne.",
"not_jmap_hint": "Cela peut arriver quand un proxy inverse achemine JMAP séparément sur le même domaine. Sinon, l’URL est généralement fausse.",
"not_jmap_confirm": "Je suis sûr que cette URL est la bonne, continuer quand même.",
"http_title": "Cette URL utilise HTTP non chiffré.",
"http_body": "Les mots de passe et le contenu des e-mails circuleront sans chiffrement entre les utilisateurs et votre serveur. Utilisez {https} en production : terminez TLS sur le serveur de messagerie ou sur un proxy inverse placé devant.",
"local_title": "Cette URL ne fonctionne qu’en local.",
"local_body": "Les e-mails sont récupérés directement par le navigateur de l’utilisateur : l’URL JMAP doit donc être accessible partout où les utilisateurs se connectent, pas seulement depuis cette machine ou ce réseau local. Utilisez un nom d’hôte public (par ex. {example}) en production.",
"additional_title": "Serveurs JMAP supplémentaires",
"additional_hint": "Facultatif. Proposez plusieurs serveurs dans la liste de connexion, utile pour les hébergeurs qui exploitent plusieurs instances Stalwart.",
"additional_show": "Afficher ({count})",
"add": "Ajouter",
"row_title": "Serveur n° {index}",
"id_label": "ID",
"id_hint": "Identifiant unique, par ex. eu-1",
"label_label": "Libellé",
"label_hint": "Affiché aux utilisateurs",
"row_url": "URL",
"domains_label": "Domaines",
"domains_hint": "Séparés par des virgules. Servent à choisir ce serveur selon le domaine de l’adresse e-mail à la connexion.",
"add_server": "Ajouter un serveur",
"auto_pick": "Choisir le serveur selon le domaine de l’adresse e-mail",
"auto_pick_hint": "Quand un utilisateur saisit son adresse e-mail, le serveur correspondant de la liste ci-dessus est sélectionné.",
"row_id_required": "Serveur n° {index} : l’ID est obligatoire",
"row_id_invalid": "Serveur n° {index} : l’ID ne peut contenir que des lettres, des chiffres, - et _, et doit commencer par une lettre ou un chiffre",
"row_id_duplicate": "Serveur n° {index} : l’ID « {id} » est utilisé deux fois",
"row_url_required": "Serveur n° {index} : l’URL est obligatoire",
"row_url_scheme": "Serveur n° {index} : l’URL doit commencer par http:// ou https://",
"stalwart": "Activer les fonctions propres à Stalwart",
"stalwart_hint": "Ajoute le changement de mot de passe et la gestion des filtres Sieve. Sans risque sur d’autres serveurs."
},
"auth": {
"title": "Authentification",
"subtitle": "La connexion par mot de passe est toujours disponible. OAuth/OIDC est facultatif.",
"oauth": "Activer OAuth2 / OpenID Connect",
"oauth_only": "Mode OAuth uniquement (masquer le formulaire de mot de passe)",
"client_id": "ID client OAuth",
"client_secret": "Secret client OAuth",
"client_secret_hint": "Laissez vide pour les clients publics qui utilisent uniquement PKCE.",
"client_secret_placeholder": "Collez le secret",
"issuer": "URL de l’émetteur OAuth",
"issuer_hint": "Pour les fournisseurs d’identité externes (Keycloak, Authentik, Entra ID, etc.)."
},
"security": {
"title": "Sécurité et sessions",
"subtitle": "Le secret de session active « Rester connecté » et la synchronisation des paramètres.",
"generated": "Secret de session généré",
"customize": "Personnaliser",
"generated_hint": "Un secret de 32 octets a été créé pour vous. Ne le changez que si vous avez une raison précise.",
"secret_label": "Secret de session",
"secret_hint": "Collez votre propre valeur ou générez-en une nouvelle.",
"regenerate": "Régénérer",
"sync": "Synchroniser les paramètres des utilisateurs entre appareils",
"sync_hint": "Enregistre les préférences des utilisateurs sur le serveur, chiffrées avec le secret de session.",
"telemetry": "Envoyer des statistiques d’utilisation anonymes pour améliorer Bulwark",
"telemetry_hint": "Désactivé par défaut. Un signal anonyme par jour avec la version, la plateforme et les fonctions activées ; jamais d’adresses e-mail, de noms d’hôte ni d’adresses IP. Vous pouvez le modifier à tout moment dans les paramètres d’administration."
},
"logging": {
"title": "Journaux",
"subtitle": "Format et niveau de détail des journaux de l’application.",
"format": "Format",
"format_text": "text : coloré, lisible",
"format_json": "json : structuré, pour l’agrégation des journaux",
"level": "Niveau",
"level_info": "info (recommandé)"
},
"branding": {
"title": "Image de marque",
"subtitle": "Tous les champs sont facultatifs. Envoyez un fichier ou collez une URL ; les valeurs par défaut s’appliquent à tout ce que vous laissez vide.",
"company": "Nom de l’entreprise ou de l’organisation",
"favicon": "Favicon",
"favicon_hint": "Icône de l’onglet du navigateur. SVG recommandé.",
"login_light": "Logo de connexion (mode clair)",
"login_light_hint": "Affiché sur la page de connexion, sur fond clair.",
"login_dark": "Logo de connexion (mode sombre)",
"login_dark_hint": "Affiché sur la page de connexion, sur fond sombre.",
"sidebar_light": "Logo de la barre latérale (mode clair)",
"sidebar_light_hint": "Affiché après la connexion. Laissez vide pour aucun.",
"sidebar_dark": "Logo de la barre latérale (mode sombre)",
"sidebar_dark_hint": "Version du logo de la barre latérale pour le mode sombre.",
"website": "URL du site web",
"imprint": "URL des mentions légales",
"privacy": "URL de la politique de confidentialité",
"drop": "Cliquez ou déposez",
"uploading": "Envoi…",
"uploaded": "Fichier envoyé",
"formats": "SVG, PNG, JPEG, WebP ou ICO · 2 Mo max.",
"use_url": "Utiliser une URL",
"hide_url": "Masquer l’URL",
"url_placeholder": "https://… ou /branding/fichier.svg"
},
"review": {
"title": "Presque terminé",
"subtitle": "Vérifiez vos paramètres, choisissez un mot de passe administrateur et appliquez.",
"server": "Serveur",
"app_name": "Nom de l’application",
"jmap_server": "Serveur JMAP",
"additional": "Serveurs supplémentaires",
"additional_count": "{count} configuré(s)",
"additional_auto_pick": "{count} configuré(s) · choix selon le domaine",
"stalwart": "Fonctions Stalwart",
"auth": "Authentification",
"method": "Méthode",
"oauth_only": "OAuth uniquement",
"password_oauth": "Mot de passe + OAuth",
"password_only": "Mot de passe uniquement",
"client_id": "ID client OAuth",
"security": "Sécurité",
"secret": "Secret de session",
"configured": "Configuré",
"not_set": "Non défini",
"remember_me": "Rester connecté",
"available": "Disponible",
"disabled": "Désactivé",
"sync": "Synchronisation des paramètres",
"needs_secret": "Nécessite un secret de session",
"telemetry": "Télémétrie anonyme",
"logging": "Journaux",
"format": "Format",
"level": "Niveau",
"branding": "Image de marque",
"customizations": "Personnalisations",
"custom_assets": "Fichiers personnalisés configurés",
"defaults": "Valeurs par défaut",
"company": "Nom de l’entreprise",
"password_title": "Choisissez un mot de passe administrateur",
"password_hint": "Vous l’utiliserez pour vous connecter sur {path}. 8 caractères minimum.",
"new_password": "Nouveau mot de passe",
"confirm": "Confirmer",
"too_short": "8 caractères minimum.",
"mismatch": "Les mots de passe ne correspondent pas.",
"looks_good": "Parfait.",
"advanced": "Avancé",
"lock": "Verrouiller la configuration après la mise en place",
"lock_hint": "Crée un fichier marqueur. Remontez ensuite le volume de configuration en lecture seule : l’application refusera toute nouvelle écriture de configuration. Les journaux d’audit et l’état de connexion restent modifiables dans le volume d’état.",
"applying": "Application…",
"finish": "Appliquer et terminer"
}
},
"errors": {
"page_error_title": "Une erreur s'est produite",
"page_error_description": "Nous avons rencontré une erreur inattendue. Veuillez réessayer ou retourner à la page d'accueil.",
+254 -5
View File
@@ -1074,6 +1074,7 @@
"settings": {
"title": "הגדרות",
"back_to_mail": "חזרה לדואר",
"back_to_settings": "חזרה להגדרות",
"save_success": "ההגדרות נשמרו בהצלחה",
"import_success": "ההגדרות יובאו בהצלחה",
"import_error": "ייבוא ההגדרות נכשל",
@@ -1310,6 +1311,7 @@
"id_exists": "מזהה מילת מפתח זה כבר קיים",
"edit": "ערוך מילת מפתח",
"delete": "מחק מילת מפתח",
"deleted": "מילת המפתח נמחקה",
"save": "שמור",
"add": "לְהוֹסִיף",
"cancel": "לְבַטֵל",
@@ -1814,6 +1816,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "מכשירים אחרים לא יוכלו עוד לשחזר ממנה את החשבונות.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1909,6 +1912,8 @@
"removed": "סיסמת האפליקציה הוסרה",
"add_error": "יצירת סיסמת האפליקציה נכשלה",
"remove_error": "הסרת סיסמת האפליקציה נכשלה",
"delete_title": "למחוק את סיסמת האפליקציה?",
"delete_message": "אפליקציות שמתחברות עם {name} לא יוכלו להתחבר יותר.",
"none": "לא הוגדרו סיסמאות לאפליקציה",
"done": "בוצע",
"expires_label": "פג תוקף (אופציונלי)",
@@ -1934,6 +1939,9 @@
"configure_title": "הפעל הצפנה עם מפתח זה",
"enable_button": "הפעל",
"disable_tooltip": "השבת הצפנה",
"disable_title": "לכבות את ההצפנה במנוחה?",
"disable_message": "דואר חדש יישמר מעכשיו ללא הצפנה.",
"disable_button": "כיבוי",
"enabled_success": "הצפנה במנוחה מופעלת"
},
"email_client": {
@@ -1954,6 +1962,8 @@
"removed": "מפתח API הוסר",
"add_error": "נכשל ביצירת מפתח API",
"remove_error": "נכשל בהסרת מפתח API",
"delete_title": "למחוק את מפתח ה-API?",
"delete_message": "סקריפטים ושילובים שמשתמשים ב-{name} יאבדו את הגישה.",
"none": "אין מפתחות API מוגדרים"
},
"link_device": {
@@ -1994,7 +2004,14 @@
"name_placeholder": "לדוגמה: המפתח שלי",
"key_label": "מפתח ציבורי (S/MIME או PGP בפורמט ASCII)",
"key_placeholder": "הדבק כאן את המפתח הציבורי שלך",
"none": "לא הוגדרו מפתחות ציבוריים"
"none": "לא הוגדרו מפתחות ציבוריים",
"title": "מפתחות ציבוריים",
"added": "המפתח הציבורי נוסף",
"removed": "המפתח הציבורי נמחק",
"add_error": "לא ניתן היה להוסיף את המפתח הציבורי",
"remove_error": "לא ניתן היה למחוק את המפתח הציבורי",
"delete_title": "למחוק את המפתח הציבורי?",
"delete_message": "{name} יוסר מהשרת."
},
"sections": {
"devices": "אפליקציות ומכשירים אחרים",
@@ -2054,6 +2071,7 @@
},
"save": "שמור שינויים",
"saving": "שומר...",
"saved": "נשמר",
"warnings": {
"end_before_start": "תאריך הסיום חייב להיות אחרי תאריך ההתחלה",
"invalid_date": "יש להזין תאריך התחלה וסיום תקינים עם שעה, או להשאיר את השדה ריק",
@@ -2119,12 +2137,17 @@
"new_folder_name": "שם התיקיה",
"rename": "שנה שם",
"change_icon": "שנה סמל",
"actions": "פעולות עבור {name}",
"expand": "הרחבת {name}",
"collapse": "כיווץ {name}",
"counter": "מונה",
"count_mode": "מונה עבור {name}",
"count_mode_default": "מונה: ברירת מחדל",
"count_mode_unread": "רק שלא נקראו",
"count_mode_all": "שלא נקראו וסך הכול",
"count_mode_none": "ללא מונה",
"delete": "לִמְחוֹק",
"delete_title": "למחוק את התיקיה?",
"confirm_delete": "האם אתה בטוח שברצונך למחוק את \"{name}\"? הודעות אימייל בתיקייה זו יועברו לאשפה.",
"create": "צור",
"cancel": "לְבַטֵל",
@@ -2145,7 +2168,8 @@
"advanced": {
"debug_mode": {
"label": "מצב ניפוי באגים",
"description": "אפשר רישום מפורט לצורך פתרון בעיות"
"description": "אפשר רישום מפורט לצורך פתרון בעיות",
"toggle": "רישום מפורט"
},
"debug_categories": {
"description": "בחר אילו קטגוריות לרשום. השבת קטגוריות שאינך צריך כדי להפחית את רעש המסוף.",
@@ -2187,12 +2211,15 @@
"refresh_cache": {
"label": "רענון נתונים שמורים",
"description": "טעינה מחדש של אנשי קשר, לוחות שנה ותיקיות מהשרת. שומר על החשבונות וההתחברויות שלך — מתקן תצוגה מיושנת או שגויה ללא התנתקות.",
"button": "רענון"
"button": "רענון",
"confirm_title": "לרענן את הנתונים השמורים במטמון?"
},
"reset_settings": {
"label": "אפס את ההגדרות",
"description": "שחזר את כל ההגדרות לערכי ברירת המחדל",
"button": "אפס לברירות מחדל"
"button": "אפס לברירות מחדל",
"confirm_title": "לאפס את כל ההגדרות?",
"done": "ההגדרות אופסו"
},
"export_settings": {
"label": "ייצוא הגדרות",
@@ -2268,11 +2295,19 @@
"categories_title": "קטגוריות",
"categories_description": "שנה שם של קטגוריות אנשי קשר",
"no_categories": "לא נמצאו קטגוריות",
"delete_category": "מחיקת קטגוריה",
"category_deleted": "הקטגוריה נמחקה",
"category_delete_failed": "לא ניתן היה למחוק את הקטגוריה",
"group_by_letter_label": "קבוצה לפי אות ראשונה",
"sort_by_last_name_label": "מיון לפי שם משפחה",
"sort_by_last_name_description": "סידור רשימת אנשי הקשר לפי שם משפחה כך שבני אותה משפחה יופיעו יחד",
"group_by_letter_description": "הצג כותרות חלקים אלפביתיים ברשימת אנשי הקשר"
},
"calendar_management": {
"delete_title": "למחוק את היומן?",
"clear_title": "לנקות את כל האירועים?",
"unsubscribe_title": "לבטל את המינוי ליומן הזה?"
},
"filters": {
"title": "מסנני דואר אלקטרוני",
"description": "צור כללים למיון, תיוג וניהול אוטומטי של הודעות דוא\"ל נכנסות",
@@ -2293,7 +2328,7 @@
"validate": "אימות",
"validation_success": "הסקריפט תקף",
"validation_error": "בסקריפט יש שגיאות",
"save": "שמור כללים",
"save": "שמירה",
"saving": "שומר...",
"saved": "המסננים נשמרו בהצלחה",
"save_failed": "שמירת המסננים נכשלה",
@@ -2307,6 +2342,8 @@
"add_condition": "הוסף מצב",
"actions": "פעולות",
"add_action": "הוסף פעולה",
"remove_condition": "הסרת תנאי",
"remove_action": "הסרת פעולה",
"stop_processing": "הפסק לעבד את הכללים הבאים",
"include_spam": "להעביר גם הודעות שסומנו כדואר זבל",
"condition_fields": {
@@ -2381,6 +2418,7 @@
"match_type": "סוג התאמה",
"reset_to_visual": "איפוס לבונה חזותי",
"reset_warning": "פעולה זו תמחק את הסקריפט הנוכחי ויתחיל מחדש.",
"reset_title": "לאפס לעורך החזותי?",
"confirm_reset": "אִתחוּל",
"validation_empty_name": "נדרש שם כלל",
"validation_empty_conditions": "נדרש לפחות תנאי אחד עם ערך",
@@ -2617,6 +2655,217 @@
}
}
},
"setup": {
"title": "הגדרת Bulwark Webmail",
"subtitle": "הגדירו את מופע דואר הרשת שלכם מהדפדפן.",
"loading": "טוען…",
"back": "חזרה",
"next": "הבא",
"saving": "שומר…",
"show": "הצגה",
"hide": "הסתרה",
"remove": "הסרה",
"on": "פעיל",
"off": "כבוי",
"dismiss": "סגירה",
"unknown_error": "שגיאה לא ידועה",
"steps": {
"welcome": "ברוכים הבאים",
"server": "שרת",
"auth": "התחברות",
"security": "אבטחה",
"logging": "יומנים",
"branding": "מיתוג",
"review": "סקירה"
},
"read_only": {
"title": "התצורה לקריאה בלבד",
"body": "אמצעי האחסון של התצורה מחובר לקריאה בלבד או שהמשתנה {variable} מוגדר. חברו אותו מחדש עם הרשאת כתיבה או הסירו את המשתנה, ואז הפעילו מחדש את הקונטיינר."
},
"done": {
"title": "הכול מוכן",
"body": "{appName} מוגדר ומוכן לשימוש.",
"admin_sign_in": "התחברות ללוח הניהול",
"webmail_login": "פתיחת ההתחברות לדואר הרשת",
"redirecting": "מעבירים אתכם ללוח הניהול…"
},
"already": {
"title": "ההגדרה כבר הושלמה",
"body": "{appName} מוגדר. התחברו כדי להמשיך."
},
"insecure": {
"title": "אתם מריצים את ההגדרה דרך HTTP לא מוצפן",
"body": "אסימון ההגדרה וסיסמת המנהל שתזינו כאן יישלחו כטקסט גלוי. השתמשו ב-HTTPS אם אפשר: סיימו את TLS בקונטיינר או בשרת proxy הפוך שלפניו.",
"try_https": "לנסות HTTPS",
"continue_http": "להמשיך דרך HTTP"
},
"errors": {
"token_expired": "אסימון ההגדרה הזה כבר אינו תקף. הפעילו מחדש את הקונטיינר כדי לקבל אסימון חדש מהיומנים.",
"session_expired": "הפעלת ההגדרה פגה. הדביקו שוב את אסימון ההגדרה כדי להמשיך.",
"token_required": "הדביקו את אסימון ההגדרה שמופיע ביומני הקונטיינר כדי להמשיך.",
"already_finished": "ההגדרה כבר הסתיימה. טענו מחדש כדי להתחבר.",
"token_rejected": "האסימון נדחה (HTTP {status})",
"step_failed": "לא ניתן היה לשמור את השלב הזה (HTTP {status})",
"upload_failed": "ההעלאה נכשלה (HTTP {status})",
"finish_failed": "לא ניתן היה להשלים את ההגדרה (HTTP {status})"
},
"welcome": {
"title": "ברוכים הבאים",
"body": "הדביקו את אסימון ההגדרה שמופיע ביומני הקונטיינר כדי להמשיך. תוקף האסימון פג אחרי שעה.",
"token_label": "אסימון הגדרה",
"token_placeholder": "אסימון הקסדצימלי של 32 בתים",
"verifying": "מאמת…",
"continue": "המשך"
},
"server": {
"title": "שרת",
"subtitle": "איפה הדואר שלכם נמצא.",
"app_name": "שם היישום",
"url_label": "כתובת URL של שרת JMAP",
"url_hint": "שרת ברירת המחדל שאליו המשתמשים מתחברים. לדוגמה: https://mail.example.com",
"test": "בדיקה",
"testing": "בודק…",
"probe_ok": "מחובר: נראה שזה שרת JMAP.",
"probe_not_jmap": "השרת עונה, אבל לא נראה כמו נקודת קצה של JMAP.",
"probe_invalid": "כתובת ה-URL הזו אינה תקינה. היא צריכה להתחיל ב-http:// או ב-https://.",
"probe_unreachable": "לא ניתן היה להתחבר לכתובת הזו. בדקו את כתובת ה-URL ושהשרת מחובר.",
"not_jmap_hint": "זה יכול לקרות כששרת proxy הפוך מנתב את JMAP בנפרד באותו דומיין. אחרת, בדרך כלל כתובת ה-URL שגויה.",
"not_jmap_confirm": "אני בטוח שכתובת ה-URL נכונה, להמשיך בכל זאת.",
"http_title": "כתובת ה-URL הזו משתמשת ב-HTTP לא מוצפן.",
"http_body": "סיסמאות ותוכן הודעות יעברו ללא הצפנה בין המשתמשים לשרת שלכם. בסביבת ייצור השתמשו ב-{https}: סיימו את TLS בשרת הדואר או בשרת proxy הפוך שלפניו.",
"local_title": "כתובת ה-URL הזו פועלת רק מקומית.",
"local_body": "הדואר נטען ישירות מהדפדפן של המשתמש, ולכן כתובת ה-JMAP חייבת להיות נגישה מכל מקום שבו המשתמשים מתחברים, לא רק מהמחשב הזה או מהרשת המקומית. בסביבת ייצור השתמשו בשם מארח ציבורי (למשל {example}).",
"additional_title": "שרתי JMAP נוספים",
"additional_hint": "אופציונלי. הציעו כמה שרתים ברשימת ההתחברות, שימושי למארחים שמריצים כמה מופעי Stalwart.",
"additional_show": "הצגה ({count})",
"add": "הוספה",
"row_title": "שרת מס׳ {index}",
"id_label": "מזהה",
"id_hint": "שם קצר ייחודי, למשל eu-1",
"label_label": "תווית",
"label_hint": "מוצגת למשתמשים",
"row_url": "URL",
"domains_label": "דומיינים",
"domains_hint": "מופרדים בפסיקים. משמשים לבחירת השרת הזה לפי הדומיין של הדוא״ל בעת ההתחברות.",
"add_server": "הוספת שרת",
"auto_pick": "לבחור את השרת לפי הדומיין של הדוא״ל",
"auto_pick_hint": "כשמשתמש מקליד את כתובת הדוא״ל שלו, נבחר השרת המתאים מהרשימה שלמעלה.",
"row_id_required": "שרת מס׳ {index}: המזהה הוא שדה חובה",
"row_id_invalid": "שרת מס׳ {index}: המזהה יכול להכיל רק אותיות, ספרות, - ו-_ ועליו להתחיל באות או בספרה",
"row_id_duplicate": "שרת מס׳ {index}: המזהה „{id}” מופיע פעמיים",
"row_url_required": "שרת מס׳ {index}: כתובת ה-URL היא שדה חובה",
"row_url_scheme": "שרת מס׳ {index}: כתובת ה-URL צריכה להתחיל ב-http:// או ב-https://",
"stalwart": "הפעלת תכונות ייעודיות ל-Stalwart",
"stalwart_hint": "מוסיף שינוי סיסמה וניהול מסנני Sieve. בטוח להפעלה גם בשרתים אחרים."
},
"auth": {
"title": "אימות",
"subtitle": "התחברות עם סיסמה זמינה תמיד. OAuth/OIDC הוא אופציונלי.",
"oauth": "הפעלת OAuth2 / OpenID Connect",
"oauth_only": "OAuth בלבד (הסתרת טופס הסיסמה)",
"client_id": "מזהה לקוח OAuth",
"client_secret": "סוד לקוח OAuth",
"client_secret_hint": "השאירו ריק עבור לקוחות ציבוריים שמשתמשים ב-PKCE בלבד.",
"client_secret_placeholder": "הדביקו את הסוד",
"issuer": "כתובת URL של מנפיק OAuth",
"issuer_hint": "עבור ספקי זהות חיצוניים (Keycloak,‏ Authentik,‏ Entra ID ועוד)."
},
"security": {
"title": "אבטחה והפעלות",
"subtitle": "סוד ההפעלה מאפשר את „זכור אותי” ואת סנכרון ההגדרות.",
"generated": "סוד ההפעלה נוצר",
"customize": "התאמה אישית",
"generated_hint": "נוצר עבורכם סוד של 32 בתים. שנו אותו רק אם יש לכם סיבה מסוימת.",
"secret_label": "סוד ההפעלה",
"secret_hint": "הדביקו ערך משלכם או צרו חדש.",
"regenerate": "יצירה מחדש",
"sync": "סנכרון הגדרות המשתמשים בין מכשירים",
"sync_hint": "שומר את העדפות המשתמשים בשרת, מוצפנות בסוד ההפעלה.",
"telemetry": "שליחת נתוני שימוש אנונימיים כדי לעזור לשפר את Bulwark",
"telemetry_hint": "כבוי כברירת מחדל. אות אנונימי אחד ביום עם הגרסה, הפלטפורמה והתכונות הפעילות; אף פעם לא כתובות דוא״ל, שמות מארחים או כתובות IP. אפשר לשנות זאת בכל עת בהגדרות הניהול."
},
"logging": {
"title": "יומנים",
"subtitle": "תבנית ורמת הפירוט של יומני היישום.",
"format": "תבנית",
"format_text": "text: צבעוני, קריא",
"format_json": "json: מובנה, לאיסוף יומנים",
"level": "רמה",
"level_info": "info (מומלץ)"
},
"branding": {
"title": "מיתוג",
"subtitle": "כל השדות אופציונליים. העלו קובץ או הדביקו כתובת URL; לכל מה שתדלגו עליו ישמשו ערכי ברירת המחדל.",
"company": "שם החברה או הארגון",
"favicon": "סמל אתר",
"favicon_hint": "הסמל בלשונית הדפדפן. מומלץ SVG.",
"login_light": "לוגו ההתחברות (מצב בהיר)",
"login_light_hint": "מוצג בדף ההתחברות, על רקע בהיר.",
"login_dark": "לוגו ההתחברות (מצב כהה)",
"login_dark_hint": "מוצג בדף ההתחברות, על רקע כהה.",
"sidebar_light": "לוגו סרגל הצד (מצב בהיר)",
"sidebar_light_hint": "מוצג אחרי ההתחברות. השאירו ריק אם אין צורך.",
"sidebar_dark": "לוגו סרגל הצד (מצב כהה)",
"sidebar_dark_hint": "גרסת המצב הכהה של לוגו סרגל הצד.",
"website": "כתובת URL של האתר",
"imprint": "כתובת URL של פרטי המפעיל",
"privacy": "כתובת URL של מדיניות הפרטיות",
"drop": "לחצו או שחררו כאן",
"uploading": "מעלה…",
"uploaded": "קובץ שהועלה",
"formats": "SVG,‏ PNG,‏ JPEG,‏ WebP או ICO · עד 2 MB",
"use_url": "שימוש בכתובת URL",
"hide_url": "הסתרת כתובת ה-URL",
"url_placeholder": "https://… או /branding/file.svg"
},
"review": {
"title": "כמעט סיימנו",
"subtitle": "בדקו את ההגדרות, בחרו סיסמת מנהל והחילו.",
"server": "שרת",
"app_name": "שם היישום",
"jmap_server": "שרת JMAP",
"additional": "שרתים נוספים",
"additional_count": "הוגדרו: {count}",
"additional_auto_pick": "הוגדרו: {count} · נבחרים לפי דומיין",
"stalwart": "תכונות Stalwart",
"auth": "אימות",
"method": "שיטה",
"oauth_only": "OAuth בלבד",
"password_oauth": "סיסמה + OAuth",
"password_only": "סיסמה בלבד",
"client_id": "מזהה לקוח OAuth",
"security": "אבטחה",
"secret": "סוד ההפעלה",
"configured": "מוגדר",
"not_set": "לא הוגדר",
"remember_me": "זכור אותי",
"available": "זמין",
"disabled": "מושבת",
"sync": "סנכרון הגדרות",
"needs_secret": "נדרש סוד הפעלה",
"telemetry": "טלמטריה אנונימית",
"logging": "יומנים",
"format": "תבנית",
"level": "רמה",
"branding": "מיתוג",
"customizations": "התאמות",
"custom_assets": "הוגדרו קבצים משלכם",
"defaults": "ערכי ברירת מחדל",
"company": "שם החברה",
"password_title": "בחרו סיסמת מנהל",
"password_hint": "תשתמשו בה כדי להתחבר בכתובת {path}. לפחות 8 תווים.",
"new_password": "סיסמה חדשה",
"confirm": "אישור",
"too_short": "לפחות 8 תווים.",
"mismatch": "הסיסמאות אינן תואמות.",
"looks_good": "נראה טוב.",
"advanced": "מתקדם",
"lock": "נעילת התצורה אחרי ההגדרה",
"lock_hint": "יוצר קובץ סימון. אחר כך חברו את אמצעי האחסון של התצורה לקריאה בלבד, והיישום יסרב לשינויי תצורה נוספים. יומני הביקורת ומצב ההתחברות נשארים ניתנים לכתיבה באמצעי האחסון של המצב.",
"applying": "מחיל…",
"finish": "החלה וסיום"
}
},
"errors": {
"page_error_title": "משהו השתבש",
"page_error_description": "נתקלנו בשגיאה בלתי צפויה. אנא נסה שוב או חזור לדף הבית.",
+255 -6
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Beállítások",
"back_to_mail": "Vissza a levelekhez",
"back_to_settings": "Vissza a beállításokhoz",
"save_success": "Beállítások sikeresen elmentve",
"import_success": "Beállítások sikeresen importálva",
"import_error": "Nem sikerült importálni a beállításokat",
@@ -1348,6 +1349,7 @@
"id_exists": "Ez a címke azonosító már létezik",
"edit": "Címke szerkesztése",
"delete": "Címke törlése",
"deleted": "Címke törölve",
"save": "Mentés",
"add": "Hozzáadás",
"cancel": "Mégse",
@@ -1852,6 +1854,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Más eszközök többé nem tudják visszaállítani belőle a fiókokat.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1953,6 +1956,8 @@
"removed": "Alkalmazás jelszó eltávolítva",
"add_error": "Nem sikerült létrehozni az alkalmazás jelszót",
"remove_error": "Nem sikerült eltávolítani az alkalmazás jelszót",
"delete_title": "Törli az alkalmazásjelszót?",
"delete_message": "A(z) {name} jelszóval bejelentkező alkalmazások többé nem tudnak bejelentkezni.",
"none": "Nincs alkalmazás jelszó beállítva"
},
"api_keys": {
@@ -1965,6 +1970,8 @@
"removed": "API kulcs eltávolítva",
"add_error": "Nem sikerült létrehozni az API kulcsot",
"remove_error": "Nem sikerült eltávolítani az API kulcsot",
"delete_title": "Törli az API-kulcsot?",
"delete_message": "A(z) {name} kulcsot használó szkriptek és integrációk elveszítik a hozzáférést.",
"none": "Nincs API kulcs beállítva"
},
"encryption": {
@@ -1984,6 +1991,9 @@
"configure_title": "Titkosítás engedélyezése ezzel a kulccsal",
"enable_button": "Engedélyezés",
"disable_tooltip": "Titkosítás letiltása",
"disable_title": "Kikapcsolja a tárolt adatok titkosítását?",
"disable_message": "Az új leveleket mostantól titkosítás nélkül tárolja a szerver.",
"disable_button": "Kikapcsolás",
"enabled_success": "Titkosítás nyugalmi állapotban engedélyezve"
},
"email_client": {
@@ -2032,7 +2042,14 @@
"name_placeholder": "pl. A kulcsom",
"key_label": "Nyilvános kulcs (S/MIME vagy PGP ASCII formátumban)",
"key_placeholder": "Illessze be ide a nyilvános kulcsát",
"none": "Nincs beállítva nyilvános kulcs"
"none": "Nincs beállítva nyilvános kulcs",
"title": "Nyilvános kulcsok",
"added": "Nyilvános kulcs hozzáadva",
"removed": "Nyilvános kulcs törölve",
"add_error": "Nem sikerült hozzáadni a nyilvános kulcsot",
"remove_error": "Nem sikerült törölni a nyilvános kulcsot",
"delete_title": "Törli a nyilvános kulcsot?",
"delete_message": "A(z) {name} törlődik a szerverről."
},
"sections": {
"devices": "Más alkalmazások és eszközök",
@@ -2092,6 +2109,7 @@
},
"save": "Változtatások mentése",
"saving": "Mentés...",
"saved": "Mentve",
"warnings": {
"end_before_start": "A befejező dátumnak a kezdő dátum után kell lennie",
"invalid_date": "Adjon meg érvényes kezdő és befejező dátumot időponttal, vagy hagyja üresen a mezőt",
@@ -2158,12 +2176,17 @@
"new_folder_name": "Mappa neve",
"rename": "Átnevezés",
"change_icon": "Ikon módosítása",
"actions": "{name} – műveletek",
"expand": "{name} kibontása",
"collapse": "{name} összecsukása",
"counter": "Számláló",
"count_mode": "Számláló: {name}",
"count_mode_default": "Számláló: alapértelmezett",
"count_mode_unread": "Csak olvasatlan",
"count_mode_all": "Olvasatlan és összes",
"count_mode_none": "Nincs számláló",
"delete": "Törlés",
"delete_title": "Törli a mappát?",
"confirm_delete": "Biztosan törölni szeretnéd a \"{name}\" mappát? A mappában lévő e-mailek a Kukába kerülnek.",
"create": "Létrehozás",
"cancel": "Mégse",
@@ -2183,7 +2206,8 @@
"advanced": {
"debug_mode": {
"label": "Hibakeresési mód",
"description": "Részletes naplózás engedélyezése hibaelhárításhoz"
"description": "Részletes naplózás engedélyezése hibaelhárításhoz",
"toggle": "Részletes naplózás"
},
"debug_categories": {
"description": "Válaszd ki a naplózandó kategóriákat. Kapcsold ki a nem szükséges kategóriákat a konzol zaj csökkentéséhez.",
@@ -2228,12 +2252,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "Frissíti a gyorsítótárazott adatokat?"
},
"reset_settings": {
"label": "Beállítások visszaállítása",
"description": "Összes beállítás visszaállítása az alapértelmezett értékekre",
"button": "Visszaállítás alapértelmezettre"
"button": "Visszaállítás alapértelmezettre",
"confirm_title": "Visszaállítja az összes beállítást?",
"done": "Beállítások visszaállítva"
},
"export_settings": {
"label": "Beállítások exportálása",
@@ -2308,7 +2335,15 @@
"no_address_books": "Nincs címjegyzék",
"categories_title": "Kategóriák",
"categories_description": "Névjegy kategóriák átnevezése",
"no_categories": "Nincs kategória"
"no_categories": "Nincs kategória",
"delete_category": "Kategória törlése",
"category_deleted": "Kategória törölve",
"category_delete_failed": "Nem sikerült törölni a kategóriát"
},
"calendar_management": {
"delete_title": "Törli a naptárt?",
"clear_title": "Törli az összes eseményt?",
"unsubscribe_title": "Leiratkozik erről a naptárról?"
},
"downloads": {
"title": "Letöltések",
@@ -2374,7 +2409,7 @@
"validate": "Ellenőrzés",
"validation_success": "A szkript érvényes",
"validation_error": "A szkript hibákat tartalmaz",
"save": "Szabályok mentése",
"save": "Mentés",
"saving": "Mentés...",
"saved": "Szűrők sikeresen elmentve",
"save_failed": "Nem sikerült menteni a szűrőket",
@@ -2388,6 +2423,8 @@
"add_condition": "Feltétel hozzáadása",
"actions": "Műveletek",
"add_action": "Művelet hozzáadása",
"remove_condition": "Feltétel eltávolítása",
"remove_action": "Művelet eltávolítása",
"stop_processing": "További szabályok feldolgozásának leállítása",
"include_spam": "A levélszemétnek jelölt üzenetek áthelyezése is",
"attachment_type_placeholder": "pl. pdf, doc, jpg",
@@ -2464,6 +2501,7 @@
"match_type": "Egyezés típusa",
"reset_to_visual": "Visszaállítás vizuális szerkesztőre",
"reset_warning": "Ez elveti az aktuális szkriptet és újat kezd.",
"reset_title": "Visszaáll a vizuális szerkesztőre?",
"confirm_reset": "Visszaállítás",
"validation_empty_name": "A szabály neve kötelező",
"validation_empty_conditions": "Legalább egy feltétel szükséges értékkel",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "A Bulwark Webmail beállítása",
"subtitle": "Állítsa be a webmail-példányát a böngészőből.",
"loading": "Betöltés…",
"back": "Vissza",
"next": "Tovább",
"saving": "Mentés…",
"show": "Megjelenítés",
"hide": "Elrejtés",
"remove": "Eltávolítás",
"on": "Be",
"off": "Ki",
"dismiss": "Bezárás",
"unknown_error": "Ismeretlen hiba",
"steps": {
"welcome": "Üdvözöljük",
"server": "Szerver",
"auth": "Bejelentkezés",
"security": "Biztonság",
"logging": "Naplózás",
"branding": "Arculat",
"review": "Áttekintés"
},
"read_only": {
"title": "A konfiguráció csak olvasható",
"body": "A konfigurációs kötet csak olvasásra van csatolva, vagy be van állítva a(z) {variable}. Csatolja írhatóként vagy törölje a változót, majd indítsa újra a konténert."
},
"done": {
"title": "Minden kész",
"body": "A(z) {appName} be van állítva és használatra kész.",
"admin_sign_in": "Bejelentkezés az adminisztrációs felületre",
"webmail_login": "A webmail bejelentkezés megnyitása",
"redirecting": "Átirányítás az adminisztrációs felületre…"
},
"already": {
"title": "A beállítás már befejeződött",
"body": "A(z) {appName} be van állítva. A folytatáshoz jelentkezzen be."
},
"insecure": {
"title": "A beállítást titkosítatlan HTTP-n keresztül futtatja",
"body": "Az itt megadott beállítási token és rendszergazdai jelszó titkosítatlanul utazik. Ha lehet, használjon HTTPS-t: a TLS-t a konténerben vagy egy előtte álló fordított proxyban zárja le.",
"try_https": "HTTPS kipróbálása",
"continue_http": "Folytatás HTTP-n"
},
"errors": {
"token_expired": "Ez a beállítási token már nem érvényes. Indítsa újra a konténert, hogy a naplóban újat kapjon.",
"session_expired": "A beállítási munkamenet lejárt. A folytatáshoz illessze be újra a beállítási tokent.",
"token_required": "A folytatáshoz illessze be a konténer naplójában látható beállítási tokent.",
"already_finished": "A beállítás már befejeződött. A bejelentkezéshez töltse újra az oldalt.",
"token_rejected": "Token elutasítva (HTTP {status})",
"step_failed": "Nem sikerült menteni ezt a lépést (HTTP {status})",
"upload_failed": "A feltöltés nem sikerült (HTTP {status})",
"finish_failed": "Nem sikerült befejezni a beállítást (HTTP {status})"
},
"welcome": {
"title": "Üdvözöljük",
"body": "A folytatáshoz illessze be a konténer naplójában látható beállítási tokent. A token 1 óra után lejár.",
"token_label": "Beállítási token",
"token_placeholder": "32 bájtos hexadecimális token",
"verifying": "Ellenőrzés…",
"continue": "Folytatás"
},
"server": {
"title": "Szerver",
"subtitle": "Ahol a levelei vannak.",
"app_name": "Az alkalmazás neve",
"url_label": "A JMAP-szerver URL-je",
"url_hint": "Az alapértelmezett szerver, amelyhez a felhasználók csatlakoznak. Példa: https://mail.example.com",
"test": "Teszt",
"testing": "Tesztelés…",
"probe_ok": "Csatlakozva: ez JMAP-szervernek tűnik.",
"probe_not_jmap": "A szerver válaszol, de nem tűnik JMAP-végpontnak.",
"probe_invalid": "Ez az URL érvénytelen. http:// vagy https:// előtaggal kell kezdődnie.",
"probe_unreachable": "Nem sikerült csatlakozni ehhez a címhez. Ellenőrizze az URL-t, és hogy a szerver elérhető-e.",
"not_jmap_hint": "Ez előfordulhat, ha egy fordított proxy ugyanazon a tartományon külön irányítja a JMAP-ot. Egyébként általában rossz az URL.",
"not_jmap_confirm": "Biztos vagyok benne, hogy az URL helyes, folytatás így is.",
"http_title": "Ez az URL titkosítatlan HTTP-t használ.",
"http_body": "A jelszavak és a levelek tartalma titkosítás nélkül utaznak a felhasználók és a szerver között. Éles környezetben használja a(z) {https} előtagot: a TLS-t a levelezőszerveren vagy egy előtte álló fordított proxyban zárja le.",
"local_title": "Ez az URL csak helyben érhető el.",
"local_body": "A leveleket közvetlenül a felhasználó böngészője tölti le, ezért a JMAP-URL-nek mindenhonnan elérhetőnek kell lennie, ahol a felhasználók bejelentkeznek, nem csak erről a gépről vagy helyi hálózatból. Éles környezetben használjon nyilvános gépnevet (pl. {example}).",
"additional_title": "További JMAP-szerverek",
"additional_hint": "Nem kötelező. Több szervert kínálhat a bejelentkezési listában, hasznos több Stalwart-példányt futtató szolgáltatóknak.",
"additional_show": "Megjelenítés ({count})",
"add": "Hozzáadás",
"row_title": "{index}. szerver",
"id_label": "Azonosító",
"id_hint": "Egyedi rövid név, pl. eu-1",
"label_label": "Felirat",
"label_hint": "A felhasználók látják",
"row_url": "URL",
"domains_label": "Tartományok",
"domains_hint": "Vesszővel elválasztva. Bejelentkezéskor ezek alapján választja ki ezt a szervert az e-mail-tartomány szerint.",
"add_server": "Szerver hozzáadása",
"auto_pick": "Szerver kiválasztása az e-mail-tartomány alapján",
"auto_pick_hint": "Amikor a felhasználó beírja az e-mail-címét, a fenti listából a megfelelő szerver lesz kiválasztva.",
"row_id_required": "{index}. szerver: az azonosító kötelező",
"row_id_invalid": "{index}. szerver: az azonosító csak betűket, számjegyeket, - és _ jelet tartalmazhat, és betűvel vagy számjeggyel kell kezdődnie",
"row_id_duplicate": "{index}. szerver: a(z) „{id}” azonosító kétszer szerepel",
"row_url_required": "{index}. szerver: az URL kötelező",
"row_url_scheme": "{index}. szerver: az URL-nek http:// vagy https:// előtaggal kell kezdődnie",
"stalwart": "Stalwart-specifikus funkciók bekapcsolása",
"stalwart_hint": "Jelszómódosítást és Sieve-szűrők kezelését ad hozzá. Más szervereken is nyugodtan bekapcsolható."
},
"auth": {
"title": "Hitelesítés",
"subtitle": "A jelszavas bejelentkezés mindig elérhető. Az OAuth/OIDC nem kötelező.",
"oauth": "OAuth2 / OpenID Connect bekapcsolása",
"oauth_only": "Csak OAuth (jelszó űrlap elrejtése)",
"client_id": "OAuth-ügyfélazonosító",
"client_secret": "OAuth-ügyféltitok",
"client_secret_hint": "Hagyja üresen a csak PKCE-t használó nyilvános ügyfeleknél.",
"client_secret_placeholder": "Illessze be a titkot",
"issuer": "OAuth-kibocsátó URL-je",
"issuer_hint": "Külső identitásszolgáltatókhoz (Keycloak, Authentik, Entra ID stb.)."
},
"security": {
"title": "Biztonság és munkamenetek",
"subtitle": "A munkamenet-titok lehetővé teszi az „Emlékezzen rám” funkciót és a beállítások szinkronizálását.",
"generated": "Munkamenet-titok létrehozva",
"customize": "Testreszabás",
"generated_hint": "Létrehoztunk egy 32 bájtos titkot. Csak akkor módosítsa, ha konkrét oka van rá.",
"secret_label": "Munkamenet-titok",
"secret_hint": "Illessze be a saját értékét, vagy hozzon létre újat.",
"regenerate": "Újragenerálás",
"sync": "Felhasználói beállítások szinkronizálása az eszközök között",
"sync_hint": "A felhasználók beállításait a szerveren tárolja, a munkamenet-titokkal titkosítva.",
"telemetry": "Névtelen használati statisztikák küldése a Bulwark fejlesztéséhez",
"telemetry_hint": "Alapértelmezés szerint ki van kapcsolva. Naponta egy névtelen jelzés a verzióval, a platformmal és a bekapcsolt funkciókkal; soha nem küld e-mail-címet, gépnevet vagy IP-címet. Ezt bármikor módosíthatja az adminisztrációs beállításokban."
},
"logging": {
"title": "Naplózás",
"subtitle": "Az alkalmazásnaplók formátuma és részletessége.",
"format": "Formátum",
"format_text": "text: színes, könnyen olvasható",
"format_json": "json: strukturált, naplógyűjtéshez",
"level": "Szint",
"level_info": "info (ajánlott)"
},
"branding": {
"title": "Arculat",
"subtitle": "Minden mező nem kötelező. Töltsön fel egy fájlt vagy illesszen be egy URL-t; a kihagyott mezőkhöz az alapértelmezések érvényesek.",
"company": "A cég vagy szervezet neve",
"favicon": "Favicon",
"favicon_hint": "A böngészőlap ikonja. SVG ajánlott.",
"login_light": "Bejelentkezési logó (világos mód)",
"login_light_hint": "A bejelentkezési oldalon, világos háttéren.",
"login_dark": "Bejelentkezési logó (sötét mód)",
"login_dark_hint": "A bejelentkezési oldalon, sötét háttéren.",
"sidebar_light": "Oldalsáv logója (világos mód)",
"sidebar_light_hint": "Bejelentkezés után látható. Hagyja üresen, ha nem kell.",
"sidebar_dark": "Oldalsáv logója (sötét mód)",
"sidebar_dark_hint": "Az oldalsáv logójának sötét módú változata.",
"website": "Weboldal URL-je",
"imprint": "Impresszum URL-je",
"privacy": "Adatvédelmi tájékoztató URL-je",
"drop": "Kattintson vagy húzza ide",
"uploading": "Feltöltés…",
"uploaded": "Feltöltött fájl",
"formats": "SVG, PNG, JPEG, WebP vagy ICO · legfeljebb 2 MB",
"use_url": "URL használata",
"hide_url": "URL elrejtése",
"url_placeholder": "https://… vagy /branding/fajl.svg"
},
"review": {
"title": "Majdnem kész",
"subtitle": "Tekintse át a beállításokat, válasszon rendszergazdai jelszót, és alkalmazza.",
"server": "Szerver",
"app_name": "Az alkalmazás neve",
"jmap_server": "JMAP-szerver",
"additional": "További szerverek",
"additional_count": "{count} beállítva",
"additional_auto_pick": "{count} beállítva · tartomány szerinti választás",
"stalwart": "Stalwart-funkciók",
"auth": "Hitelesítés",
"method": "Módszer",
"oauth_only": "Csak OAuth",
"password_oauth": "Jelszó + OAuth",
"password_only": "Csak jelszó",
"client_id": "OAuth-ügyfélazonosító",
"security": "Biztonság",
"secret": "Munkamenet-titok",
"configured": "Beállítva",
"not_set": "Nincs megadva",
"remember_me": "Emlékezzen rám",
"available": "Elérhető",
"disabled": "Kikapcsolva",
"sync": "Beállítások szinkronizálása",
"needs_secret": "Munkamenet-titok szükséges",
"telemetry": "Névtelen telemetria",
"logging": "Naplózás",
"format": "Formátum",
"level": "Szint",
"branding": "Arculat",
"customizations": "Testreszabások",
"custom_assets": "Saját fájlok beállítva",
"defaults": "Alapértelmezések",
"company": "A cég neve",
"password_title": "Válasszon rendszergazdai jelszót",
"password_hint": "Ezzel jelentkezik be itt: {path}. Legalább 8 karakter.",
"new_password": "Új jelszó",
"confirm": "Megerősítés",
"too_short": "Legalább 8 karakter.",
"mismatch": "A jelszavak nem egyeznek.",
"looks_good": "Rendben.",
"advanced": "Speciális",
"lock": "Konfiguráció zárolása a beállítás után",
"lock_hint": "Jelölőfájlt hoz létre. Ezután csatolja csak olvasásra a konfigurációs kötetet, és az alkalmazás elutasítja a további konfigurációs írásokat. A naplók és a bejelentkezési állapot írható marad az állapotkötetben.",
"applying": "Alkalmazás…",
"finish": "Alkalmazás és befejezés"
}
},
"errors": {
"page_error_title": "Valami hiba történt",
"page_error_description": "Váratlan hibával találkoztunk. Kérjük, próbáld újra, vagy térj vissza a kezdőoldalra.",
+254 -5
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Impostazioni",
"back_to_mail": "Torna alla posta",
"back_to_settings": "Torna alle impostazioni",
"save_success": "Impostazioni salvate con successo",
"import_success": "Impostazioni importate con successo",
"import_error": "Impossibile importare le impostazioni",
@@ -1345,6 +1346,7 @@
"id_exists": "Questo ID etichetta esiste già",
"edit": "Modifica etichetta",
"delete": "Elimina etichetta",
"deleted": "Etichetta eliminata",
"save": "Salva",
"add": "Aggiungi",
"cancel": "Annulla",
@@ -1849,6 +1851,7 @@
"delete": "Elimina elenco salvato",
"cancel": "Annulla",
"delete_confirm": "Eliminare dal server l'elenco account salvato?",
"delete_message": "Gli altri dispositivi non potranno più ripristinare gli account da essa.",
"password": "Password del backup",
"password_hint": "Almeno 10 caratteri. Non si può recuperare: se la dimentichi, salva di nuovo l'elenco con una nuova password.",
"restore_hint": "La password scelta quando l'elenco è stato salvato.",
@@ -1944,6 +1947,8 @@
"removed": "Password per l'app rimossa",
"add_error": "Impossibile creare la password per l'app",
"remove_error": "Impossibile rimuovere la password per l'app",
"delete_title": "Eliminare la password per l'app?",
"delete_message": "Le app che accedono con {name} non potranno più accedere.",
"none": "Nessuna password per le app configurata",
"done": "Fatto",
"expires_label": "Scadenza (facoltativa)",
@@ -1962,6 +1967,8 @@
"removed": "Chiave API rimossa",
"add_error": "Impossibile creare la chiave API",
"remove_error": "Impossibile rimuovere la chiave API",
"delete_title": "Eliminare la chiave API?",
"delete_message": "Gli script e le integrazioni che usano {name} perdono l'accesso.",
"none": "Nessuna chiave API configurata"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "Abilita la crittografia con questa chiave",
"enable_button": "Abilita",
"disable_tooltip": "Disabilita la crittografia",
"disable_title": "Disattivare la crittografia a riposo?",
"disable_message": "D'ora in poi la nuova posta viene salvata senza crittografia.",
"disable_button": "Disattiva",
"enabled_success": "Crittografia a riposo abilitata"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "es. La mia chiave",
"key_label": "Chiave pubblica (S/MIME o PGP in formato ASCII)",
"key_placeholder": "Incolla qui la tua chiave pubblica",
"none": "Nessuna chiave pubblica configurata"
"none": "Nessuna chiave pubblica configurata",
"title": "Chiavi pubbliche",
"added": "Chiave pubblica aggiunta",
"removed": "Chiave pubblica eliminata",
"add_error": "Impossibile aggiungere la chiave pubblica",
"remove_error": "Impossibile eliminare la chiave pubblica",
"delete_title": "Eliminare la chiave pubblica?",
"delete_message": "{name} viene rimossa dal server."
},
"sections": {
"devices": "Altre app e dispositivi",
@@ -2089,6 +2106,7 @@
},
"save": "Salva modifiche",
"saving": "Salvataggio...",
"saved": "Salvato",
"warnings": {
"end_before_start": "La data di fine deve essere successiva alla data di inizio",
"invalid_date": "Inserisci una data di inizio e di fine valide con l'ora oppure lascia vuoto il campo",
@@ -2155,12 +2173,17 @@
"new_folder_name": "Nome della cartella",
"rename": "Rinomina",
"change_icon": "Cambia icona",
"actions": "Azioni per {name}",
"expand": "Espandi {name}",
"collapse": "Comprimi {name}",
"counter": "Contatore",
"count_mode": "Contatore per {name}",
"count_mode_default": "Contatore: predefinito",
"count_mode_unread": "Solo non letti",
"count_mode_all": "Non letti e totale",
"count_mode_none": "Nessun contatore",
"delete": "Elimina",
"delete_title": "Eliminare la cartella?",
"confirm_delete": "Sei sicuro di voler eliminare \"{name}\"? Le e-mail in questa cartella verranno spostate nel Cestino.",
"create": "Crea",
"cancel": "Annulla",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "Modalità debug",
"description": "Abilita registrazione dettagliata per la risoluzione dei problemi"
"description": "Abilita registrazione dettagliata per la risoluzione dei problemi",
"toggle": "Registrazione dettagliata"
},
"debug_categories": {
"description": "Seleziona quali categorie registrare. Disattiva quelle che non ti servono per ridurre il rumore nella console.",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "Aggiornare i dati nella cache?"
},
"reset_settings": {
"label": "Ripristina impostazioni",
"description": "Ripristina tutte le impostazioni ai valori predefiniti",
"button": "Ripristina ai valori predefiniti"
"button": "Ripristina ai valori predefiniti",
"confirm_title": "Ripristinare tutte le impostazioni?",
"done": "Impostazioni ripristinate"
},
"export_settings": {
"label": "Esporta impostazioni",
@@ -2302,11 +2329,19 @@
"categories_title": "Categorie",
"categories_description": "Rinomina le categorie dei contatti",
"no_categories": "Nessuna categoria trovata",
"delete_category": "Elimina categoria",
"category_deleted": "Categoria eliminata",
"category_delete_failed": "Impossibile eliminare la categoria",
"group_by_letter_description": "Mostra intestazioni alfabetiche nell'elenco dei contatti",
"group_by_letter_label": "Raggruppa per prima lettera",
"sort_by_last_name_label": "Ordina per cognome",
"sort_by_last_name_description": "Ordina l'elenco dei contatti per cognome così i familiari compaiono insieme"
},
"calendar_management": {
"delete_title": "Eliminare il calendario?",
"clear_title": "Cancellare tutti gli eventi?",
"unsubscribe_title": "Annullare l'iscrizione a questo calendario?"
},
"downloads": {
"title": "Download",
"description": "Personalizza come vengono nominati gli email e gli allegati scaricati.",
@@ -2371,7 +2406,7 @@
"validate": "Convalida",
"validation_success": "Lo script è valido",
"validation_error": "Lo script contiene errori",
"save": "Salva regole",
"save": "Salva",
"saving": "Salvataggio...",
"saved": "Filtri salvati con successo",
"save_failed": "Impossibile salvare i filtri",
@@ -2385,6 +2420,8 @@
"add_condition": "Aggiungi condizione",
"actions": "Azioni",
"add_action": "Aggiungi azione",
"remove_condition": "Rimuovi condizione",
"remove_action": "Rimuovi azione",
"stop_processing": "Interrompere l'elaborazione delle regole successive",
"include_spam": "Sposta anche i messaggi contrassegnati come spam",
"attachment_type_placeholder": "es. pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "Tipo di corrispondenza",
"reset_to_visual": "Ripristina il costruttore visuale",
"reset_warning": "Questo eliminerà lo script attuale e ricomincerà da zero.",
"reset_title": "Tornare all'editor visuale?",
"confirm_reset": "Ripristina",
"validation_empty_name": "Il nome della regola è obbligatorio",
"validation_empty_conditions": "È necessaria almeno una condizione con un valore",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Configurazione di Bulwark Webmail",
"subtitle": "Configura la tua istanza webmail dal browser.",
"loading": "Caricamento…",
"back": "Indietro",
"next": "Avanti",
"saving": "Salvataggio…",
"show": "Mostra",
"hide": "Nascondi",
"remove": "Rimuovi",
"on": "Attivo",
"off": "Disattivo",
"dismiss": "Chiudi",
"unknown_error": "Errore sconosciuto",
"steps": {
"welcome": "Benvenuto",
"server": "Server",
"auth": "Accesso",
"security": "Sicurezza",
"logging": "Log",
"branding": "Personalizzazione",
"review": "Riepilogo"
},
"read_only": {
"title": "La configurazione è di sola lettura",
"body": "Il volume di configurazione è montato in sola lettura oppure è impostata la variabile {variable}. Rimontalo in lettura e scrittura o rimuovi la variabile, poi riavvia il container."
},
"done": {
"title": "Tutto pronto",
"body": "{appName} è configurato e pronto all’uso.",
"admin_sign_in": "Accedi alla dashboard di amministrazione",
"webmail_login": "Apri l’accesso alla webmail",
"redirecting": "Ti stiamo portando alla dashboard di amministrazione…"
},
"already": {
"title": "La configurazione è già completa",
"body": "{appName} è configurato. Accedi per continuare."
},
"insecure": {
"title": "Stai eseguendo la configurazione su HTTP non cifrato",
"body": "Il token di configurazione e la password di amministratore inseriti qui viaggeranno in chiaro. Usa HTTPS se possibile: termina TLS sul container o su un reverse proxy davanti.",
"try_https": "Prova HTTPS",
"continue_http": "Continua su HTTP"
},
"errors": {
"token_expired": "Questo token di configurazione non è più valido. Riavvia il container per ottenerne uno nuovo dai log.",
"session_expired": "La sessione di configurazione è scaduta. Incolla di nuovo il token di configurazione per continuare.",
"token_required": "Incolla il token di configurazione mostrato nei log del container per continuare.",
"already_finished": "La configurazione è già terminata. Ricarica per accedere.",
"token_rejected": "Token rifiutato (HTTP {status})",
"step_failed": "Impossibile salvare questo passaggio (HTTP {status})",
"upload_failed": "Caricamento non riuscito (HTTP {status})",
"finish_failed": "Impossibile completare la configurazione (HTTP {status})"
},
"welcome": {
"title": "Benvenuto",
"body": "Incolla il token di configurazione mostrato nei log del container per continuare. Il token scade dopo 1 ora.",
"token_label": "Token di configurazione",
"token_placeholder": "Token esadecimale da 32 byte",
"verifying": "Verifica…",
"continue": "Continua"
},
"server": {
"title": "Server",
"subtitle": "Dove si trova la tua posta.",
"app_name": "Nome dell’applicazione",
"url_label": "URL del server JMAP",
"url_hint": "Il server predefinito a cui si collegano gli utenti. Esempio: https://mail.example.com",
"test": "Prova",
"testing": "Prova in corso…",
"probe_ok": "Connesso: sembra un server JMAP.",
"probe_not_jmap": "Il server risponde, ma non sembra un endpoint JMAP.",
"probe_invalid": "Questo URL non è valido. Deve iniziare con http:// o https://.",
"probe_unreachable": "Impossibile collegarsi a questo indirizzo. Controlla l’URL e che il server sia online.",
"not_jmap_hint": "Può succedere quando un reverse proxy instrada JMAP separatamente sullo stesso dominio. Altrimenti di solito l’URL è sbagliato.",
"not_jmap_confirm": "Sono sicuro che l’URL sia giusto, continua comunque.",
"http_title": "Questo URL usa HTTP non cifrato.",
"http_body": "Password e contenuto delle email viaggeranno senza cifratura tra gli utenti e il tuo server. Usa {https} in produzione: termina TLS sul server di posta o su un reverse proxy davanti.",
"local_title": "Questo URL funziona solo in locale.",
"local_body": "La posta viene recuperata direttamente dal browser dell’utente, quindi l’URL JMAP deve essere raggiungibile ovunque gli utenti accedano, non solo da questo computer o dalla rete locale. In produzione usa un nome host pubblico (ad es. {example}).",
"additional_title": "Server JMAP aggiuntivi",
"additional_hint": "Facoltativo. Offri più server nell’elenco di accesso, utile per chi gestisce più istanze Stalwart.",
"additional_show": "Mostra ({count})",
"add": "Aggiungi",
"row_title": "Server n. {index}",
"id_label": "ID",
"id_hint": "Identificativo univoco, ad es. eu-1",
"label_label": "Etichetta",
"label_hint": "Visibile agli utenti",
"row_url": "URL",
"domains_label": "Domini",
"domains_hint": "Separati da virgole. Servono a scegliere questo server in base al dominio email all’accesso.",
"add_server": "Aggiungi server",
"auto_pick": "Scegli il server in base al dominio email",
"auto_pick_hint": "Quando un utente digita la sua email, viene selezionato il server corrispondente dall’elenco qui sopra.",
"row_id_required": "Server n. {index}: l’ID è obbligatorio",
"row_id_invalid": "Server n. {index}: l’ID può contenere solo lettere, cifre, - e _ e deve iniziare con una lettera o una cifra",
"row_id_duplicate": "Server n. {index}: l’ID «{id}» è ripetuto",
"row_url_required": "Server n. {index}: l’URL è obbligatorio",
"row_url_scheme": "Server n. {index}: l’URL deve iniziare con http:// o https://",
"stalwart": "Attiva le funzioni specifiche di Stalwart",
"stalwart_hint": "Aggiunge il cambio password e la gestione dei filtri Sieve. Si può attivare senza rischi anche su altri server."
},
"auth": {
"title": "Autenticazione",
"subtitle": "L’accesso con password è sempre disponibile. OAuth/OIDC è facoltativo.",
"oauth": "Attiva OAuth2 / OpenID Connect",
"oauth_only": "Solo OAuth (nascondi il modulo della password)",
"client_id": "ID client OAuth",
"client_secret": "Segreto client OAuth",
"client_secret_hint": "Lascia vuoto per i client pubblici che usano solo PKCE.",
"client_secret_placeholder": "Incolla il segreto",
"issuer": "URL dell’emittente OAuth",
"issuer_hint": "Per provider di identità esterni (Keycloak, Authentik, Entra ID ecc.)."
},
"security": {
"title": "Sicurezza e sessioni",
"subtitle": "Il segreto di sessione abilita «Ricordami» e la sincronizzazione delle impostazioni.",
"generated": "Segreto di sessione generato",
"customize": "Personalizza",
"generated_hint": "È stato creato per te un segreto da 32 byte. Cambialo solo se hai un motivo preciso.",
"secret_label": "Segreto di sessione",
"secret_hint": "Incolla un tuo valore o rigeneralo.",
"regenerate": "Rigenera",
"sync": "Sincronizza le impostazioni degli utenti tra dispositivi",
"sync_hint": "Salva le preferenze degli utenti sul server, cifrate con il segreto di sessione.",
"telemetry": "Invia statistiche d’uso anonime per migliorare Bulwark",
"telemetry_hint": "Disattivato per impostazione predefinita. Un segnale anonimo al giorno con versione, piattaforma e funzioni attive; mai indirizzi email, nomi host o IP. Puoi cambiarlo in qualsiasi momento nelle impostazioni di amministrazione."
},
"logging": {
"title": "Log",
"subtitle": "Formato e livello di dettaglio dei log dell’applicazione.",
"format": "Formato",
"format_text": "text: a colori, leggibile",
"format_json": "json: strutturato, per l’aggregazione dei log",
"level": "Livello",
"level_info": "info (consigliato)"
},
"branding": {
"title": "Personalizzazione",
"subtitle": "Tutti i campi sono facoltativi. Carica un file o incolla un URL; per ciò che salti si usano i valori predefiniti.",
"company": "Nome dell’azienda o dell’organizzazione",
"favicon": "Favicon",
"favicon_hint": "Icona della scheda del browser. Consigliato SVG.",
"login_light": "Logo di accesso (modalità chiara)",
"login_light_hint": "Mostrato nella pagina di accesso, su sfondi chiari.",
"login_dark": "Logo di accesso (modalità scura)",
"login_dark_hint": "Mostrato nella pagina di accesso, su sfondi scuri.",
"sidebar_light": "Logo della barra laterale (modalità chiara)",
"sidebar_light_hint": "Mostrato dopo l’accesso. Lascia vuoto per nessuno.",
"sidebar_dark": "Logo della barra laterale (modalità scura)",
"sidebar_dark_hint": "Versione per la modalità scura del logo della barra laterale.",
"website": "URL del sito web",
"imprint": "URL delle note legali",
"privacy": "URL dell’informativa sulla privacy",
"drop": "Fai clic o trascina qui",
"uploading": "Caricamento…",
"uploaded": "File caricato",
"formats": "SVG, PNG, JPEG, WebP o ICO · max 2 MB",
"use_url": "Usa URL",
"hide_url": "Nascondi URL",
"url_placeholder": "https://… o /branding/file.svg"
},
"review": {
"title": "Quasi fatto",
"subtitle": "Controlla le impostazioni, scegli una password di amministratore e applica.",
"server": "Server",
"app_name": "Nome dell’app",
"jmap_server": "Server JMAP",
"additional": "Server aggiuntivi",
"additional_count": "{count} configurati",
"additional_auto_pick": "{count} configurati · scelti per dominio",
"stalwart": "Funzioni di Stalwart",
"auth": "Autenticazione",
"method": "Metodo",
"oauth_only": "Solo OAuth",
"password_oauth": "Password + OAuth",
"password_only": "Solo password",
"client_id": "ID client OAuth",
"security": "Sicurezza",
"secret": "Segreto di sessione",
"configured": "Configurato",
"not_set": "Non impostato",
"remember_me": "Ricordami",
"available": "Disponibile",
"disabled": "Disattivato",
"sync": "Sincronizzazione impostazioni",
"needs_secret": "Richiede un segreto di sessione",
"telemetry": "Telemetria anonima",
"logging": "Log",
"format": "Formato",
"level": "Livello",
"branding": "Personalizzazione",
"customizations": "Personalizzazioni",
"custom_assets": "File personalizzati configurati",
"defaults": "Valori predefiniti",
"company": "Nome dell’azienda",
"password_title": "Scegli una password di amministratore",
"password_hint": "La userai per accedere a {path}. Almeno 8 caratteri.",
"new_password": "Nuova password",
"confirm": "Conferma",
"too_short": "Almeno 8 caratteri.",
"mismatch": "Le password non coincidono.",
"looks_good": "Tutto a posto.",
"advanced": "Avanzate",
"lock": "Blocca la configurazione dopo l’installazione",
"lock_hint": "Crea un file di marcatura. Al termine, rimonta il volume di configurazione in sola lettura: l’app rifiuterà ulteriori modifiche alla configurazione. I log di controllo e lo stato di accesso restano scrivibili nel volume di stato.",
"applying": "Applicazione…",
"finish": "Applica e termina"
}
},
"errors": {
"page_error_title": "Qualcosa è andato storto",
"page_error_description": "Si è verificato un errore imprevisto. Riprova o torna alla pagina principale.",
+254 -5
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "設定",
"back_to_mail": "メールに戻る",
"back_to_settings": "設定に戻る",
"save_success": "設定を保存しました",
"import_success": "設定をインポートしました",
"import_error": "設定のインポートに失敗しました",
@@ -1345,6 +1346,7 @@
"id_exists": "このラベルIDは既に存在します",
"edit": "ラベルを編集",
"delete": "ラベルを削除",
"deleted": "ラベルを削除しました",
"save": "保存",
"add": "追加",
"cancel": "キャンセル",
@@ -1849,6 +1851,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "ほかのデバイスからこのリストでアカウントを復元できなくなります。",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1944,6 +1947,8 @@
"removed": "アプリパスワードが削除されました",
"add_error": "アプリパスワードを作成できませんでした",
"remove_error": "アプリパスワードを削除できませんでした",
"delete_title": "アプリパスワードを削除しますか?",
"delete_message": "{name} でサインインしているアプリは、今後サインインできなくなります。",
"none": "アプリパスワードは設定されていません",
"done": "完了",
"expires_label": "有効期限(任意)",
@@ -1962,6 +1967,8 @@
"removed": "APIキーを削除しました",
"add_error": "APIキーの作成に失敗しました",
"remove_error": "APIキーの削除に失敗しました",
"delete_title": "API キーを削除しますか?",
"delete_message": "{name} を使うスクリプトや連携はアクセスできなくなります。",
"none": "APIキーは設定されていません"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "この鍵で暗号化を有効にする",
"enable_button": "有効にする",
"disable_tooltip": "暗号化を無効にする",
"disable_title": "保存時の暗号化をオフにしますか?",
"disable_message": "今後、新しいメールは暗号化されずに保存されます。",
"disable_button": "オフにする",
"enabled_success": "保存時の暗号化が有効になりました"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "例:マイキー",
"key_label": "公開鍵(S/MIME または PGP の ASCII 形式)",
"key_placeholder": "ここに公開鍵を貼り付けてください",
"none": "公開鍵が設定されていません"
"none": "公開鍵が設定されていません",
"title": "公開鍵",
"added": "公開鍵を追加しました",
"removed": "公開鍵を削除しました",
"add_error": "公開鍵を追加できませんでした",
"remove_error": "公開鍵を削除できませんでした",
"delete_title": "公開鍵を削除しますか?",
"delete_message": "{name} はサーバーから削除されます。"
},
"sections": {
"devices": "他のアプリとデバイス",
@@ -2089,6 +2106,7 @@
},
"save": "変更を保存",
"saving": "保存中...",
"saved": "保存しました",
"warnings": {
"end_before_start": "終了日は開始日より後に設定してください",
"invalid_date": "開始日と終了日は時刻付きの有効な日付を入力するか、欄を空のままにしてください",
@@ -2155,12 +2173,17 @@
"new_folder_name": "フォルダー名",
"rename": "名前を変更",
"change_icon": "アイコンを変更",
"actions": "{name} の操作",
"expand": "{name} を展開",
"collapse": "{name} を折りたたむ",
"counter": "カウンター",
"count_mode": "{name} のカウンター",
"count_mode_default": "カウンター: 既定",
"count_mode_unread": "未読のみ",
"count_mode_all": "未読と合計",
"count_mode_none": "カウンターなし",
"delete": "削除",
"delete_title": "フォルダーを削除しますか?",
"confirm_delete": "「{name}」を削除してもよろしいですか?このフォルダー内のメールはゴミ箱に移動されます。",
"create": "作成",
"cancel": "キャンセル",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "デバッグモード",
"description": "トラブルシューティング用の詳細ログを有効化"
"description": "トラブルシューティング用の詳細ログを有効化",
"toggle": "詳細なログ"
},
"debug_categories": {
"description": "記録するカテゴリを選択してください。不要なカテゴリを無効にすると、コンソールのノイズを減らせます。",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "キャッシュされたデータを更新しますか?"
},
"reset_settings": {
"label": "設定をリセット",
"description": "すべての設定をデフォルト値に戻す",
"button": "デフォルトにリセット"
"button": "デフォルトにリセット",
"confirm_title": "すべての設定をリセットしますか?",
"done": "設定をリセットしました"
},
"export_settings": {
"label": "設定をエクスポート",
@@ -2302,11 +2329,19 @@
"categories_title": "カテゴリ",
"categories_description": "連絡先カテゴリの名前を変更",
"no_categories": "カテゴリが見つかりません",
"delete_category": "カテゴリーを削除",
"category_deleted": "カテゴリーを削除しました",
"category_delete_failed": "カテゴリーを削除できませんでした",
"group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示",
"group_by_letter_label": "頭文字でグループ化",
"sort_by_last_name_label": "姓で並べ替え",
"sort_by_last_name_description": "家族が並んで表示されるように、連絡先一覧を姓の順に並べ替えます"
},
"calendar_management": {
"delete_title": "カレンダーを削除しますか?",
"clear_title": "すべての予定を消去しますか?",
"unsubscribe_title": "このカレンダーの購読を解除しますか?"
},
"downloads": {
"title": "ダウンロード",
"description": "ダウンロードしたメールや添付ファイルの名前の付け方をカスタマイズします。",
@@ -2371,7 +2406,7 @@
"validate": "検証",
"validation_success": "スクリプトは有効です",
"validation_error": "スクリプトにエラーがあります",
"save": "ルールを保存",
"save": "保存",
"saving": "保存中...",
"saved": "フィルターを保存しました",
"save_failed": "フィルターの保存に失敗しました",
@@ -2385,6 +2420,8 @@
"add_condition": "条件を追加",
"actions": "アクション",
"add_action": "アクションを追加",
"remove_condition": "条件を削除",
"remove_action": "操作を削除",
"stop_processing": "以降のルールの処理を停止",
"include_spam": "迷惑メールと判定されたメッセージも移動する",
"attachment_type_placeholder": "例: pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "一致タイプ",
"reset_to_visual": "ビジュアルビルダーにリセット",
"reset_warning": "現在のスクリプトを破棄して最初からやり直します。",
"reset_title": "ビジュアルエディターに戻しますか?",
"confirm_reset": "リセット",
"validation_empty_name": "ルール名は必須です",
"validation_empty_conditions": "値を持つ条件が少なくとも1つ必要です",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Bulwark Webmail のセットアップ",
"subtitle": "ブラウザーから Web メールのインスタンスを設定します。",
"loading": "読み込み中…",
"back": "戻る",
"next": "次へ",
"saving": "保存中…",
"show": "表示",
"hide": "非表示",
"remove": "削除",
"on": "オン",
"off": "オフ",
"dismiss": "閉じる",
"unknown_error": "不明なエラー",
"steps": {
"welcome": "ようこそ",
"server": "サーバー",
"auth": "認証",
"security": "セキュリティ",
"logging": "ログ",
"branding": "ブランディング",
"review": "確認"
},
"read_only": {
"title": "設定は読み取り専用です",
"body": "設定ボリュームが読み取り専用でマウントされているか、{variable} が設定されています。書き込み可能でマウントし直すか変数を削除してから、コンテナーを再起動してください。"
},
"done": {
"title": "準備ができました",
"body": "{appName} の設定が完了し、使用できます。",
"admin_sign_in": "管理ダッシュボードにサインイン",
"webmail_login": "Web メールのログインを開く",
"redirecting": "管理ダッシュボードに移動しています…"
},
"already": {
"title": "セットアップはすでに完了しています",
"body": "{appName} は設定済みです。続けるにはサインインしてください。"
},
"insecure": {
"title": "暗号化されていない HTTP でセットアップを実行しています",
"body": "ここで入力するセットアップトークンと管理者パスワードは平文で送信されます。可能な限り HTTPS を使用してください。TLS はコンテナーか、その手前のリバースプロキシで終端します。",
"try_https": "HTTPS を試す",
"continue_http": "HTTP で続ける"
},
"errors": {
"token_expired": "このセットアップトークンはもう有効ではありません。コンテナーを再起動すると、ログに新しいトークンが表示されます。",
"session_expired": "セットアップのセッションが期限切れになりました。続けるにはセットアップトークンをもう一度貼り付けてください。",
"token_required": "続けるには、コンテナーのログに表示されたセットアップトークンを貼り付けてください。",
"already_finished": "セットアップはすでに完了しています。サインインするには再読み込みしてください。",
"token_rejected": "トークンが拒否されました (HTTP {status})",
"step_failed": "この手順を保存できませんでした (HTTP {status})",
"upload_failed": "アップロードに失敗しました (HTTP {status})",
"finish_failed": "セットアップを完了できませんでした (HTTP {status})"
},
"welcome": {
"title": "ようこそ",
"body": "続けるには、コンテナーのログに表示されたセットアップトークンを貼り付けてください。トークンの有効期限は 1 時間です。",
"token_label": "セットアップトークン",
"token_placeholder": "32 バイトの 16 進トークン",
"verifying": "確認中…",
"continue": "続ける"
},
"server": {
"title": "サーバー",
"subtitle": "メールの保存場所です。",
"app_name": "アプリケーション名",
"url_label": "JMAP サーバーの URL",
"url_hint": "ユーザーが接続する既定のサーバーです。例: https://mail.example.com",
"test": "テスト",
"testing": "テスト中…",
"probe_ok": "接続しました。JMAP サーバーのようです。",
"probe_not_jmap": "サーバーには届きましたが、JMAP エンドポイントではないようです。",
"probe_invalid": "この URL は無効です。http:// または https:// で始まる必要があります。",
"probe_unreachable": "このアドレスに接続できませんでした。URL とサーバーがオンラインかを確認してください。",
"not_jmap_hint": "リバースプロキシが同じドメインで JMAP を別にルーティングしている場合に起こることがあります。それ以外の場合は、たいてい URL が間違っています。",
"not_jmap_confirm": "この URL で正しいので、このまま続ける。",
"http_title": "この URL は暗号化されていない HTTP を使用しています。",
"http_body": "パスワードとメールの内容が、ユーザーとサーバーの間を暗号化されずに送信されます。本番環境では {https} を使用してください。TLS はメールサーバーか、その手前のリバースプロキシで終端します。",
"local_title": "この URL はローカルでしか解決できません。",
"local_body": "メールはユーザーのブラウザーから直接取得されるため、JMAP の URL はこのマシンや LAN だけでなく、ユーザーがサインインするあらゆる場所から到達できる必要があります。本番環境では公開ホスト名 (例: {example}) を使用してください。",
"additional_title": "追加の JMAP サーバー",
"additional_hint": "任意です。ログイン時のリストに複数のサーバーを表示します。複数の Stalwart インスタンスを運用するホストに便利です。",
"additional_show": "表示 ({count})",
"add": "追加",
"row_title": "サーバー {index}",
"id_label": "ID",
"id_hint": "一意の短い名前 (例: eu-1)",
"label_label": "ラベル",
"label_hint": "ユーザーに表示されます",
"row_url": "URL",
"domains_label": "ドメイン",
"domains_hint": "カンマ区切り。ログイン時にメールのドメインからこのサーバーを選ぶのに使います。",
"add_server": "サーバーを追加",
"auto_pick": "メールのドメインでサーバーを選ぶ",
"auto_pick_hint": "ユーザーがメールアドレスを入力すると、上のリストから該当するサーバーが選ばれます。",
"row_id_required": "サーバー {index}: ID は必須です",
"row_id_invalid": "サーバー {index}: ID に使えるのは英字、数字、- と _ だけで、英字か数字で始める必要があります",
"row_id_duplicate": "サーバー {index}: ID「{id}」が重複しています",
"row_url_required": "サーバー {index}: URL は必須です",
"row_url_scheme": "サーバー {index}: URL は http:// または https:// で始める必要があります",
"stalwart": "Stalwart 固有の機能を有効にする",
"stalwart_hint": "パスワード変更と Sieve フィルターの管理を追加します。ほかのサーバーで有効にしても問題ありません。"
},
"auth": {
"title": "認証",
"subtitle": "パスワードでのログインは常に使えます。OAuth/OIDC は任意です。",
"oauth": "OAuth2 / OpenID Connect を有効にする",
"oauth_only": "OAuth のみ (パスワードフォームを隠す)",
"client_id": "OAuth クライアント ID",
"client_secret": "OAuth クライアントシークレット",
"client_secret_hint": "PKCE のみを使う公開クライアントでは空欄にします。",
"client_secret_placeholder": "シークレットを貼り付け",
"issuer": "OAuth 発行者の URL",
"issuer_hint": "外部の ID プロバイダー (Keycloak、Authentik、Entra ID など) 用です。"
},
"security": {
"title": "セキュリティとセッション",
"subtitle": "セッションシークレットがあると「ログインしたままにする」と設定の同期を使えます。",
"generated": "セッションシークレットを生成しました",
"customize": "カスタマイズ",
"generated_hint": "32 バイトのシークレットを生成しました。特別な理由がない限り変更する必要はありません。",
"secret_label": "セッションシークレット",
"secret_hint": "独自の値を貼り付けるか、新しく生成してください。",
"regenerate": "再生成",
"sync": "ユーザー設定をデバイス間で同期する",
"sync_hint": "ユーザーの設定をサーバーに保存し、セッションシークレットで暗号化します。",
"telemetry": "Bulwark の改善のために匿名の使用統計を送信する",
"telemetry_hint": "既定ではオフです。バージョン、プラットフォーム、有効な機能を含む匿名の信号を 1 日 1 回送信します。メールアドレス、ホスト名、IP アドレスは送信しません。管理設定でいつでも変更できます。"
},
"logging": {
"title": "ログ",
"subtitle": "アプリケーションログの形式と詳細度です。",
"format": "形式",
"format_text": "text: 色付きで読みやすい",
"format_json": "json: 構造化、ログ集約向け",
"level": "レベル",
"level_info": "info (推奨)"
},
"branding": {
"title": "ブランディング",
"subtitle": "すべて任意です。ファイルをアップロードするか URL を貼り付けてください。省略した項目には既定値が使われます。",
"company": "会社名または組織名",
"favicon": "ファビコン",
"favicon_hint": "ブラウザーのタブのアイコンです。SVG を推奨します。",
"login_light": "ログインのロゴ (ライトモード)",
"login_light_hint": "サインインページの明るい背景に表示されます。",
"login_dark": "ログインのロゴ (ダークモード)",
"login_dark_hint": "サインインページの暗い背景に表示されます。",
"sidebar_light": "サイドバーのロゴ (ライトモード)",
"sidebar_light_hint": "サインイン後に表示されます。表示しない場合は空欄にします。",
"sidebar_dark": "サイドバーのロゴ (ダークモード)",
"sidebar_dark_hint": "サイドバーのロゴのダークモード版です。",
"website": "Web サイトの URL",
"imprint": "運営者情報の URL",
"privacy": "プライバシーポリシーの URL",
"drop": "クリックまたはドロップ",
"uploading": "アップロード中…",
"uploaded": "アップロードしたファイル",
"formats": "SVG、PNG、JPEG、WebP、ICO · 最大 2 MB",
"use_url": "URL を使う",
"hide_url": "URL を隠す",
"url_placeholder": "https://… または /branding/file.svg"
},
"review": {
"title": "あと少しです",
"subtitle": "設定を確認し、管理者パスワードを決めて適用してください。",
"server": "サーバー",
"app_name": "アプリ名",
"jmap_server": "JMAP サーバー",
"additional": "追加のサーバー",
"additional_count": "{count} 件設定済み",
"additional_auto_pick": "{count} 件設定済み · ドメインで選択",
"stalwart": "Stalwart の機能",
"auth": "認証",
"method": "方式",
"oauth_only": "OAuth のみ",
"password_oauth": "パスワード + OAuth",
"password_only": "パスワードのみ",
"client_id": "OAuth クライアント ID",
"security": "セキュリティ",
"secret": "セッションシークレット",
"configured": "設定済み",
"not_set": "未設定",
"remember_me": "ログインしたままにする",
"available": "利用可能",
"disabled": "無効",
"sync": "設定の同期",
"needs_secret": "セッションシークレットが必要です",
"telemetry": "匿名テレメトリー",
"logging": "ログ",
"format": "形式",
"level": "レベル",
"branding": "ブランディング",
"customizations": "カスタマイズ",
"custom_assets": "独自のファイルを設定済み",
"defaults": "既定値",
"company": "会社名",
"password_title": "管理者パスワードを決めてください",
"password_hint": "{path} へのサインインに使います。8 文字以上です。",
"new_password": "新しいパスワード",
"confirm": "確認",
"too_short": "8 文字以上にしてください。",
"mismatch": "パスワードが一致しません。",
"looks_good": "問題ありません。",
"advanced": "詳細",
"lock": "セットアップ後に設定をロックする",
"lock_hint": "マーカーファイルを作成します。完了後に設定ボリュームを読み取り専用でマウントし直すと、アプリは以降の設定の書き込みを拒否します。監査ログとログイン状態は状態ボリュームに引き続き書き込めます。",
"applying": "適用中…",
"finish": "適用して完了"
}
},
"errors": {
"page_error_title": "問題が発生しました",
"page_error_description": "予期しないエラーが発生しました。もう一度お試しいただくか、ホームページに戻ってください。",
+254 -5
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "설정",
"back_to_mail": "메일로 돌아가기",
"back_to_settings": "설정으로 돌아가기",
"save_success": "설정이 저장되었어요",
"import_success": "설정을 성공적으로 가져왔어요",
"import_error": "설정 가져오기에 실패했어요",
@@ -1345,6 +1346,7 @@
"id_exists": "이 태그 ID는 이미 존재합니다",
"edit": "태그 편집",
"delete": "태그 삭제",
"deleted": "태그가 삭제되었어요",
"save": "저장",
"add": "추가",
"cancel": "취소",
@@ -1849,6 +1851,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "다른 기기에서 더 이상 이 목록으로 계정을 복원할 수 없어요.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1944,6 +1947,8 @@
"removed": "앱 비밀번호가 삭제되었어요",
"add_error": "앱 비밀번호를 만들지 못했어요",
"remove_error": "앱 비밀번호를 삭제하지 못했어요",
"delete_title": "앱 비밀번호를 삭제할까요?",
"delete_message": "{name}(으)로 로그인하는 앱은 더 이상 로그인할 수 없어요.",
"none": "설정된 앱 비밀번호가 없어요",
"done": "완료",
"expires_label": "만료 (선택 사항)",
@@ -1962,6 +1967,8 @@
"removed": "API 키가 삭제되었습니다",
"add_error": "API 키 생성에 실패했습니다",
"remove_error": "API 키 삭제에 실패했습니다",
"delete_title": "API 키를 삭제할까요?",
"delete_message": "{name}을(를) 사용하는 스크립트와 통합은 접근 권한을 잃어요.",
"none": "구성된 API 키가 없습니다"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "이 키로 암호화 사용",
"enable_button": "사용",
"disable_tooltip": "암호화 사용 안 함",
"disable_title": "저장 데이터 암호화를 끌까요?",
"disable_message": "이제부터 새 메일은 암호화 없이 저장돼요.",
"disable_button": "끄기",
"enabled_success": "저장 데이터 암호화가 켜졌어요"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "예: 내 키",
"key_label": "공개 키(S/MIME 또는 PGP ASCII 형식)",
"key_placeholder": "여기에 공개 키를 붙여넣으세요",
"none": "설정된 공개 키가 없어요"
"none": "설정된 공개 키가 없어요",
"title": "공개 키",
"added": "공개 키를 추가했어요",
"removed": "공개 키를 삭제했어요",
"add_error": "공개 키를 추가하지 못했어요",
"remove_error": "공개 키를 삭제하지 못했어요",
"delete_title": "공개 키를 삭제할까요?",
"delete_message": "{name}이(가) 서버에서 삭제돼요."
},
"sections": {
"devices": "다른 앱 및 기기",
@@ -2089,6 +2106,7 @@
},
"save": "변경 사항 저장",
"saving": "저장 중...",
"saved": "저장됨",
"warnings": {
"end_before_start": "종료일은 시작일보다 나중이어야 해요",
"invalid_date": "시작일과 종료일에 시간을 포함한 올바른 날짜를 입력하거나 칸을 비워 두세요",
@@ -2155,12 +2173,17 @@
"new_folder_name": "폴더 이름",
"rename": "이름 바꾸기",
"change_icon": "아이콘 바꾸기",
"actions": "{name} 작업",
"expand": "{name} 펼치기",
"collapse": "{name} 접기",
"counter": "카운터",
"count_mode": "{name} 카운터",
"count_mode_default": "카운터: 기본값",
"count_mode_unread": "읽지 않음만",
"count_mode_all": "읽지 않음 및 전체",
"count_mode_none": "카운터 없음",
"delete": "삭제",
"delete_title": "폴더를 삭제할까요?",
"confirm_delete": "정말 \"{name}\" 폴더를 삭제할까요? 폴더 안의 메일은 휴지통으로 이동돼요.",
"create": "만들기",
"cancel": "취소",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "디버그 모드",
"description": "문제 해결을 위해 자세한 로그를 기록해요"
"description": "문제 해결을 위해 자세한 로그를 기록해요",
"toggle": "자세한 로그 기록"
},
"debug_categories": {
"description": "기록할 카테고리를 선택해 주세요. 필요 없는 항목은 꺼두면 좋아요.",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "캐시된 데이터를 새로 고칠까요?"
},
"reset_settings": {
"label": "설정 초기화",
"description": "모든 설정을 기본값으로 되돌려요",
"button": "기본값으로 초기화"
"button": "기본값으로 초기화",
"confirm_title": "모든 설정을 초기화할까요?",
"done": "설정을 초기화했어요"
},
"export_settings": {
"label": "설정 내보내기",
@@ -2302,11 +2329,19 @@
"categories_title": "카테고리",
"categories_description": "연락처 카테고리 이름 변경",
"no_categories": "카테고리를 찾을 수 없음",
"delete_category": "카테고리 삭제",
"category_deleted": "카테고리를 삭제했어요",
"category_delete_failed": "카테고리를 삭제하지 못했어요",
"group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시",
"group_by_letter_label": "첫 글자로 그룹화",
"sort_by_last_name_label": "성으로 정렬",
"sort_by_last_name_description": "가족 구성원이 함께 표시되도록 연락처 목록을 성 기준으로 정렬합니다"
},
"calendar_management": {
"delete_title": "캘린더를 삭제할까요?",
"clear_title": "모든 일정을 지울까요?",
"unsubscribe_title": "이 캘린더 구독을 해지할까요?"
},
"downloads": {
"title": "다운로드",
"description": "다운로드한 이메일과 첨부 파일의 이름을 지정하는 방식을 사용자 정의합니다.",
@@ -2371,7 +2406,7 @@
"validate": "검증",
"validation_success": "스크립트가 유효해요",
"validation_error": "스크립트에 오류가 있어요",
"save": "규칙 저장",
"save": "저장",
"saving": "저장 중...",
"saved": "필터가 성공적으로 저장되었어요",
"save_failed": "필터를 저장하지 못했어요",
@@ -2385,6 +2420,8 @@
"add_condition": "조건 추가",
"actions": "동작",
"add_action": "동작 추가",
"remove_condition": "조건 제거",
"remove_action": "동작 제거",
"stop_processing": "이후 규칙 무시하기",
"include_spam": "스팸으로 표시된 메시지도 이동",
"attachment_type_placeholder": "예: pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "일치 유형",
"reset_to_visual": "시각적 에디터로 초기화",
"reset_warning": "현재 스크립트가 지워지고 처음부터 다시 시작하게 돼요.",
"reset_title": "시각적 편집기로 재설정할까요?",
"confirm_reset": "초기화",
"validation_empty_name": "규칙 이름을 입력해 주세요",
"validation_empty_conditions": "값을 포함한 조건이 하나 이상 필요해요",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Bulwark Webmail 설정",
"subtitle": "브라우저에서 웹메일 인스턴스를 설정하세요.",
"loading": "불러오는 중…",
"back": "뒤로",
"next": "다음",
"saving": "저장하는 중…",
"show": "표시",
"hide": "숨기기",
"remove": "제거",
"on": "켜짐",
"off": "꺼짐",
"dismiss": "닫기",
"unknown_error": "알 수 없는 오류",
"steps": {
"welcome": "환영합니다",
"server": "서버",
"auth": "인증",
"security": "보안",
"logging": "로그",
"branding": "브랜딩",
"review": "검토"
},
"read_only": {
"title": "구성이 읽기 전용이에요",
"body": "구성 볼륨이 읽기 전용으로 마운트되었거나 {variable}이(가) 설정되어 있어요. 쓰기 가능하게 다시 마운트하거나 변수를 해제한 뒤 컨테이너를 다시 시작하세요."
},
"done": {
"title": "모두 준비되었어요",
"body": "{appName}이(가) 설정되어 사용할 준비가 되었어요.",
"admin_sign_in": "관리 대시보드에 로그인",
"webmail_login": "웹메일 로그인 열기",
"redirecting": "관리 대시보드로 이동하는 중…"
},
"already": {
"title": "설정이 이미 완료되었어요",
"body": "{appName}이(가) 설정되었어요. 계속하려면 로그인하세요."
},
"insecure": {
"title": "암호화되지 않은 HTTP로 설정을 실행하고 있어요",
"body": "여기에 입력하는 설정 토큰과 관리자 비밀번호는 평문으로 전송돼요. 가능하면 HTTPS를 사용하세요. TLS는 컨테이너나 그 앞의 리버스 프록시에서 종료하세요.",
"try_https": "HTTPS 시도",
"continue_http": "HTTP로 계속"
},
"errors": {
"token_expired": "이 설정 토큰은 더 이상 유효하지 않아요. 컨테이너를 다시 시작하면 로그에서 새 토큰을 받을 수 있어요.",
"session_expired": "설정 세션이 만료되었어요. 계속하려면 설정 토큰을 다시 붙여넣으세요.",
"token_required": "계속하려면 컨테이너 로그에 표시된 설정 토큰을 붙여넣으세요.",
"already_finished": "설정이 이미 끝났어요. 로그인하려면 새로 고치세요.",
"token_rejected": "토큰이 거부되었어요 (HTTP {status})",
"step_failed": "이 단계를 저장하지 못했어요 (HTTP {status})",
"upload_failed": "업로드하지 못했어요 (HTTP {status})",
"finish_failed": "설정을 완료하지 못했어요 (HTTP {status})"
},
"welcome": {
"title": "환영합니다",
"body": "계속하려면 컨테이너 로그에 표시된 설정 토큰을 붙여넣으세요. 토큰은 1시간 후에 만료돼요.",
"token_label": "설정 토큰",
"token_placeholder": "32바이트 16진수 토큰",
"verifying": "확인하는 중…",
"continue": "계속"
},
"server": {
"title": "서버",
"subtitle": "메일이 있는 곳이에요.",
"app_name": "애플리케이션 이름",
"url_label": "JMAP 서버 URL",
"url_hint": "사용자가 연결하는 기본 서버예요. 예: https://mail.example.com",
"test": "테스트",
"testing": "테스트하는 중…",
"probe_ok": "연결됨: JMAP 서버로 보여요.",
"probe_not_jmap": "서버에 연결되었지만 JMAP 엔드포인트로 보이지 않아요.",
"probe_invalid": "유효하지 않은 URL이에요. http:// 또는 https://로 시작해야 해요.",
"probe_unreachable": "이 주소에 연결하지 못했어요. URL과 서버가 온라인인지 확인하세요.",
"not_jmap_hint": "리버스 프록시가 같은 도메인에서 JMAP를 따로 라우팅하면 이럴 수 있어요. 그렇지 않다면 대개 URL이 잘못된 거예요.",
"not_jmap_confirm": "이 URL이 맞다고 확신해요. 그래도 계속할게요.",
"http_title": "이 URL은 암호화되지 않은 HTTP를 사용해요.",
"http_body": "비밀번호와 메일 내용이 사용자와 서버 사이에서 암호화되지 않은 채 전송돼요. 운영 환경에서는 {https}을(를) 사용하세요. TLS는 메일 서버나 그 앞의 리버스 프록시에서 종료하세요.",
"local_title": "이 URL은 로컬에서만 접근할 수 있어요.",
"local_body": "메일은 사용자의 브라우저에서 직접 가져오므로, JMAP URL은 이 컴퓨터나 LAN뿐 아니라 사용자가 로그인하는 모든 곳에서 접근할 수 있어야 해요. 운영 환경에서는 공개 호스트 이름(예: {example})을 사용하세요.",
"additional_title": "추가 JMAP 서버",
"additional_hint": "선택 사항이에요. 로그인 목록에 여러 서버를 표시해요. Stalwart 인스턴스를 여러 개 운영하는 호스트에 유용해요.",
"additional_show": "표시 ({count})",
"add": "추가",
"row_title": "서버 {index}",
"id_label": "ID",
"id_hint": "고유한 짧은 이름, 예: eu-1",
"label_label": "레이블",
"label_hint": "사용자에게 표시돼요",
"row_url": "URL",
"domains_label": "도메인",
"domains_hint": "쉼표로 구분해요. 로그인할 때 메일 도메인으로 이 서버를 고르는 데 쓰여요.",
"add_server": "서버 추가",
"auto_pick": "메일 도메인으로 서버 선택",
"auto_pick_hint": "사용자가 메일 주소를 입력하면 위 목록에서 맞는 서버가 선택돼요.",
"row_id_required": "서버 {index}: ID는 필수예요",
"row_id_invalid": "서버 {index}: ID에는 문자, 숫자, -, _만 쓸 수 있고 문자나 숫자로 시작해야 해요",
"row_id_duplicate": "서버 {index}: ID \"{id}\"이(가) 중복되었어요",
"row_url_required": "서버 {index}: URL은 필수예요",
"row_url_scheme": "서버 {index}: URL은 http:// 또는 https://로 시작해야 해요",
"stalwart": "Stalwart 전용 기능 사용",
"stalwart_hint": "비밀번호 변경과 Sieve 필터 관리를 추가해요. 다른 서버에서 켜도 문제없어요."
},
"auth": {
"title": "인증",
"subtitle": "비밀번호 로그인은 항상 사용할 수 있어요. OAuth/OIDC는 선택 사항이에요.",
"oauth": "OAuth2 / OpenID Connect 사용",
"oauth_only": "OAuth만 사용 (비밀번호 양식 숨기기)",
"client_id": "OAuth 클라이언트 ID",
"client_secret": "OAuth 클라이언트 시크릿",
"client_secret_hint": "PKCE만 사용하는 공개 클라이언트라면 비워 두세요.",
"client_secret_placeholder": "시크릿 붙여넣기",
"issuer": "OAuth 발급자 URL",
"issuer_hint": "외부 ID 공급자용이에요 (Keycloak, Authentik, Entra ID 등)."
},
"security": {
"title": "보안 및 세션",
"subtitle": "세션 시크릿이 있으면 \"로그인 유지\"와 설정 동기화를 사용할 수 있어요.",
"generated": "세션 시크릿 생성됨",
"customize": "사용자 지정",
"generated_hint": "32바이트 시크릿을 만들었어요. 특별한 이유가 있을 때만 바꾸세요.",
"secret_label": "세션 시크릿",
"secret_hint": "직접 값을 붙여넣거나 새로 생성하세요.",
"regenerate": "다시 생성",
"sync": "기기 간에 사용자 설정 동기화",
"sync_hint": "사용자 설정을 세션 시크릿으로 암호화해 서버에 저장해요.",
"telemetry": "Bulwark 개선을 위해 익명 사용 통계 보내기",
"telemetry_hint": "기본값은 꺼짐이에요. 하루에 한 번 버전, 플랫폼, 켜진 기능이 담긴 익명 신호를 보내요. 메일 주소, 호스트 이름, IP는 절대 보내지 않아요. 관리 설정에서 언제든 바꿀 수 있어요."
},
"logging": {
"title": "로그",
"subtitle": "애플리케이션 로그의 형식과 상세 수준이에요.",
"format": "형식",
"format_text": "text: 색상 있음, 읽기 쉬움",
"format_json": "json: 구조화됨, 로그 수집용",
"level": "수준",
"level_info": "info (권장)"
},
"branding": {
"title": "브랜딩",
"subtitle": "모든 항목은 선택 사항이에요. 파일을 올리거나 URL을 붙여넣으세요. 건너뛴 항목에는 기본값이 쓰여요.",
"company": "회사 또는 조직 이름",
"favicon": "파비콘",
"favicon_hint": "브라우저 탭 아이콘이에요. SVG를 권장해요.",
"login_light": "로그인 로고 (라이트 모드)",
"login_light_hint": "로그인 페이지의 밝은 배경에 표시돼요.",
"login_dark": "로그인 로고 (다크 모드)",
"login_dark_hint": "로그인 페이지의 어두운 배경에 표시돼요.",
"sidebar_light": "사이드바 로고 (라이트 모드)",
"sidebar_light_hint": "로그인 후 표시돼요. 표시하지 않으려면 비워 두세요.",
"sidebar_dark": "사이드바 로고 (다크 모드)",
"sidebar_dark_hint": "사이드바 로고의 다크 모드 버전이에요.",
"website": "웹사이트 URL",
"imprint": "사업자 정보 URL",
"privacy": "개인정보 처리방침 URL",
"drop": "클릭하거나 끌어다 놓기",
"uploading": "업로드하는 중…",
"uploaded": "업로드한 파일",
"formats": "SVG, PNG, JPEG, WebP 또는 ICO · 최대 2MB",
"use_url": "URL 사용",
"hide_url": "URL 숨기기",
"url_placeholder": "https://… 또는 /branding/file.svg"
},
"review": {
"title": "거의 다 됐어요",
"subtitle": "설정을 검토하고 관리자 비밀번호를 정한 뒤 적용하세요.",
"server": "서버",
"app_name": "앱 이름",
"jmap_server": "JMAP 서버",
"additional": "추가 서버",
"additional_count": "{count}개 설정됨",
"additional_auto_pick": "{count}개 설정됨 · 도메인으로 선택",
"stalwart": "Stalwart 기능",
"auth": "인증",
"method": "방식",
"oauth_only": "OAuth만",
"password_oauth": "비밀번호 + OAuth",
"password_only": "비밀번호만",
"client_id": "OAuth 클라이언트 ID",
"security": "보안",
"secret": "세션 시크릿",
"configured": "설정됨",
"not_set": "설정 안 됨",
"remember_me": "로그인 유지",
"available": "사용 가능",
"disabled": "사용 안 함",
"sync": "설정 동기화",
"needs_secret": "세션 시크릿이 필요해요",
"telemetry": "익명 원격 분석",
"logging": "로그",
"format": "형식",
"level": "수준",
"branding": "브랜딩",
"customizations": "사용자 지정",
"custom_assets": "사용자 파일 설정됨",
"defaults": "기본값 사용",
"company": "회사 이름",
"password_title": "관리자 비밀번호 정하기",
"password_hint": "{path}에 로그인할 때 사용해요. 최소 8자예요.",
"new_password": "새 비밀번호",
"confirm": "확인",
"too_short": "8자 이상이어야 해요.",
"mismatch": "비밀번호가 일치하지 않아요.",
"looks_good": "좋아요.",
"advanced": "고급",
"lock": "설정 후 구성 잠그기",
"lock_hint": "표시 파일을 만들어요. 그런 다음 구성 볼륨을 읽기 전용으로 다시 마운트하면 앱이 이후의 구성 쓰기를 거부해요. 감사 로그와 로그인 상태는 상태 볼륨에서 계속 쓸 수 있어요.",
"applying": "적용하는 중…",
"finish": "적용하고 완료"
}
},
"errors": {
"page_error_title": "문제가 발생했어요",
"page_error_description": "예상치 못한 오류가 생겼어요. 다시 시도하거나 홈 화면으로 돌아가 주세요.",
+254 -5
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Iestatījumi",
"back_to_mail": "Atgriezties pie pasta",
"back_to_settings": "Atpakaļ uz iestatījumiem",
"save_success": "Iestatījumi veiksmīgi saglabāti",
"import_success": "Iestatījumi veiksmīgi importēti",
"import_error": "Neizdevās importēt iestatījumus",
@@ -1345,6 +1346,7 @@
"id_exists": "Šāds taga identifikators jau pastāv",
"edit": "Rediģēt tagu",
"delete": "Dzēst tagu",
"deleted": "Tags izdzēsts",
"save": "Saglabāt",
"add": "Pievienot",
"cancel": "Atcelt",
@@ -1849,6 +1851,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Citas ierīces vairs nevarēs no tā atjaunot kontus.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1944,6 +1947,8 @@
"removed": "Lietotnes parole izdzēsta",
"add_error": "Neizdevās izveidot lietotnes paroli",
"remove_error": "Neizdevās izdzēst lietotnes paroli",
"delete_title": "Dzēst lietotnes paroli?",
"delete_message": "Lietotnes, kas pierakstās ar {name}, vairs nevarēs pierakstīties.",
"none": "Lietotņu paroles nav iestatītas",
"done": "Gatavs",
"expires_label": "Derīguma termiņš (pēc izvēles)",
@@ -1962,6 +1967,8 @@
"removed": "API atslēga noņemta",
"add_error": "Neizdevās izveidot API atslēgu",
"remove_error": "Neizdevās noņemt API atslēgu",
"delete_title": "Dzēst API atslēgu?",
"delete_message": "Skripti un integrācijas, kas izmanto {name}, zaudēs piekļuvi.",
"none": "API atslēgas nav konfigurētas"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "Ieslēgt šifrēšanu ar šo atslēgu",
"enable_button": "Ieslēgt",
"disable_tooltip": "Izslēgt šifrēšanu",
"disable_title": "Izslēgt glabāto datu šifrēšanu?",
"disable_message": "Jaunais pasts turpmāk tiks glabāts bez šifrēšanas.",
"disable_button": "Izslēgt",
"enabled_success": "Krātuves šifrēšana ir iespējota"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "piem., Mana atslēga",
"key_label": "Publiskā atslēga (S/MIME vai PGP ASCII formātā)",
"key_placeholder": "Ielīmējiet šeit savu publisko atslēgu",
"none": "Nav konfigurēta neviena publiskā atslēga"
"none": "Nav konfigurēta neviena publiskā atslēga",
"title": "Publiskās atslēgas",
"added": "Publiskā atslēga pievienota",
"removed": "Publiskā atslēga izdzēsta",
"add_error": "Neizdevās pievienot publisko atslēgu",
"remove_error": "Neizdevās izdzēst publisko atslēgu",
"delete_title": "Dzēst publisko atslēgu?",
"delete_message": "{name} tiks noņemta no servera."
},
"sections": {
"devices": "Citas lietotnes un ierīces",
@@ -2089,6 +2106,7 @@
},
"save": "Saglabāt izmaiņas",
"saving": "Saglabā...",
"saved": "Saglabāts",
"warnings": {
"end_before_start": "Beigu datumam jābūt vēlāk par sākuma datumu",
"invalid_date": "Norādiet derīgu sākuma un beigu datumu ar laiku vai atstājiet lauku tukšu",
@@ -2155,12 +2173,17 @@
"new_folder_name": "Mapes nosaukums",
"rename": "Pārdēvēt",
"change_icon": "Mainīt ikonu",
"actions": "Darbības: {name}",
"expand": "Izvērst: {name}",
"collapse": "Sakļaut: {name}",
"counter": "Skaitītājs",
"count_mode": "Skaitītājs mapei {name}",
"count_mode_default": "Skaitītājs: noklusējums",
"count_mode_unread": "Tikai nelasītie",
"count_mode_all": "Nelasītie un kopā",
"count_mode_none": "Bez skaitītāja",
"delete": "Dzēst",
"delete_title": "Dzēst mapi?",
"confirm_delete": "Vai tiešām vēlaties dzēst \"{name}\"? Vēstules tiks pārvietotas uz atkritni.",
"create": "Izveidot",
"cancel": "Atcelt",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "Atkļūdošanas režīms",
"description": "Iespējot detalizētu žurnālēšanu diagnostikai"
"description": "Iespējot detalizētu žurnālēšanu diagnostikai",
"toggle": "Detalizēta žurnalēšana"
},
"settings_sync": {
"label": "Iestatījumu sinhronizācija",
@@ -2206,12 +2230,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "Atsvaidzināt kešatmiņas datus?"
},
"reset_settings": {
"label": "Atiestatīt iestatījumus",
"description": "Atjaunot visus iestatījumus uz noklusējuma vērtībām",
"button": "Atiestatīt"
"button": "Atiestatīt",
"confirm_title": "Atiestatīt visus iestatījumus?",
"done": "Iestatījumi atiestatīti"
},
"export_settings": {
"label": "Eksportēt iestatījumus",
@@ -2302,11 +2329,19 @@
"categories_title": "Kategorijas",
"categories_description": "Pārdēvēt kontaktu kategorijas",
"no_categories": "Kategorijas nav atrastas",
"delete_category": "Dzēst kategoriju",
"category_deleted": "Kategorija izdzēsta",
"category_delete_failed": "Neizdevās izdzēst kategoriju",
"group_by_letter_description": "Rādīt alfabētiskos sadaļu virsrakstus kontaktu sarakstā",
"group_by_letter_label": "Grupēt pēc pirmā burta",
"sort_by_last_name_label": "Kārtot pēc uzvārda",
"sort_by_last_name_description": "Kārtot kontaktu sarakstu pēc uzvārda, lai ģimenes locekļi būtu kopā"
},
"calendar_management": {
"delete_title": "Dzēst kalendāru?",
"clear_title": "Notīrīt visus notikumus?",
"unsubscribe_title": "Atteikties no šī kalendāra abonementa?"
},
"downloads": {
"title": "Lejupielādes",
"description": "Pielāgojiet, kā tiek nosaukti lejupielādētie e-pasti un pielikumi.",
@@ -2371,7 +2406,7 @@
"validate": "Pārbaudīt",
"validation_success": "Skripts ir derīgs",
"validation_error": "Skriptā ir kļūdas",
"save": "Saglabāt noteikumus",
"save": "Saglabāt",
"saving": "Saglabā...",
"saved": "Filtri veiksmīgi saglabāti",
"save_failed": "Neizdevās saglabāt filtrus",
@@ -2385,6 +2420,8 @@
"add_condition": "Pievienot nosacījumu",
"actions": "Darbības",
"add_action": "Pievienot darbību",
"remove_condition": "Noņemt nosacījumu",
"remove_action": "Noņemt darbību",
"stop_processing": "Pārtraukt nākamo noteikumu apstrādi",
"include_spam": "Pārvietot arī ziņas, kas atzīmētas kā mēstules",
"attachment_type_placeholder": "piem. pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "Atbilstības veids",
"reset_to_visual": "Atiestatīt uz vizuālo konstruktoru",
"reset_warning": "Tas dzēsīs pašreizējo skriptu un sāks no jauna.",
"reset_title": "Atiestatīt uz vizuālo redaktoru?",
"confirm_reset": "Atiestatīt",
"validation_empty_name": "Nepieciešams noteikuma nosaukums",
"validation_empty_conditions": "Nepieciešams vismaz viens nosacījums ar vērtību",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Bulwark Webmail iestatīšana",
"subtitle": "Iestatiet savu tīmekļa pasta instanci pārlūkā.",
"loading": "Ielādē…",
"back": "Atpakaļ",
"next": "Tālāk",
"saving": "Saglabā…",
"show": "Rādīt",
"hide": "Paslēpt",
"remove": "Noņemt",
"on": "Ieslēgts",
"off": "Izslēgts",
"dismiss": "Aizvērt",
"unknown_error": "Nezināma kļūda",
"steps": {
"welcome": "Sveicināti",
"server": "Serveris",
"auth": "Pieteikšanās",
"security": "Drošība",
"logging": "Žurnalēšana",
"branding": "Zīmols",
"review": "Pārskats"
},
"read_only": {
"title": "Konfigurācija ir tikai lasāma",
"body": "Konfigurācijas sējums ir pievienots tikai lasīšanai vai ir iestatīts {variable}. Pievienojiet to ar rakstīšanas tiesībām vai noņemiet mainīgo un restartējiet konteineru."
},
"done": {
"title": "Viss gatavs",
"body": "{appName} ir iestatīts un gatavs lietošanai.",
"admin_sign_in": "Pieteikties administrēšanas panelī",
"webmail_login": "Atvērt tīmekļa pasta pieteikšanos",
"redirecting": "Pāriet uz administrēšanas paneli…"
},
"already": {
"title": "Iestatīšana jau ir pabeigta",
"body": "{appName} ir iestatīts. Lai turpinātu, piesakieties."
},
"insecure": {
"title": "Jūs veicat iestatīšanu, izmantojot nešifrētu HTTP",
"body": "Šeit ievadītais iestatīšanas marķieris un administratora parole tiks pārsūtīti atklātā tekstā. Ja iespējams, izmantojiet HTTPS: pārtrauciet TLS konteinerā vai tā priekšā esošā reversajā starpniekserverī.",
"try_https": "Mēģināt HTTPS",
"continue_http": "Turpināt ar HTTP"
},
"errors": {
"token_expired": "Šis iestatīšanas marķieris vairs nav derīgs. Restartējiet konteineru, lai žurnālā iegūtu jaunu.",
"session_expired": "Iestatīšanas sesija beidzās. Lai turpinātu, vēlreiz ielīmējiet iestatīšanas marķieri.",
"token_required": "Lai turpinātu, ielīmējiet konteinera žurnālā redzamo iestatīšanas marķieri.",
"already_finished": "Iestatīšana jau ir pabeigta. Pārlādējiet lapu, lai pieteiktos.",
"token_rejected": "Marķieris noraidīts (HTTP {status})",
"step_failed": "Šo soli neizdevās saglabāt (HTTP {status})",
"upload_failed": "Augšupielāde neizdevās (HTTP {status})",
"finish_failed": "Iestatīšanu neizdevās pabeigt (HTTP {status})"
},
"welcome": {
"title": "Sveicināti",
"body": "Lai turpinātu, ielīmējiet konteinera žurnālā redzamo iestatīšanas marķieri. Marķieris beidzas pēc 1 stundas.",
"token_label": "Iestatīšanas marķieris",
"token_placeholder": "32 baitu heksadecimāls marķieris",
"verifying": "Pārbauda…",
"continue": "Turpināt"
},
"server": {
"title": "Serveris",
"subtitle": "Kur atrodas jūsu pasts.",
"app_name": "Lietotnes nosaukums",
"url_label": "JMAP servera URL",
"url_hint": "Noklusējuma serveris, ar kuru savienojas lietotāji. Piemērs: https://mail.example.com",
"test": "Pārbaudīt",
"testing": "Pārbauda…",
"probe_ok": "Savienots: tas izskatās pēc JMAP servera.",
"probe_not_jmap": "Serveris atbild, bet neizskatās pēc JMAP galapunkta.",
"probe_invalid": "Šis URL nav derīgs. Tam jāsākas ar http:// vai https://.",
"probe_unreachable": "Neizdevās izveidot savienojumu ar šo adresi. Pārbaudiet URL un vai serveris darbojas.",
"not_jmap_hint": "Tā var notikt, ja reversais starpniekserveris tajā pašā domēnā JMAP maršrutē atsevišķi. Citādi URL parasti ir nepareizs.",
"not_jmap_confirm": "Esmu pārliecināts, ka URL ir pareizs, tomēr turpināt.",
"http_title": "Šis URL izmanto nešifrētu HTTP.",
"http_body": "Paroles un vēstuļu saturs starp lietotājiem un jūsu serveri tiks pārsūtīts nešifrēti. Ražošanā izmantojiet {https}: pārtrauciet TLS pasta serverī vai tā priekšā esošā reversajā starpniekserverī.",
"local_title": "Šis URL darbojas tikai lokāli.",
"local_body": "Pasts tiek ielādēts tieši lietotāja pārlūkā, tāpēc JMAP URL jābūt sasniedzamam visur, kur lietotāji piesakās, nevis tikai no šī datora vai lokālā tīkla. Ražošanā izmantojiet publisku resursdatora nosaukumu (piem., {example}).",
"additional_title": "Papildu JMAP serveri",
"additional_hint": "Neobligāti. Piedāvājiet pieteikšanās sarakstā vairākus serverus, noderīgi pakalpojumu sniedzējiem ar vairākām Stalwart instancēm.",
"additional_show": "Rādīt ({count})",
"add": "Pievienot",
"row_title": "Serveris Nr. {index}",
"id_label": "ID",
"id_hint": "Unikāls īsais nosaukums, piem., eu-1",
"label_label": "Nosaukums",
"label_hint": "Redzams lietotājiem",
"row_url": "URL",
"domains_label": "Domēni",
"domains_hint": "Atdalīti ar komatiem. Pēc e-pasta domēna pieteikšanās laikā tiek izvēlēts šis serveris.",
"add_server": "Pievienot serveri",
"auto_pick": "Izvēlēties serveri pēc e-pasta domēna",
"auto_pick_hint": "Kad lietotājs ievada savu e-pastu, tiek izvēlēts atbilstošais serveris no iepriekš redzamā saraksta.",
"row_id_required": "Serveris Nr. {index}: ID ir obligāts",
"row_id_invalid": "Serveris Nr. {index}: ID var saturēt tikai burtus, ciparus, - un _, un tam jāsākas ar burtu vai ciparu",
"row_id_duplicate": "Serveris Nr. {index}: ID “{id}” atkārtojas",
"row_url_required": "Serveris Nr. {index}: URL ir obligāts",
"row_url_scheme": "Serveris Nr. {index}: URL jāsākas ar http:// vai https://",
"stalwart": "Iespējot Stalwart specifiskās funkcijas",
"stalwart_hint": "Pievieno paroles maiņu un Sieve filtru pārvaldību. Droši var iespējot arī citiem serveriem."
},
"auth": {
"title": "Autentifikācija",
"subtitle": "Pieteikšanās ar paroli ir pieejama vienmēr. OAuth/OIDC nav obligāts.",
"oauth": "Iespējot OAuth2 / OpenID Connect",
"oauth_only": "Tikai OAuth (paslēpt paroles veidlapu)",
"client_id": "OAuth klienta ID",
"client_secret": "OAuth klienta noslēpums",
"client_secret_hint": "Atstājiet tukšu publiskiem klientiem, kas izmanto tikai PKCE.",
"client_secret_placeholder": "Ielīmējiet noslēpumu",
"issuer": "OAuth izdevēja URL",
"issuer_hint": "Ārējiem identitātes nodrošinātājiem (Keycloak, Authentik, Entra ID u. c.)."
},
"security": {
"title": "Drošība un sesijas",
"subtitle": "Sesijas noslēpums ļauj izmantot “Atcerēties mani” un iestatījumu sinhronizāciju.",
"generated": "Sesijas noslēpums izveidots",
"customize": "Pielāgot",
"generated_hint": "Jums tika izveidots 32 baitu noslēpums. Mainiet to tikai tad, ja ir konkrēts iemesls.",
"secret_label": "Sesijas noslēpums",
"secret_hint": "Ielīmējiet savu vērtību vai izveidojiet jaunu.",
"regenerate": "Izveidot no jauna",
"sync": "Sinhronizēt lietotāju iestatījumus starp ierīcēm",
"sync_hint": "Saglabā lietotāju preferences serverī, šifrētas ar sesijas noslēpumu.",
"telemetry": "Sūtīt anonīmu lietojuma statistiku, lai uzlabotu Bulwark",
"telemetry_hint": "Pēc noklusējuma izslēgts. Viens anonīms signāls dienā ar versiju, platformu un ieslēgtajām funkcijām; nekad e-pasta adreses, resursdatoru nosaukumi vai IP adreses. To var jebkurā laikā mainīt administrēšanas iestatījumos."
},
"logging": {
"title": "Žurnalēšana",
"subtitle": "Lietotnes žurnālu formāts un detalizācija.",
"format": "Formāts",
"format_text": "text: krāsains, viegli lasāms",
"format_json": "json: strukturēts, žurnālu apkopošanai",
"level": "Līmenis",
"level_info": "info (ieteicams)"
},
"branding": {
"title": "Zīmols",
"subtitle": "Visi lauki nav obligāti. Augšupielādējiet failu vai ielīmējiet URL; izlaistajam tiek izmantotas noklusējuma vērtības.",
"company": "Uzņēmuma vai organizācijas nosaukums",
"favicon": "Favicon",
"favicon_hint": "Pārlūka cilnes ikona. Ieteicams SVG.",
"login_light": "Pieteikšanās logotips (gaišais režīms)",
"login_light_hint": "Rāda pieteikšanās lapā uz gaiša fona.",
"login_dark": "Pieteikšanās logotips (tumšais režīms)",
"login_dark_hint": "Rāda pieteikšanās lapā uz tumša fona.",
"sidebar_light": "Sānjoslas logotips (gaišais režīms)",
"sidebar_light_hint": "Rāda pēc pieteikšanās. Atstājiet tukšu, ja nav vajadzīgs.",
"sidebar_dark": "Sānjoslas logotips (tumšais režīms)",
"sidebar_dark_hint": "Sānjoslas logotipa versija tumšajam režīmam.",
"website": "Tīmekļa vietnes URL",
"imprint": "Juridiskās informācijas URL",
"privacy": "Privātuma politikas URL",
"drop": "Noklikšķiniet vai nometiet šeit",
"uploading": "Augšupielādē…",
"uploaded": "Augšupielādēts fails",
"formats": "SVG, PNG, JPEG, WebP vai ICO · līdz 2 MB",
"use_url": "Izmantot URL",
"hide_url": "Paslēpt URL",
"url_placeholder": "https://… vai /branding/fails.svg"
},
"review": {
"title": "Gandrīz gatavs",
"subtitle": "Pārskatiet iestatījumus, izvēlieties administratora paroli un lietojiet.",
"server": "Serveris",
"app_name": "Lietotnes nosaukums",
"jmap_server": "JMAP serveris",
"additional": "Papildu serveri",
"additional_count": "Iestatīti: {count}",
"additional_auto_pick": "Iestatīti: {count} · izvēle pēc domēna",
"stalwart": "Stalwart funkcijas",
"auth": "Autentifikācija",
"method": "Metode",
"oauth_only": "Tikai OAuth",
"password_oauth": "Parole + OAuth",
"password_only": "Tikai parole",
"client_id": "OAuth klienta ID",
"security": "Drošība",
"secret": "Sesijas noslēpums",
"configured": "Iestatīts",
"not_set": "Nav iestatīts",
"remember_me": "Atcerēties mani",
"available": "Pieejams",
"disabled": "Atspējots",
"sync": "Iestatījumu sinhronizācija",
"needs_secret": "Nepieciešams sesijas noslēpums",
"telemetry": "Anonīma telemetrija",
"logging": "Žurnalēšana",
"format": "Formāts",
"level": "Līmenis",
"branding": "Zīmols",
"customizations": "Pielāgojumi",
"custom_assets": "Iestatīti savi faili",
"defaults": "Noklusējuma vērtības",
"company": "Uzņēmuma nosaukums",
"password_title": "Izvēlieties administratora paroli",
"password_hint": "Ar to piesakīsieties vietnē {path}. Vismaz 8 rakstzīmes.",
"new_password": "Jaunā parole",
"confirm": "Apstiprināt",
"too_short": "Vismaz 8 rakstzīmes.",
"mismatch": "Paroles nesakrīt.",
"looks_good": "Izskatās labi.",
"advanced": "Papildu",
"lock": "Pēc iestatīšanas bloķēt konfigurāciju",
"lock_hint": "Izveido marķiera failu. Pēc tam pievienojiet konfigurācijas sējumu tikai lasīšanai, un lietotne atteiks turpmākas konfigurācijas izmaiņas. Audita žurnāli un pieteikšanās stāvoklis paliek rakstāmi stāvokļa sējumā.",
"applying": "Lieto…",
"finish": "Lietot un pabeigt"
}
},
"errors": {
"page_error_title": "Kaut kas nogāja griezi",
"page_error_description": "Notikusi neparedzēta kļūda. Lūdzu, mēģiniet vēlreiz vai atgriezieties sākumlapā.",
+255 -6
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Тохиргоо",
"back_to_mail": "Мэйл рүү буцах",
"back_to_settings": "Тохиргоо руу буцах",
"save_success": "Тохиргоог амжилттай хадгаллаа",
"import_success": "Тохиргоог амжилттай импортоллоо",
"import_error": "Тохиргоог оруулж чадсангүй",
@@ -1348,6 +1349,7 @@
"id_exists": "Энэ хаягийн ID аль хэдийн байна",
"edit": "Таг засах",
"delete": "Тагыг устгах",
"deleted": "Таг устгасан",
"save": "Хадгалах",
"add": "Нэмэх",
"cancel": "Цуцлах",
@@ -1852,6 +1854,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Бусад төхөөрөмж үүнээс бүртгэлүүдийг сэргээх боломжгүй болно.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1957,6 +1960,8 @@
"removed": "Аппын нууц үгийг устгасан",
"add_error": "Аппын нууц үгийг үүсгэж чадсангүй",
"remove_error": "Аппын нууц үгийг устгаж чадсангүй",
"delete_title": "Аппын нууц үгийг устгах уу?",
"delete_message": "{name}-ээр нэвтэрдэг аппууд цаашид нэвтэрч чадахгүй.",
"none": "Аппын нууц үгийг тохируулаагүй"
},
"api_keys": {
@@ -1969,6 +1974,8 @@
"removed": "API түлхүүрийг устгасан",
"add_error": "API түлхүүр үүсгэж чадсангүй",
"remove_error": "API түлхүүрийг устгаж чадсангүй",
"delete_title": "API түлхүүрийг устгах уу?",
"delete_message": "{name}-ийг ашигладаг скрипт, холболтууд хандах эрхээ алдана.",
"none": " API түлхүүр тохируулаагүй"
},
"encryption": {
@@ -1988,6 +1995,9 @@
"configure_title": "Энэ түлхүүрээр шифрлэлтийг идэвхжүүлнэ",
"enable_button": "Идэвхжүүлэх",
"disable_tooltip": "Шифрлэлтийг идэвхгүй болгох",
"disable_title": "Хадгалсан өгөгдлийн шифрлэлтийг унтраах уу?",
"disable_message": "Одооноос шинэ захидлыг шифрлэлтгүй хадгална.",
"disable_button": "Унтраах",
"enabled_success": "Амарсан үед шифрлэлт идэвхжсэн"
},
"public_keys": {
@@ -1995,7 +2005,14 @@
"name_placeholder": "жишээ нь. Миний түлхүүр",
"key_label": "Нийтийн түлхүүр (S/MIME эсвэл PGP ASCII хуягт)",
"key_placeholder": "Нийтийн түлхүүрээ энд буулгана уу",
"none": "Нийтийн түлхүүр тохируулаагүй"
"none": "Нийтийн түлхүүр тохируулаагүй",
"title": "Нийтийн түлхүүрүүд",
"added": "Нийтийн түлхүүр нэмсэн",
"removed": "Нийтийн түлхүүр устгасан",
"add_error": "Нийтийн түлхүүр нэмж чадсангүй",
"remove_error": "Нийтийн түлхүүр устгаж чадсангүй",
"delete_title": "Нийтийн түлхүүрийг устгах уу?",
"delete_message": "{name}-ийг серверээс устгана."
},
"email_client": {
"title": "И-мэйл үйлчлүүлэгчийн тохиргоо",
@@ -2096,6 +2113,7 @@
},
"save": "Өөрчлөлтийг хадгалах",
"saving": "Хадгалж байна...",
"saved": "Хадгалсан",
"warnings": {
"end_before_start": "Төгсгөлийн огноо нь эхлэх огнооноос хойш байх ёстой",
"invalid_date": "Цагтай хүчинтэй эхлэх болон дуусах огноо оруулах эсвэл талбарыг хоосон үлдээнэ үү",
@@ -2162,12 +2180,17 @@
"new_folder_name": "Хавтасны нэр",
"rename": "Нэрээ өөрчлөх",
"change_icon": " Дүрсийг өөрчлөх",
"actions": "{name}-ийн үйлдлүүд",
"expand": "{name}-ийг дэлгэх",
"collapse": "{name}-ийг хураах",
"counter": "Тоолуур",
"count_mode": "{name}-ийн тоолуур",
"count_mode_default": "Тоолуур: үндсэн",
"count_mode_unread": "Зөвхөн уншаагүй",
"count_mode_all": "Уншаагүй ба нийт",
"count_mode_none": "Тоолуургүй",
"delete": "Устгах",
"delete_title": "Хавтсыг устгах уу?",
"confirm_delete": "Та \"{name}\"-г устгахдаа итгэлтэй байна уу? Энэ хавтсанд байгаа имэйлийг Хогийн сав руу зөөх болно.",
"create": "Үүсгэх",
"cancel": "Цуцлах",
@@ -2187,7 +2210,8 @@
"advanced": {
"debug_mode": {
"label": "Дбаг хийх горим",
"description": "Алдааг олж засварлахын тулд нарийвчилсан бүртгэлийг идэвхжүүлэх"
"description": "Алдааг олж засварлахын тулд нарийвчилсан бүртгэлийг идэвхжүүлэх",
"toggle": "Дэлгэрэнгүй бүртгэл"
},
"debug_categories": {
"description": "Ямар ангилалд бүртгүүлэхээ сонгоно уу. Консолын дуу чимээг багасгахын тулд шаардлагагүй ангиллыг идэвхгүй болго.",
@@ -2232,12 +2256,15 @@
"refresh_cache": {
"label": "Кэш хийсэн өгөгдлийг сэргээх",
"description": "Харилцагч, хуанли, фолдеруудыг серверээс дахин ачаална уу. Таны бүртгэл болон сешнүүдийг хадгална — гаралгүй хуучирсан эсвэл буруу харагдацыг засна.",
"button": "Сэргээх"
"button": "Сэргээх",
"confirm_title": "Кэшлэгдсэн өгөгдлийг шинэчлэх үү?"
},
"reset_settings": {
"label": "Тохиргоог дахин тохируулах",
"description": "Бүх тохиргоог анхдагч утга руу нь сэргээнэ",
"button": "Өгөгдмөл рүү дахин тохируулах"
"button": "Өгөгдмөл рүү дахин тохируулах",
"confirm_title": "Бүх тохиргоог анхны байдалд нь оруулах уу?",
"done": "Тохиргоог анхны байдалд оруулсан"
},
"export_settings": {
"label": "Экспортын тохиргоо",
@@ -2312,7 +2339,15 @@
"no_address_books": "Хаягийн дэвтэр олдсонгүй",
"categories_title": "Ангилал",
"categories_description": "Харилцах ангиллын нэрийг өөрчлөх",
"no_categories": "Ангилал олдсонгүй"
"no_categories": "Ангилал олдсонгүй",
"delete_category": "Ангиллыг устгах",
"category_deleted": "Ангиллыг устгасан",
"category_delete_failed": "Ангиллыг устгаж чадсангүй"
},
"calendar_management": {
"delete_title": "Хуанлийг устгах уу?",
"clear_title": "Бүх үйл явдлыг цэвэрлэх үү?",
"unsubscribe_title": "Энэ хуанлийн захиалгыг цуцлах уу?"
},
"downloads": {
"title": "Татаж авах",
@@ -2378,7 +2413,7 @@
"validate": "Баталгаажуулах",
"validation_success": "Скрипт хүчинтэй",
"validation_error": "Script алдаатай байна",
"save": "Дүрэм хадгалах",
"save": "Хадгалах",
"saving": "Хадгалж байна...",
"saved": "Шүүлтүүрийг амжилттай хадгаллаа",
"save_failed": "Шүүлтүүрийг хадгалж чадсангүй",
@@ -2392,6 +2427,8 @@
"add_condition": "Нөхцөл нэмэх",
"actions": "Үйлдэл",
"add_action": "Үйлдэл нэмэх",
"remove_condition": "Нөхцөлийг хасах",
"remove_action": "Үйлдлийг хасах",
"stop_processing": "Дараагийн дүрмийг боловсруулахаа зогсоох",
"include_spam": "Спам гэж тэмдэглэсэн захидлыг мөн зөөх",
"attachment_type_placeholder": "жишээ нь pdf, doc, jpg",
@@ -2468,6 +2505,7 @@
"match_type": "Тохирох төрөл",
"reset_to_visual": "Visual builder руу дахин тохируулах",
"reset_warning": "Энэ нь одоогийн скриптийг устгаж, шинээр эхэлнэ.",
"reset_title": "Харагдах засварлагч руу буцаах уу?",
"confirm_reset": "Дахин тохируулах",
"validation_empty_name": "Дүрмийн нэр шаардлагатай",
"validation_empty_conditions": "Дор хаяж нэг утга бүхий нөхцөл шаардлагатай",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Bulwark Webmail-ийн тохиргоо",
"subtitle": "Вэб шуудангийн инстансаа хөтчөөс тохируулна уу.",
"loading": "Ачаалж байна…",
"back": "Буцах",
"next": "Дараах",
"saving": "Хадгалж байна…",
"show": "Харуулах",
"hide": "Нуух",
"remove": "Хасах",
"on": "Асаалттай",
"off": "Унтраалттай",
"dismiss": "Хаах",
"unknown_error": "Үл мэдэгдэх алдаа",
"steps": {
"welcome": "Тавтай морил",
"server": "Сервер",
"auth": "Нэвтрэлт",
"security": "Аюулгүй байдал",
"logging": "Лог",
"branding": "Брэнд",
"review": "Хянах"
},
"read_only": {
"title": "Тохиргоо зөвхөн уншигдана",
"body": "Тохиргооны боть зөвхөн уншихаар холбогдсон эсвэл {variable} тохируулагдсан байна. Бичих эрхтэй холбох эсвэл хувьсагчийг устгаад контейнерыг дахин эхлүүлнэ үү."
},
"done": {
"title": "Бүх зүйл бэлэн",
"body": "{appName} тохируулагдаж, ашиглахад бэлэн боллоо.",
"admin_sign_in": "Админ самбарт нэвтрэх",
"webmail_login": "Вэб шуудангийн нэвтрэх хуудсыг нээх",
"redirecting": "Админ самбар руу шилжүүлж байна…"
},
"already": {
"title": "Тохиргоо аль хэдийн дууссан",
"body": "{appName} тохируулагдсан. Үргэлжлүүлэхийн тулд нэвтэрнэ үү."
},
"insecure": {
"title": "Та тохиргоог шифрлэгдээгүй HTTP-ээр ажиллуулж байна",
"body": "Энд оруулах тохиргооны токен болон админы нууц үг энгийн текстээр дамжина. Боломжтой бол HTTPS ашиглана уу: TLS-ийг контейнер эсвэл түүний өмнөх урвуу прокси дээр төгсгөнө.",
"try_https": "HTTPS туршиж үзэх",
"continue_http": "HTTP-ээр үргэлжлүүлэх"
},
"errors": {
"token_expired": "Энэ тохиргооны токен хүчингүй болсон. Логоос шинийг авахын тулд контейнерыг дахин эхлүүлнэ үү.",
"session_expired": "Тохиргооны сешн дууссан. Үргэлжлүүлэхийн тулд тохиргооны токеноо дахин буулгана уу.",
"token_required": "Үргэлжлүүлэхийн тулд контейнерын логт харагдах тохиргооны токеныг буулгана уу.",
"already_finished": "Тохиргоо аль хэдийн дууссан. Нэвтрэхийн тулд хуудсыг дахин ачаална уу.",
"token_rejected": "Токеныг татгалзлаа (HTTP {status})",
"step_failed": "Энэ алхмыг хадгалж чадсангүй (HTTP {status})",
"upload_failed": "Байршуулж чадсангүй (HTTP {status})",
"finish_failed": "Тохиргоог дуусгаж чадсангүй (HTTP {status})"
},
"welcome": {
"title": "Тавтай морил",
"body": "Үргэлжлүүлэхийн тулд контейнерын логт харагдах тохиргооны токеныг буулгана уу. Токен 1 цагийн дараа хүчингүй болно.",
"token_label": "Тохиргооны токен",
"token_placeholder": "32 байтын арван зургаатын токен",
"verifying": "Шалгаж байна…",
"continue": "Үргэлжлүүлэх"
},
"server": {
"title": "Сервер",
"subtitle": "Таны шуудан хаана байдаг.",
"app_name": "Аппликейшний нэр",
"url_label": "JMAP серверийн URL",
"url_hint": "Хэрэглэгчдийн холбогдох үндсэн сервер. Жишээ: https://mail.example.com",
"test": "Турших",
"testing": "Туршиж байна…",
"probe_ok": "Холбогдлоо: JMAP сервер бололтой.",
"probe_not_jmap": "Сервер хариу өгсөн ч JMAP төгсгөлийн цэг биш бололтой.",
"probe_invalid": "Энэ URL буруу байна. http:// эсвэл https://-ээр эхлэх ёстой.",
"probe_unreachable": "Энэ хаяг руу холбогдож чадсангүй. URL болон сервер ажиллаж байгаа эсэхийг шалгана уу.",
"not_jmap_hint": "Урвуу прокси нэг домэйн дээр JMAP-ийг тусад нь чиглүүлж байвал ингэж болно. Үгүй бол URL ихэвчлэн буруу байдаг.",
"not_jmap_confirm": "Энэ URL зөв гэдэгт итгэлтэй байна, үргэлжлүүлэх.",
"http_title": "Энэ URL шифрлэгдээгүй HTTP ашиглаж байна.",
"http_body": "Нууц үг болон захидлын агуулга хэрэглэгчид болон таны серверийн хооронд шифрлэлтгүй дамжина. Үйлдвэрлэлийн орчинд {https} ашиглана уу: TLS-ийг шуудангийн сервер эсвэл түүний өмнөх урвуу прокси дээр төгсгөнө.",
"local_title": "Энэ URL зөвхөн дотоод сүлжээнд ажиллана.",
"local_body": "Шууданг хэрэглэгчийн хөтөч шууд татдаг тул JMAP URL нь зөвхөн энэ компьютер эсвэл дотоод сүлжээнээс бус, хэрэглэгчид нэвтэрдэг бүх газраас хүрэх боломжтой байх ёстой. Үйлдвэрлэлийн орчинд нийтийн хостын нэр (жишээ нь {example}) ашиглана уу.",
"additional_title": "Нэмэлт JMAP серверүүд",
"additional_hint": "Заавал биш. Нэвтрэх жагсаалтад хэд хэдэн сервер санал болгоно; олон Stalwart инстанс ажиллуулдаг үйлчилгээ үзүүлэгчдэд тохиромжтой.",
"additional_show": "Харуулах ({count})",
"add": "Нэмэх",
"row_title": "Сервер №{index}",
"id_label": "ID",
"id_hint": "Давтагдашгүй богино нэр, жишээ нь eu-1",
"label_label": "Шошго",
"label_hint": "Хэрэглэгчдэд харагдана",
"row_url": "URL",
"domains_label": "Домэйнууд",
"domains_hint": "Таслалаар тусгаарлана. Нэвтрэх үед имэйлийн домэйноор энэ серверийг сонгоход ашиглана.",
"add_server": "Сервер нэмэх",
"auto_pick": "Имэйлийн домэйноор сервер сонгох",
"auto_pick_hint": "Хэрэглэгч имэйлээ бичихэд дээрх жагсаалтаас тохирох сервер сонгогдоно.",
"row_id_required": "Сервер №{index}: ID заавал шаардлагатай",
"row_id_invalid": "Сервер №{index}: ID зөвхөн үсэг, тоо, - болон _ агуулж, үсэг эсвэл тоогоор эхлэх ёстой",
"row_id_duplicate": "Сервер №{index}: “{id}” ID давхардсан байна",
"row_url_required": "Сервер №{index}: URL заавал шаардлагатай",
"row_url_scheme": "Сервер №{index}: URL нь http:// эсвэл https://-ээр эхлэх ёстой",
"stalwart": "Stalwart-ийн тусгай боломжуудыг идэвхжүүлэх",
"stalwart_hint": "Нууц үг солих болон Sieve шүүлтүүрийн удирдлагыг нэмнэ. Бусад сервер дээр идэвхжүүлэхэд аюулгүй."
},
"auth": {
"title": "Баталгаажуулалт",
"subtitle": "Нууц үгээр нэвтрэх нь үргэлж боломжтой. OAuth/OIDC заавал биш.",
"oauth": "OAuth2 / OpenID Connect идэвхжүүлэх",
"oauth_only": "Зөвхөн OAuth (нууц үгийн маягтыг нуух)",
"client_id": "OAuth клиентийн ID",
"client_secret": "OAuth клиентийн нууц",
"client_secret_hint": "Зөвхөн PKCE ашигладаг нийтийн клиентэд хоосон үлдээнэ үү.",
"client_secret_placeholder": "Нууцыг буулгана уу",
"issuer": "OAuth гаргагчийн URL",
"issuer_hint": "Гадны таних үйлчилгээ үзүүлэгчдэд (Keycloak, Authentik, Entra ID гэх мэт)."
},
"security": {
"title": "Аюулгүй байдал ба сешн",
"subtitle": "Сешний нууц нь “Намайг санах” болон тохиргооны синк-ийг боломжтой болгоно.",
"generated": "Сешний нууц үүсгэлээ",
"customize": "Тохируулах",
"generated_hint": "Танд 32 байтын нууц үүсгэлээ. Тодорхой шалтгаан байвал л өөрчилнө үү.",
"secret_label": "Сешний нууц",
"secret_hint": "Өөрийн утгыг буулгах эсвэл шинээр үүсгэнэ үү.",
"regenerate": "Дахин үүсгэх",
"sync": "Хэрэглэгчийн тохиргоог төхөөрөмж хооронд синк хийх",
"sync_hint": "Хэрэглэгчдийн тохиргоог сешний нууцаар шифрлэн сервер дээр хадгална.",
"telemetry": "Bulwark-ийг сайжруулахад туслах нэргүй хэрэглээний статистик илгээх",
"telemetry_hint": "Анхдагчаар унтраалттай. Өдөрт нэг удаа хувилбар, платформ, идэвхтэй боломжуудыг агуулсан нэргүй дохио илгээнэ; имэйл хаяг, хостын нэр, IP хэзээ ч илгээхгүй. Үүнийг админ тохиргоонд хүссэн үедээ өөрчилж болно."
},
"logging": {
"title": "Лог",
"subtitle": "Аппликейшний логийн формат болон дэлгэрэнгүй байдал.",
"format": "Формат",
"format_text": "text: өнгөтэй, уншихад хялбар",
"format_json": "json: бүтэцтэй, лог нэгтгэхэд",
"level": "Түвшин",
"level_info": "info (санал болгосон)"
},
"branding": {
"title": "Брэнд",
"subtitle": "Бүх талбар заавал биш. Файл байршуулах эсвэл URL буулгана уу; алгассан зүйлд анхдагч утга ашиглана.",
"company": "Компани эсвэл байгууллагын нэр",
"favicon": "Сайтын тэмдэг",
"favicon_hint": "Хөтчийн табын дүрс. SVG санал болгоно.",
"login_light": "Нэвтрэх лого (цайвар горим)",
"login_light_hint": "Нэвтрэх хуудсанд цайвар дэвсгэр дээр харагдана.",
"login_dark": "Нэвтрэх лого (харанхуй горим)",
"login_dark_hint": "Нэвтрэх хуудсанд харанхуй дэвсгэр дээр харагдана.",
"sidebar_light": "Хажуугийн самбарын лого (цайвар горим)",
"sidebar_light_hint": "Нэвтэрсний дараа харагдана. Хэрэггүй бол хоосон үлдээнэ үү.",
"sidebar_dark": "Хажуугийн самбарын лого (харанхуй горим)",
"sidebar_dark_hint": "Хажуугийн самбарын логоны харанхуй горимын хувилбар.",
"website": "Вэбсайтын URL",
"imprint": "Хууль зүйн мэдээллийн URL",
"privacy": "Нууцлалын бодлогын URL",
"drop": "Дарах эсвэл энд чирж оруулах",
"uploading": "Байршуулж байна…",
"uploaded": "Байршуулсан файл",
"formats": "SVG, PNG, JPEG, WebP эсвэл ICO · хамгийн ихдээ 2 MB",
"use_url": "URL ашиглах",
"hide_url": "URL нуух",
"url_placeholder": "https://… эсвэл /branding/file.svg"
},
"review": {
"title": "Бараг дууслаа",
"subtitle": "Тохиргоогоо хянаж, админы нууц үг сонгоод хэрэгжүүлнэ үү.",
"server": "Сервер",
"app_name": "Аппын нэр",
"jmap_server": "JMAP сервер",
"additional": "Нэмэлт серверүүд",
"additional_count": "{count} тохируулсан",
"additional_auto_pick": "{count} тохируулсан · домэйноор сонгоно",
"stalwart": "Stalwart-ийн боломжууд",
"auth": "Баталгаажуулалт",
"method": "Арга",
"oauth_only": "Зөвхөн OAuth",
"password_oauth": "Нууц үг + OAuth",
"password_only": "Зөвхөн нууц үг",
"client_id": "OAuth клиентийн ID",
"security": "Аюулгүй байдал",
"secret": "Сешний нууц",
"configured": "Тохируулсан",
"not_set": "Тохируулаагүй",
"remember_me": "Намайг санах",
"available": "Боломжтой",
"disabled": "Идэвхгүй",
"sync": "Тохиргооны синк",
"needs_secret": "Сешний нууц шаардлагатай",
"telemetry": "Нэргүй телеметр",
"logging": "Лог",
"format": "Формат",
"level": "Түвшин",
"branding": "Брэнд",
"customizations": "Тохируулгууд",
"custom_assets": "Өөрийн файлуудыг тохируулсан",
"defaults": "Анхдагч утга",
"company": "Компанийн нэр",
"password_title": "Админы нууц үг сонгох",
"password_hint": "Үүгээр {path} хаягт нэвтэрнэ. Хамгийн багадаа 8 тэмдэгт.",
"new_password": "Шинэ нууц үг",
"confirm": "Баталгаажуулах",
"too_short": "Хамгийн багадаа 8 тэмдэгт.",
"mismatch": "Нууц үгнүүд таарахгүй байна.",
"looks_good": "Зүгээр байна.",
"advanced": "Нарийвчилсан",
"lock": "Тохиргооны дараа тохиргоог түгжих",
"lock_hint": "Тэмдэглэгээний файл үүсгэнэ. Дараа нь тохиргооны ботийг зөвхөн уншихаар холбоход апп цаашдын тохиргоо бичихээс татгалзана. Аудитын лог болон нэвтрэлтийн төлөв төлөвийн ботид бичигдэх боломжтой хэвээр үлдэнэ.",
"applying": "Хэрэгжүүлж байна…",
"finish": "Хэрэгжүүлээд дуусгах"
}
},
"errors": {
"page_error_title": "Алдаа гарлаа",
"page_error_description": "Бидэнд гэнэтийн алдаа гарлаа. Дахин оролдох эсвэл нүүр хуудас руу буцна уу.",
+255 -6
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Innstillinger",
"back_to_mail": "Tilbake til e-post",
"back_to_settings": "Tilbake til innstillinger",
"save_success": "Innstillingene er lagret",
"import_success": "Innstillingene er importert",
"import_error": "Kunne ikke importere innstillinger",
@@ -1348,6 +1349,7 @@
"id_exists": "Denne etikett-ID-en finnes allerede",
"edit": "Rediger etikett",
"delete": "Slett etikett",
"deleted": "Etikett slettet",
"save": "Lagre",
"add": "Legg til",
"cancel": "Avbryt",
@@ -1852,6 +1854,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Andre enheter kan ikke lenger gjenopprette kontoene fra den.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1957,6 +1960,8 @@
"removed": "App-passordet er fjernet",
"add_error": "Kunne ikke opprette app-passord",
"remove_error": "Kunne ikke fjerne app-passordet",
"delete_title": "Slette app-passordet?",
"delete_message": "Apper som logger på med {name}, kan ikke logge på lenger.",
"none": "Ingen app-passord er opprettet"
},
"api_keys": {
@@ -1969,6 +1974,8 @@
"removed": "API-nøkkelen er fjernet",
"add_error": "Kunne ikke opprette API-nøkkel",
"remove_error": "Kunne ikke fjerne API-nøkkelen",
"delete_title": "Slette API-nøkkelen?",
"delete_message": "Skript og integrasjoner som bruker {name}, mister tilgangen.",
"none": "Ingen API-nøkler er opprettet"
},
"encryption": {
@@ -1988,6 +1995,9 @@
"configure_title": "Aktiver kryptering med denne nøkkelen",
"enable_button": "Aktiver",
"disable_tooltip": "Deaktiver kryptering",
"disable_title": "Slå av kryptering av lagrede data?",
"disable_message": "Ny e-post lagres heretter uten kryptering.",
"disable_button": "Slå av",
"enabled_success": "Kryptering av lagrede data er aktivert"
},
"public_keys": {
@@ -1995,7 +2005,14 @@
"name_placeholder": "f.eks. Min nøkkel",
"key_label": "Offentlig nøkkel (S/MIME eller ASCII-armert PGP)",
"key_placeholder": "Lim inn den offentlige nøkkelen din her",
"none": "Ingen offentlige nøkler er lagt til"
"none": "Ingen offentlige nøkler er lagt til",
"title": "Offentlige nøkler",
"added": "Offentlig nøkkel lagt til",
"removed": "Offentlig nøkkel slettet",
"add_error": "Kunne ikke legge til den offentlige nøkkelen",
"remove_error": "Kunne ikke slette den offentlige nøkkelen",
"delete_title": "Slette den offentlige nøkkelen?",
"delete_message": "{name} fjernes fra serveren."
},
"email_client": {
"title": "Oppsett av e-postklient",
@@ -2096,6 +2113,7 @@
},
"save": "Lagre endringer",
"saving": "Lagrer...",
"saved": "Lagret",
"warnings": {
"end_before_start": "Sluttdatoen må være etter startdatoen",
"invalid_date": "Angi en gyldig start- og sluttdato med klokkeslett, eller la feltet stå tomt",
@@ -2162,12 +2180,17 @@
"new_folder_name": "Mappenavn",
"rename": "Gi nytt navn",
"change_icon": "Endre ikon",
"actions": "Handlinger for {name}",
"expand": "Vis innholdet i {name}",
"collapse": "Skjul innholdet i {name}",
"counter": "Teller",
"count_mode": "Teller for {name}",
"count_mode_default": "Teller: standard",
"count_mode_unread": "Bare uleste",
"count_mode_all": "Uleste og totalt",
"count_mode_none": "Ingen teller",
"delete": "Slett",
"delete_title": "Slette mappen?",
"confirm_delete": "Er du sikker på at du vil slette «{name}»? E-postene i denne mappen flyttes til papirkurven.",
"create": "Opprett",
"cancel": "Avbryt",
@@ -2187,7 +2210,8 @@
"advanced": {
"debug_mode": {
"label": "Feilsøkingsmodus",
"description": "Aktiver detaljert logging for feilsøking"
"description": "Aktiver detaljert logging for feilsøking",
"toggle": "Detaljert logging"
},
"debug_categories": {
"description": "Velg hvilke kategorier som skal logges. Deaktiver kategorier du ikke trenger, for å redusere antallet meldinger i konsollen.",
@@ -2232,12 +2256,15 @@
"refresh_cache": {
"label": "Oppdater bufrede data",
"description": "Last inn kontakter, kalendere og mapper på nytt fra serveren. Kontoene og øktene beholdes, slik at du kan rette en utdatert eller feil visning uten å logge av.",
"button": "Oppdater"
"button": "Oppdater",
"confirm_title": "Oppdatere bufrede data?"
},
"reset_settings": {
"label": "Tilbakestill innstillinger",
"description": "Gjenopprett standardinnstillingene",
"button": "Tilbakestill til standard"
"button": "Tilbakestill til standard",
"confirm_title": "Tilbakestille alle innstillinger?",
"done": "Innstillinger tilbakestilt"
},
"export_settings": {
"label": "Eksporter innstillinger",
@@ -2312,7 +2339,15 @@
"no_address_books": "Ingen adressebøker funnet",
"categories_title": "Kategorier",
"categories_description": "Gi nytt navn til kontaktkategorier",
"no_categories": "Ingen kategorier funnet"
"no_categories": "Ingen kategorier funnet",
"delete_category": "Slett kategori",
"category_deleted": "Kategori slettet",
"category_delete_failed": "Kunne ikke slette kategorien"
},
"calendar_management": {
"delete_title": "Slette kalenderen?",
"clear_title": "Fjerne alle hendelser?",
"unsubscribe_title": "Avslutte abonnementet på denne kalenderen?"
},
"downloads": {
"title": "Nedlastinger",
@@ -2378,7 +2413,7 @@
"validate": "Valider",
"validation_success": "Skriptet er gyldig",
"validation_error": "Skriptet har feil",
"save": "Lagre regler",
"save": "Lagre",
"saving": "Lagrer...",
"saved": "Filtrene er lagret",
"save_failed": "Kunne ikke lagre filtrene",
@@ -2392,6 +2427,8 @@
"add_condition": "Legg til vilkår",
"actions": "Handlinger",
"add_action": "Legg til handling",
"remove_condition": "Fjern betingelse",
"remove_action": "Fjern handling",
"stop_processing": "Ikke behandle flere regler",
"include_spam": "Flytt også meldinger som er merket som søppelpost",
"attachment_type_placeholder": "f.eks. pdf, doc, jpg",
@@ -2468,6 +2505,7 @@
"match_type": "Samsvar",
"reset_to_visual": "Gå tilbake til visuell redigering",
"reset_warning": "Dette forkaster det gjeldende skriptet og lar deg begynne på nytt.",
"reset_title": "Tilbakestille til den visuelle redigereren?",
"confirm_reset": "Tilbakestill",
"validation_empty_name": "Regelnavn må fylles ut",
"validation_empty_conditions": "Minst ett vilkår må ha en verdi",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Oppsett av Bulwark Webmail",
"subtitle": "Sett opp webmail-instansen din i nettleseren.",
"loading": "Laster inn…",
"back": "Tilbake",
"next": "Neste",
"saving": "Lagrer…",
"show": "Vis",
"hide": "Skjul",
"remove": "Fjern",
"on": "På",
"off": "Av",
"dismiss": "Lukk",
"unknown_error": "Ukjent feil",
"steps": {
"welcome": "Velkommen",
"server": "Server",
"auth": "Pålogging",
"security": "Sikkerhet",
"logging": "Logging",
"branding": "Merkevare",
"review": "Gjennomgang"
},
"read_only": {
"title": "Konfigurasjonen er skrivebeskyttet",
"body": "Konfigurasjonsvolumet er montert skrivebeskyttet, eller {variable} er satt. Monter det med skrivetilgang eller fjern variabelen, og start containeren på nytt."
},
"done": {
"title": "Alt er klart",
"body": "{appName} er satt opp og klar til bruk.",
"admin_sign_in": "Logg på administrasjonspanelet",
"webmail_login": "Åpne pålogging til webmail",
"redirecting": "Du sendes til administrasjonspanelet…"
},
"already": {
"title": "Oppsettet er allerede fullført",
"body": "{appName} er satt opp. Logg på for å fortsette."
},
"insecure": {
"title": "Du kjører oppsettet over ukryptert HTTP",
"body": "Oppsettstokenet og administratorpassordet du skriver inn her, sendes i klartekst. Bruk HTTPS om mulig: avslutt TLS i containeren eller i en omvendt proxy foran den.",
"try_https": "Prøv HTTPS",
"continue_http": "Fortsett over HTTP"
},
"errors": {
"token_expired": "Oppsettstokenet er ikke lenger gyldig. Start containeren på nytt for å få et nytt fra loggen.",
"session_expired": "Oppsettsøkten er utløpt. Lim inn oppsettstokenet på nytt for å fortsette.",
"token_required": "Lim inn oppsettstokenet fra containerloggen for å fortsette.",
"already_finished": "Oppsettet er allerede fullført. Last inn siden på nytt for å logge på.",
"token_rejected": "Token avvist (HTTP {status})",
"step_failed": "Kunne ikke lagre dette trinnet (HTTP {status})",
"upload_failed": "Opplasting mislyktes (HTTP {status})",
"finish_failed": "Kunne ikke fullføre oppsettet (HTTP {status})"
},
"welcome": {
"title": "Velkommen",
"body": "Lim inn oppsettstokenet fra containerloggen for å fortsette. Tokenet utløper etter 1 time.",
"token_label": "Oppsettstoken",
"token_placeholder": "32-byte heks-token",
"verifying": "Bekrefter…",
"continue": "Fortsett"
},
"server": {
"title": "Server",
"subtitle": "Der e-posten din ligger.",
"app_name": "Navn på programmet",
"url_label": "URL til JMAP-serveren",
"url_hint": "Standardserveren brukerne kobler til. Eksempel: https://mail.example.com",
"test": "Test",
"testing": "Tester…",
"probe_ok": "Tilkoblet: dette ser ut som en JMAP-server.",
"probe_not_jmap": "Serveren svarer, men ser ikke ut som et JMAP-endepunkt.",
"probe_invalid": "URL-en er ugyldig. Den må begynne med http:// eller https://.",
"probe_unreachable": "Kunne ikke koble til adressen. Sjekk URL-en og at serveren er tilgjengelig.",
"not_jmap_hint": "Dette kan skje når en omvendt proxy ruter JMAP separat på samme domene. Ellers er URL-en som regel feil.",
"not_jmap_confirm": "Jeg er sikker på at URL-en er riktig, fortsett likevel.",
"http_title": "Denne URL-en bruker ukryptert HTTP.",
"http_body": "Passord og e-postinnhold sendes ukryptert mellom brukerne og serveren din. Bruk {https} i produksjon: avslutt TLS på e-postserveren eller i en omvendt proxy foran den.",
"local_title": "Denne URL-en virker bare lokalt.",
"local_body": "E-post hentes direkte fra brukerens nettleser, så JMAP-URL-en må kunne nås overalt der brukerne logger på, ikke bare fra denne maskinen eller det lokale nettverket. Bruk et offentlig vertsnavn (f.eks. {example}) i produksjon.",
"additional_title": "Flere JMAP-servere",
"additional_hint": "Valgfritt. Vis flere servere i listen ved pålogging, nyttig for leverandører med flere Stalwart-instanser.",
"additional_show": "Vis ({count})",
"add": "Legg til",
"row_title": "Server nr. {index}",
"id_label": "ID",
"id_hint": "Unikt kortnavn, f.eks. eu-1",
"label_label": "Navn",
"label_hint": "Vises for brukerne",
"row_url": "URL",
"domains_label": "Domener",
"domains_hint": "Kommaseparert. Brukes til å velge denne serveren ut fra e-postdomenet ved pålogging.",
"add_server": "Legg til server",
"auto_pick": "Velg server ut fra e-postdomenet",
"auto_pick_hint": "Når en bruker skriver e-postadressen sin, velges den tilsvarende serveren fra listen over.",
"row_id_required": "Server nr. {index}: ID er påkrevd",
"row_id_invalid": "Server nr. {index}: ID kan bare inneholde bokstaver, sifre, - og _ og må begynne med en bokstav eller et siffer",
"row_id_duplicate": "Server nr. {index}: ID-en «{id}» brukes to ganger",
"row_url_required": "Server nr. {index}: URL-en er påkrevd",
"row_url_scheme": "Server nr. {index}: URL-en må begynne med http:// eller https://",
"stalwart": "Slå på Stalwart-spesifikke funksjoner",
"stalwart_hint": "Legger til endring av passord og administrasjon av Sieve-filtre. Trygt å slå på også for andre servere."
},
"auth": {
"title": "Autentisering",
"subtitle": "Pålogging med passord er alltid tilgjengelig. OAuth/OIDC er valgfritt.",
"oauth": "Slå på OAuth2 / OpenID Connect",
"oauth_only": "Bare OAuth (skjul passordskjemaet)",
"client_id": "OAuth-klient-ID",
"client_secret": "OAuth-klienthemmelighet",
"client_secret_hint": "La stå tomt for offentlige klienter som bare bruker PKCE.",
"client_secret_placeholder": "Lim inn hemmeligheten",
"issuer": "URL til OAuth-utsteder",
"issuer_hint": "For eksterne identitetsleverandører (Keycloak, Authentik, Entra ID osv.)."
},
"security": {
"title": "Sikkerhet og økter",
"subtitle": "Økthemmeligheten gjør «Husk meg» og synkronisering av innstillinger mulig.",
"generated": "Økthemmelighet opprettet",
"customize": "Tilpass",
"generated_hint": "En hemmelighet på 32 byte er opprettet for deg. Endre den bare hvis du har en bestemt grunn.",
"secret_label": "Økthemmelighet",
"secret_hint": "Lim inn din egen verdi eller lag en ny.",
"regenerate": "Lag ny",
"sync": "Synkroniser brukerinnstillinger mellom enheter",
"sync_hint": "Lagrer brukernes innstillinger på serveren, kryptert med økthemmeligheten.",
"telemetry": "Send anonym bruksstatistikk for å forbedre Bulwark",
"telemetry_hint": "Av som standard. Ett anonymt signal per dag med versjon, plattform og hvilke funksjoner som er på; aldri e-postadresser, vertsnavn eller IP-adresser. Du kan endre dette når som helst i administrasjonsinnstillingene."
},
"logging": {
"title": "Logging",
"subtitle": "Format og detaljnivå for programloggene.",
"format": "Format",
"format_text": "text: i farger, lettlest",
"format_json": "json: strukturert, for logginnsamling",
"level": "Nivå",
"level_info": "info (anbefalt)"
},
"branding": {
"title": "Merkevare",
"subtitle": "Alle feltene er valgfrie. Last opp en fil eller lim inn en URL; standardverdier brukes for alt du hopper over.",
"company": "Navn på firma eller organisasjon",
"favicon": "Favicon",
"favicon_hint": "Ikon på nettleserfanen. SVG anbefales.",
"login_light": "Påloggingslogo (lys modus)",
"login_light_hint": "Vises på påloggingssiden på lys bakgrunn.",
"login_dark": "Påloggingslogo (mørk modus)",
"login_dark_hint": "Vises på påloggingssiden på mørk bakgrunn.",
"sidebar_light": "Logo i sidepanelet (lys modus)",
"sidebar_light_hint": "Vises etter pålogging. La stå tomt for ingen logo.",
"sidebar_dark": "Logo i sidepanelet (mørk modus)",
"sidebar_dark_hint": "Versjon av sidepanellogoen for mørk modus.",
"website": "URL til nettstedet",
"imprint": "URL til kolofon",
"privacy": "URL til personvernerklæringen",
"drop": "Klikk eller slipp her",
"uploading": "Laster opp…",
"uploaded": "Opplastet fil",
"formats": "SVG, PNG, JPEG, WebP eller ICO · maks. 2 MB",
"use_url": "Bruk URL",
"hide_url": "Skjul URL",
"url_placeholder": "https://… eller /branding/fil.svg"
},
"review": {
"title": "Nesten ferdig",
"subtitle": "Gå gjennom innstillingene, velg et administratorpassord og bruk dem.",
"server": "Server",
"app_name": "Navn på appen",
"jmap_server": "JMAP-server",
"additional": "Flere servere",
"additional_count": "{count} satt opp",
"additional_auto_pick": "{count} satt opp · valgt ut fra domene",
"stalwart": "Stalwart-funksjoner",
"auth": "Autentisering",
"method": "Metode",
"oauth_only": "Bare OAuth",
"password_oauth": "Passord + OAuth",
"password_only": "Bare passord",
"client_id": "OAuth-klient-ID",
"security": "Sikkerhet",
"secret": "Økthemmelighet",
"configured": "Satt opp",
"not_set": "Ikke angitt",
"remember_me": "Husk meg",
"available": "Tilgjengelig",
"disabled": "Deaktivert",
"sync": "Synkronisering av innstillinger",
"needs_secret": "Krever en økthemmelighet",
"telemetry": "Anonym telemetri",
"logging": "Logging",
"format": "Format",
"level": "Nivå",
"branding": "Merkevare",
"customizations": "Tilpasninger",
"custom_assets": "Egne filer satt opp",
"defaults": "Standardverdier",
"company": "Firmanavn",
"password_title": "Velg et administratorpassord",
"password_hint": "Du bruker det til å logge på {path}. Minst 8 tegn.",
"new_password": "Nytt passord",
"confirm": "Bekreft",
"too_short": "Minst 8 tegn.",
"mismatch": "Passordene er ikke like.",
"looks_good": "Ser bra ut.",
"advanced": "Avansert",
"lock": "Lås konfigurasjonen etter oppsettet",
"lock_hint": "Oppretter en markørfil. Monter deretter konfigurasjonsvolumet skrivebeskyttet, så avviser appen flere endringer i konfigurasjonen. Revisjonslogger og påloggingsstatus kan fortsatt skrives i tilstandsvolumet.",
"applying": "Bruker…",
"finish": "Bruk og fullfør"
}
},
"errors": {
"page_error_title": "Noe gikk galt",
"page_error_description": "Det oppstod en uventet feil. Prøv igjen eller gå tilbake til innboksen.",
+254 -5
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Instellingen",
"back_to_mail": "Terug naar Mail",
"back_to_settings": "Terug naar instellingen",
"save_success": "Instellingen succesvol opgeslagen",
"import_success": "Instellingen succesvol geïmporteerd",
"import_error": "Kan instellingen niet importeren",
@@ -1345,6 +1346,7 @@
"id_exists": "Deze label-ID bestaat al",
"edit": "Label bewerken",
"delete": "Label verwijderen",
"deleted": "Label verwijderd",
"save": "Opslaan",
"add": "Toevoegen",
"cancel": "Annuleren",
@@ -1849,6 +1851,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Andere apparaten kunnen de accounts er daarna niet meer uit herstellen.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1944,6 +1947,8 @@
"removed": "App-wachtwoord verwijderd",
"add_error": "Kan app-wachtwoord niet aanmaken",
"remove_error": "Kan app-wachtwoord niet verwijderen",
"delete_title": "App-wachtwoord verwijderen?",
"delete_message": "Apps die zich aanmelden met {name} kunnen zich daarna niet meer aanmelden.",
"none": "Geen app-wachtwoorden geconfigureerd",
"done": "Klaar",
"expires_label": "Verloopt (optioneel)",
@@ -1962,6 +1967,8 @@
"removed": "API-sleutel verwijderd",
"add_error": "API-sleutel aanmaken mislukt",
"remove_error": "API-sleutel verwijderen mislukt",
"delete_title": "API-sleutel verwijderen?",
"delete_message": "Scripts en integraties die {name} gebruiken, verliezen hun toegang.",
"none": "Geen API-sleutels geconfigureerd"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "Versleuteling met deze sleutel inschakelen",
"enable_button": "Inschakelen",
"disable_tooltip": "Versleuteling uitschakelen",
"disable_title": "Versleuteling in rust uitschakelen?",
"disable_message": "Nieuwe e-mail wordt vanaf nu onversleuteld opgeslagen.",
"disable_button": "Uitschakelen",
"enabled_success": "Versleuteling in rust ingeschakeld"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "bijv. Mijn sleutel",
"key_label": "Openbare sleutel (S/MIME of PGP in ASCII-formaat)",
"key_placeholder": "Plak hier uw openbare sleutel",
"none": "Geen openbare sleutels geconfigureerd"
"none": "Geen openbare sleutels geconfigureerd",
"title": "Openbare sleutels",
"added": "Openbare sleutel toegevoegd",
"removed": "Openbare sleutel verwijderd",
"add_error": "Kan openbare sleutel niet toevoegen",
"remove_error": "Kan openbare sleutel niet verwijderen",
"delete_title": "Openbare sleutel verwijderen?",
"delete_message": "{name} wordt van de server verwijderd."
},
"sections": {
"devices": "Andere apps en apparaten",
@@ -2089,6 +2106,7 @@
},
"save": "Wijzigingen opslaan",
"saving": "Opslaan...",
"saved": "Opgeslagen",
"warnings": {
"end_before_start": "De einddatum moet na de startdatum liggen",
"invalid_date": "Vul een geldige start- en einddatum met tijd in, of laat het veld leeg",
@@ -2155,12 +2173,17 @@
"new_folder_name": "Mapnaam",
"rename": "Hernoemen",
"change_icon": "Pictogram wijzigen",
"actions": "Acties voor {name}",
"expand": "{name} uitvouwen",
"collapse": "{name} samenvouwen",
"counter": "Teller",
"count_mode": "Teller voor {name}",
"count_mode_default": "Teller: standaard",
"count_mode_unread": "Alleen ongelezen",
"count_mode_all": "Ongelezen en totaal",
"count_mode_none": "Geen teller",
"delete": "Verwijderen",
"delete_title": "Map verwijderen?",
"confirm_delete": "Weet u zeker dat u \"{name}\" wilt verwijderen? E-mails in deze map worden naar de Prullenbak verplaatst.",
"create": "Maken",
"cancel": "Annuleren",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "Debugmodus",
"description": "Schakel gedetailleerde logging in voor probleemoplossing"
"description": "Schakel gedetailleerde logging in voor probleemoplossing",
"toggle": "Uitgebreide logboekregistratie"
},
"debug_categories": {
"description": "Selecteer welke categorieën moeten worden gelogd. Schakel categorieën die u niet nodig hebt uit om ruis in de console te verminderen.",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "Gegevens in de cache vernieuwen?"
},
"reset_settings": {
"label": "Instellingen resetten",
"description": "Herstel alle instellingen naar standaardwaarden",
"button": "Resetten naar standaard"
"button": "Resetten naar standaard",
"confirm_title": "Alle instellingen herstellen?",
"done": "Instellingen hersteld"
},
"export_settings": {
"label": "Instellingen exporteren",
@@ -2302,11 +2329,19 @@
"categories_title": "Categorieën",
"categories_description": "Contactcategorieën hernoemen",
"no_categories": "Geen categorieën gevonden",
"delete_category": "Categorie verwijderen",
"category_deleted": "Categorie verwijderd",
"category_delete_failed": "Kan categorie niet verwijderen",
"group_by_letter_description": "Toon alfabetische sectiekoppen in de contactenlijst",
"group_by_letter_label": "Groeperen op eerste letter",
"sort_by_last_name_label": "Sorteren op achternaam",
"sort_by_last_name_description": "Sorteer de contactenlijst op achternaam zodat gezinsleden bij elkaar staan"
},
"calendar_management": {
"delete_title": "Agenda verwijderen?",
"clear_title": "Alle afspraken wissen?",
"unsubscribe_title": "Abonnement op deze agenda opzeggen?"
},
"downloads": {
"title": "Downloads",
"description": "Pas aan hoe gedownloade e-mails en bijlagen worden genoemd.",
@@ -2371,7 +2406,7 @@
"validate": "Valideren",
"validation_success": "Het script is geldig",
"validation_error": "Het script bevat fouten",
"save": "Regels opslaan",
"save": "Opslaan",
"saving": "Opslaan...",
"saved": "Filters succesvol opgeslagen",
"save_failed": "Filters opslaan mislukt",
@@ -2385,6 +2420,8 @@
"add_condition": "Voorwaarde toevoegen",
"actions": "Acties",
"add_action": "Actie toevoegen",
"remove_condition": "Voorwaarde verwijderen",
"remove_action": "Actie verwijderen",
"stop_processing": "Verwerking van volgende regels stoppen",
"include_spam": "Ook berichten verplaatsen die als spam zijn gemarkeerd",
"attachment_type_placeholder": "bijv. pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "Overeenkomsttype",
"reset_to_visual": "Terugzetten naar visuele builder",
"reset_warning": "Dit verwijdert het huidige script en begint opnieuw.",
"reset_title": "Terugzetten naar de visuele editor?",
"confirm_reset": "Terugzetten",
"validation_empty_name": "Regelnaam is verplicht",
"validation_empty_conditions": "Minstens één voorwaarde met een waarde is vereist",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Bulwark Webmail instellen",
"subtitle": "Stel je webmailinstantie in vanuit de browser.",
"loading": "Laden…",
"back": "Terug",
"next": "Volgende",
"saving": "Opslaan…",
"show": "Tonen",
"hide": "Verbergen",
"remove": "Verwijderen",
"on": "Aan",
"off": "Uit",
"dismiss": "Sluiten",
"unknown_error": "Onbekende fout",
"steps": {
"welcome": "Welkom",
"server": "Server",
"auth": "Aanmelden",
"security": "Beveiliging",
"logging": "Logboek",
"branding": "Huisstijl",
"review": "Controle"
},
"read_only": {
"title": "De configuratie is alleen-lezen",
"body": "Het configuratievolume is alleen-lezen gekoppeld of {variable} is ingesteld. Koppel het met schrijfrechten of verwijder die variabele en start de container opnieuw."
},
"done": {
"title": "Alles is klaar",
"body": "{appName} is ingesteld en klaar voor gebruik.",
"admin_sign_in": "Aanmelden bij het beheerdashboard",
"webmail_login": "De webmailaanmelding openen",
"redirecting": "Je gaat naar het beheerdashboard…"
},
"already": {
"title": "De installatie is al voltooid",
"body": "{appName} is ingesteld. Meld je aan om verder te gaan."
},
"insecure": {
"title": "Je voert de installatie uit via onversleuteld HTTP",
"body": "Het installatietoken en het beheerderswachtwoord die je hier invoert, gaan als leesbare tekst over het netwerk. Gebruik zo mogelijk HTTPS: beëindig TLS in de container of in een reverse proxy ervoor.",
"try_https": "HTTPS proberen",
"continue_http": "Doorgaan via HTTP"
},
"errors": {
"token_expired": "Dit installatietoken is niet meer geldig. Start de container opnieuw om een nieuw token uit de logboeken te halen.",
"session_expired": "Je installatiesessie is verlopen. Plak het installatietoken opnieuw om verder te gaan.",
"token_required": "Plak het installatietoken uit de containerlogboeken om verder te gaan.",
"already_finished": "De installatie is al voltooid. Laad de pagina opnieuw om je aan te melden.",
"token_rejected": "Token geweigerd (HTTP {status})",
"step_failed": "Kan deze stap niet opslaan (HTTP {status})",
"upload_failed": "Uploaden mislukt (HTTP {status})",
"finish_failed": "Kan de installatie niet voltooien (HTTP {status})"
},
"welcome": {
"title": "Welkom",
"body": "Plak het installatietoken uit de containerlogboeken om verder te gaan. Het token verloopt na 1 uur.",
"token_label": "Installatietoken",
"token_placeholder": "Hexadecimaal token van 32 bytes",
"verifying": "Controleren…",
"continue": "Doorgaan"
},
"server": {
"title": "Server",
"subtitle": "Waar je e-mail staat.",
"app_name": "Naam van de applicatie",
"url_label": "URL van de JMAP-server",
"url_hint": "De standaardserver waarmee gebruikers verbinden. Voorbeeld: https://mail.example.com",
"test": "Testen",
"testing": "Testen…",
"probe_ok": "Verbonden: dit lijkt een JMAP-server.",
"probe_not_jmap": "De server is bereikbaar, maar lijkt geen JMAP-eindpunt te zijn.",
"probe_invalid": "Deze URL is ongeldig. Hij moet beginnen met http:// of https://.",
"probe_unreachable": "Kan geen verbinding maken met dit adres. Controleer de URL en of de server online is.",
"not_jmap_hint": "Dit kan gebeuren als een reverse proxy JMAP apart routeert op hetzelfde domein. Anders is de URL meestal onjuist.",
"not_jmap_confirm": "Ik weet zeker dat dit de juiste URL is, toch doorgaan.",
"http_title": "Deze URL gebruikt onversleuteld HTTP.",
"http_body": "Wachtwoorden en e-mailinhoud gaan onversleuteld tussen gebruikers en je server. Gebruik {https} in productie: beëindig TLS op de mailserver of in een reverse proxy ervoor.",
"local_title": "Deze URL werkt alleen lokaal.",
"local_body": "E-mail wordt rechtstreeks door de browser van de gebruiker opgehaald, dus de JMAP-URL moet bereikbaar zijn overal waar gebruikers zich aanmelden, niet alleen vanaf deze computer of dit lokale netwerk. Gebruik in productie een openbare hostnaam (bijv. {example}).",
"additional_title": "Extra JMAP-servers",
"additional_hint": "Optioneel. Bied meerdere servers aan in de keuzelijst bij het aanmelden, handig voor hosts met meerdere Stalwart-instanties.",
"additional_show": "Tonen ({count})",
"add": "Toevoegen",
"row_title": "Server #{index}",
"id_label": "ID",
"id_hint": "Unieke korte naam, bijv. eu-1",
"label_label": "Label",
"label_hint": "Zichtbaar voor gebruikers",
"row_url": "URL",
"domains_label": "Domeinen",
"domains_hint": "Gescheiden door komma’s. Hiermee wordt deze server bij het aanmelden gekozen op basis van het e-maildomein.",
"add_server": "Server toevoegen",
"auto_pick": "Server kiezen op basis van het e-maildomein",
"auto_pick_hint": "Als een gebruiker zijn e-mailadres typt, wordt de bijpassende server uit de lijst hierboven gekozen.",
"row_id_required": "Server #{index}: de ID is verplicht",
"row_id_invalid": "Server #{index}: de ID mag alleen letters, cijfers, - en _ bevatten en moet met een letter of cijfer beginnen",
"row_id_duplicate": "Server #{index}: de ID ‘{id}’ komt twee keer voor",
"row_url_required": "Server #{index}: de URL is verplicht",
"row_url_scheme": "Server #{index}: de URL moet beginnen met http:// of https://",
"stalwart": "Stalwart-specifieke functies inschakelen",
"stalwart_hint": "Voegt wachtwoord wijzigen en het beheer van Sieve-filters toe. Kan zonder risico ook bij andere servers aan."
},
"auth": {
"title": "Aanmelden",
"subtitle": "Aanmelden met wachtwoord kan altijd. OAuth/OIDC is optioneel.",
"oauth": "OAuth2 / OpenID Connect inschakelen",
"oauth_only": "Alleen OAuth (wachtwoordformulier verbergen)",
"client_id": "OAuth-client-ID",
"client_secret": "OAuth-clientgeheim",
"client_secret_hint": "Laat leeg voor openbare clients die alleen PKCE gebruiken.",
"client_secret_placeholder": "Plak het geheim",
"issuer": "URL van de OAuth-uitgever",
"issuer_hint": "Voor externe identiteitsproviders (Keycloak, Authentik, Entra ID enz.)."
},
"security": {
"title": "Beveiliging en sessies",
"subtitle": "Het sessiegeheim maakt ‘Aangemeld blijven’ en het synchroniseren van instellingen mogelijk.",
"generated": "Sessiegeheim aangemaakt",
"customize": "Aanpassen",
"generated_hint": "Er is een geheim van 32 bytes voor je aangemaakt. Wijzig het alleen als je daar een specifieke reden voor hebt.",
"secret_label": "Sessiegeheim",
"secret_hint": "Plak je eigen waarde of maak een nieuwe aan.",
"regenerate": "Opnieuw aanmaken",
"sync": "Gebruikersinstellingen synchroniseren tussen apparaten",
"sync_hint": "Slaat gebruikersvoorkeuren op de server op, versleuteld met het sessiegeheim.",
"telemetry": "Anonieme gebruiksstatistieken sturen om Bulwark te verbeteren",
"telemetry_hint": "Standaard uit. Eén anoniem signaal per dag met versie, platform en welke functies aan staan; nooit e-mailadressen, hostnamen of IP-adressen. Je kunt dit altijd wijzigen in de beheerinstellingen."
},
"logging": {
"title": "Logboek",
"subtitle": "Opmaak en detailniveau van de applicatielogboeken.",
"format": "Opmaak",
"format_text": "text: in kleur, goed leesbaar",
"format_json": "json: gestructureerd, voor het verzamelen van logboeken",
"level": "Niveau",
"level_info": "info (aanbevolen)"
},
"branding": {
"title": "Huisstijl",
"subtitle": "Alle velden zijn optioneel. Upload een bestand of plak een URL; voor alles wat je overslaat gelden de standaardwaarden.",
"company": "Naam van het bedrijf of de organisatie",
"favicon": "Favicon",
"favicon_hint": "Pictogram op het browsertabblad. SVG aanbevolen.",
"login_light": "Aanmeldlogo (lichte modus)",
"login_light_hint": "Op de aanmeldpagina, op een lichte achtergrond.",
"login_dark": "Aanmeldlogo (donkere modus)",
"login_dark_hint": "Op de aanmeldpagina, op een donkere achtergrond.",
"sidebar_light": "Logo in de zijbalk (lichte modus)",
"sidebar_light_hint": "Zichtbaar na het aanmelden. Leeg laten voor geen logo.",
"sidebar_dark": "Logo in de zijbalk (donkere modus)",
"sidebar_dark_hint": "Versie van het zijbalklogo voor de donkere modus.",
"website": "URL van de website",
"imprint": "URL van het colofon",
"privacy": "URL van de privacyverklaring",
"drop": "Klik of sleep hierheen",
"uploading": "Uploaden…",
"uploaded": "Geüpload bestand",
"formats": "SVG, PNG, JPEG, WebP of ICO · max. 2 MB",
"use_url": "URL gebruiken",
"hide_url": "URL verbergen",
"url_placeholder": "https://… of /branding/bestand.svg"
},
"review": {
"title": "Bijna klaar",
"subtitle": "Controleer je instellingen, kies een beheerderswachtwoord en pas toe.",
"server": "Server",
"app_name": "Naam van de app",
"jmap_server": "JMAP-server",
"additional": "Extra servers",
"additional_count": "{count} ingesteld",
"additional_auto_pick": "{count} ingesteld · gekozen op domein",
"stalwart": "Stalwart-functies",
"auth": "Aanmelden",
"method": "Methode",
"oauth_only": "Alleen OAuth",
"password_oauth": "Wachtwoord + OAuth",
"password_only": "Alleen wachtwoord",
"client_id": "OAuth-client-ID",
"security": "Beveiliging",
"secret": "Sessiegeheim",
"configured": "Ingesteld",
"not_set": "Niet ingesteld",
"remember_me": "Aangemeld blijven",
"available": "Beschikbaar",
"disabled": "Uitgeschakeld",
"sync": "Instellingen synchroniseren",
"needs_secret": "Vereist een sessiegeheim",
"telemetry": "Anonieme telemetrie",
"logging": "Logboek",
"format": "Opmaak",
"level": "Niveau",
"branding": "Huisstijl",
"customizations": "Aanpassingen",
"custom_assets": "Eigen bestanden ingesteld",
"defaults": "Standaardwaarden",
"company": "Naam van het bedrijf",
"password_title": "Kies een beheerderswachtwoord",
"password_hint": "Hiermee meld je je aan op {path}. Minimaal 8 tekens.",
"new_password": "Nieuw wachtwoord",
"confirm": "Bevestigen",
"too_short": "Minimaal 8 tekens.",
"mismatch": "De wachtwoorden komen niet overeen.",
"looks_good": "Ziet er goed uit.",
"advanced": "Geavanceerd",
"lock": "Configuratie vergrendelen na de installatie",
"lock_hint": "Maakt een markeringsbestand aan. Koppel daarna het configuratievolume alleen-lezen; de app weigert dan verdere wijzigingen aan de configuratie. Auditlogboeken en de aanmeldstatus blijven beschrijfbaar in het statusvolume.",
"applying": "Toepassen…",
"finish": "Toepassen en voltooien"
}
},
"errors": {
"page_error_title": "Er is iets misgegaan",
"page_error_description": "We hebben een onverwachte fout ondervonden. Probeer het opnieuw of keer terug naar de startpagina.",
+254 -5
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Ustawienia",
"back_to_mail": "Powrót do poczty",
"back_to_settings": "Wróć do ustawień",
"save_success": "Ustawienia zostały zapisane",
"import_success": "Ustawienia zostały zaimportowane",
"import_error": "Nie udało się zaimportować ustawień",
@@ -1345,6 +1346,7 @@
"id_exists": "Ten ID etykiety już istnieje",
"edit": "Edytuj etykietę",
"delete": "Usuń etykietę",
"deleted": "Etykieta usunięta",
"save": "Zapisz",
"add": "Dodaj",
"cancel": "Anuluj",
@@ -1849,6 +1851,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Inne urządzenia nie będą już mogły przywrócić z niej kont.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1944,6 +1947,8 @@
"removed": "Hasło aplikacji zostało usunięte",
"add_error": "Nie udało się utworzyć hasła aplikacji",
"remove_error": "Nie udało się usunąć hasła aplikacji",
"delete_title": "Usunąć hasło aplikacji?",
"delete_message": "Aplikacje logujące się za pomocą {name} nie będą mogły się już zalogować.",
"none": "Brak skonfigurowanych haseł aplikacji",
"done": "Gotowe",
"expires_label": "Wygasa (opcjonalnie)",
@@ -1962,6 +1967,8 @@
"removed": "Klucz API usunięty",
"add_error": "Nie udało się utworzyć klucza API",
"remove_error": "Nie udało się usunąć klucza API",
"delete_title": "Usunąć klucz API?",
"delete_message": "Skrypty i integracje używające {name} stracą dostęp.",
"none": "Brak skonfigurowanych kluczy API"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "Włącz szyfrowanie tym kluczem",
"enable_button": "Włącz",
"disable_tooltip": "Wyłącz szyfrowanie",
"disable_title": "Wyłączyć szyfrowanie danych w spoczynku?",
"disable_message": "Nowa poczta będzie od teraz zapisywana bez szyfrowania.",
"disable_button": "Wyłącz",
"enabled_success": "Szyfrowanie danych w spoczynku włączone"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "np. Mój klucz",
"key_label": "Klucz publiczny (S/MIME lub PGP w formacie ASCII)",
"key_placeholder": "Wklej tutaj swój klucz publiczny",
"none": "Nie skonfigurowano żadnych kluczy publicznych"
"none": "Nie skonfigurowano żadnych kluczy publicznych",
"title": "Klucze publiczne",
"added": "Dodano klucz publiczny",
"removed": "Usunięto klucz publiczny",
"add_error": "Nie udało się dodać klucza publicznego",
"remove_error": "Nie udało się usunąć klucza publicznego",
"delete_title": "Usunąć klucz publiczny?",
"delete_message": "{name} zostanie usunięty z serwera."
},
"sections": {
"devices": "Inne aplikacje i urządzenia",
@@ -2089,6 +2106,7 @@
},
"save": "Zapisz zmiany",
"saving": "Zapisywanie...",
"saved": "Zapisano",
"warnings": {
"end_before_start": "Data zakończenia musi być późniejsza niż data rozpoczęcia",
"invalid_date": "Podaj prawidłową datę rozpoczęcia i zakończenia z godziną albo pozostaw pole puste",
@@ -2155,12 +2173,17 @@
"new_folder_name": "Nazwa folderu",
"rename": "Zmień nazwę",
"change_icon": "Zmień ikonę",
"actions": "Działania dla {name}",
"expand": "Rozwiń {name}",
"collapse": "Zwiń {name}",
"counter": "Licznik",
"count_mode": "Licznik dla {name}",
"count_mode_default": "Licznik: domyślny",
"count_mode_unread": "Tylko nieprzeczytane",
"count_mode_all": "Nieprzeczytane i łącznie",
"count_mode_none": "Bez licznika",
"delete": "Usuń",
"delete_title": "Usunąć folder?",
"confirm_delete": "Czy na pewno chcesz usunąć „{name}”? Wiadomości w tym folderze zostaną przeniesione do Kosza.",
"create": "Utwórz",
"cancel": "Anuluj",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "Tryb debugowania",
"description": "Włącz szczegółowe logowanie do rozwiązywania problemów"
"description": "Włącz szczegółowe logowanie do rozwiązywania problemów",
"toggle": "Szczegółowe logowanie"
},
"debug_categories": {
"description": "Wybierz kategorie do logowania. Wyłącz kategorie, których nie potrzebujesz, aby zmniejszyć szum w konsoli.",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "Odświeżyć dane w pamięci podręcznej?"
},
"reset_settings": {
"label": "Resetuj ustawienia",
"description": "Przywróć wszystkie ustawienia do wartości domyślnych",
"button": "Przywróć domyślne"
"button": "Przywróć domyślne",
"confirm_title": "Zresetować wszystkie ustawienia?",
"done": "Ustawienia zresetowane"
},
"export_settings": {
"label": "Eksportuj ustawienia",
@@ -2302,11 +2329,19 @@
"categories_title": "Kategorie",
"categories_description": "Zmień nazwy kategorii kontaktów",
"no_categories": "Nie znaleziono kategorii",
"delete_category": "Usuń kategorię",
"category_deleted": "Usunięto kategorię",
"category_delete_failed": "Nie udało się usunąć kategorii",
"group_by_letter_description": "Pokaż alfabetyczne nagłówki sekcji na liście kontaktów",
"group_by_letter_label": "Grupuj według pierwszej litery",
"sort_by_last_name_label": "Sortuj według nazwiska",
"sort_by_last_name_description": "Uporządkuj listę kontaktów według nazwiska, aby członkowie rodziny byli obok siebie"
},
"calendar_management": {
"delete_title": "Usunąć kalendarz?",
"clear_title": "Wyczyścić wszystkie wydarzenia?",
"unsubscribe_title": "Anulować subskrypcję tego kalendarza?"
},
"downloads": {
"title": "Pobrane",
"description": "Dostosuj sposób nazywania pobranych wiadomości i załączników.",
@@ -2371,7 +2406,7 @@
"validate": "Waliduj",
"validation_success": "Skrypt jest poprawny",
"validation_error": "Skrypt zawiera błędy",
"save": "Zapisz reguły",
"save": "Zapisz",
"saving": "Zapisywanie...",
"saved": "Filtry zostały zapisane",
"save_failed": "Nie udało się zapisać filtrów",
@@ -2385,6 +2420,8 @@
"add_condition": "Dodaj warunek",
"actions": "Akcje",
"add_action": "Dodaj akcję",
"remove_condition": "Usuń warunek",
"remove_action": "Usuń akcję",
"stop_processing": "Zatrzymaj przetwarzanie kolejnych reguł",
"include_spam": "Przenoś również wiadomości oznaczone jako spam",
"attachment_type_placeholder": "np. pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "Typ dopasowania",
"reset_to_visual": "Resetuj do kreatora wizualnego",
"reset_warning": "To odrzuci bieżący skrypt i pozwoli zacząć od nowa.",
"reset_title": "Przywrócić edytor wizualny?",
"confirm_reset": "Resetuj",
"validation_empty_name": "Nazwa reguły jest wymagana",
"validation_empty_conditions": "Wymagany jest co najmniej jeden warunek z wartością",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Konfiguracja Bulwark Webmail",
"subtitle": "Skonfiguruj swoją instancję poczty w przeglądarce.",
"loading": "Wczytywanie…",
"back": "Wstecz",
"next": "Dalej",
"saving": "Zapisywanie…",
"show": "Pokaż",
"hide": "Ukryj",
"remove": "Usuń",
"on": "Włączone",
"off": "Wyłączone",
"dismiss": "Zamknij",
"unknown_error": "Nieznany błąd",
"steps": {
"welcome": "Powitanie",
"server": "Serwer",
"auth": "Logowanie",
"security": "Bezpieczeństwo",
"logging": "Dziennik",
"branding": "Wygląd marki",
"review": "Podsumowanie"
},
"read_only": {
"title": "Konfiguracja jest tylko do odczytu",
"body": "Wolumin konfiguracji jest zamontowany tylko do odczytu lub ustawiono zmienną {variable}. Zamontuj go z prawem zapisu lub usuń tę zmienną, a potem uruchom ponownie kontener."
},
"done": {
"title": "Wszystko gotowe",
"body": "{appName} jest skonfigurowany i gotowy do użycia.",
"admin_sign_in": "Zaloguj się do panelu administracyjnego",
"webmail_login": "Otwórz logowanie do poczty",
"redirecting": "Przechodzisz do panelu administracyjnego…"
},
"already": {
"title": "Konfiguracja jest już zakończona",
"body": "{appName} jest skonfigurowany. Zaloguj się, aby kontynuować."
},
"insecure": {
"title": "Uruchamiasz konfigurację przez niezaszyfrowane HTTP",
"body": "Token konfiguracji i hasło administratora wpisane tutaj zostaną przesłane jawnym tekstem. Jeśli to możliwe, użyj HTTPS: zakończ TLS w kontenerze lub w odwrotnym proxy przed nim.",
"try_https": "Spróbuj HTTPS",
"continue_http": "Kontynuuj przez HTTP"
},
"errors": {
"token_expired": "Ten token konfiguracji jest już nieważny. Uruchom ponownie kontener, aby dostać nowy z dziennika.",
"session_expired": "Sesja konfiguracji wygasła. Wklej ponownie token konfiguracji, aby kontynuować.",
"token_required": "Wklej token konfiguracji z dziennika kontenera, aby kontynuować.",
"already_finished": "Konfiguracja jest już zakończona. Odśwież stronę, aby się zalogować.",
"token_rejected": "Token odrzucony (HTTP {status})",
"step_failed": "Nie udało się zapisać tego kroku (HTTP {status})",
"upload_failed": "Nie udało się przesłać pliku (HTTP {status})",
"finish_failed": "Nie udało się zakończyć konfiguracji (HTTP {status})"
},
"welcome": {
"title": "Powitanie",
"body": "Wklej token konfiguracji z dziennika kontenera, aby kontynuować. Token wygasa po 1 godzinie.",
"token_label": "Token konfiguracji",
"token_placeholder": "32-bajtowy token szesnastkowy",
"verifying": "Sprawdzanie…",
"continue": "Kontynuuj"
},
"server": {
"title": "Serwer",
"subtitle": "Gdzie jest Twoja poczta.",
"app_name": "Nazwa aplikacji",
"url_label": "Adres URL serwera JMAP",
"url_hint": "Domyślny serwer, z którym łączą się użytkownicy. Przykład: https://mail.example.com",
"test": "Testuj",
"testing": "Testowanie…",
"probe_ok": "Połączono: to wygląda na serwer JMAP.",
"probe_not_jmap": "Serwer odpowiada, ale nie wygląda na punkt końcowy JMAP.",
"probe_invalid": "Ten adres URL jest nieprawidłowy. Musi zaczynać się od http:// lub https://.",
"probe_unreachable": "Nie udało się połączyć z tym adresem. Sprawdź adres URL i czy serwer działa.",
"not_jmap_hint": "Tak bywa, gdy odwrotne proxy kieruje JMAP osobno w tej samej domenie. W przeciwnym razie adres URL jest zwykle błędny.",
"not_jmap_confirm": "Jestem pewien, że ten adres URL jest prawidłowy, kontynuuj mimo to.",
"http_title": "Ten adres URL używa niezaszyfrowanego HTTP.",
"http_body": "Hasła i treść wiadomości będą przesyłane bez szyfrowania między użytkownikami a Twoim serwerem. W środowisku produkcyjnym użyj {https}: zakończ TLS na serwerze poczty lub w odwrotnym proxy przed nim.",
"local_title": "Ten adres URL działa tylko lokalnie.",
"local_body": "Poczta jest pobierana bezpośrednio przez przeglądarkę użytkownika, więc adres URL JMAP musi być osiągalny wszędzie, gdzie użytkownicy się logują, nie tylko z tego komputera czy sieci lokalnej. W środowisku produkcyjnym użyj publicznej nazwy hosta (np. {example}).",
"additional_title": "Dodatkowe serwery JMAP",
"additional_hint": "Opcjonalnie. Pokaż kilka serwerów na liście przy logowaniu, przydatne dla dostawców z kilkoma instancjami Stalwart.",
"additional_show": "Pokaż ({count})",
"add": "Dodaj",
"row_title": "Serwer nr {index}",
"id_label": "ID",
"id_hint": "Unikalny identyfikator, np. eu-1",
"label_label": "Etykieta",
"label_hint": "Widoczna dla użytkowników",
"row_url": "URL",
"domains_label": "Domeny",
"domains_hint": "Oddzielone przecinkami. Służą do wyboru tego serwera przy logowaniu na podstawie domeny adresu e-mail.",
"add_server": "Dodaj serwer",
"auto_pick": "Wybieraj serwer na podstawie domeny adresu e-mail",
"auto_pick_hint": "Gdy użytkownik wpisze swój adres e-mail, zostanie wybrany pasujący serwer z listy powyżej.",
"row_id_required": "Serwer nr {index}: ID jest wymagane",
"row_id_invalid": "Serwer nr {index}: ID może zawierać tylko litery, cyfry, - i _ oraz musi zaczynać się literą lub cyfrą",
"row_id_duplicate": "Serwer nr {index}: ID „{id}” się powtarza",
"row_url_required": "Serwer nr {index}: adres URL jest wymagany",
"row_url_scheme": "Serwer nr {index}: adres URL musi zaczynać się od http:// lub https://",
"stalwart": "Włącz funkcje specyficzne dla Stalwart",
"stalwart_hint": "Dodaje zmianę hasła i zarządzanie filtrami Sieve. Można bezpiecznie włączyć także na innych serwerach."
},
"auth": {
"title": "Uwierzytelnianie",
"subtitle": "Logowanie hasłem jest zawsze dostępne. OAuth/OIDC jest opcjonalne.",
"oauth": "Włącz OAuth2 / OpenID Connect",
"oauth_only": "Tylko OAuth (ukryj formularz hasła)",
"client_id": "ID klienta OAuth",
"client_secret": "Sekret klienta OAuth",
"client_secret_hint": "Pozostaw puste dla klientów publicznych używających tylko PKCE.",
"client_secret_placeholder": "Wklej sekret",
"issuer": "Adres URL wystawcy OAuth",
"issuer_hint": "Dla zewnętrznych dostawców tożsamości (Keycloak, Authentik, Entra ID itp.)."
},
"security": {
"title": "Bezpieczeństwo i sesje",
"subtitle": "Sekret sesji umożliwia „Zapamiętaj mnie” i synchronizację ustawień.",
"generated": "Wygenerowano sekret sesji",
"customize": "Dostosuj",
"generated_hint": "Utworzono dla Ciebie 32-bajtowy sekret. Zmieniaj go tylko z konkretnego powodu.",
"secret_label": "Sekret sesji",
"secret_hint": "Wklej własną wartość lub wygeneruj nową.",
"regenerate": "Wygeneruj ponownie",
"sync": "Synchronizuj ustawienia użytkowników między urządzeniami",
"sync_hint": "Przechowuje preferencje użytkowników na serwerze, zaszyfrowane sekretem sesji.",
"telemetry": "Wysyłaj anonimowe statystyki użycia, aby pomóc ulepszać Bulwark",
"telemetry_hint": "Domyślnie wyłączone. Jeden anonimowy sygnał dziennie z wersją, platformą i listą włączonych funkcji; nigdy adresy e-mail, nazwy hostów ani adresy IP. Możesz to zmienić w każdej chwili w ustawieniach administracyjnych."
},
"logging": {
"title": "Dziennik",
"subtitle": "Format i szczegółowość dzienników aplikacji.",
"format": "Format",
"format_text": "text: kolorowy, czytelny",
"format_json": "json: strukturalny, do agregacji dzienników",
"level": "Poziom",
"level_info": "info (zalecany)"
},
"branding": {
"title": "Wygląd marki",
"subtitle": "Wszystkie pola są opcjonalne. Prześlij plik lub wklej adres URL; dla pominiętych pól używane są wartości domyślne.",
"company": "Nazwa firmy lub organizacji",
"favicon": "Favicon",
"favicon_hint": "Ikona karty przeglądarki. Zalecany SVG.",
"login_light": "Logo logowania (tryb jasny)",
"login_light_hint": "Na stronie logowania, na jasnym tle.",
"login_dark": "Logo logowania (tryb ciemny)",
"login_dark_hint": "Na stronie logowania, na ciemnym tle.",
"sidebar_light": "Logo paska bocznego (tryb jasny)",
"sidebar_light_hint": "Widoczne po zalogowaniu. Pozostaw puste, aby nie pokazywać.",
"sidebar_dark": "Logo paska bocznego (tryb ciemny)",
"sidebar_dark_hint": "Wersja logo paska bocznego dla trybu ciemnego.",
"website": "Adres URL strony internetowej",
"imprint": "Adres URL noty prawnej",
"privacy": "Adres URL polityki prywatności",
"drop": "Kliknij lub upuść",
"uploading": "Przesyłanie…",
"uploaded": "Przesłany plik",
"formats": "SVG, PNG, JPEG, WebP lub ICO · maks. 2 MB",
"use_url": "Użyj adresu URL",
"hide_url": "Ukryj adres URL",
"url_placeholder": "https://… lub /branding/plik.svg"
},
"review": {
"title": "Prawie gotowe",
"subtitle": "Sprawdź ustawienia, wybierz hasło administratora i zastosuj.",
"server": "Serwer",
"app_name": "Nazwa aplikacji",
"jmap_server": "Serwer JMAP",
"additional": "Dodatkowe serwery",
"additional_count": "Skonfigurowane: {count}",
"additional_auto_pick": "Skonfigurowane: {count} · wybór według domeny",
"stalwart": "Funkcje Stalwart",
"auth": "Uwierzytelnianie",
"method": "Metoda",
"oauth_only": "Tylko OAuth",
"password_oauth": "Hasło + OAuth",
"password_only": "Tylko hasło",
"client_id": "ID klienta OAuth",
"security": "Bezpieczeństwo",
"secret": "Sekret sesji",
"configured": "Skonfigurowany",
"not_set": "Nie ustawiono",
"remember_me": "Zapamiętaj mnie",
"available": "Dostępne",
"disabled": "Wyłączone",
"sync": "Synchronizacja ustawień",
"needs_secret": "Wymaga sekretu sesji",
"telemetry": "Anonimowa telemetria",
"logging": "Dziennik",
"format": "Format",
"level": "Poziom",
"branding": "Wygląd marki",
"customizations": "Dostosowania",
"custom_assets": "Skonfigurowano własne pliki",
"defaults": "Wartości domyślne",
"company": "Nazwa firmy",
"password_title": "Wybierz hasło administratora",
"password_hint": "Użyjesz go do logowania pod adresem {path}. Co najmniej 8 znaków.",
"new_password": "Nowe hasło",
"confirm": "Potwierdź",
"too_short": "Co najmniej 8 znaków.",
"mismatch": "Hasła nie są takie same.",
"looks_good": "Wygląda dobrze.",
"advanced": "Zaawansowane",
"lock": "Zablokuj konfigurację po instalacji",
"lock_hint": "Tworzy plik znacznika. Następnie zamontuj wolumin konfiguracji tylko do odczytu, a aplikacja odrzuci dalsze zmiany konfiguracji. Dzienniki audytu i stan logowania pozostają zapisywalne w woluminie stanu.",
"applying": "Stosowanie…",
"finish": "Zastosuj i zakończ"
}
},
"errors": {
"page_error_title": "Coś poszło nie tak",
"page_error_description": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie lub wróć do strony głównej.",
+254 -5
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Configurações",
"back_to_mail": "Voltar para E-mails",
"back_to_settings": "Voltar às definições",
"save_success": "Configurações salvas com sucesso",
"import_success": "Configurações importadas com sucesso",
"import_error": "Falha ao importar configurações",
@@ -1345,6 +1346,7 @@
"id_exists": "Este ID de etiqueta já existe",
"edit": "Editar etiqueta",
"delete": "Eliminar etiqueta",
"deleted": "Etiqueta eliminada",
"save": "Salvar",
"add": "Adicionar",
"cancel": "Cancelar",
@@ -1849,6 +1851,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Os outros dispositivos deixam de poder restaurar as contas a partir dela.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1944,6 +1947,8 @@
"removed": "Senha de aplicativo removida",
"add_error": "Não foi possível criar a senha de aplicativo",
"remove_error": "Não foi possível remover a senha de aplicativo",
"delete_title": "Eliminar a palavra-passe de aplicação?",
"delete_message": "As aplicações que iniciam sessão com {name} deixam de conseguir fazê-lo.",
"none": "Nenhuma senha de aplicativo configurada",
"done": "Concluído",
"expires_label": "Expira (opcional)",
@@ -1962,6 +1967,8 @@
"removed": "Chave de API removida",
"add_error": "Falha ao criar chave de API",
"remove_error": "Falha ao remover chave de API",
"delete_title": "Eliminar a chave de API?",
"delete_message": "Os scripts e integrações que usam {name} perdem o acesso.",
"none": "Nenhuma chave de API configurada"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "Ativar criptografia com esta chave",
"enable_button": "Ativar",
"disable_tooltip": "Desativar criptografia",
"disable_title": "Desativar a encriptação em repouso?",
"disable_message": "A partir de agora, o correio novo é guardado sem encriptação.",
"disable_button": "Desativar",
"enabled_success": "Criptografia em repouso habilitada"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "ex. A minha chave",
"key_label": "Chave pública (S/MIME ou PGP em formato ASCII)",
"key_placeholder": "Cole aqui a sua chave pública",
"none": "Nenhuma chave pública configurada"
"none": "Nenhuma chave pública configurada",
"title": "Chaves públicas",
"added": "Chave pública adicionada",
"removed": "Chave pública eliminada",
"add_error": "Não foi possível adicionar a chave pública",
"remove_error": "Não foi possível eliminar a chave pública",
"delete_title": "Eliminar a chave pública?",
"delete_message": "{name} é removida do servidor."
},
"sections": {
"devices": "Outras aplicações e dispositivos",
@@ -2089,6 +2106,7 @@
},
"save": "Salvar alterações",
"saving": "Salvando...",
"saved": "Salvo",
"warnings": {
"end_before_start": "A data de fim deve ser posterior à data de início",
"invalid_date": "Indique uma data de início e de fim válidas com hora ou deixe o campo vazio",
@@ -2155,12 +2173,17 @@
"new_folder_name": "Nome da pasta",
"rename": "Renomear",
"change_icon": "Alterar ícone",
"actions": "Ações para {name}",
"expand": "Expandir {name}",
"collapse": "Recolher {name}",
"counter": "Contador",
"count_mode": "Contador de {name}",
"count_mode_default": "Contador: padrão",
"count_mode_unread": "Apenas não lidas",
"count_mode_all": "Não lidas e total",
"count_mode_none": "Sem contador",
"delete": "Excluir",
"delete_title": "Eliminar a pasta?",
"confirm_delete": "Tem certeza de que deseja excluir \"{name}\"? Os e-mails nesta pasta serão movidos para a Lixeira.",
"create": "Criar",
"cancel": "Cancelar",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "Modo de Depuração",
"description": "Habilitar registro detalhado para solução de problemas"
"description": "Habilitar registro detalhado para solução de problemas",
"toggle": "Registo detalhado"
},
"debug_categories": {
"description": "Selecione quais categorias registrar. Desative as categorias de que você não precisa para reduzir o ruído no console.",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Atualizar dados armazenados em cache",
"description": "Atualize os contatos, calendários e pastas do servidor. Mantém suas contas e sessões — corrige uma visualização desatualizada ou incorreta sem precisar sair da conta.",
"button": "Recarregar"
"button": "Recarregar",
"confirm_title": "Atualizar os dados em cache?"
},
"reset_settings": {
"label": "Redefinir Configurações",
"description": "Restaurar todas as configurações para valores padrão",
"button": "Redefinir para Padrões"
"button": "Redefinir para Padrões",
"confirm_title": "Repor todas as definições?",
"done": "Definições repostas"
},
"export_settings": {
"label": "Exportar Configurações",
@@ -2302,11 +2329,19 @@
"categories_title": "Categorias",
"categories_description": "Renomear categorias de contatos",
"no_categories": "Nenhuma categoria encontrada",
"delete_category": "Eliminar categoria",
"category_deleted": "Categoria eliminada",
"category_delete_failed": "Não foi possível eliminar a categoria",
"group_by_letter_description": "Mostrar cabeçalhos de seção alfabéticos na lista de contatos",
"group_by_letter_label": "Agrupar pela primeira letra",
"sort_by_last_name_label": "Ordenar por sobrenome",
"sort_by_last_name_description": "Ordenar a lista de contatos por sobrenome para que os membros da família apareçam juntos"
},
"calendar_management": {
"delete_title": "Eliminar o calendário?",
"clear_title": "Limpar todos os eventos?",
"unsubscribe_title": "Cancelar a subscrição deste calendário?"
},
"downloads": {
"title": "Downloads",
"description": "Personalize como os e-mails e anexos baixados são nomeados.",
@@ -2371,7 +2406,7 @@
"validate": "Validar",
"validation_success": "O script é válido",
"validation_error": "O script contém erros",
"save": "Salvar regras",
"save": "Salvar",
"saving": "Salvando...",
"saved": "Filtros salvos com sucesso",
"save_failed": "Falha ao salvar os filtros",
@@ -2385,6 +2420,8 @@
"add_condition": "Adicionar condição",
"actions": "Ações",
"add_action": "Adicionar ação",
"remove_condition": "Remover condição",
"remove_action": "Remover ação",
"stop_processing": "Parar o processamento das regras seguintes",
"include_spam": "Mover também as mensagens marcadas como spam",
"attachment_type_placeholder": "ex.: pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "Tipo de correspondência",
"reset_to_visual": "Redefinir para o construtor visual",
"reset_warning": "Isto irá descartar o script atual e começar do zero.",
"reset_title": "Repor o editor visual?",
"confirm_reset": "Redefinir",
"validation_empty_name": "O nome da regra é obrigatório",
"validation_empty_conditions": "É necessária pelo menos uma condição com um valor",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Configuração do Bulwark Webmail",
"subtitle": "Configure a sua instância de webmail a partir do navegador.",
"loading": "A carregar…",
"back": "Voltar",
"next": "Seguinte",
"saving": "A guardar…",
"show": "Mostrar",
"hide": "Ocultar",
"remove": "Remover",
"on": "Ligado",
"off": "Desligado",
"dismiss": "Fechar",
"unknown_error": "Erro desconhecido",
"steps": {
"welcome": "Boas-vindas",
"server": "Servidor",
"auth": "Início de sessão",
"security": "Segurança",
"logging": "Registos",
"branding": "Marca",
"review": "Revisão"
},
"read_only": {
"title": "A configuração é só de leitura",
"body": "O volume de configuração está montado só de leitura ou a variável {variable} está definida. Volte a montá-lo com escrita ou remova essa variável e reinicie o contentor."
},
"done": {
"title": "Está tudo pronto",
"body": "O {appName} está configurado e pronto a usar.",
"admin_sign_in": "Iniciar sessão no painel de administração",
"webmail_login": "Abrir o início de sessão do webmail",
"redirecting": "A levá-lo para o painel de administração…"
},
"already": {
"title": "A configuração já está concluída",
"body": "O {appName} está configurado. Inicie sessão para continuar."
},
"insecure": {
"title": "Está a executar a configuração por HTTP sem encriptação",
"body": "O token de configuração e a palavra-passe de administrador que introduzir aqui vão circular em texto simples. Use HTTPS sempre que possível: termine o TLS no contentor ou num proxy inverso à frente dele.",
"try_https": "Tentar HTTPS",
"continue_http": "Continuar por HTTP"
},
"errors": {
"token_expired": "Esse token de configuração já não é válido. Reinicie o contentor para obter um novo nos registos.",
"session_expired": "A sua sessão de configuração expirou. Cole novamente o token de configuração para continuar.",
"token_required": "Cole o token de configuração mostrado nos registos do contentor para continuar.",
"already_finished": "A configuração já terminou. Recarregue para iniciar sessão.",
"token_rejected": "Token recusado (HTTP {status})",
"step_failed": "Não foi possível guardar este passo (HTTP {status})",
"upload_failed": "Falha no carregamento (HTTP {status})",
"finish_failed": "Não foi possível concluir a configuração (HTTP {status})"
},
"welcome": {
"title": "Boas-vindas",
"body": "Cole o token de configuração mostrado nos registos do contentor para continuar. O token expira ao fim de 1 hora.",
"token_label": "Token de configuração",
"token_placeholder": "Token hexadecimal de 32 bytes",
"verifying": "A verificar…",
"continue": "Continuar"
},
"server": {
"title": "Servidor",
"subtitle": "Onde está o seu correio.",
"app_name": "Nome da aplicação",
"url_label": "URL do servidor JMAP",
"url_hint": "O servidor predefinido a que os utilizadores se ligam. Exemplo: https://mail.example.com",
"test": "Testar",
"testing": "A testar…",
"probe_ok": "Ligado: parece um servidor JMAP.",
"probe_not_jmap": "O servidor responde, mas não parece um ponto de acesso JMAP.",
"probe_invalid": "Esse URL não é válido. Tem de começar por http:// ou https://.",
"probe_unreachable": "Não foi possível ligar a esse endereço. Verifique o URL e se o servidor está online.",
"not_jmap_hint": "Pode acontecer quando um proxy inverso encaminha o JMAP à parte no mesmo domínio. Caso contrário, o URL costuma estar errado.",
"not_jmap_confirm": "Tenho a certeza de que o URL está certo, continuar mesmo assim.",
"http_title": "Este URL usa HTTP sem encriptação.",
"http_body": "As palavras-passe e o conteúdo dos emails vão circular sem encriptação entre os utilizadores e o seu servidor. Use {https} em produção: termine o TLS no servidor de correio ou num proxy inverso à frente dele.",
"local_title": "Este URL só funciona localmente.",
"local_body": "O correio é obtido diretamente pelo navegador do utilizador, por isso o URL JMAP tem de estar acessível em qualquer lugar onde os utilizadores iniciem sessão, não só a partir desta máquina ou rede local. Em produção, use um nome de anfitrião público (por ex. {example}).",
"additional_title": "Servidores JMAP adicionais",
"additional_hint": "Opcional. Ofereça vários servidores na lista de início de sessão, útil para quem tem várias instâncias Stalwart.",
"additional_show": "Mostrar ({count})",
"add": "Adicionar",
"row_title": "Servidor n.º {index}",
"id_label": "ID",
"id_hint": "Identificador único, por ex. eu-1",
"label_label": "Etiqueta",
"label_hint": "Visível para os utilizadores",
"row_url": "URL",
"domains_label": "Domínios",
"domains_hint": "Separados por vírgulas. Servem para escolher este servidor pelo domínio do email ao iniciar sessão.",
"add_server": "Adicionar servidor",
"auto_pick": "Escolher o servidor pelo domínio do email",
"auto_pick_hint": "Quando um utilizador escreve o email, é selecionado o servidor correspondente da lista acima.",
"row_id_required": "Servidor n.º {index}: o ID é obrigatório",
"row_id_invalid": "Servidor n.º {index}: o ID só pode ter letras, algarismos, - e _ e tem de começar por uma letra ou algarismo",
"row_id_duplicate": "Servidor n.º {index}: o ID «{id}» está repetido",
"row_url_required": "Servidor n.º {index}: o URL é obrigatório",
"row_url_scheme": "Servidor n.º {index}: o URL tem de começar por http:// ou https://",
"stalwart": "Ativar funcionalidades específicas do Stalwart",
"stalwart_hint": "Adiciona a alteração da palavra-passe e a gestão de filtros Sieve. Pode ser ativado sem risco noutros servidores."
},
"auth": {
"title": "Autenticação",
"subtitle": "O início de sessão com palavra-passe está sempre disponível. OAuth/OIDC é opcional.",
"oauth": "Ativar OAuth2 / OpenID Connect",
"oauth_only": "Só OAuth (ocultar o formulário da palavra-passe)",
"client_id": "ID de cliente OAuth",
"client_secret": "Segredo de cliente OAuth",
"client_secret_hint": "Deixe em branco para clientes públicos que usam só PKCE.",
"client_secret_placeholder": "Cole o segredo",
"issuer": "URL do emissor OAuth",
"issuer_hint": "Para fornecedores de identidade externos (Keycloak, Authentik, Entra ID, etc.)."
},
"security": {
"title": "Segurança e sessões",
"subtitle": "O segredo de sessão ativa «Lembrar-me» e a sincronização das definições.",
"generated": "Segredo de sessão gerado",
"customize": "Personalizar",
"generated_hint": "Foi criado para si um segredo de 32 bytes. Só o altere se tiver um motivo concreto.",
"secret_label": "Segredo de sessão",
"secret_hint": "Cole o seu próprio valor ou gere um novo.",
"regenerate": "Gerar novo",
"sync": "Sincronizar as definições dos utilizadores entre dispositivos",
"sync_hint": "Guarda as preferências dos utilizadores no servidor, encriptadas com o segredo de sessão.",
"telemetry": "Enviar estatísticas de utilização anónimas para melhorar o Bulwark",
"telemetry_hint": "Desligado por predefinição. Um sinal anónimo por dia com a versão, a plataforma e as funcionalidades ativas; nunca endereços de email, nomes de anfitrião ou IPs. Pode alterar isto a qualquer momento nas definições de administração."
},
"logging": {
"title": "Registos",
"subtitle": "Formato e nível de detalhe dos registos da aplicação.",
"format": "Formato",
"format_text": "text: a cores, fácil de ler",
"format_json": "json: estruturado, para agregação de registos",
"level": "Nível",
"level_info": "info (recomendado)"
},
"branding": {
"title": "Marca",
"subtitle": "Todos os campos são opcionais. Carregue um ficheiro ou cole um URL; para o que deixar em branco usam-se os valores predefinidos.",
"company": "Nome da empresa ou organização",
"favicon": "Favicon",
"favicon_hint": "Ícone do separador do navegador. Recomenda-se SVG.",
"login_light": "Logótipo de início de sessão (modo claro)",
"login_light_hint": "Mostrado na página de início de sessão, em fundos claros.",
"login_dark": "Logótipo de início de sessão (modo escuro)",
"login_dark_hint": "Mostrado na página de início de sessão, em fundos escuros.",
"sidebar_light": "Logótipo da barra lateral (modo claro)",
"sidebar_light_hint": "Mostrado após o início de sessão. Deixe em branco para nenhum.",
"sidebar_dark": "Logótipo da barra lateral (modo escuro)",
"sidebar_dark_hint": "Versão do logótipo da barra lateral para o modo escuro.",
"website": "URL do site",
"imprint": "URL das informações legais",
"privacy": "URL da política de privacidade",
"drop": "Clique ou largue aqui",
"uploading": "A carregar…",
"uploaded": "Ficheiro carregado",
"formats": "SVG, PNG, JPEG, WebP ou ICO · máx. 2 MB",
"use_url": "Usar URL",
"hide_url": "Ocultar URL",
"url_placeholder": "https://… ou /branding/ficheiro.svg"
},
"review": {
"title": "Quase pronto",
"subtitle": "Reveja as definições, escolha uma palavra-passe de administrador e aplique.",
"server": "Servidor",
"app_name": "Nome da aplicação",
"jmap_server": "Servidor JMAP",
"additional": "Servidores adicionais",
"additional_count": "{count} configurados",
"additional_auto_pick": "{count} configurados · escolhidos por domínio",
"stalwart": "Funcionalidades do Stalwart",
"auth": "Autenticação",
"method": "Método",
"oauth_only": "Só OAuth",
"password_oauth": "Palavra-passe + OAuth",
"password_only": "Só palavra-passe",
"client_id": "ID de cliente OAuth",
"security": "Segurança",
"secret": "Segredo de sessão",
"configured": "Configurado",
"not_set": "Não definido",
"remember_me": "Lembrar-me",
"available": "Disponível",
"disabled": "Desativado",
"sync": "Sincronização das definições",
"needs_secret": "Requer um segredo de sessão",
"telemetry": "Telemetria anónima",
"logging": "Registos",
"format": "Formato",
"level": "Nível",
"branding": "Marca",
"customizations": "Personalizações",
"custom_assets": "Ficheiros próprios configurados",
"defaults": "Valores predefinidos",
"company": "Nome da empresa",
"password_title": "Escolha uma palavra-passe de administrador",
"password_hint": "Vai usá-la para iniciar sessão em {path}. Mínimo de 8 caracteres.",
"new_password": "Nova palavra-passe",
"confirm": "Confirmar",
"too_short": "Pelo menos 8 caracteres.",
"mismatch": "As palavras-passe não coincidem.",
"looks_good": "Tudo certo.",
"advanced": "Avançado",
"lock": "Bloquear a configuração após a instalação",
"lock_hint": "Cria um ficheiro marcador. Depois, monte o volume de configuração só de leitura e a aplicação recusará novas alterações à configuração. Os registos de auditoria e o estado de início de sessão continuam editáveis no volume de estado.",
"applying": "A aplicar…",
"finish": "Aplicar e concluir"
}
},
"errors": {
"page_error_title": "Algo deu errado",
"page_error_description": "Encontramos um erro inesperado. Por favor, tente novamente ou volte para a página inicial.",
+255 -6
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Setări",
"back_to_mail": "Înapoi la Mail",
"back_to_settings": "Înapoi la setări",
"save_success": "Setările au fost salvate cu succes",
"import_success": "Setările au fost importate cu succes",
"import_error": "Nu s-au putut importa setările",
@@ -1348,6 +1349,7 @@
"id_exists": "Această etichetă ID există deja",
"edit": "Editați eticheta",
"delete": "Șterge eticheta",
"deleted": "Eticheta a fost ștearsă",
"save": "Salvare",
"add": "Adăugați",
"cancel": "Anulează",
@@ -1852,6 +1854,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Alte dispozitive nu vor mai putea restaura conturile din ea.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1957,6 +1960,8 @@
"removed": "Parola aplicației a fost eliminată",
"add_error": "Nu s-a putut crea parola aplicației",
"remove_error": "Nu s-a reușit eliminarea parolei aplicației",
"delete_title": "Ștergeți parola de aplicație?",
"delete_message": "Aplicațiile care se conectează cu {name} nu se vor mai putea conecta.",
"none": "Nu sunt configurate parole pentru aplicații"
},
"api_keys": {
@@ -1969,6 +1974,8 @@
"removed": "API cheie eliminată",
"add_error": "Nu s-a putut crea cheia dAPI",
"remove_error": "Nu s-a putut elimina cheia „API”",
"delete_title": "Ștergeți cheia API?",
"delete_message": "Scripturile și integrările care folosesc {name} pierd accesul.",
"none": "Nu sunt configurate tastele „API”"
},
"encryption": {
@@ -1988,6 +1995,9 @@
"configure_title": "Activați criptarea cu această cheie",
"enable_button": "Activați",
"disable_tooltip": "Dezactivați criptarea",
"disable_title": "Dezactivați criptarea datelor stocate?",
"disable_message": "E-mailurile noi vor fi stocate de acum fără criptare.",
"disable_button": "Dezactivează",
"enabled_success": "Criptarea datelor stocate este activată"
},
"email_client": {
@@ -2036,7 +2046,14 @@
"name_placeholder": "de ex. Cheia mea",
"key_label": "Cheie publică (S/MIME sau PGP în format ASCII)",
"key_placeholder": "Lipiți aici cheia dvs. publică",
"none": "Nicio cheie publică configurată"
"none": "Nicio cheie publică configurată",
"title": "Chei publice",
"added": "Cheie publică adăugată",
"removed": "Cheie publică ștearsă",
"add_error": "Cheia publică nu a putut fi adăugată",
"remove_error": "Cheia publică nu a putut fi ștearsă",
"delete_title": "Ștergeți cheia publică?",
"delete_message": "{name} este eliminată de pe server."
},
"sections": {
"devices": "Alte aplicații și dispozitive",
@@ -2096,6 +2113,7 @@
},
"save": "Salvați modificările",
"saving": "Se salvează...",
"saved": "Salvat",
"warnings": {
"end_before_start": "Data de încheiere trebuie să fie ulterioară datei de începere",
"invalid_date": "Introduceți o dată de început și de sfârșit validă, cu oră, sau lăsați câmpul gol",
@@ -2162,12 +2180,17 @@
"new_folder_name": "Numele folderului",
"rename": "Redenumire",
"change_icon": "Schimbă pictograma",
"actions": "Acțiuni pentru {name}",
"expand": "Extinde {name}",
"collapse": "Restrânge {name}",
"counter": "Contor",
"count_mode": "Contor pentru {name}",
"count_mode_default": "Contor: implicit",
"count_mode_unread": "Doar necitite",
"count_mode_all": "Necitite și total",
"count_mode_none": "Fără contor",
"delete": "Șterge",
"delete_title": "Ștergeți dosarul?",
"confirm_delete": "Sunteți sigur că doriți să ștergeți „{name}”? E-mailurile din acest folder vor fi mutate în Coșul de gunoi.",
"create": "Creați",
"cancel": "Anulează",
@@ -2187,7 +2210,8 @@
"advanced": {
"debug_mode": {
"label": "Modul de depanare",
"description": "Activați înregistrarea detaliată a evenimentelor pentru depanare"
"description": "Activați înregistrarea detaliată a evenimentelor pentru depanare",
"toggle": "Jurnalizare detaliată"
},
"debug_categories": {
"description": "Selectați categoriile pe care doriți să le înregistrați. Dezactivați categoriile de care nu aveți nevoie pentru a reduce zgomotul din consolă.",
@@ -2232,12 +2256,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "Reîmprospătați datele din cache?"
},
"reset_settings": {
"label": "Resetare setări",
"description": "Restabiliți toate setările la valorile implicite",
"button": "Resezare la setările implicite"
"button": "Resezare la setările implicite",
"confirm_title": "Resetați toate setările?",
"done": "Setări resetate"
},
"export_settings": {
"label": "Setări de export",
@@ -2312,7 +2339,15 @@
"no_address_books": "Nu s-au găsit agende de adrese",
"categories_title": "Categorii",
"categories_description": "Redenumiți categoriile de contacte",
"no_categories": "Nu s-au găsit categorii"
"no_categories": "Nu s-au găsit categorii",
"delete_category": "Șterge categoria",
"category_deleted": "Categorie ștearsă",
"category_delete_failed": "Categoria nu a putut fi ștearsă"
},
"calendar_management": {
"delete_title": "Ștergeți calendarul?",
"clear_title": "Ștergeți toate evenimentele?",
"unsubscribe_title": "Vă dezabonați de la acest calendar?"
},
"downloads": {
"title": "Descărcări",
@@ -2378,7 +2413,7 @@
"validate": "Validați",
"validation_success": "Scriptul este valid",
"validation_error": "Scriptul conține erori",
"save": "Salvați regulile",
"save": "Salvare",
"saving": "Se salvează...",
"saved": "Filtrele au fost salvate cu succes",
"save_failed": "Nu s-au putut salva filtrele",
@@ -2392,6 +2427,8 @@
"add_condition": "Adăugați condiție",
"actions": "Acțiuni",
"add_action": "Adăugare acțiune",
"remove_condition": "Elimină condiția",
"remove_action": "Elimină acțiunea",
"stop_processing": "Opriți procesarea regulilor următoare",
"include_spam": "Mută și mesajele marcate ca spam",
"attachment_type_placeholder": "de ex. pdf, doc, jpg",
@@ -2468,6 +2505,7 @@
"match_type": "Potriviți tipul",
"reset_to_visual": "Reveniți la editorul vizual",
"reset_warning": "Aceasta va anula scriptul curent și va începe de la zero.",
"reset_title": "Reveniți la editorul vizual?",
"confirm_reset": "Resetare",
"validation_empty_name": "Numele regulii este obligatoriu",
"validation_empty_conditions": "Este necesară cel puțin o condiție cu o valoare",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Configurarea Bulwark Webmail",
"subtitle": "Configurați instanța de webmail din browser.",
"loading": "Se încarcă…",
"back": "Înapoi",
"next": "Înainte",
"saving": "Se salvează…",
"show": "Afișează",
"hide": "Ascunde",
"remove": "Elimină",
"on": "Activat",
"off": "Dezactivat",
"dismiss": "Închide",
"unknown_error": "Eroare necunoscută",
"steps": {
"welcome": "Bun venit",
"server": "Server",
"auth": "Autentificare",
"security": "Securitate",
"logging": "Jurnale",
"branding": "Identitate vizuală",
"review": "Verificare"
},
"read_only": {
"title": "Configurația este doar în citire",
"body": "Volumul de configurare este montat doar în citire sau este setată variabila {variable}. Montați-l cu drept de scriere sau eliminați variabila, apoi reporniți containerul."
},
"done": {
"title": "Totul este gata",
"body": "{appName} este configurat și gata de utilizare.",
"admin_sign_in": "Conectați-vă la panoul de administrare",
"webmail_login": "Deschideți autentificarea în webmail",
"redirecting": "Vă ducem la panoul de administrare…"
},
"already": {
"title": "Configurarea este deja finalizată",
"body": "{appName} este configurat. Conectați-vă pentru a continua."
},
"insecure": {
"title": "Rulați configurarea prin HTTP necriptat",
"body": "Tokenul de configurare și parola de administrator introduse aici vor circula în text clar. Folosiți HTTPS dacă este posibil: terminați TLS în container sau într-un proxy invers aflat în fața lui.",
"try_https": "Încercați HTTPS",
"continue_http": "Continuați prin HTTP"
},
"errors": {
"token_expired": "Tokenul de configurare nu mai este valid. Reporniți containerul pentru a obține unul nou din jurnale.",
"session_expired": "Sesiunea de configurare a expirat. Lipiți din nou tokenul de configurare pentru a continua.",
"token_required": "Lipiți tokenul de configurare afișat în jurnalele containerului pentru a continua.",
"already_finished": "Configurarea s-a încheiat deja. Reîncărcați pentru a vă conecta.",
"token_rejected": "Token respins (HTTP {status})",
"step_failed": "Pasul nu a putut fi salvat (HTTP {status})",
"upload_failed": "Încărcarea a eșuat (HTTP {status})",
"finish_failed": "Configurarea nu a putut fi finalizată (HTTP {status})"
},
"welcome": {
"title": "Bun venit",
"body": "Lipiți tokenul de configurare afișat în jurnalele containerului pentru a continua. Tokenul expiră după 1 oră.",
"token_label": "Token de configurare",
"token_placeholder": "Token hexazecimal de 32 de octeți",
"verifying": "Se verifică…",
"continue": "Continuați"
},
"server": {
"title": "Server",
"subtitle": "Unde se află e-mailul dvs.",
"app_name": "Numele aplicației",
"url_label": "URL-ul serverului JMAP",
"url_hint": "Serverul implicit la care se conectează utilizatorii. Exemplu: https://mail.example.com",
"test": "Testează",
"testing": "Se testează…",
"probe_ok": "Conectat: pare un server JMAP.",
"probe_not_jmap": "Serverul răspunde, dar nu pare un punct de acces JMAP.",
"probe_invalid": "URL-ul nu este valid. Trebuie să înceapă cu http:// sau https://.",
"probe_unreachable": "Nu s-a putut face conexiunea la această adresă. Verificați URL-ul și dacă serverul este online.",
"not_jmap_hint": "Se poate întâmpla când un proxy invers direcționează separat JMAP pe același domeniu. Altfel, de obicei URL-ul este greșit.",
"not_jmap_confirm": "Sunt sigur că URL-ul este corect, continuă oricum.",
"http_title": "Acest URL folosește HTTP necriptat.",
"http_body": "Parolele și conținutul e-mailurilor vor circula necriptat între utilizatori și serverul dvs. În producție folosiți {https}: terminați TLS pe serverul de e-mail sau într-un proxy invers aflat în fața lui.",
"local_title": "Acest URL funcționează doar local.",
"local_body": "E-mailul este preluat direct din browserul utilizatorului, așa că URL-ul JMAP trebuie să fie accesibil de oriunde se conectează utilizatorii, nu doar de pe acest calculator sau din rețeaua locală. În producție folosiți un nume de gazdă public (de ex. {example}).",
"additional_title": "Servere JMAP suplimentare",
"additional_hint": "Opțional. Oferiți mai multe servere în lista de autentificare, util pentru furnizorii cu mai multe instanțe Stalwart.",
"additional_show": "Afișează ({count})",
"add": "Adaugă",
"row_title": "Serverul nr. {index}",
"id_label": "ID",
"id_hint": "Identificator unic, de ex. eu-1",
"label_label": "Etichetă",
"label_hint": "Vizibilă pentru utilizatori",
"row_url": "URL",
"domains_label": "Domenii",
"domains_hint": "Separate prin virgule. Folosite pentru a alege acest server după domeniul e-mailului la autentificare.",
"add_server": "Adaugă server",
"auto_pick": "Alege serverul după domeniul e-mailului",
"auto_pick_hint": "Când un utilizator își scrie adresa de e-mail, se selectează serverul potrivit din lista de mai sus.",
"row_id_required": "Serverul nr. {index}: ID-ul este obligatoriu",
"row_id_invalid": "Serverul nr. {index}: ID-ul poate conține doar litere, cifre, - și _ și trebuie să înceapă cu o literă sau o cifră",
"row_id_duplicate": "Serverul nr. {index}: ID-ul „{id}” apare de două ori",
"row_url_required": "Serverul nr. {index}: URL-ul este obligatoriu",
"row_url_scheme": "Serverul nr. {index}: URL-ul trebuie să înceapă cu http:// sau https://",
"stalwart": "Activați funcțiile specifice Stalwart",
"stalwart_hint": "Adaugă schimbarea parolei și gestionarea filtrelor Sieve. Poate fi activat fără risc și pe alte servere."
},
"auth": {
"title": "Autentificare",
"subtitle": "Autentificarea cu parolă este mereu disponibilă. OAuth/OIDC este opțional.",
"oauth": "Activați OAuth2 / OpenID Connect",
"oauth_only": "Doar OAuth (ascunde formularul de parolă)",
"client_id": "ID client OAuth",
"client_secret": "Secret client OAuth",
"client_secret_hint": "Lăsați gol pentru clienții publici care folosesc doar PKCE.",
"client_secret_placeholder": "Lipiți secretul",
"issuer": "URL-ul emitentului OAuth",
"issuer_hint": "Pentru furnizori de identitate externi (Keycloak, Authentik, Entra ID etc.)."
},
"security": {
"title": "Securitate și sesiuni",
"subtitle": "Secretul de sesiune permite „Ține-mă minte” și sincronizarea setărilor.",
"generated": "Secret de sesiune generat",
"customize": "Personalizează",
"generated_hint": "A fost creat pentru dvs. un secret de 32 de octeți. Schimbați-l doar dacă aveți un motiv anume.",
"secret_label": "Secret de sesiune",
"secret_hint": "Lipiți propria valoare sau generați una nouă.",
"regenerate": "Generează din nou",
"sync": "Sincronizează setările utilizatorilor între dispozitive",
"sync_hint": "Stochează preferințele utilizatorilor pe server, criptate cu secretul de sesiune.",
"telemetry": "Trimite statistici anonime de utilizare pentru a îmbunătăți Bulwark",
"telemetry_hint": "Dezactivat implicit. Un semnal anonim pe zi cu versiunea, platforma și funcțiile activate; niciodată adrese de e-mail, nume de gazdă sau IP-uri. Puteți schimba oricând în setările de administrare."
},
"logging": {
"title": "Jurnale",
"subtitle": "Formatul și nivelul de detaliu al jurnalelor aplicației.",
"format": "Format",
"format_text": "text: colorat, ușor de citit",
"format_json": "json: structurat, pentru agregarea jurnalelor",
"level": "Nivel",
"level_info": "info (recomandat)"
},
"branding": {
"title": "Identitate vizuală",
"subtitle": "Toate câmpurile sunt opționale. Încărcați un fișier sau lipiți un URL; pentru ce omiteți se folosesc valorile implicite.",
"company": "Numele companiei sau al organizației",
"favicon": "Favicon",
"favicon_hint": "Pictograma filei din browser. Recomandat SVG.",
"login_light": "Logo de autentificare (mod luminos)",
"login_light_hint": "Afișat pe pagina de autentificare, pe fundal deschis.",
"login_dark": "Logo de autentificare (mod întunecat)",
"login_dark_hint": "Afișat pe pagina de autentificare, pe fundal închis.",
"sidebar_light": "Logo bară laterală (mod luminos)",
"sidebar_light_hint": "Afișat după autentificare. Lăsați gol pentru niciunul.",
"sidebar_dark": "Logo bară laterală (mod întunecat)",
"sidebar_dark_hint": "Versiunea pentru modul întunecat a logoului din bara laterală.",
"website": "URL-ul site-ului",
"imprint": "URL-ul paginii cu informații legale",
"privacy": "URL-ul politicii de confidențialitate",
"drop": "Faceți clic sau plasați aici",
"uploading": "Se încarcă…",
"uploaded": "Fișier încărcat",
"formats": "SVG, PNG, JPEG, WebP sau ICO · max. 2 MB",
"use_url": "Folosește URL",
"hide_url": "Ascunde URL",
"url_placeholder": "https://… sau /branding/fisier.svg"
},
"review": {
"title": "Aproape gata",
"subtitle": "Verificați setările, alegeți o parolă de administrator și aplicați.",
"server": "Server",
"app_name": "Numele aplicației",
"jmap_server": "Server JMAP",
"additional": "Servere suplimentare",
"additional_count": "{count} configurate",
"additional_auto_pick": "{count} configurate · alese după domeniu",
"stalwart": "Funcții Stalwart",
"auth": "Autentificare",
"method": "Metodă",
"oauth_only": "Doar OAuth",
"password_oauth": "Parolă + OAuth",
"password_only": "Doar parolă",
"client_id": "ID client OAuth",
"security": "Securitate",
"secret": "Secret de sesiune",
"configured": "Configurat",
"not_set": "Nesetat",
"remember_me": "Ține-mă minte",
"available": "Disponibil",
"disabled": "Dezactivat",
"sync": "Sincronizarea setărilor",
"needs_secret": "Necesită un secret de sesiune",
"telemetry": "Telemetrie anonimă",
"logging": "Jurnale",
"format": "Format",
"level": "Nivel",
"branding": "Identitate vizuală",
"customizations": "Personalizări",
"custom_assets": "Fișiere proprii configurate",
"defaults": "Valori implicite",
"company": "Numele companiei",
"password_title": "Alegeți o parolă de administrator",
"password_hint": "O veți folosi pentru a vă conecta la {path}. Minimum 8 caractere.",
"new_password": "Parolă nouă",
"confirm": "Confirmați",
"too_short": "Cel puțin 8 caractere.",
"mismatch": "Parolele nu se potrivesc.",
"looks_good": "Arată bine.",
"advanced": "Avansat",
"lock": "Blochează configurația după instalare",
"lock_hint": "Creează un fișier marcaj. Apoi montați volumul de configurare doar în citire, iar aplicația va refuza alte modificări ale configurației. Jurnalele de audit și starea autentificării rămân editabile în volumul de stare.",
"applying": "Se aplică…",
"finish": "Aplică și finalizează"
}
},
"errors": {
"page_error_title": "A apărut o eroare",
"page_error_description": "A apărut o eroare neașteptată. Vă rugăm să încercați din nou sau să reveniți la pagina principală.",
+254 -5
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Настройки",
"back_to_mail": "Вернуться к почте",
"back_to_settings": "Назад к настройкам",
"save_success": "Настройки успешно сохранены",
"import_success": "Настройки успешно импортированы",
"import_error": "Не удалось импортировать настройки",
@@ -1345,6 +1346,7 @@
"id_exists": "Этот идентификатор тега уже существует",
"edit": "Редактировать тег",
"delete": "Удалить тег",
"deleted": "Тег удалён",
"save": "Сохранить",
"add": "Добавить",
"cancel": "Отмена",
@@ -1849,6 +1851,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Другие устройства больше не смогут восстановить из него учётные записи.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1944,6 +1947,8 @@
"removed": "Пароль приложения удалён",
"add_error": "Не удалось создать пароль приложения",
"remove_error": "Не удалось удалить пароль приложения",
"delete_title": "Удалить пароль приложения?",
"delete_message": "Приложения, которые входят с паролем {name}, больше не смогут войти.",
"none": "Пароли приложений не настроены",
"done": "Готово",
"expires_label": "Срок действия (необязательно)",
@@ -1962,6 +1967,8 @@
"removed": "API-ключ удалён",
"add_error": "Не удалось создать API-ключ",
"remove_error": "Не удалось удалить API-ключ",
"delete_title": "Удалить ключ API?",
"delete_message": "Скрипты и интеграции, использующие {name}, потеряют доступ.",
"none": "API-ключи не настроены"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "Включить шифрование с этим ключом",
"enable_button": "Включить",
"disable_tooltip": "Отключить шифрование",
"disable_title": "Отключить шифрование хранимых данных?",
"disable_message": "Новые письма теперь будут храниться без шифрования.",
"disable_button": "Отключить",
"enabled_success": "Шифрование хранилища включено"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "напр., Мой ключ",
"key_label": "Открытый ключ (S/MIME или PGP в формате ASCII)",
"key_placeholder": "Вставьте сюда свой открытый ключ",
"none": "Открытые ключи не настроены"
"none": "Открытые ключи не настроены",
"title": "Открытые ключи",
"added": "Открытый ключ добавлен",
"removed": "Открытый ключ удалён",
"add_error": "Не удалось добавить открытый ключ",
"remove_error": "Не удалось удалить открытый ключ",
"delete_title": "Удалить открытый ключ?",
"delete_message": "Ключ {name} будет удалён с сервера."
},
"sections": {
"devices": "Другие приложения и устройства",
@@ -2089,6 +2106,7 @@
},
"save": "Сохранить изменения",
"saving": "Сохранение...",
"saved": "Сохранено",
"warnings": {
"end_before_start": "Дата окончания должна быть позже даты начала",
"invalid_date": "Укажите корректные даты начала и окончания со временем или оставьте поле пустым",
@@ -2155,12 +2173,17 @@
"new_folder_name": "Название папки",
"rename": "Переименовать",
"change_icon": "Изменить значок",
"actions": "Действия для «{name}»",
"expand": "Развернуть «{name}»",
"collapse": "Свернуть «{name}»",
"counter": "Счётчик",
"count_mode": "Счётчик для {name}",
"count_mode_default": "Счётчик: по умолчанию",
"count_mode_unread": "Только непрочитанные",
"count_mode_all": "Непрочитанные и всего",
"count_mode_none": "Без счётчика",
"delete": "Удалить",
"delete_title": "Удалить папку?",
"confirm_delete": "Вы уверены, что хотите удалить \"{name}\"? Письма в этой папке будут перемещены в корзину.",
"create": "Создать",
"cancel": "Отмена",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "Режим отладки",
"description": "Включить подробное журналирование для диагностики"
"description": "Включить подробное журналирование для диагностики",
"toggle": "Подробное журналирование"
},
"debug_categories": {
"description": "Выберите, какие категории журналировать. Отключите ненужные категории, чтобы уменьшить шум в консоли.",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "Обновить кэшированные данные?"
},
"reset_settings": {
"label": "Сбросить настройки",
"description": "Восстановить все настройки до значений по умолчанию",
"button": "Сбросить"
"button": "Сбросить",
"confirm_title": "Сбросить все настройки?",
"done": "Настройки сброшены"
},
"export_settings": {
"label": "Экспортировать настройки",
@@ -2302,11 +2329,19 @@
"categories_title": "Категории",
"categories_description": "Переименование категорий контактов",
"no_categories": "Категории не найдены",
"delete_category": "Удалить категорию",
"category_deleted": "Категория удалена",
"category_delete_failed": "Не удалось удалить категорию",
"group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов",
"group_by_letter_label": "Группировать по первой букве",
"sort_by_last_name_label": "Сортировать по фамилии",
"sort_by_last_name_description": "Упорядочить список контактов по фамилии, чтобы члены семьи шли подряд"
},
"calendar_management": {
"delete_title": "Удалить календарь?",
"clear_title": "Очистить все события?",
"unsubscribe_title": "Отписаться от этого календаря?"
},
"downloads": {
"title": "Загрузки",
"description": "Настройте, как именуются загруженные письма и вложения.",
@@ -2371,7 +2406,7 @@
"validate": "Проверить",
"validation_success": "Скрипт корректен",
"validation_error": "Скрипт содержит ошибки",
"save": "Сохранить правила",
"save": "Сохранить",
"saving": "Сохранение...",
"saved": "Фильтры успешно сохранены",
"save_failed": "Не удалось сохранить фильтры",
@@ -2385,6 +2420,8 @@
"add_condition": "Добавить условие",
"actions": "Действия",
"add_action": "Добавить действие",
"remove_condition": "Убрать условие",
"remove_action": "Убрать действие",
"stop_processing": "Прекратить обработку последующих правил",
"include_spam": "Перемещать также письма, помеченные как спам",
"attachment_type_placeholder": "напр. pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "Тип совпадения",
"reset_to_visual": "Сбросить до визуального конструктора",
"reset_warning": "Это удалит текущий скрипт и начнёт заново.",
"reset_title": "Вернуться к визуальному редактору?",
"confirm_reset": "Сбросить",
"validation_empty_name": "Требуется название правила",
"validation_empty_conditions": "Требуется хотя бы одно условие со значением",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Настройка Bulwark Webmail",
"subtitle": "Настройте свой экземпляр веб-почты в браузере.",
"loading": "Загрузка…",
"back": "Назад",
"next": "Далее",
"saving": "Сохранение…",
"show": "Показать",
"hide": "Скрыть",
"remove": "Удалить",
"on": "Вкл.",
"off": "Выкл.",
"dismiss": "Закрыть",
"unknown_error": "Неизвестная ошибка",
"steps": {
"welcome": "Добро пожаловать",
"server": "Сервер",
"auth": "Вход",
"security": "Безопасность",
"logging": "Журналы",
"branding": "Оформление",
"review": "Проверка"
},
"read_only": {
"title": "Конфигурация доступна только для чтения",
"body": "Том с конфигурацией смонтирован только для чтения или задана переменная {variable}. Смонтируйте его с правом записи или удалите переменную, затем перезапустите контейнер."
},
"done": {
"title": "Всё готово",
"body": "{appName} настроен и готов к работе.",
"admin_sign_in": "Войти в панель администратора",
"webmail_login": "Открыть вход в веб-почту",
"redirecting": "Переходим в панель администратора…"
},
"already": {
"title": "Настройка уже завершена",
"body": "{appName} настроен. Войдите, чтобы продолжить."
},
"insecure": {
"title": "Вы запускаете настройку по незашифрованному HTTP",
"body": "Токен настройки и пароль администратора, введённые здесь, будут переданы открытым текстом. По возможности используйте HTTPS: завершайте TLS в контейнере или на обратном прокси перед ним.",
"try_https": "Попробовать HTTPS",
"continue_http": "Продолжить по HTTP"
},
"errors": {
"token_expired": "Этот токен настройки больше не действует. Перезапустите контейнер, чтобы получить новый в журналах.",
"session_expired": "Сеанс настройки истёк. Вставьте токен настройки ещё раз, чтобы продолжить.",
"token_required": "Вставьте токен настройки из журналов контейнера, чтобы продолжить.",
"already_finished": "Настройка уже завершена. Перезагрузите страницу, чтобы войти.",
"token_rejected": "Токен отклонён (HTTP {status})",
"step_failed": "Не удалось сохранить этот шаг (HTTP {status})",
"upload_failed": "Не удалось загрузить файл (HTTP {status})",
"finish_failed": "Не удалось завершить настройку (HTTP {status})"
},
"welcome": {
"title": "Добро пожаловать",
"body": "Вставьте токен настройки из журналов контейнера, чтобы продолжить. Токен действует 1 час.",
"token_label": "Токен настройки",
"token_placeholder": "32-байтовый шестнадцатеричный токен",
"verifying": "Проверка…",
"continue": "Продолжить"
},
"server": {
"title": "Сервер",
"subtitle": "Где хранится ваша почта.",
"app_name": "Название приложения",
"url_label": "URL сервера JMAP",
"url_hint": "Сервер по умолчанию, к которому подключаются пользователи. Пример: https://mail.example.com",
"test": "Проверить",
"testing": "Проверка…",
"probe_ok": "Подключено: похоже на сервер JMAP.",
"probe_not_jmap": "Сервер отвечает, но не похож на конечную точку JMAP.",
"probe_invalid": "Этот URL недействителен. Он должен начинаться с http:// или https://.",
"probe_unreachable": "Не удалось подключиться к этому адресу. Проверьте URL и доступность сервера.",
"not_jmap_hint": "Так бывает, если обратный прокси отдельно маршрутизирует JMAP в том же домене. Иначе URL, скорее всего, неверный.",
"not_jmap_confirm": "Я уверен, что URL правильный, продолжить всё равно.",
"http_title": "Этот URL использует незашифрованный HTTP.",
"http_body": "Пароли и содержимое писем будут передаваться между пользователями и вашим сервером без шифрования. В рабочей среде используйте {https}: завершайте TLS на почтовом сервере или на обратном прокси перед ним.",
"local_title": "Этот URL работает только локально.",
"local_body": "Почта загружается прямо в браузере пользователя, поэтому URL JMAP должен быть доступен отовсюду, где входят пользователи, а не только с этого компьютера или из локальной сети. В рабочей среде используйте публичное имя хоста (например, {example}).",
"additional_title": "Дополнительные серверы JMAP",
"additional_hint": "Необязательно. Предложите несколько серверов в списке при входе; удобно для хостингов с несколькими экземплярами Stalwart.",
"additional_show": "Показать ({count})",
"add": "Добавить",
"row_title": "Сервер № {index}",
"id_label": "ID",
"id_hint": "Уникальное короткое имя, например eu-1",
"label_label": "Название",
"label_hint": "Видно пользователям",
"row_url": "URL",
"domains_label": "Домены",
"domains_hint": "Через запятую. По ним при входе выбирается этот сервер в зависимости от домена почты.",
"add_server": "Добавить сервер",
"auto_pick": "Выбирать сервер по домену почты",
"auto_pick_hint": "Когда пользователь вводит адрес почты, выбирается подходящий сервер из списка выше.",
"row_id_required": "Сервер № {index}: ID обязателен",
"row_id_invalid": "Сервер № {index}: ID может содержать только буквы, цифры, - и _ и должен начинаться с буквы или цифры",
"row_id_duplicate": "Сервер № {index}: ID «{id}» повторяется",
"row_url_required": "Сервер № {index}: URL обязателен",
"row_url_scheme": "Сервер № {index}: URL должен начинаться с http:// или https://",
"stalwart": "Включить функции, специфичные для Stalwart",
"stalwart_hint": "Добавляет смену пароля и управление фильтрами Sieve. Безопасно включать и для других серверов."
},
"auth": {
"title": "Аутентификация",
"subtitle": "Вход по паролю доступен всегда. OAuth/OIDC — по желанию.",
"oauth": "Включить OAuth2 / OpenID Connect",
"oauth_only": "Только OAuth (скрыть форму пароля)",
"client_id": "ID клиента OAuth",
"client_secret": "Секрет клиента OAuth",
"client_secret_hint": "Оставьте пустым для публичных клиентов, использующих только PKCE.",
"client_secret_placeholder": "Вставьте секрет",
"issuer": "URL издателя OAuth",
"issuer_hint": "Для внешних поставщиков удостоверений (Keycloak, Authentik, Entra ID и др.)."
},
"security": {
"title": "Безопасность и сеансы",
"subtitle": "Секрет сеанса включает «Запомнить меня» и синхронизацию настроек.",
"generated": "Секрет сеанса создан",
"customize": "Настроить",
"generated_hint": "Для вас создан 32-байтовый секрет. Меняйте его, только если есть конкретная причина.",
"secret_label": "Секрет сеанса",
"secret_hint": "Вставьте своё значение или создайте новое.",
"regenerate": "Создать заново",
"sync": "Синхронизировать настройки пользователей между устройствами",
"sync_hint": "Хранит настройки пользователей на сервере в зашифрованном секретом сеанса виде.",
"telemetry": "Отправлять анонимную статистику использования, чтобы улучшить Bulwark",
"telemetry_hint": "По умолчанию выключено. Один анонимный сигнал в день с версией, платформой и списком включённых функций; никогда не передаются адреса почты, имена хостов или IP-адреса. Это можно изменить в любой момент в настройках администратора."
},
"logging": {
"title": "Журналы",
"subtitle": "Формат и подробность журналов приложения.",
"format": "Формат",
"format_text": "text: цветной, удобный для чтения",
"format_json": "json: структурированный, для сбора журналов",
"level": "Уровень",
"level_info": "info (рекомендуется)"
},
"branding": {
"title": "Оформление",
"subtitle": "Все поля необязательны. Загрузите файл или вставьте URL; для пропущенного используются значения по умолчанию.",
"company": "Название компании или организации",
"favicon": "Значок сайта",
"favicon_hint": "Значок вкладки браузера. Рекомендуется SVG.",
"login_light": "Логотип входа (светлая тема)",
"login_light_hint": "На странице входа, на светлом фоне.",
"login_dark": "Логотип входа (тёмная тема)",
"login_dark_hint": "На странице входа, на тёмном фоне.",
"sidebar_light": "Логотип боковой панели (светлая тема)",
"sidebar_light_hint": "Показывается после входа. Оставьте пустым, если не нужен.",
"sidebar_dark": "Логотип боковой панели (тёмная тема)",
"sidebar_dark_hint": "Вариант логотипа боковой панели для тёмной темы.",
"website": "URL сайта",
"imprint": "URL выходных данных",
"privacy": "URL политики конфиденциальности",
"drop": "Нажмите или перетащите сюда",
"uploading": "Загрузка…",
"uploaded": "Загруженный файл",
"formats": "SVG, PNG, JPEG, WebP или ICO · до 2 МБ",
"use_url": "Указать URL",
"hide_url": "Скрыть URL",
"url_placeholder": "https://… или /branding/file.svg"
},
"review": {
"title": "Почти готово",
"subtitle": "Проверьте настройки, выберите пароль администратора и примените их.",
"server": "Сервер",
"app_name": "Название приложения",
"jmap_server": "Сервер JMAP",
"additional": "Дополнительные серверы",
"additional_count": "Настроено: {count}",
"additional_auto_pick": "Настроено: {count} · выбор по домену",
"stalwart": "Функции Stalwart",
"auth": "Аутентификация",
"method": "Способ",
"oauth_only": "Только OAuth",
"password_oauth": "Пароль + OAuth",
"password_only": "Только пароль",
"client_id": "ID клиента OAuth",
"security": "Безопасность",
"secret": "Секрет сеанса",
"configured": "Настроен",
"not_set": "Не задан",
"remember_me": "Запомнить меня",
"available": "Доступно",
"disabled": "Отключено",
"sync": "Синхронизация настроек",
"needs_secret": "Нужен секрет сеанса",
"telemetry": "Анонимная телеметрия",
"logging": "Журналы",
"format": "Формат",
"level": "Уровень",
"branding": "Оформление",
"customizations": "Настройки оформления",
"custom_assets": "Свои файлы настроены",
"defaults": "Значения по умолчанию",
"company": "Название компании",
"password_title": "Выберите пароль администратора",
"password_hint": "Он нужен для входа на {path}. Не менее 8 символов.",
"new_password": "Новый пароль",
"confirm": "Подтверждение",
"too_short": "Не менее 8 символов.",
"mismatch": "Пароли не совпадают.",
"looks_good": "Всё в порядке.",
"advanced": "Дополнительно",
"lock": "Заблокировать конфигурацию после настройки",
"lock_hint": "Создаёт файл-метку. После этого смонтируйте том с конфигурацией только для чтения, и приложение будет отклонять дальнейшие изменения конфигурации. Журналы аудита и состояние входа остаются доступными для записи в томе состояния.",
"applying": "Применение…",
"finish": "Применить и завершить"
}
},
"errors": {
"page_error_title": "Что-то пошло не так",
"page_error_description": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте снова или вернитесь на главную страницу.",
+255 -6
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Nastavenia",
"back_to_mail": "Späť na poštu",
"back_to_settings": "Späť do nastavení",
"save_success": "Nastavenia boli úspešne uložené",
"import_success": "Nastavenia boli úspešne importované",
"import_error": "Import nastavení zlyhal",
@@ -1348,6 +1349,7 @@
"id_exists": "Toto ID štítku už existuje",
"edit": "Upraviť štítok",
"delete": "Odstrániť štítok",
"deleted": "Štítok bol odstránený",
"save": "Uložiť",
"add": "Pridať",
"cancel": "Zrušiť",
@@ -1852,6 +1854,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Iné zariadenia z neho už účty neobnovia.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1957,6 +1960,8 @@
"removed": "Heslo aplikácie odstránené",
"add_error": "Chyba pri vytváraní hesla aplikácie",
"remove_error": "Chyba pri odstraňovaní hesla aplikácie",
"delete_title": "Odstrániť heslo aplikácie?",
"delete_message": "Aplikácie, ktoré sa prihlasujú pomocou {name}, sa už neprihlásia.",
"none": "Žiadne heslá aplikácií"
},
"api_keys": {
@@ -1969,6 +1974,8 @@
"removed": "API kľúč odstránený",
"add_error": "Chyba pri vytváraní API kľúča",
"remove_error": "Chyba pri odstraňovaní API kľúča",
"delete_title": "Odstrániť kľúč API?",
"delete_message": "Skripty a integrácie, ktoré používajú {name}, stratia prístup.",
"none": "Žiadne API kľúče"
},
"encryption": {
@@ -1988,6 +1995,9 @@
"configure_title": "Zapnúť šifrovanie týmto kľúčom",
"enable_button": "Zapnúť",
"disable_tooltip": "Vypnúť šifrovanie",
"disable_title": "Vypnúť šifrovanie uložených dát?",
"disable_message": "Nová pošta sa odteraz ukladá bez šifrovania.",
"disable_button": "Vypnúť",
"enabled_success": "Šifrovanie dát v pokoji zapnuté"
},
"email_client": {
@@ -2036,7 +2046,14 @@
"name_placeholder": "napr. Môj kľúč",
"key_label": "Verejný kľúč (S/MIME alebo PGP vo formáte ASCII)",
"key_placeholder": "Sem vložte svoj verejný kľúč",
"none": "Nie sú nastavené žiadne verejné kľúče"
"none": "Nie sú nastavené žiadne verejné kľúče",
"title": "Verejné kľúče",
"added": "Verejný kľúč bol pridaný",
"removed": "Verejný kľúč bol odstránený",
"add_error": "Verejný kľúč sa nepodarilo pridať",
"remove_error": "Verejný kľúč sa nepodarilo odstrániť",
"delete_title": "Odstrániť verejný kľúč?",
"delete_message": "{name} bude odobratý zo servera."
},
"sections": {
"devices": "Ďalšie aplikácie a zariadenia",
@@ -2096,6 +2113,7 @@
},
"save": "Uložiť zmeny",
"saving": "Ukladanie...",
"saved": "Uložené",
"warnings": {
"end_before_start": "Dátum ukončenia musí byť po dátume začiatku",
"invalid_date": "Zadajte platný dátum začiatku a konca aj s časom alebo pole nechajte prázdne",
@@ -2162,12 +2180,17 @@
"new_folder_name": "Názov priečinka",
"rename": "Premenovať",
"change_icon": "Zmeniť ikonu",
"actions": "Akcie pre {name}",
"expand": "Rozbaliť {name}",
"collapse": "Zbaliť {name}",
"counter": "Počítadlo",
"count_mode": "Počítadlo pre {name}",
"count_mode_default": "Počítadlo: predvolené",
"count_mode_unread": "Len neprečítané",
"count_mode_all": "Neprečítané a celkom",
"count_mode_none": "Bez počítadla",
"delete": "Odstrániť",
"delete_title": "Odstrániť priečinok?",
"confirm_delete": "Naozaj chcete odstrániť priečinok \"{name}\"? E-maily v tomto priečinku budú presunuté do Koša.",
"create": "Vytvoriť",
"cancel": "Zrušiť",
@@ -2187,7 +2210,8 @@
"advanced": {
"debug_mode": {
"label": "Režim ladenia",
"description": "Povoliť podrobné protokolovanie na riešenie problémov"
"description": "Povoliť podrobné protokolovanie na riešenie problémov",
"toggle": "Podrobné protokolovanie"
},
"debug_categories": {
"description": "Vyberte kategórie na protokolovanie.",
@@ -2232,12 +2256,15 @@
"refresh_cache": {
"label": "Obnoviť uložené údaje",
"description": "Znova načíta kontakty, kalendáre a priečinky zo servera. Zachová vaše účty a prihlásenia — opraví zastarané alebo nesprávne zobrazenie bez odhlásenia.",
"button": "Obnoviť"
"button": "Obnoviť",
"confirm_title": "Obnoviť údaje vo vyrovnávacej pamäti?"
},
"reset_settings": {
"label": "Resetovať nastavenia",
"description": "Obnoviť všetky nastavenia na predvolené hodnoty",
"button": "Obnoviť predvolené"
"button": "Obnoviť predvolené",
"confirm_title": "Obnoviť všetky nastavenia?",
"done": "Nastavenia boli obnovené"
},
"export_settings": {
"label": "Exportovať nastavenia",
@@ -2312,7 +2339,15 @@
"no_address_books": "Neboli nájdené žiadne adresáre",
"categories_title": "Kategórie",
"categories_description": "Premenovať kategórie kontaktov",
"no_categories": "Neboli nájdené žiadne kategórie"
"no_categories": "Neboli nájdené žiadne kategórie",
"delete_category": "Odstrániť kategóriu",
"category_deleted": "Kategória bola odstránená",
"category_delete_failed": "Kategóriu sa nepodarilo odstrániť"
},
"calendar_management": {
"delete_title": "Odstrániť kalendár?",
"clear_title": "Vymazať všetky udalosti?",
"unsubscribe_title": "Zrušiť odber tohto kalendára?"
},
"downloads": {
"title": "Stiahnuté",
@@ -2378,7 +2413,7 @@
"validate": "Overiť",
"validation_success": "Skript je platný",
"validation_error": "Skript obsahuje chyby",
"save": "Uložiť pravidlá",
"save": "Uložiť",
"saving": "Ukladanie...",
"saved": "Filtre boli uložené",
"save_failed": "Uloženie filtrov zlyhalo",
@@ -2392,6 +2427,8 @@
"add_condition": "Pridať podmienku",
"actions": "Akcie",
"add_action": "Pridať akciu",
"remove_condition": "Odobrať podmienku",
"remove_action": "Odobrať akciu",
"stop_processing": "Zastaviť spracovanie ďalších pravidiel",
"include_spam": "Presúvať aj správy označené ako spam",
"attachment_type_placeholder": "napr. pdf, doc, jpg",
@@ -2468,6 +2505,7 @@
"match_type": "Typ zhody",
"reset_to_visual": "Resetovať na vizuálneho sprievodcu",
"reset_warning": "Tým zahodíte aktuálny skript a začnete odznova.",
"reset_title": "Obnoviť vizuálny editor?",
"confirm_reset": "Resetovať",
"validation_empty_name": "Názov pravidla je povinný",
"validation_empty_conditions": "Je potrebná aspoň jedna podmienka s hodnotou",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Nastavenie Bulwark Webmail",
"subtitle": "Nastavte svoju inštanciu webmailu v prehliadači.",
"loading": "Načítava sa…",
"back": "Späť",
"next": "Ďalej",
"saving": "Ukladá sa…",
"show": "Zobraziť",
"hide": "Skryť",
"remove": "Odobrať",
"on": "Zapnuté",
"off": "Vypnuté",
"dismiss": "Zavrieť",
"unknown_error": "Neznáma chyba",
"steps": {
"welcome": "Vitajte",
"server": "Server",
"auth": "Prihlásenie",
"security": "Zabezpečenie",
"logging": "Protokol",
"branding": "Vzhľad značky",
"review": "Kontrola"
},
"read_only": {
"title": "Konfigurácia je len na čítanie",
"body": "Zväzok s konfiguráciou je pripojený len na čítanie alebo je nastavená premenná {variable}. Pripojte ho na zápis alebo premennú odstráňte a reštartujte kontajner."
},
"done": {
"title": "Všetko je pripravené",
"body": "{appName} je nastavený a pripravený na použitie.",
"admin_sign_in": "Prihlásiť sa do administrácie",
"webmail_login": "Otvoriť prihlásenie do webmailu",
"redirecting": "Presmerovanie do administrácie…"
},
"already": {
"title": "Nastavenie je už dokončené",
"body": "{appName} je nastavený. Pokračujte prihlásením."
},
"insecure": {
"title": "Nastavenie spúšťate cez nešifrované HTTP",
"body": "Token na nastavenie a heslo správcu, ktoré tu zadáte, sa prenesú ako obyčajný text. Ak sa dá, použite HTTPS: ukončite TLS v kontajneri alebo v reverznej proxy pred ním.",
"try_https": "Skúsiť HTTPS",
"continue_http": "Pokračovať cez HTTP"
},
"errors": {
"token_expired": "Tento token na nastavenie už neplatí. Reštartujte kontajner a v protokoloch nájdete nový.",
"session_expired": "Relácia nastavenia vypršala. Pokračujte opätovným vložením tokenu.",
"token_required": "Pokračujte vložením tokenu na nastavenie z protokolov kontajnera.",
"already_finished": "Nastavenie je už dokončené. Na prihlásenie stránku obnovte.",
"token_rejected": "Token odmietnutý (HTTP {status})",
"step_failed": "Tento krok sa nepodarilo uložiť (HTTP {status})",
"upload_failed": "Nahrávanie zlyhalo (HTTP {status})",
"finish_failed": "Nastavenie sa nepodarilo dokončiť (HTTP {status})"
},
"welcome": {
"title": "Vitajte",
"body": "Pokračujte vložením tokenu na nastavenie z protokolov kontajnera. Token vyprší po 1 hodine.",
"token_label": "Token na nastavenie",
"token_placeholder": "32-bajtový hexadecimálny token",
"verifying": "Overuje sa…",
"continue": "Pokračovať"
},
"server": {
"title": "Server",
"subtitle": "Kde je vaša pošta.",
"app_name": "Názov aplikácie",
"url_label": "URL servera JMAP",
"url_hint": "Predvolený server, ku ktorému sa používatelia pripájajú. Príklad: https://mail.example.com",
"test": "Otestovať",
"testing": "Testuje sa…",
"probe_ok": "Pripojené: vyzerá to na server JMAP.",
"probe_not_jmap": "Server odpovedá, ale nevyzerá ako koncový bod JMAP.",
"probe_invalid": "Táto URL nie je platná. Musí začínať http:// alebo https://.",
"probe_unreachable": "K tejto adrese sa nepodarilo pripojiť. Skontrolujte URL a či je server online.",
"not_jmap_hint": "Stáva sa to, keď reverzná proxy smeruje JMAP na tej istej doméne zvlášť. Inak je URL zvyčajne nesprávna.",
"not_jmap_confirm": "Som si istý, že URL je správna, aj tak pokračovať.",
"http_title": "Táto URL používa nešifrované HTTP.",
"http_body": "Heslá a obsah e-mailov sa budú medzi používateľmi a vaším serverom prenášať nešifrovane. V prevádzke použite {https}: ukončite TLS na poštovom serveri alebo v reverznej proxy pred ním.",
"local_title": "Táto URL funguje len lokálne.",
"local_body": "Pošta sa načítava priamo v prehliadači používateľa, takže URL JMAP musí byť dostupná odkiaľkoľvek, kde sa používatelia prihlasujú, nielen z tohto počítača alebo lokálnej siete. V prevádzke použite verejný názov hostiteľa (napr. {example}).",
"additional_title": "Ďalšie servery JMAP",
"additional_hint": "Voliteľné. Ponúknite pri prihlásení viac serverov, užitočné pre hostingy s viacerými inštanciami Stalwart.",
"additional_show": "Zobraziť ({count})",
"add": "Pridať",
"row_title": "Server č. {index}",
"id_label": "ID",
"id_hint": "Jedinečný identifikátor, napr. eu-1",
"label_label": "Označenie",
"label_hint": "Zobrazí sa používateľom",
"row_url": "URL",
"domains_label": "Domény",
"domains_hint": "Oddelené čiarkami. Podľa domény e-mailu sa pri prihlásení zvolí tento server.",
"add_server": "Pridať server",
"auto_pick": "Voliť server podľa domény e-mailu",
"auto_pick_hint": "Keď používateľ zadá svoj e-mail, zvolí sa zodpovedajúci server zo zoznamu vyššie.",
"row_id_required": "Server č. {index}: ID je povinné",
"row_id_invalid": "Server č. {index}: ID môže obsahovať len písmená, číslice, - a _ a musí začínať písmenom alebo číslicou",
"row_id_duplicate": "Server č. {index}: ID „{id}“ sa opakuje",
"row_url_required": "Server č. {index}: URL je povinná",
"row_url_scheme": "Server č. {index}: URL musí začínať http:// alebo https://",
"stalwart": "Zapnúť funkcie špecifické pre Stalwart",
"stalwart_hint": "Pridá zmenu hesla a správu filtrov Sieve. Bezpečne sa dá zapnúť aj pri iných serveroch."
},
"auth": {
"title": "Overovanie",
"subtitle": "Prihlásenie heslom je vždy k dispozícii. OAuth/OIDC je voliteľné.",
"oauth": "Zapnúť OAuth2 / OpenID Connect",
"oauth_only": "Len OAuth (skryť formulár hesla)",
"client_id": "ID klienta OAuth",
"client_secret": "Tajný kľúč klienta OAuth",
"client_secret_hint": "Pri verejných klientoch, ktorí používajú len PKCE, nechajte prázdne.",
"client_secret_placeholder": "Vložte tajný kľúč",
"issuer": "URL vydavateľa OAuth",
"issuer_hint": "Pre externých poskytovateľov identít (Keycloak, Authentik, Entra ID atď.)."
},
"security": {
"title": "Zabezpečenie a relácie",
"subtitle": "Tajný kľúč relácie umožní „Zapamätať si ma“ a synchronizáciu nastavení.",
"generated": "Tajný kľúč relácie vygenerovaný",
"customize": "Upraviť",
"generated_hint": "Bol pre vás vytvorený 32-bajtový tajný kľúč. Meňte ho len z konkrétneho dôvodu.",
"secret_label": "Tajný kľúč relácie",
"secret_hint": "Vložte vlastnú hodnotu alebo vygenerujte novú.",
"regenerate": "Vygenerovať znova",
"sync": "Synchronizovať nastavenia používateľov medzi zariadeniami",
"sync_hint": "Ukladá predvoľby používateľov na server, šifrované tajným kľúčom relácie.",
"telemetry": "Odosielať anonymné štatistiky používania na zlepšenie Bulwarku",
"telemetry_hint": "Predvolene vypnuté. Jeden anonymný signál denne s verziou, platformou a zapnutými funkciami; nikdy e-mailové adresy, názvy hostiteľov ani IP adresy. Kedykoľvek to môžete zmeniť v nastaveniach administrácie."
},
"logging": {
"title": "Protokol",
"subtitle": "Formát a podrobnosť protokolov aplikácie.",
"format": "Formát",
"format_text": "text: farebný, čitateľný",
"format_json": "json: štruktúrovaný, na zber protokolov",
"level": "Úroveň",
"level_info": "info (odporúčané)"
},
"branding": {
"title": "Vzhľad značky",
"subtitle": "Všetky polia sú voliteľné. Nahrajte súbor alebo vložte URL; pre všetko, čo vynecháte, platia predvolené hodnoty.",
"company": "Názov firmy alebo organizácie",
"favicon": "Favicon",
"favicon_hint": "Ikona karty prehliadača. Odporúča sa SVG.",
"login_light": "Logo prihlásenia (svetlý režim)",
"login_light_hint": "Na prihlasovacej stránke, na svetlom pozadí.",
"login_dark": "Logo prihlásenia (tmavý režim)",
"login_dark_hint": "Na prihlasovacej stránke, na tmavom pozadí.",
"sidebar_light": "Logo bočného panela (svetlý režim)",
"sidebar_light_hint": "Zobrazí sa po prihlásení. Nechajte prázdne pre žiadne.",
"sidebar_dark": "Logo bočného panela (tmavý režim)",
"sidebar_dark_hint": "Verzia loga bočného panela pre tmavý režim.",
"website": "URL webu",
"imprint": "URL tiráže",
"privacy": "URL zásad ochrany osobných údajov",
"drop": "Kliknite alebo pretiahnite",
"uploading": "Nahráva sa…",
"uploaded": "Nahraný súbor",
"formats": "SVG, PNG, JPEG, WebP alebo ICO · max. 2 MB",
"use_url": "Použiť URL",
"hide_url": "Skryť URL",
"url_placeholder": "https://… alebo /branding/subor.svg"
},
"review": {
"title": "Skoro hotovo",
"subtitle": "Skontrolujte nastavenia, zvoľte heslo správcu a použite ich.",
"server": "Server",
"app_name": "Názov aplikácie",
"jmap_server": "Server JMAP",
"additional": "Ďalšie servery",
"additional_count": "Nastavené: {count}",
"additional_auto_pick": "Nastavené: {count} · voľba podľa domény",
"stalwart": "Funkcie Stalwart",
"auth": "Overovanie",
"method": "Metóda",
"oauth_only": "Len OAuth",
"password_oauth": "Heslo + OAuth",
"password_only": "Len heslo",
"client_id": "ID klienta OAuth",
"security": "Zabezpečenie",
"secret": "Tajný kľúč relácie",
"configured": "Nastavené",
"not_set": "Nenastavené",
"remember_me": "Zapamätať si ma",
"available": "K dispozícii",
"disabled": "Vypnuté",
"sync": "Synchronizácia nastavení",
"needs_secret": "Vyžaduje tajný kľúč relácie",
"telemetry": "Anonymná telemetria",
"logging": "Protokol",
"format": "Formát",
"level": "Úroveň",
"branding": "Vzhľad značky",
"customizations": "Úpravy",
"custom_assets": "Vlastné súbory nastavené",
"defaults": "Predvolené hodnoty",
"company": "Názov firmy",
"password_title": "Zvoľte heslo správcu",
"password_hint": "Budete sa ním prihlasovať na {path}. Aspoň 8 znakov.",
"new_password": "Nové heslo",
"confirm": "Potvrdiť",
"too_short": "Aspoň 8 znakov.",
"mismatch": "Heslá sa nezhodujú.",
"looks_good": "V poriadku.",
"advanced": "Pokročilé",
"lock": "Po nastavení zamknúť konfiguráciu",
"lock_hint": "Vytvorí značkovací súbor. Potom pripojte zväzok s konfiguráciou len na čítanie a aplikácia odmietne ďalšie zápisy do konfigurácie. Auditné protokoly a stav prihlásenia zostanú zapisovateľné vo zväzku so stavom.",
"applying": "Používa sa…",
"finish": "Použiť a dokončiť"
}
},
"errors": {
"page_error_title": "Niečo sa pokazilo",
"page_error_description": "Vyskytla sa neočakávaná chyba. Skúste to znova alebo sa vráťte na domovskú stránku.",
+255 -6
View File
@@ -3272,6 +3272,7 @@
"settings": {
"title": "การตั้งค่า",
"back_to_mail": "กลับไปยังอีเมล",
"back_to_settings": "กลับไปที่การตั้งค่า",
"save_success": "บันทึกการตั้งค่าเรียบร้อยแล้ว",
"import_success": "นำเข้าการตั้งค่าเรียบร้อยแล้ว",
"import_error": "นำเข้าการตั้งค่าไม่สำเร็จ",
@@ -3511,6 +3512,7 @@
"id_exists": "รหัสแท็กนี้มีอยู่แล้ว",
"edit": "แก้ไขแท็ก",
"delete": "ลบแท็ก",
"deleted": "ลบแท็กแล้ว",
"save": "บันทึก",
"add": "เพิ่ม",
"cancel": "ยกเลิก",
@@ -4015,6 +4017,7 @@
"delete": "ลบรายการที่บันทึกไว้",
"cancel": "ยกเลิก",
"delete_confirm": "ลบรายการบัญชีที่บันทึกไว้ออกจากเซิร์ฟเวอร์หรือไม่?",
"delete_message": "อุปกรณ์อื่นจะกู้คืนบัญชีจากรายการนี้ไม่ได้อีก",
"password": "รหัสผ่านการสำรองข้อมูล",
"password_hint": "อย่างน้อย 10 อักขระ รหัสผ่านนี้ไม่สามารถกู้คืนได้: หากคุณลืม ให้บันทึกรายการใหม่อีกครั้งด้วยรหัสผ่านใหม่",
"restore_hint": "รหัสผ่านที่เลือกไว้เมื่อบันทึกรายการ",
@@ -4107,6 +4110,7 @@
},
"save": "บันทึกการเปลี่ยนแปลง",
"saving": "กำลังบันทึก...",
"saved": "บันทึกแล้ว",
"warnings": {
"end_before_start": "วันที่สิ้นสุดต้องอยู่หลังวันที่เริ่มต้น",
"invalid_date": "ป้อนวันที่เริ่มต้นและวันที่สิ้นสุดที่ถูกต้องพร้อมเวลา หรือเว้นว่างไว้",
@@ -4173,12 +4177,17 @@
"new_folder_name": "ชื่อโฟลเดอร์",
"rename": "เปลี่ยนชื่อ",
"change_icon": "เปลี่ยนไอคอน",
"actions": "การทำงานสำหรับ {name}",
"expand": "ขยาย {name}",
"collapse": "ยุบ {name}",
"counter": "ตัวนับ",
"count_mode": "ตัวนับสำหรับ {name}",
"count_mode_default": "ตัวนับ: ค่าเริ่มต้น",
"count_mode_unread": "เฉพาะที่ยังไม่ได้อ่าน",
"count_mode_all": "ที่ยังไม่ได้อ่านและทั้งหมด",
"count_mode_none": "ไม่แสดงตัวนับ",
"delete": "ลบ",
"delete_title": "ลบโฟลเดอร์ไหม",
"confirm_delete": "คุณแน่ใจหรือไม่ว่าต้องการลบ “{name}”? อีเมลในโฟลเดอร์นี้จะถูกย้ายไปยังถังขยะ",
"create": "สร้าง",
"cancel": "ยกเลิก",
@@ -4260,6 +4269,8 @@
"removed": "ลบรหัสผ่านแอปแล้ว",
"add_error": "สร้างรหัสผ่านแอปไม่สำเร็จ",
"remove_error": "ลบรหัสผ่านแอปไม่สำเร็จ",
"delete_title": "ลบรหัสผ่านแอปไหม",
"delete_message": "แอปที่ลงชื่อเข้าใช้ด้วย {name} จะลงชื่อเข้าใช้ไม่ได้อีก",
"none": "ไม่มีการกำหนดค่ารหัสผ่านแอป"
},
"api_keys": {
@@ -4272,6 +4283,8 @@
"removed": "ลบคีย์ API แล้ว",
"add_error": "สร้างคีย์ API ไม่สำเร็จ",
"remove_error": "ลบคีย์ API ไม่สำเร็จ",
"delete_title": "ลบคีย์ API ไหม",
"delete_message": "สคริปต์และการเชื่อมต่อที่ใช้ {name} จะเข้าถึงไม่ได้อีก",
"none": "ไม่มีการกำหนดค่าคีย์ API"
},
"encryption": {
@@ -4291,6 +4304,9 @@
"configure_title": "เปิดใช้งานการเข้ารหัสด้วยคีย์นี้",
"enable_button": "เปิดใช้งาน",
"disable_tooltip": "ปิดใช้งานการเข้ารหัส",
"disable_title": "ปิดการเข้ารหัสข้อมูลที่จัดเก็บไหม",
"disable_message": "ต่อจากนี้อีเมลใหม่จะถูกจัดเก็บโดยไม่เข้ารหัส",
"disable_button": "ปิด",
"enabled_success": "เปิดใช้งานการเข้ารหัสขณะจัดเก็บแล้ว"
},
"public_keys": {
@@ -4298,7 +4314,14 @@
"name_placeholder": "เช่น My Key",
"key_label": "พับลิกคีย์ (S/MIME หรือ PGP ASCII-armored)",
"key_placeholder": "วางพับลิกคีย์ของคุณที่นี่",
"none": "ไม่มีการกำหนดค่าพับลิกคีย์"
"none": "ไม่มีการกำหนดค่าพับลิกคีย์",
"title": "คีย์สาธารณะ",
"added": "เพิ่มคีย์สาธารณะแล้ว",
"removed": "ลบคีย์สาธารณะแล้ว",
"add_error": "เพิ่มคีย์สาธารณะไม่ได้",
"remove_error": "ลบคีย์สาธารณะไม่ได้",
"delete_title": "ลบคีย์สาธารณะไหม",
"delete_message": "{name} จะถูกนำออกจากเซิร์ฟเวอร์"
},
"email_client": {
"title": "การตั้งค่าโปรแกรมอีเมลภายนอก",
@@ -4350,7 +4373,8 @@
"advanced": {
"debug_mode": {
"label": "โหมดแก้ไขข้อบกพร่อง",
"description": "เปิดใช้งานการบันทึกข้อมูลอย่างละเอียดเพื่อการแก้ไขปัญหา"
"description": "เปิดใช้งานการบันทึกข้อมูลอย่างละเอียดเพื่อการแก้ไขปัญหา",
"toggle": "บันทึกโดยละเอียด"
},
"debug_categories": {
"description": "เลือกหมวดหมู่ที่ต้องการบันทึกข้อมูล ปิดหมวดหมู่ที่คุณไม่ต้องการเพื่อลดข้อมูลในคอนโซล",
@@ -4395,12 +4419,15 @@
"refresh_cache": {
"label": "รีเฟรชข้อมูลในแคช",
"description": "โหลดผู้ติดต่อ ปฏิทิน และโฟลเดอร์จากเซิร์ฟเวอร์ใหม่ โดยยังคงรักษาบัญชีและเซสชันของคุณไว้ — แก้ไขมุมมองที่ไม่อัปเดตหรือไม่ถูกต้องโดยไม่ต้องลงชื่อออก",
"button": "รีเฟรช"
"button": "รีเฟรช",
"confirm_title": "รีเฟรชข้อมูลที่แคชไว้ไหม"
},
"reset_settings": {
"label": "รีเซ็ตการตั้งค่า",
"description": "คืนค่าการตั้งค่าทั้งหมดกลับเป็นค่าเริ่มต้น",
"button": "รีเซ็ตเป็นค่าเริ่มต้น"
"button": "รีเซ็ตเป็นค่าเริ่มต้น",
"confirm_title": "รีเซ็ตการตั้งค่าทั้งหมดไหม",
"done": "รีเซ็ตการตั้งค่าแล้ว"
},
"export_settings": {
"label": "ส่งออกการตั้งค่า",
@@ -4475,7 +4502,15 @@
"no_address_books": "ไม่พบสมุดที่อยู่",
"categories_title": "หมวดหมู่",
"categories_description": "เปลี่ยนชื่อหมวดหมู่ผู้ติดต่อ",
"no_categories": "ไม่พบหมวดหมู่"
"no_categories": "ไม่พบหมวดหมู่",
"delete_category": "ลบหมวดหมู่",
"category_deleted": "ลบหมวดหมู่แล้ว",
"category_delete_failed": "ลบหมวดหมู่ไม่ได้"
},
"calendar_management": {
"delete_title": "ลบปฏิทินไหม",
"clear_title": "ล้างกิจกรรมทั้งหมดไหม",
"unsubscribe_title": "เลิกติดตามปฏิทินนี้ไหม"
},
"downloads": {
"title": "การดาวน์โหลด",
@@ -4541,7 +4576,7 @@
"validate": "ตรวจสอบความถูกต้อง",
"validation_success": "สคริปต์ถูกต้อง",
"validation_error": "สคริปต์มีข้อผิดพลาด",
"save": "บันทึกกฎ",
"save": "บันทึก",
"saving": "กำลังบันทึก...",
"saved": "บันทึกตัวกรองเรียบร้อยแล้ว",
"save_failed": "บันทึกตัวกรองไม่สำเร็จ",
@@ -4555,6 +4590,8 @@
"add_condition": "เพิ่มเงื่อนไข",
"actions": "การดำเนินการ",
"add_action": "เพิ่มการดำเนินการ",
"remove_condition": "นำเงื่อนไขออก",
"remove_action": "นำการทำงานออก",
"stop_processing": "หยุดการประมวลผลกฎข้อถัดไป",
"include_spam": "ย้ายข้อความที่ทำเครื่องหมายว่าเป็นสแปมด้วย",
"attachment_type_placeholder": "เช่น pdf, doc, jpg",
@@ -4631,6 +4668,7 @@
"match_type": "ประเภทการจับคู่",
"reset_to_visual": "รีเซ็ตเป็นตัวสร้างแบบภาพ",
"reset_warning": "การดำเนินการนี้จะละทิ้งสคริปต์ปัจจุบันและเริ่มต้นใหม่",
"reset_title": "รีเซ็ตเป็นตัวแก้ไขแบบภาพไหม",
"confirm_reset": "รีเซ็ต",
"validation_empty_name": "จำเป็นต้องระบุชื่อกฎ",
"validation_empty_conditions": "จำเป็นต้องมีเงื่อนไขพร้อมค่าอย่างน้อยหนึ่งข้อ",
@@ -4813,5 +4851,216 @@
"receipts": "ใบตอบรับการเปิดอ่าน"
}
}
},
"setup": {
"title": "ตั้งค่า Bulwark Webmail",
"subtitle": "ตั้งค่าอินสแตนซ์เว็บเมลของคุณจากเบราว์เซอร์",
"loading": "กำลังโหลด…",
"back": "ย้อนกลับ",
"next": "ถัดไป",
"saving": "กำลังบันทึก…",
"show": "แสดง",
"hide": "ซ่อน",
"remove": "นำออก",
"on": "เปิด",
"off": "ปิด",
"dismiss": "ปิด",
"unknown_error": "ข้อผิดพลาดที่ไม่รู้จัก",
"steps": {
"welcome": "ยินดีต้อนรับ",
"server": "เซิร์ฟเวอร์",
"auth": "การยืนยันตัวตน",
"security": "ความปลอดภัย",
"logging": "บันทึก",
"branding": "แบรนด์",
"review": "ตรวจทาน"
},
"read_only": {
"title": "การกำหนดค่าเป็นแบบอ่านอย่างเดียว",
"body": "โวลุ่มการกำหนดค่าถูกเมานต์แบบอ่านอย่างเดียว หรือมีการตั้งค่า {variable} ไว้ เมานต์ใหม่แบบเขียนได้หรือยกเลิกตัวแปรนั้น แล้วรีสตาร์ตคอนเทนเนอร์"
},
"done": {
"title": "พร้อมแล้ว",
"body": "{appName} ตั้งค่าเสร็จและพร้อมใช้งาน",
"admin_sign_in": "ลงชื่อเข้าใช้แดชบอร์ดผู้ดูแล",
"webmail_login": "เปิดหน้าลงชื่อเข้าใช้เว็บเมล",
"redirecting": "กำลังพาคุณไปที่แดชบอร์ดผู้ดูแล…"
},
"already": {
"title": "การตั้งค่าเสร็จสมบูรณ์แล้ว",
"body": "{appName} ตั้งค่าแล้ว ลงชื่อเข้าใช้เพื่อดำเนินการต่อ"
},
"insecure": {
"title": "คุณกำลังตั้งค่าผ่าน HTTP ที่ไม่เข้ารหัส",
"body": "โทเค็นการตั้งค่าและรหัสผ่านผู้ดูแลที่คุณป้อนที่นี่จะถูกส่งเป็นข้อความธรรมดา ใช้ HTTPS หากทำได้: สิ้นสุด TLS ที่คอนเทนเนอร์หรือที่รีเวิร์สพร็อกซีด้านหน้า",
"try_https": "ลองใช้ HTTPS",
"continue_http": "ดำเนินการต่อผ่าน HTTP"
},
"errors": {
"token_expired": "โทเค็นการตั้งค่านี้ใช้ไม่ได้แล้ว รีสตาร์ตคอนเทนเนอร์เพื่อรับโทเค็นใหม่จากบันทึก",
"session_expired": "เซสชันการตั้งค่าหมดอายุแล้ว วางโทเค็นการตั้งค่าอีกครั้งเพื่อดำเนินการต่อ",
"token_required": "วางโทเค็นการตั้งค่าที่แสดงในบันทึกของคอนเทนเนอร์เพื่อดำเนินการต่อ",
"already_finished": "การตั้งค่าเสร็จสิ้นแล้ว โหลดหน้าใหม่เพื่อลงชื่อเข้าใช้",
"token_rejected": "โทเค็นถูกปฏิเสธ (HTTP {status})",
"step_failed": "บันทึกขั้นตอนนี้ไม่ได้ (HTTP {status})",
"upload_failed": "อัปโหลดไม่สำเร็จ (HTTP {status})",
"finish_failed": "ตั้งค่าให้เสร็จไม่ได้ (HTTP {status})"
},
"welcome": {
"title": "ยินดีต้อนรับ",
"body": "วางโทเค็นการตั้งค่าที่แสดงในบันทึกของคอนเทนเนอร์เพื่อดำเนินการต่อ โทเค็นจะหมดอายุภายใน 1 ชั่วโมง",
"token_label": "โทเค็นการตั้งค่า",
"token_placeholder": "โทเค็นฐานสิบหก 32 ไบต์",
"verifying": "กำลังตรวจสอบ…",
"continue": "ดำเนินการต่อ"
},
"server": {
"title": "เซิร์ฟเวอร์",
"subtitle": "ที่อยู่ของอีเมลของคุณ",
"app_name": "ชื่อแอปพลิเคชัน",
"url_label": "URL ของเซิร์ฟเวอร์ JMAP",
"url_hint": "เซิร์ฟเวอร์เริ่มต้นที่ผู้ใช้เชื่อมต่อ ตัวอย่าง: https://mail.example.com",
"test": "ทดสอบ",
"testing": "กำลังทดสอบ…",
"probe_ok": "เชื่อมต่อแล้ว: ดูเหมือนเป็นเซิร์ฟเวอร์ JMAP",
"probe_not_jmap": "เข้าถึงเซิร์ฟเวอร์ได้ แต่ดูไม่เหมือนปลายทาง JMAP",
"probe_invalid": "URL นี้ไม่ถูกต้อง ต้องขึ้นต้นด้วย http:// หรือ https://",
"probe_unreachable": "เชื่อมต่อกับที่อยู่นี้ไม่ได้ ตรวจสอบ URL และดูว่าเซิร์ฟเวอร์ออนไลน์อยู่",
"not_jmap_hint": "อาจเกิดขึ้นเมื่อรีเวิร์สพร็อกซีส่งต่อ JMAP แยกต่างหากบนโดเมนเดียวกัน มิฉะนั้นมักแปลว่า URL ผิด",
"not_jmap_confirm": "ฉันมั่นใจว่า URL นี้ถูกต้อง ดำเนินการต่อเลย",
"http_title": "URL นี้ใช้ HTTP ที่ไม่เข้ารหัส",
"http_body": "รหัสผ่านและเนื้อหาอีเมลจะถูกส่งระหว่างผู้ใช้กับเซิร์ฟเวอร์ของคุณโดยไม่เข้ารหัส ในการใช้งานจริงให้ใช้ {https}: สิ้นสุด TLS ที่เซิร์ฟเวอร์อีเมลหรือที่รีเวิร์สพร็อกซีด้านหน้า",
"local_title": "URL นี้ใช้ได้เฉพาะในเครื่อง",
"local_body": "อีเมลถูกดึงโดยตรงจากเบราว์เซอร์ของผู้ใช้ ดังนั้น URL ของ JMAP ต้องเข้าถึงได้จากทุกที่ที่ผู้ใช้ลงชื่อเข้าใช้ ไม่ใช่แค่จากเครื่องนี้หรือ LAN ในการใช้งานจริงให้ใช้ชื่อโฮสต์สาธารณะ (เช่น {example})",
"additional_title": "เซิร์ฟเวอร์ JMAP เพิ่มเติม",
"additional_hint": "ไม่บังคับ แสดงหลายเซิร์ฟเวอร์ในรายการตอนลงชื่อเข้าใช้ เหมาะกับผู้ให้บริการที่มี Stalwart หลายอินสแตนซ์",
"additional_show": "แสดง ({count})",
"add": "เพิ่ม",
"row_title": "เซิร์ฟเวอร์ {index}",
"id_label": "ID",
"id_hint": "ชื่อย่อที่ไม่ซ้ำ เช่น eu-1",
"label_label": "ป้ายชื่อ",
"label_hint": "แสดงให้ผู้ใช้เห็น",
"row_url": "URL",
"domains_label": "โดเมน",
"domains_hint": "คั่นด้วยจุลภาค ใช้เลือกเซิร์ฟเวอร์นี้ตามโดเมนอีเมลตอนลงชื่อเข้าใช้",
"add_server": "เพิ่มเซิร์ฟเวอร์",
"auto_pick": "เลือกเซิร์ฟเวอร์ตามโดเมนอีเมล",
"auto_pick_hint": "เมื่อผู้ใช้พิมพ์อีเมล ระบบจะเลือกเซิร์ฟเวอร์ที่ตรงกันจากรายการด้านบน",
"row_id_required": "เซิร์ฟเวอร์ {index}: ต้องระบุ ID",
"row_id_invalid": "เซิร์ฟเวอร์ {index}: ID ใช้ได้เฉพาะตัวอักษร ตัวเลข - และ _ และต้องขึ้นต้นด้วยตัวอักษรหรือตัวเลข",
"row_id_duplicate": "เซิร์ฟเวอร์ {index}: ID “{id}” ซ้ำกัน",
"row_url_required": "เซิร์ฟเวอร์ {index}: ต้องระบุ URL",
"row_url_scheme": "เซิร์ฟเวอร์ {index}: URL ต้องขึ้นต้นด้วย http:// หรือ https://",
"stalwart": "เปิดใช้ฟีเจอร์เฉพาะของ Stalwart",
"stalwart_hint": "เพิ่มการเปลี่ยนรหัสผ่านและการจัดการตัวกรอง Sieve เปิดใช้กับเซิร์ฟเวอร์อื่นได้อย่างปลอดภัย"
},
"auth": {
"title": "การยืนยันตัวตน",
"subtitle": "ลงชื่อเข้าใช้ด้วยรหัสผ่านได้เสมอ OAuth/OIDC เป็นตัวเลือก",
"oauth": "เปิดใช้ OAuth2 / OpenID Connect",
"oauth_only": "OAuth เท่านั้น (ซ่อนฟอร์มรหัสผ่าน)",
"client_id": "ID ไคลเอนต์ OAuth",
"client_secret": "ข้อมูลลับไคลเอนต์ OAuth",
"client_secret_hint": "เว้นว่างไว้สำหรับไคลเอนต์สาธารณะที่ใช้ PKCE เท่านั้น",
"client_secret_placeholder": "วางข้อมูลลับ",
"issuer": "URL ผู้ออก OAuth",
"issuer_hint": "สำหรับผู้ให้บริการข้อมูลประจำตัวภายนอก (Keycloak, Authentik, Entra ID ฯลฯ)"
},
"security": {
"title": "ความปลอดภัยและเซสชัน",
"subtitle": "ข้อมูลลับของเซสชันช่วยให้ใช้ “จดจำฉัน” และการซิงค์การตั้งค่าได้",
"generated": "สร้างข้อมูลลับของเซสชันแล้ว",
"customize": "ปรับแต่ง",
"generated_hint": "สร้างข้อมูลลับขนาด 32 ไบต์ให้คุณแล้ว เปลี่ยนเฉพาะเมื่อมีเหตุผลเฉพาะ",
"secret_label": "ข้อมูลลับของเซสชัน",
"secret_hint": "วางค่าของคุณเองหรือสร้างใหม่",
"regenerate": "สร้างใหม่",
"sync": "ซิงค์การตั้งค่าผู้ใช้ข้ามอุปกรณ์",
"sync_hint": "เก็บการตั้งค่าของผู้ใช้ไว้บนเซิร์ฟเวอร์ โดยเข้ารหัสด้วยข้อมูลลับของเซสชัน",
"telemetry": "ส่งสถิติการใช้งานแบบไม่ระบุตัวตนเพื่อช่วยปรับปรุง Bulwark",
"telemetry_hint": "ปิดไว้โดยค่าเริ่มต้น ส่งสัญญาณแบบไม่ระบุตัวตนวันละครั้งพร้อมเวอร์ชัน แพลตฟอร์ม และฟีเจอร์ที่เปิดอยู่ ไม่มีที่อยู่อีเมล ชื่อโฮสต์ หรือ IP เลย เปลี่ยนได้ทุกเมื่อในการตั้งค่าผู้ดูแล"
},
"logging": {
"title": "บันทึก",
"subtitle": "รูปแบบและระดับรายละเอียดของบันทึกแอปพลิเคชัน",
"format": "รูปแบบ",
"format_text": "text: มีสี อ่านง่าย",
"format_json": "json: มีโครงสร้าง สำหรับรวบรวมบันทึก",
"level": "ระดับ",
"level_info": "info (แนะนำ)"
},
"branding": {
"title": "แบรนด์",
"subtitle": "ทุกช่องไม่บังคับ อัปโหลดไฟล์หรือวาง URL ส่วนที่ข้ามไปจะใช้ค่าเริ่มต้น",
"company": "ชื่อบริษัทหรือองค์กร",
"favicon": "ไอคอนเว็บไซต์",
"favicon_hint": "ไอคอนแท็บเบราว์เซอร์ แนะนำ SVG",
"login_light": "โลโก้หน้าลงชื่อเข้าใช้ (โหมดสว่าง)",
"login_light_hint": "แสดงบนหน้าลงชื่อเข้าใช้ บนพื้นหลังสว่าง",
"login_dark": "โลโก้หน้าลงชื่อเข้าใช้ (โหมดมืด)",
"login_dark_hint": "แสดงบนหน้าลงชื่อเข้าใช้ บนพื้นหลังมืด",
"sidebar_light": "โลโก้แถบด้านข้าง (โหมดสว่าง)",
"sidebar_light_hint": "แสดงหลังลงชื่อเข้าใช้ เว้นว่างไว้หากไม่ต้องการ",
"sidebar_dark": "โลโก้แถบด้านข้าง (โหมดมืด)",
"sidebar_dark_hint": "โลโก้แถบด้านข้างสำหรับโหมดมืด",
"website": "URL เว็บไซต์",
"imprint": "URL ข้อมูลผู้ให้บริการ",
"privacy": "URL นโยบายความเป็นส่วนตัว",
"drop": "คลิกหรือวางที่นี่",
"uploading": "กำลังอัปโหลด…",
"uploaded": "ไฟล์ที่อัปโหลด",
"formats": "SVG, PNG, JPEG, WebP หรือ ICO · สูงสุด 2 MB",
"use_url": "ใช้ URL",
"hide_url": "ซ่อน URL",
"url_placeholder": "https://… หรือ /branding/file.svg"
},
"review": {
"title": "เกือบเสร็จแล้ว",
"subtitle": "ตรวจทานการตั้งค่า เลือกรหัสผ่านผู้ดูแล แล้วนำไปใช้",
"server": "เซิร์ฟเวอร์",
"app_name": "ชื่อแอป",
"jmap_server": "เซิร์ฟเวอร์ JMAP",
"additional": "เซิร์ฟเวอร์เพิ่มเติม",
"additional_count": "ตั้งค่าแล้ว {count} รายการ",
"additional_auto_pick": "ตั้งค่าแล้ว {count} รายการ · เลือกตามโดเมน",
"stalwart": "ฟีเจอร์ของ Stalwart",
"auth": "การยืนยันตัวตน",
"method": "วิธีการ",
"oauth_only": "OAuth เท่านั้น",
"password_oauth": "รหัสผ่าน + OAuth",
"password_only": "รหัสผ่านเท่านั้น",
"client_id": "ID ไคลเอนต์ OAuth",
"security": "ความปลอดภัย",
"secret": "ข้อมูลลับของเซสชัน",
"configured": "ตั้งค่าแล้ว",
"not_set": "ยังไม่ได้ตั้งค่า",
"remember_me": "จดจำฉัน",
"available": "ใช้ได้",
"disabled": "ปิดใช้งาน",
"sync": "การซิงค์การตั้งค่า",
"needs_secret": "ต้องมีข้อมูลลับของเซสชัน",
"telemetry": "การส่งข้อมูลการใช้งานแบบไม่ระบุตัวตน",
"logging": "บันทึก",
"format": "รูปแบบ",
"level": "ระดับ",
"branding": "แบรนด์",
"customizations": "การปรับแต่ง",
"custom_assets": "ตั้งค่าไฟล์ของคุณเองแล้ว",
"defaults": "ใช้ค่าเริ่มต้น",
"company": "ชื่อบริษัท",
"password_title": "เลือกรหัสผ่านผู้ดูแล",
"password_hint": "ใช้รหัสนี้ลงชื่อเข้าใช้ที่ {path} อย่างน้อย 8 ตัวอักษร",
"new_password": "รหัสผ่านใหม่",
"confirm": "ยืนยัน",
"too_short": "อย่างน้อย 8 ตัวอักษร",
"mismatch": "รหัสผ่านไม่ตรงกัน",
"looks_good": "เรียบร้อย",
"advanced": "ขั้นสูง",
"lock": "ล็อกการกำหนดค่าหลังตั้งค่าเสร็จ",
"lock_hint": "สร้างไฟล์เครื่องหมาย หลังจากนั้นให้เมานต์โวลุ่มการกำหนดค่าแบบอ่านอย่างเดียว แล้วแอปจะปฏิเสธการเขียนการกำหนดค่าเพิ่มเติม บันทึกการตรวจสอบและสถานะการลงชื่อเข้าใช้ยังเขียนได้ในโวลุ่มสถานะ",
"applying": "กำลังนำไปใช้…",
"finish": "นำไปใช้และเสร็จสิ้น"
}
}
}
+255 -6
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Ayarlar",
"back_to_mail": "Postaya Dön",
"back_to_settings": "Ayarlara dön",
"save_success": "Ayarlar başarıyla kaydedildi",
"import_success": "Ayarlar başarıyla içe aktarıldı",
"import_error": "Ayarlar içe aktarılamadı",
@@ -1345,6 +1346,7 @@
"id_exists": "Bu etiket kimliği zaten mevcut",
"edit": "Etiketi düzenle",
"delete": "Etiketi sil",
"deleted": "Etiket silindi",
"save": "Kaydet",
"add": "Ekle",
"cancel": "İptal",
@@ -1849,6 +1851,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Diğer cihazlar artık hesapları buradan geri yükleyemez.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1950,6 +1953,8 @@
"removed": "Uygulama parolası kaldırıldı",
"add_error": "Uygulama parolası oluşturulamadı",
"remove_error": "Uygulama parolası kaldırılamadı",
"delete_title": "Uygulama parolası silinsin mi?",
"delete_message": "{name} ile oturum açan uygulamalar artık oturum açamaz.",
"none": "Yapılandırılmış uygulama parolası yok"
},
"api_keys": {
@@ -1962,6 +1967,8 @@
"removed": "API anahtarı kaldırıldı",
"add_error": "API anahtarı oluşturulamadı",
"remove_error": "API anahtarı kaldırılamadı",
"delete_title": "API anahtarı silinsin mi?",
"delete_message": "{name} kullanan betikler ve entegrasyonlar erişimi kaybeder.",
"none": "Yapılandırılmış API anahtarı yok"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "Bu anahtarla şifrelemeyi etkinleştir",
"enable_button": "Etkinleştir",
"disable_tooltip": "Şifrelemeyi devre dışı bırak",
"disable_title": "Bekleyen veri şifrelemesi kapatılsın mı?",
"disable_message": "Yeni postalar bundan sonra şifrelenmeden saklanır.",
"disable_button": "Kapat",
"enabled_success": "Bekleme süresi şifrelemesi etkinleştirildi"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "ör. Anahtarım",
"key_label": "Ortak anahtar (S/MIME veya PGP ASCII biçiminde)",
"key_placeholder": "Ortak anahtarınızı buraya yapıştırın",
"none": "Yapılandırılmış ortak anahtar yok"
"none": "Yapılandırılmış ortak anahtar yok",
"title": "Açık anahtarlar",
"added": "Açık anahtar eklendi",
"removed": "Açık anahtar silindi",
"add_error": "Açık anahtar eklenemedi",
"remove_error": "Açık anahtar silinemedi",
"delete_title": "Açık anahtar silinsin mi?",
"delete_message": "{name} sunucudan kaldırılır."
},
"sections": {
"devices": "Diğer uygulamalar ve cihazlar",
@@ -2089,6 +2106,7 @@
},
"save": "Değişiklikleri Kaydet",
"saving": "Kaydediliyor...",
"saved": "Kaydedildi",
"warnings": {
"end_before_start": "Bitiş tarihi başlangıç tarihinden sonra olmalıdır",
"invalid_date": "Saatiyle birlikte geçerli bir başlangıç ve bitiş tarihi girin veya alanı boş bırakın",
@@ -2155,12 +2173,17 @@
"new_folder_name": "Klasör adı",
"rename": "Yeniden Adlandır",
"change_icon": "Simgeyi değiştir",
"actions": "{name} için işlemler",
"expand": "{name} klasörünü genişlet",
"collapse": "{name} klasörünü daralt",
"counter": "Sayaç",
"count_mode": "{name} sayacı",
"count_mode_default": "Sayaç: varsayılan",
"count_mode_unread": "Yalnızca okunmamış",
"count_mode_all": "Okunmamış ve toplam",
"count_mode_none": "Sayaç yok",
"delete": "Sil",
"delete_title": "Klasör silinsin mi?",
"confirm_delete": "\"{name}\" klasörünü silmek istediğinizden emin misiniz? Bu klasördeki e-postalar Çöp Kutusu'na taşınacak.",
"create": "Oluştur",
"cancel": "İptal",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "Hata Ayıklama Modu",
"description": "Sorun giderme için ayrıntılı günlük kaydını etkinleştir"
"description": "Sorun giderme için ayrıntılı günlük kaydını etkinleştir",
"toggle": "Ayrıntılı günlük kaydı"
},
"debug_categories": {
"description": "Hangi kategorilerin günlüğe kaydedileceğini seçin. Konsol gürültüsünü azaltmak için ihtiyaç duymadığınız kategorileri devre dışı bırakın.",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "Önbellekteki veriler yenilensin mi?"
},
"reset_settings": {
"label": "Ayarları Sıfırla",
"description": "Tüm ayarları varsayılan değerlerine geri yükle",
"button": "Varsayılanlara Sıfırla"
"button": "Varsayılanlara Sıfırla",
"confirm_title": "Tüm ayarlar sıfırlansın mı?",
"done": "Ayarlar sıfırlandı"
},
"export_settings": {
"label": "Ayarları Dışa Aktar",
@@ -2305,7 +2332,15 @@
"no_address_books": "Adres defteri bulunamadı",
"categories_title": "Kategoriler",
"categories_description": "Kişi kategorilerini yeniden adlandırın",
"no_categories": "Kategori bulunamadı"
"no_categories": "Kategori bulunamadı",
"delete_category": "Kategoriyi sil",
"category_deleted": "Kategori silindi",
"category_delete_failed": "Kategori silinemedi"
},
"calendar_management": {
"delete_title": "Takvim silinsin mi?",
"clear_title": "Tüm etkinlikler temizlensin mi?",
"unsubscribe_title": "Bu takvim aboneliği iptal edilsin mi?"
},
"downloads": {
"title": "İndirilenler",
@@ -2371,7 +2406,7 @@
"validate": "Doğrula",
"validation_success": "Betik geçerli",
"validation_error": "Betikte hatalar var",
"save": "Kuralları Kaydet",
"save": "Kaydet",
"saving": "Kaydediliyor...",
"saved": "Filtreler başarıyla kaydedildi",
"save_failed": "Filtreler kaydedilemedi",
@@ -2385,6 +2420,8 @@
"add_condition": "Koşul Ekle",
"actions": "İşlemler",
"add_action": "İşlem Ekle",
"remove_condition": "Koşulu kaldır",
"remove_action": "Eylemi kaldır",
"stop_processing": "Sonraki kuralları işlemeyi durdur",
"include_spam": "İstenmeyen olarak işaretlenen iletileri de taşı",
"attachment_type_placeholder": "örn. pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "Eşleşme türü",
"reset_to_visual": "Görsel oluşturucuya sıfırla",
"reset_warning": "Bu işlem mevcut betiği atacak ve yeniden başlatacak.",
"reset_title": "Görsel düzenleyiciye sıfırlansın mı?",
"confirm_reset": "Sıfırla",
"validation_empty_name": "Kural adı gerekli",
"validation_empty_conditions": "En az bir değeri olan koşul gerekli",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Bulwark Webmail kurulumu",
"subtitle": "Webmail örneğinizi tarayıcıdan yapılandırın.",
"loading": "Yükleniyor…",
"back": "Geri",
"next": "İleri",
"saving": "Kaydediliyor…",
"show": "Göster",
"hide": "Gizle",
"remove": "Kaldır",
"on": "Açık",
"off": "Kapalı",
"dismiss": "Kapat",
"unknown_error": "Bilinmeyen hata",
"steps": {
"welcome": "Hoş geldiniz",
"server": "Sunucu",
"auth": "Oturum açma",
"security": "Güvenlik",
"logging": "Günlük",
"branding": "Marka",
"review": "Gözden geçirme"
},
"read_only": {
"title": "Yapılandırma salt okunur",
"body": "Yapılandırma birimi salt okunur bağlanmış ya da {variable} ayarlanmış. Birimi yazılabilir olarak bağlayın veya değişkeni kaldırın, ardından kapsayıcıyı yeniden başlatın."
},
"done": {
"title": "Her şey hazır",
"body": "{appName} yapılandırıldı ve kullanıma hazır.",
"admin_sign_in": "Yönetim paneline giriş yapın",
"webmail_login": "Webmail oturum açma sayfasını açın",
"redirecting": "Yönetim paneline yönlendiriliyorsunuz…"
},
"already": {
"title": "Kurulum zaten tamamlandı",
"body": "{appName} yapılandırıldı. Devam etmek için oturum açın."
},
"insecure": {
"title": "Kurulumu şifrelenmemiş HTTP üzerinden çalıştırıyorsunuz",
"body": "Burada gireceğiniz kurulum belirteci ve yönetici parolası düz metin olarak iletilecek. Mümkünse HTTPS kullanın: TLS’yi kapsayıcıda veya önündeki bir ters vekil sunucuda sonlandırın.",
"try_https": "HTTPS’yi deneyin",
"continue_http": "HTTP ile devam edin"
},
"errors": {
"token_expired": "Bu kurulum belirteci artık geçerli değil. Günlüklerden yenisini almak için kapsayıcıyı yeniden başlatın.",
"session_expired": "Kurulum oturumunuzun süresi doldu. Devam etmek için kurulum belirtecini yeniden yapıştırın.",
"token_required": "Devam etmek için kapsayıcı günlüklerinde gösterilen kurulum belirtecini yapıştırın.",
"already_finished": "Kurulum zaten tamamlandı. Oturum açmak için sayfayı yenileyin.",
"token_rejected": "Belirteç reddedildi (HTTP {status})",
"step_failed": "Bu adım kaydedilemedi (HTTP {status})",
"upload_failed": "Yükleme başarısız oldu (HTTP {status})",
"finish_failed": "Kurulum tamamlanamadı (HTTP {status})"
},
"welcome": {
"title": "Hoş geldiniz",
"body": "Devam etmek için kapsayıcı günlüklerinde gösterilen kurulum belirtecini yapıştırın. Belirtecin süresi 1 saat sonra dolar.",
"token_label": "Kurulum belirteci",
"token_placeholder": "32 baytlık onaltılık belirteç",
"verifying": "Doğrulanıyor…",
"continue": "Devam"
},
"server": {
"title": "Sunucu",
"subtitle": "Postalarınızın bulunduğu yer.",
"app_name": "Uygulama adı",
"url_label": "JMAP sunucu URL’si",
"url_hint": "Kullanıcıların bağlandığı varsayılan sunucu. Örnek: https://mail.example.com",
"test": "Test et",
"testing": "Test ediliyor…",
"probe_ok": "Bağlandı: bu bir JMAP sunucusuna benziyor.",
"probe_not_jmap": "Sunucuya ulaşıldı ama bir JMAP uç noktasına benzemiyor.",
"probe_invalid": "Bu URL geçersiz. http:// veya https:// ile başlamalı.",
"probe_unreachable": "Bu adrese bağlanılamadı. URL’yi ve sunucunun çevrimiçi olduğunu kontrol edin.",
"not_jmap_hint": "Bu, bir ters vekil sunucu JMAP’i aynı alan adında ayrı yönlendirdiğinde olabilir. Aksi halde URL genellikle yanlıştır.",
"not_jmap_confirm": "URL’nin doğru olduğundan eminim, yine de devam et.",
"http_title": "Bu URL şifrelenmemiş HTTP kullanıyor.",
"http_body": "Parolalar ve e-posta içerikleri kullanıcılarla sunucunuz arasında şifrelenmeden iletilecek. Üretimde {https} kullanın: TLS’yi posta sunucusunda veya önündeki bir ters vekil sunucuda sonlandırın.",
"local_title": "Bu URL yalnızca yerelde çalışıyor.",
"local_body": "Postalar doğrudan kullanıcının tarayıcısından alınır; bu yüzden JMAP URL’si yalnızca bu makineden veya yerel ağdan değil, kullanıcıların oturum açtığı her yerden erişilebilir olmalıdır. Üretimde herkese açık bir ana bilgisayar adı (ör. {example}) kullanın.",
"additional_title": "Ek JMAP sunucuları",
"additional_hint": "İsteğe bağlı. Oturum açma listesinde birden çok sunucu sunun; birden fazla Stalwart örneği çalıştıran sağlayıcılar için kullanışlıdır.",
"additional_show": "Göster ({count})",
"add": "Ekle",
"row_title": "Sunucu {index}",
"id_label": "Kimlik",
"id_hint": "Benzersiz kısa ad, ör. eu-1",
"label_label": "Etiket",
"label_hint": "Kullanıcılara gösterilir",
"row_url": "URL",
"domains_label": "Alan adları",
"domains_hint": "Virgülle ayrılmış. Oturum açarken bu sunucunun e-posta alan adına göre seçilmesi için kullanılır.",
"add_server": "Sunucu ekle",
"auto_pick": "Sunucuyu e-posta alan adına göre seç",
"auto_pick_hint": "Kullanıcı e-posta adresini yazdığında yukarıdaki listeden uygun sunucu seçilir.",
"row_id_required": "Sunucu {index}: kimlik gerekli",
"row_id_invalid": "Sunucu {index}: kimlik yalnızca harf, rakam, - ve _ içerebilir ve harf ya da rakamla başlamalıdır",
"row_id_duplicate": "Sunucu {index}: “{id}” kimliği iki kez kullanılmış",
"row_url_required": "Sunucu {index}: URL gerekli",
"row_url_scheme": "Sunucu {index}: URL http:// veya https:// ile başlamalı",
"stalwart": "Stalwart’a özel özellikleri etkinleştir",
"stalwart_hint": "Parola değiştirme ve Sieve filtre yönetimi ekler. Diğer sunucularda da güvenle etkinleştirilebilir."
},
"auth": {
"title": "Kimlik doğrulama",
"subtitle": "Parolayla oturum açma her zaman kullanılabilir. OAuth/OIDC isteğe bağlıdır.",
"oauth": "OAuth2 / OpenID Connect’i etkinleştir",
"oauth_only": "Yalnızca OAuth (parola formunu gizle)",
"client_id": "OAuth istemci kimliği",
"client_secret": "OAuth istemci gizli anahtarı",
"client_secret_hint": "Yalnızca PKCE kullanan herkese açık istemciler için boş bırakın.",
"client_secret_placeholder": "Gizli anahtarı yapıştırın",
"issuer": "OAuth yayıncı URL’si",
"issuer_hint": "Harici kimlik sağlayıcıları için (Keycloak, Authentik, Entra ID vb.)."
},
"security": {
"title": "Güvenlik ve oturumlar",
"subtitle": "Oturum gizli anahtarı “Beni hatırla” özelliğini ve ayar eşitlemeyi etkinleştirir.",
"generated": "Oturum gizli anahtarı oluşturuldu",
"customize": "Özelleştir",
"generated_hint": "Sizin için 32 baytlık bir gizli anahtar oluşturuldu. Yalnızca belirli bir nedeniniz varsa değiştirin.",
"secret_label": "Oturum gizli anahtarı",
"secret_hint": "Kendi değerinizi yapıştırın veya yenisini oluşturun.",
"regenerate": "Yeniden oluştur",
"sync": "Kullanıcı ayarlarını cihazlar arasında eşitle",
"sync_hint": "Kullanıcı tercihlerini sunucuda, oturum gizli anahtarıyla şifrelenmiş olarak saklar.",
"telemetry": "Bulwark’ı geliştirmek için anonim kullanım istatistikleri gönder",
"telemetry_hint": "Varsayılan olarak kapalı. Günde bir kez sürüm, platform ve etkin özellikleri içeren anonim bir sinyal; asla e-posta adresi, ana bilgisayar adı veya IP içermez. Bunu yönetim ayarlarından istediğiniz zaman değiştirebilirsiniz."
},
"logging": {
"title": "Günlük",
"subtitle": "Uygulama günlüklerinin biçimi ve ayrıntı düzeyi.",
"format": "Biçim",
"format_text": "text: renkli, okunabilir",
"format_json": "json: yapılandırılmış, günlük toplama için",
"level": "Düzey",
"level_info": "info (önerilen)"
},
"branding": {
"title": "Marka",
"subtitle": "Tüm alanlar isteğe bağlıdır. Bir dosya yükleyin veya URL yapıştırın; atladığınız her şey için varsayılanlar kullanılır.",
"company": "Şirket veya kuruluş adı",
"favicon": "Favicon",
"favicon_hint": "Tarayıcı sekmesi simgesi. SVG önerilir.",
"login_light": "Oturum açma logosu (açık mod)",
"login_light_hint": "Oturum açma sayfasında, açık arka planda gösterilir.",
"login_dark": "Oturum açma logosu (koyu mod)",
"login_dark_hint": "Oturum açma sayfasında, koyu arka planda gösterilir.",
"sidebar_light": "Kenar çubuğu logosu (açık mod)",
"sidebar_light_hint": "Oturum açtıktan sonra gösterilir. Logo istemiyorsanız boş bırakın.",
"sidebar_dark": "Kenar çubuğu logosu (koyu mod)",
"sidebar_dark_hint": "Kenar çubuğu logosunun koyu mod sürümü.",
"website": "Web sitesi URL’si",
"imprint": "Künye URL’si",
"privacy": "Gizlilik politikası URL’si",
"drop": "Tıklayın veya buraya bırakın",
"uploading": "Yükleniyor…",
"uploaded": "Yüklenen dosya",
"formats": "SVG, PNG, JPEG, WebP veya ICO · en fazla 2 MB",
"use_url": "URL kullan",
"hide_url": "URL’yi gizle",
"url_placeholder": "https://… veya /branding/dosya.svg"
},
"review": {
"title": "Neredeyse bitti",
"subtitle": "Ayarlarınızı gözden geçirin, bir yönetici parolası seçin ve uygulayın.",
"server": "Sunucu",
"app_name": "Uygulama adı",
"jmap_server": "JMAP sunucusu",
"additional": "Ek sunucular",
"additional_count": "{count} yapılandırıldı",
"additional_auto_pick": "{count} yapılandırıldı · alan adına göre seçilir",
"stalwart": "Stalwart özellikleri",
"auth": "Kimlik doğrulama",
"method": "Yöntem",
"oauth_only": "Yalnızca OAuth",
"password_oauth": "Parola + OAuth",
"password_only": "Yalnızca parola",
"client_id": "OAuth istemci kimliği",
"security": "Güvenlik",
"secret": "Oturum gizli anahtarı",
"configured": "Yapılandırıldı",
"not_set": "Ayarlanmadı",
"remember_me": "Beni hatırla",
"available": "Kullanılabilir",
"disabled": "Devre dışı",
"sync": "Ayar eşitleme",
"needs_secret": "Oturum gizli anahtarı gerektirir",
"telemetry": "Anonim telemetri",
"logging": "Günlük",
"format": "Biçim",
"level": "Düzey",
"branding": "Marka",
"customizations": "Özelleştirmeler",
"custom_assets": "Özel dosyalar yapılandırıldı",
"defaults": "Varsayılanlar",
"company": "Şirket adı",
"password_title": "Bir yönetici parolası seçin",
"password_hint": "{path} adresinde oturum açmak için bunu kullanacaksınız. En az 8 karakter.",
"new_password": "Yeni parola",
"confirm": "Onayla",
"too_short": "En az 8 karakter.",
"mismatch": "Parolalar eşleşmiyor.",
"looks_good": "Uygun.",
"advanced": "Gelişmiş",
"lock": "Kurulumdan sonra yapılandırmayı kilitle",
"lock_hint": "Bir işaret dosyası bırakır. Ardından yapılandırma birimini salt okunur bağlayın; uygulama başka yapılandırma yazımlarını reddeder. Denetim günlükleri ve oturum durumu, durum biriminde yazılabilir kalır.",
"applying": "Uygulanıyor…",
"finish": "Uygula ve bitir"
}
},
"errors": {
"page_error_title": "Bir şeyler ters gitti",
"page_error_description": "Beklenmeyen bir hatayla karşılaştık. Lütfen tekrar deneyin veya ana sayfaya dönün.",
+254 -5
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "Налаштування",
"back_to_mail": "Назад до пошти",
"back_to_settings": "Назад до налаштувань",
"save_success": "Налаштування успішно збережено",
"import_success": "Налаштування успішно імпортовано",
"import_error": "Не вдалося імпортувати налаштування",
@@ -1345,6 +1346,7 @@
"id_exists": "Цей ідентифікатор ключового слова вже існує",
"edit": "Редагувати ключове слово",
"delete": "Видалити ключове слово",
"deleted": "Ключове слово видалено",
"save": "зберегти",
"add": "додати",
"cancel": "Скасувати",
@@ -1849,6 +1851,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "Інші пристрої більше не зможуть відновити з нього облікові записи.",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1944,6 +1947,8 @@
"removed": "Пароль програми видалено",
"add_error": "Не вдалося створити пароль програми",
"remove_error": "Не вдалося видалити пароль програми",
"delete_title": "Видалити пароль застосунку?",
"delete_message": "Застосунки, що входять із паролем {name}, більше не зможуть увійти.",
"none": "Паролі програм не налаштовано",
"done": "Готово",
"expires_label": "Термін дії (необов’язково)",
@@ -1962,6 +1967,8 @@
"removed": "API-ключ видалено",
"add_error": "Не вдалося створити API-ключ",
"remove_error": "Не вдалося видалити API-ключ",
"delete_title": "Видалити ключ API?",
"delete_message": "Скрипти й інтеграції, що використовують {name}, втратять доступ.",
"none": "API-ключі не налаштовано"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "Увімкнути шифрування цим ключем",
"enable_button": "Увімкнути",
"disable_tooltip": "Вимкнути шифрування",
"disable_title": "Вимкнути шифрування збережених даних?",
"disable_message": "Нові листи відтепер зберігатимуться без шифрування.",
"disable_button": "Вимкнути",
"enabled_success": "Шифрування в стані спокою ввімкнено"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "напр. Мій ключ",
"key_label": "Відкритий ключ (S/MIME або PGP у форматі ASCII)",
"key_placeholder": "Вставте сюди свій відкритий ключ",
"none": "Відкриті ключі не налаштовано"
"none": "Відкриті ключі не налаштовано",
"title": "Відкриті ключі",
"added": "Відкритий ключ додано",
"removed": "Відкритий ключ видалено",
"add_error": "Не вдалося додати відкритий ключ",
"remove_error": "Не вдалося видалити відкритий ключ",
"delete_title": "Видалити відкритий ключ?",
"delete_message": "Ключ {name} буде видалено із сервера."
},
"sections": {
"devices": "Інші застосунки та пристрої",
@@ -2089,6 +2106,7 @@
},
"save": "Зберегти зміни",
"saving": "Збереження...",
"saved": "Збережено",
"warnings": {
"end_before_start": "Дата завершення має бути після дати початку",
"invalid_date": "Вкажіть дійсні дати початку й закінчення з часом або залиште поле порожнім",
@@ -2155,12 +2173,17 @@
"new_folder_name": "Назва папки",
"rename": "Перейменувати",
"change_icon": "Змінити значок",
"actions": "Дії для «{name}»",
"expand": "Розгорнути «{name}»",
"collapse": "Згорнути «{name}»",
"counter": "Лічильник",
"count_mode": "Лічильник для {name}",
"count_mode_default": "Лічильник: типово",
"count_mode_unread": "Лише непрочитані",
"count_mode_all": "Непрочитані й усього",
"count_mode_none": "Без лічильника",
"delete": "Видалити",
"delete_title": "Видалити папку?",
"confirm_delete": "Ви впевнені, що хочете видалити \"{name}\"? Електронні листи в цій папці буде переміщено в кошик.",
"create": "Створити",
"cancel": "Скасувати",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "Режим налагодження",
"description": "Увімкніть детальне ведення журналу для усунення несправностей"
"description": "Увімкніть детальне ведення журналу для усунення несправностей",
"toggle": "Докладне журналювання"
},
"debug_categories": {
"description": "Виберіть категорії для реєстрації. Вимкніть категорії, які вам не потрібні, щоб зменшити шум консолі.",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "Оновити кешовані дані?"
},
"reset_settings": {
"label": "Скинути налаштування",
"description": "Відновити всі налаштування до значень за замовчуванням",
"button": "Скинути до значень за замовчуванням"
"button": "Скинути до значень за замовчуванням",
"confirm_title": "Скинути всі налаштування?",
"done": "Налаштування скинуто"
},
"export_settings": {
"label": "Налаштування експорту",
@@ -2302,11 +2329,19 @@
"categories_title": "Категорії",
"categories_description": "Перейменувати категорії контактів",
"no_categories": "Категорії не знайдено",
"delete_category": "Видалити категорію",
"category_deleted": "Категорію видалено",
"category_delete_failed": "Не вдалося видалити категорію",
"group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів",
"group_by_letter_label": "Групувати за першою літерою",
"sort_by_last_name_label": "Сортувати за прізвищем",
"sort_by_last_name_description": "Упорядкувати список контактів за прізвищем, щоб члени родини були поруч"
},
"calendar_management": {
"delete_title": "Видалити календар?",
"clear_title": "Очистити всі події?",
"unsubscribe_title": "Відписатися від цього календаря?"
},
"downloads": {
"title": "Завантаження",
"description": "Налаштуйте, як називаються завантажені листи та вкладення.",
@@ -2371,7 +2406,7 @@
"validate": "Перевірити",
"validation_success": "Сценарій дійсний",
"validation_error": "Скрипт містить помилки",
"save": "Зберегти правила",
"save": "зберегти",
"saving": "Збереження...",
"saved": "Фільтри успішно збережено",
"save_failed": "Не вдалося зберегти фільтри",
@@ -2385,6 +2420,8 @@
"add_condition": "Додати умову",
"actions": "Дії",
"add_action": "Додати дію",
"remove_condition": "Прибрати умову",
"remove_action": "Прибрати дію",
"stop_processing": "Зупинити обробку наступних правил",
"include_spam": "Переміщувати також листи, позначені як спам",
"attachment_type_placeholder": "напр. pdf, doc, jpg",
@@ -2461,6 +2498,7 @@
"match_type": "Тип відповідності",
"reset_to_visual": "Скинути до Visual Builder",
"reset_warning": "Це призведе до скасування поточного сценарію та запуску заново.",
"reset_title": "Повернутися до візуального редактора?",
"confirm_reset": "Скинути",
"validation_empty_name": "Потрібно вказати назву правила",
"validation_empty_conditions": "Потрібна принаймні одна умова зі значенням",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Налаштування Bulwark Webmail",
"subtitle": "Налаштуйте свій екземпляр вебпошти в браузері.",
"loading": "Завантаження…",
"back": "Назад",
"next": "Далі",
"saving": "Збереження…",
"show": "Показати",
"hide": "Сховати",
"remove": "Прибрати",
"on": "Увімк.",
"off": "Вимк.",
"dismiss": "Закрити",
"unknown_error": "Невідома помилка",
"steps": {
"welcome": "Вітаємо",
"server": "Сервер",
"auth": "Вхід",
"security": "Безпека",
"logging": "Журнали",
"branding": "Оформлення",
"review": "Перевірка"
},
"read_only": {
"title": "Конфігурація доступна лише для читання",
"body": "Том із конфігурацією змонтовано лише для читання або задано змінну {variable}. Змонтуйте його з правом запису або приберіть змінну, а потім перезапустіть контейнер."
},
"done": {
"title": "Усе готово",
"body": "{appName} налаштовано й готово до роботи.",
"admin_sign_in": "Увійти до панелі адміністратора",
"webmail_login": "Відкрити вхід до вебпошти",
"redirecting": "Переходимо до панелі адміністратора…"
},
"already": {
"title": "Налаштування вже завершено",
"body": "{appName} налаштовано. Увійдіть, щоб продовжити."
},
"insecure": {
"title": "Ви запускаєте налаштування через незашифрований HTTP",
"body": "Токен налаштування й пароль адміністратора, які ви тут введете, буде передано відкритим текстом. За можливості використовуйте HTTPS: завершуйте TLS у контейнері або на зворотному проксі перед ним.",
"try_https": "Спробувати HTTPS",
"continue_http": "Продовжити через HTTP"
},
"errors": {
"token_expired": "Цей токен налаштування більше не дійсний. Перезапустіть контейнер, щоб отримати новий у журналах.",
"session_expired": "Сеанс налаштування завершився. Вставте токен налаштування ще раз, щоб продовжити.",
"token_required": "Вставте токен налаштування з журналів контейнера, щоб продовжити.",
"already_finished": "Налаштування вже завершено. Перезавантажте сторінку, щоб увійти.",
"token_rejected": "Токен відхилено (HTTP {status})",
"step_failed": "Не вдалося зберегти цей крок (HTTP {status})",
"upload_failed": "Не вдалося завантажити файл (HTTP {status})",
"finish_failed": "Не вдалося завершити налаштування (HTTP {status})"
},
"welcome": {
"title": "Вітаємо",
"body": "Вставте токен налаштування з журналів контейнера, щоб продовжити. Токен дійсний 1 годину.",
"token_label": "Токен налаштування",
"token_placeholder": "32-байтовий шістнадцятковий токен",
"verifying": "Перевірка…",
"continue": "Продовжити"
},
"server": {
"title": "Сервер",
"subtitle": "Де зберігається ваша пошта.",
"app_name": "Назва застосунку",
"url_label": "URL сервера JMAP",
"url_hint": "Сервер за замовчуванням, до якого підключаються користувачі. Приклад: https://mail.example.com",
"test": "Перевірити",
"testing": "Перевірка…",
"probe_ok": "Підключено: схоже на сервер JMAP.",
"probe_not_jmap": "Сервер відповідає, але не схожий на кінцеву точку JMAP.",
"probe_invalid": "Цей URL недійсний. Він має починатися з http:// або https://.",
"probe_unreachable": "Не вдалося підключитися до цієї адреси. Перевірте URL і доступність сервера.",
"not_jmap_hint": "Таке буває, коли зворотний проксі окремо маршрутизує JMAP у тому самому домені. Інакше URL, найімовірніше, неправильний.",
"not_jmap_confirm": "Я впевнений, що URL правильний, однаково продовжити.",
"http_title": "Цей URL використовує незашифрований HTTP.",
"http_body": "Паролі та вміст листів передаватимуться між користувачами й вашим сервером без шифрування. У робочому середовищі використовуйте {https}: завершуйте TLS на поштовому сервері або на зворотному проксі перед ним.",
"local_title": "Цей URL працює лише локально.",
"local_body": "Пошта завантажується просто в браузері користувача, тому URL JMAP має бути доступним звідусіль, де входять користувачі, а не лише з цього комп’ютера чи локальної мережі. У робочому середовищі використовуйте публічне ім’я хоста (наприклад, {example}).",
"additional_title": "Додаткові сервери JMAP",
"additional_hint": "Необов’язково. Запропонуйте кілька серверів у списку під час входу; зручно для хостингів із кількома екземплярами Stalwart.",
"additional_show": "Показати ({count})",
"add": "Додати",
"row_title": "Сервер № {index}",
"id_label": "ID",
"id_hint": "Унікальна коротка назва, наприклад eu-1",
"label_label": "Назва",
"label_hint": "Бачать користувачі",
"row_url": "URL",
"domains_label": "Домени",
"domains_hint": "Через кому. За ними під час входу вибирається цей сервер залежно від домену пошти.",
"add_server": "Додати сервер",
"auto_pick": "Вибирати сервер за доменом пошти",
"auto_pick_hint": "Коли користувач вводить адресу пошти, вибирається відповідний сервер зі списку вище.",
"row_id_required": "Сервер № {index}: ID обов’язковий",
"row_id_invalid": "Сервер № {index}: ID може містити лише літери, цифри, - і _ та має починатися з літери або цифри",
"row_id_duplicate": "Сервер № {index}: ID «{id}» повторюється",
"row_url_required": "Сервер № {index}: URL обов’язковий",
"row_url_scheme": "Сервер № {index}: URL має починатися з http:// або https://",
"stalwart": "Увімкнути функції, специфічні для Stalwart",
"stalwart_hint": "Додає зміну пароля й керування фільтрами Sieve. Безпечно вмикати й для інших серверів."
},
"auth": {
"title": "Автентифікація",
"subtitle": "Вхід за паролем доступний завжди. OAuth/OIDC — за бажанням.",
"oauth": "Увімкнути OAuth2 / OpenID Connect",
"oauth_only": "Лише OAuth (сховати форму пароля)",
"client_id": "ID клієнта OAuth",
"client_secret": "Секрет клієнта OAuth",
"client_secret_hint": "Залиште порожнім для публічних клієнтів, які використовують лише PKCE.",
"client_secret_placeholder": "Вставте секрет",
"issuer": "URL видавця OAuth",
"issuer_hint": "Для зовнішніх постачальників ідентичності (Keycloak, Authentik, Entra ID тощо)."
},
"security": {
"title": "Безпека й сеанси",
"subtitle": "Секрет сеансу вмикає «Запам’ятати мене» й синхронізацію налаштувань.",
"generated": "Секрет сеансу створено",
"customize": "Налаштувати",
"generated_hint": "Для вас створено 32-байтовий секрет. Змінюйте його, лише якщо маєте конкретну причину.",
"secret_label": "Секрет сеансу",
"secret_hint": "Вставте своє значення або створіть нове.",
"regenerate": "Створити знову",
"sync": "Синхронізувати налаштування користувачів між пристроями",
"sync_hint": "Зберігає налаштування користувачів на сервері, зашифровані секретом сеансу.",
"telemetry": "Надсилати анонімну статистику використання, щоб покращити Bulwark",
"telemetry_hint": "Типово вимкнено. Один анонімний сигнал на день із версією, платформою та увімкненими функціями; ніколи не передаються адреси пошти, імена хостів чи IP-адреси. Це можна змінити будь-коли в налаштуваннях адміністратора."
},
"logging": {
"title": "Журнали",
"subtitle": "Формат і докладність журналів застосунку.",
"format": "Формат",
"format_text": "text: кольоровий, зручний для читання",
"format_json": "json: структурований, для збирання журналів",
"level": "Рівень",
"level_info": "info (рекомендовано)"
},
"branding": {
"title": "Оформлення",
"subtitle": "Усі поля необов’язкові. Завантажте файл або вставте URL; для пропущеного використовуються типові значення.",
"company": "Назва компанії чи організації",
"favicon": "Значок сайту",
"favicon_hint": "Значок вкладки браузера. Рекомендовано SVG.",
"login_light": "Логотип входу (світла тема)",
"login_light_hint": "На сторінці входу, на світлому тлі.",
"login_dark": "Логотип входу (темна тема)",
"login_dark_hint": "На сторінці входу, на темному тлі.",
"sidebar_light": "Логотип бічної панелі (світла тема)",
"sidebar_light_hint": "Показується після входу. Залиште порожнім, якщо не потрібен.",
"sidebar_dark": "Логотип бічної панелі (темна тема)",
"sidebar_dark_hint": "Варіант логотипа бічної панелі для темної теми.",
"website": "URL сайту",
"imprint": "URL вихідних даних",
"privacy": "URL політики конфіденційності",
"drop": "Натисніть або перетягніть сюди",
"uploading": "Завантаження…",
"uploaded": "Завантажений файл",
"formats": "SVG, PNG, JPEG, WebP або ICO · до 2 МБ",
"use_url": "Вказати URL",
"hide_url": "Сховати URL",
"url_placeholder": "https://… або /branding/file.svg"
},
"review": {
"title": "Майже готово",
"subtitle": "Перевірте налаштування, виберіть пароль адміністратора й застосуйте їх.",
"server": "Сервер",
"app_name": "Назва застосунку",
"jmap_server": "Сервер JMAP",
"additional": "Додаткові сервери",
"additional_count": "Налаштовано: {count}",
"additional_auto_pick": "Налаштовано: {count} · вибір за доменом",
"stalwart": "Функції Stalwart",
"auth": "Автентифікація",
"method": "Спосіб",
"oauth_only": "Лише OAuth",
"password_oauth": "Пароль + OAuth",
"password_only": "Лише пароль",
"client_id": "ID клієнта OAuth",
"security": "Безпека",
"secret": "Секрет сеансу",
"configured": "Налаштовано",
"not_set": "Не задано",
"remember_me": "Запам’ятати мене",
"available": "Доступно",
"disabled": "Вимкнено",
"sync": "Синхронізація налаштувань",
"needs_secret": "Потрібен секрет сеансу",
"telemetry": "Анонімна телеметрія",
"logging": "Журнали",
"format": "Формат",
"level": "Рівень",
"branding": "Оформлення",
"customizations": "Налаштування оформлення",
"custom_assets": "Власні файли налаштовано",
"defaults": "Типові значення",
"company": "Назва компанії",
"password_title": "Виберіть пароль адміністратора",
"password_hint": "Він потрібен для входу на {path}. Щонайменше 8 символів.",
"new_password": "Новий пароль",
"confirm": "Підтвердження",
"too_short": "Щонайменше 8 символів.",
"mismatch": "Паролі не збігаються.",
"looks_good": "Усе гаразд.",
"advanced": "Додатково",
"lock": "Заблокувати конфігурацію після налаштування",
"lock_hint": "Створює файл-позначку. Після цього змонтуйте том із конфігурацією лише для читання, і застосунок відхилятиме подальші зміни конфігурації. Журнали аудиту й стан входу залишаються доступними для запису в томі стану.",
"applying": "Застосування…",
"finish": "Застосувати й завершити"
}
},
"errors": {
"page_error_title": "Щось пішло не так",
"page_error_description": "Ми зіткнулися з неочікуваною помилкою. Спробуйте ще раз або поверніться на головну сторінку.",
+255 -6
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "設定",
"back_to_mail": "回到郵件",
"back_to_settings": "返回設定",
"save_success": "設定已成功儲存",
"import_success": "已成功匯入設定",
"import_error": "無法匯入設定",
@@ -1348,6 +1349,7 @@
"id_exists": "此標籤 ID 已存在",
"edit": "編輯標籤",
"delete": "刪除標籤",
"deleted": "標籤已刪除",
"save": "儲存",
"add": "新增",
"cancel": "取消",
@@ -1852,6 +1854,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "其他裝置將無法再從中還原帳號。",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1957,6 +1960,8 @@
"removed": "已移除應用程式密碼",
"add_error": "無法建立應用程式密碼",
"remove_error": "無法移除應用程式密碼",
"delete_title": "刪除應用程式密碼?",
"delete_message": "使用 {name} 登入的應用程式將無法再登入。",
"none": "尚未設定應用程式密碼"
},
"api_keys": {
@@ -1969,6 +1974,8 @@
"removed": "API 金鑰已刪除",
"add_error": "建立 API 金鑰失敗",
"remove_error": "刪除 API 金鑰失敗",
"delete_title": "刪除 API 金鑰?",
"delete_message": "使用 {name} 的指令碼和整合將失去存取權。",
"none": "未設定 API 金鑰"
},
"encryption": {
@@ -1988,6 +1995,9 @@
"configure_title": "使用此金鑰啟用加密",
"enable_button": "啟用",
"disable_tooltip": "停用加密",
"disable_title": "關閉靜態加密?",
"disable_message": "從現在起,新郵件將不加密儲存。",
"disable_button": "關閉",
"enabled_success": "已啟用靜態資料加密"
},
"public_keys": {
@@ -1995,7 +2005,14 @@
"name_placeholder": "例如:我的金鑰",
"key_label": "公開金鑰(S/MIME 或 PGP ASCII 裝甲格式)",
"key_placeholder": "在此貼上您的公鑰",
"none": "未設定公鑰"
"none": "未設定公鑰",
"title": "公開金鑰",
"added": "已新增公開金鑰",
"removed": "已刪除公開金鑰",
"add_error": "無法新增公開金鑰",
"remove_error": "無法刪除公開金鑰",
"delete_title": "刪除公開金鑰?",
"delete_message": "{name} 將從伺服器中移除。"
},
"email_client": {
"title": "郵件用戶端設定",
@@ -2096,6 +2113,7 @@
},
"save": "儲存變更",
"saving": "正在儲存…",
"saved": "已儲存",
"warnings": {
"end_before_start": "結束日期必須晚於開始日期",
"invalid_date": "請輸入含時間的有效開始與結束日期,或將該欄位留空",
@@ -2162,12 +2180,17 @@
"new_folder_name": "信件匣名稱",
"rename": "重新命名",
"change_icon": "變更圖示",
"actions": "{name} 的動作",
"expand": "展開 {name}",
"collapse": "摺疊 {name}",
"counter": "計數",
"count_mode": "{name} 的計數",
"count_mode_default": "計數:預設",
"count_mode_unread": "僅未讀",
"count_mode_all": "未讀與總數",
"count_mode_none": "不顯示計數",
"delete": "刪除",
"delete_title": "刪除信件匣?",
"confirm_delete": "您確定要刪除「{name}」嗎?此信件匣中的郵件將移至垃圾桶。",
"create": "建立",
"cancel": "取消",
@@ -2187,7 +2210,8 @@
"advanced": {
"debug_mode": {
"label": "除錯模式",
"description": "啟用詳細記錄以進行疑難排解"
"description": "啟用詳細記錄以進行疑難排解",
"toggle": "詳細記錄"
},
"debug_categories": {
"description": "選取要記錄的類別。停用不需要的類別,可減少主控台雜訊。",
@@ -2232,12 +2256,15 @@
"refresh_cache": {
"label": "重新整理快取資料",
"description": "從伺服器重新載入聯絡人、行事曆與信件匣。保留帳號與工作階段,不需登出即可修正過時或錯誤的畫面。",
"button": "重新整理"
"button": "重新整理",
"confirm_title": "重新整理快取資料?"
},
"reset_settings": {
"label": "重設設定",
"description": "將所有設定還原為預設值",
"button": "重設為預設值"
"button": "重設為預設值",
"confirm_title": "重設所有設定?",
"done": "設定已重設"
},
"export_settings": {
"label": "匯出設定",
@@ -2312,7 +2339,15 @@
"no_address_books": "找不到通訊錄",
"categories_title": "類別",
"categories_description": "重新命名聯絡人類別",
"no_categories": "找不到類別"
"no_categories": "找不到類別",
"delete_category": "刪除類別",
"category_deleted": "類別已刪除",
"category_delete_failed": "無法刪除類別"
},
"calendar_management": {
"delete_title": "刪除行事曆?",
"clear_title": "清除所有活動?",
"unsubscribe_title": "取消訂閱此行事曆?"
},
"downloads": {
"title": "下載",
@@ -2378,7 +2413,7 @@
"validate": "驗證",
"validation_success": "指令碼有效",
"validation_error": "指令碼有錯誤",
"save": "儲存規則",
"save": "儲存",
"saving": "正在儲存…",
"saved": "篩選規則已成功儲存",
"save_failed": "無法儲存篩選規則",
@@ -2392,6 +2427,8 @@
"add_condition": "新增條件",
"actions": "動作",
"add_action": "新增動作",
"remove_condition": "移除條件",
"remove_action": "移除動作",
"stop_processing": "停止處理後續規則",
"include_spam": "同時移動被標記為垃圾郵件的郵件",
"attachment_type_placeholder": "例如:pdf、doc、jpg",
@@ -2468,6 +2505,7 @@
"match_type": "比對方式",
"reset_to_visual": "重設為視覺化建構工具",
"reset_warning": "這會捨棄目前的指令碼並重新開始。",
"reset_title": "重設為視覺化編輯器?",
"confirm_reset": "重設",
"validation_empty_name": "規則名稱為必填欄位",
"validation_empty_conditions": "至少需要一個具有值的條件",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Bulwark Webmail 設定",
"subtitle": "在瀏覽器中設定你的網頁郵件執行個體。",
"loading": "載入中…",
"back": "返回",
"next": "下一步",
"saving": "儲存中…",
"show": "顯示",
"hide": "隱藏",
"remove": "移除",
"on": "開",
"off": "關",
"dismiss": "關閉",
"unknown_error": "未知的錯誤",
"steps": {
"welcome": "歡迎",
"server": "伺服器",
"auth": "驗證",
"security": "安全性",
"logging": "記錄",
"branding": "品牌",
"review": "檢查"
},
"read_only": {
"title": "設定為唯讀",
"body": "設定磁碟區以唯讀方式掛載,或設定了 {variable}。請以可寫入方式重新掛載或取消該變數,然後重新啟動容器。"
},
"done": {
"title": "一切就緒",
"body": "{appName} 已設定完成,可以使用了。",
"admin_sign_in": "登入管理儀表板",
"webmail_login": "開啟網頁郵件登入頁",
"redirecting": "正在前往管理儀表板…"
},
"already": {
"title": "設定已完成",
"body": "{appName} 已設定。請登入以繼續。"
},
"insecure": {
"title": "你正透過未加密的 HTTP 執行設定",
"body": "你在此輸入的設定權杖和管理員密碼將以明文傳送。請盡可能使用 HTTPS:在容器或其前方的反向代理伺服器上終止 TLS。",
"try_https": "嘗試 HTTPS",
"continue_http": "繼續使用 HTTP"
},
"errors": {
"token_expired": "此設定權杖已失效。重新啟動容器即可在記錄中取得新權杖。",
"session_expired": "設定工作階段已過期。請重新貼上設定權杖以繼續。",
"token_required": "請貼上容器記錄中顯示的設定權杖以繼續。",
"already_finished": "設定已結束。請重新整理頁面以登入。",
"token_rejected": "權杖遭拒 (HTTP {status})",
"step_failed": "無法儲存此步驟 (HTTP {status})",
"upload_failed": "上傳失敗 (HTTP {status})",
"finish_failed": "無法完成設定 (HTTP {status})"
},
"welcome": {
"title": "歡迎",
"body": "請貼上容器記錄中顯示的設定權杖以繼續。權杖會在 1 小時後過期。",
"token_label": "設定權杖",
"token_placeholder": "32 位元組十六進位權杖",
"verifying": "驗證中…",
"continue": "繼續"
},
"server": {
"title": "伺服器",
"subtitle": "你的郵件所在之處。",
"app_name": "應用程式名稱",
"url_label": "JMAP 伺服器 URL",
"url_hint": "使用者預設連線的伺服器。例如:https://mail.example.com",
"test": "測試",
"testing": "測試中…",
"probe_ok": "已連線:這看起來是 JMAP 伺服器。",
"probe_not_jmap": "已連上伺服器,但它看起來不是 JMAP 端點。",
"probe_invalid": "此 URL 無效。必須以 http:// 或 https:// 開頭。",
"probe_unreachable": "無法連線到此位址。請檢查 URL 以及伺服器是否在線上。",
"not_jmap_hint": "當反向代理伺服器在同一網域下另外路由 JMAP 時可能發生這種情況。否則通常是 URL 有誤。",
"not_jmap_confirm": "我確定此 URL 正確,仍要繼續。",
"http_title": "此 URL 使用未加密的 HTTP。",
"http_body": "密碼和郵件內容將在使用者與伺服器之間以未加密方式傳送。正式環境請使用 {https}:在郵件伺服器或其前方的反向代理伺服器上終止 TLS。",
"local_title": "此 URL 只能在本機存取。",
"local_body": "郵件直接由使用者的瀏覽器擷取,因此 JMAP URL 必須能從使用者登入的任何地方存取,而不僅是本機或區域網路。正式環境請使用公開的主機名稱(例如 {example})。",
"additional_title": "其他 JMAP 伺服器",
"additional_hint": "選用。在登入清單中提供多台伺服器,適合執行多個 Stalwart 執行個體的託管業者。",
"additional_show": "顯示 ({count})",
"add": "新增",
"row_title": "伺服器 {index}",
"id_label": "ID",
"id_hint": "唯一的簡短名稱,例如 eu-1",
"label_label": "標籤",
"label_hint": "向使用者顯示",
"row_url": "URL",
"domains_label": "網域",
"domains_hint": "以逗號分隔。用於登入時依電子郵件網域選擇此伺服器。",
"add_server": "新增伺服器",
"auto_pick": "依電子郵件網域選擇伺服器",
"auto_pick_hint": "使用者輸入電子郵件地址時,會從上方清單中選擇對應的伺服器。",
"row_id_required": "伺服器 {index}:ID 為必填",
"row_id_invalid": "伺服器 {index}:ID 只能包含字母、數字、- 和 _,且必須以字母或數字開頭",
"row_id_duplicate": "伺服器 {index}:ID「{id}」重複",
"row_url_required": "伺服器 {index}:URL 為必填",
"row_url_scheme": "伺服器 {index}:URL 必須以 http:// 或 https:// 開頭",
"stalwart": "啟用 Stalwart 專屬功能",
"stalwart_hint": "新增變更密碼與 Sieve 篩選器管理。在其他伺服器上啟用也沒有問題。"
},
"auth": {
"title": "驗證",
"subtitle": "密碼登入隨時可用。OAuth/OIDC 為選用。",
"oauth": "啟用 OAuth2 / OpenID Connect",
"oauth_only": "僅限 OAuth(隱藏密碼表單)",
"client_id": "OAuth 用戶端 ID",
"client_secret": "OAuth 用戶端密鑰",
"client_secret_hint": "對於只使用 PKCE 的公開用戶端,請留空。",
"client_secret_placeholder": "貼上密鑰",
"issuer": "OAuth 簽發者 URL",
"issuer_hint": "用於外部身分識別提供者(Keycloak、Authentik、Entra ID 等)。"
},
"security": {
"title": "安全性與工作階段",
"subtitle": "工作階段密鑰可啟用「記住我」和設定同步。",
"generated": "已產生工作階段密鑰",
"customize": "自訂",
"generated_hint": "已為你建立 32 位元組的密鑰。除非有特定原因,否則不需變更。",
"secret_label": "工作階段密鑰",
"secret_hint": "貼上你自己的值,或重新產生。",
"regenerate": "重新產生",
"sync": "在裝置之間同步使用者設定",
"sync_hint": "將使用者偏好設定儲存在伺服器上,並以工作階段密鑰加密。",
"telemetry": "傳送匿名使用統計資料以協助改善 Bulwark",
"telemetry_hint": "預設關閉。每天傳送一次匿名訊號,包含版本、平台和已啟用的功能;絕不包含電子郵件地址、主機名稱或 IP。你可以隨時在管理設定中變更。"
},
"logging": {
"title": "記錄",
"subtitle": "應用程式記錄的格式和詳細程度。",
"format": "格式",
"format_text": "text:彩色,容易閱讀",
"format_json": "json:結構化,便於彙整記錄",
"level": "層級",
"level_info": "info(建議)"
},
"branding": {
"title": "品牌",
"subtitle": "所有欄位皆為選用。上傳檔案或貼上 URL;略過的部分將使用預設值。",
"company": "公司或組織名稱",
"favicon": "網站圖示",
"favicon_hint": "瀏覽器分頁圖示。建議使用 SVG。",
"login_light": "登入標誌(淺色模式)",
"login_light_hint": "顯示在登入頁的淺色背景上。",
"login_dark": "登入標誌(深色模式)",
"login_dark_hint": "顯示在登入頁的深色背景上。",
"sidebar_light": "側邊欄標誌(淺色模式)",
"sidebar_light_hint": "登入後顯示。留空則不顯示。",
"sidebar_dark": "側邊欄標誌(深色模式)",
"sidebar_dark_hint": "側邊欄標誌的深色模式版本。",
"website": "網站 URL",
"imprint": "法律資訊 URL",
"privacy": "隱私權政策 URL",
"drop": "按一下或拖放到此處",
"uploading": "上傳中…",
"uploaded": "已上傳的檔案",
"formats": "SVG、PNG、JPEG、WebP 或 ICO · 最大 2 MB",
"use_url": "使用 URL",
"hide_url": "隱藏 URL",
"url_placeholder": "https://… 或 /branding/file.svg"
},
"review": {
"title": "快完成了",
"subtitle": "檢查設定,選擇管理員密碼並套用。",
"server": "伺服器",
"app_name": "應用程式名稱",
"jmap_server": "JMAP 伺服器",
"additional": "其他伺服器",
"additional_count": "已設定 {count} 台",
"additional_auto_pick": "已設定 {count} 台 · 依網域選擇",
"stalwart": "Stalwart 功能",
"auth": "驗證",
"method": "方式",
"oauth_only": "僅 OAuth",
"password_oauth": "密碼 + OAuth",
"password_only": "僅密碼",
"client_id": "OAuth 用戶端 ID",
"security": "安全性",
"secret": "工作階段密鑰",
"configured": "已設定",
"not_set": "未設定",
"remember_me": "記住我",
"available": "可用",
"disabled": "已停用",
"sync": "設定同步",
"needs_secret": "需要工作階段密鑰",
"telemetry": "匿名遙測",
"logging": "記錄",
"format": "格式",
"level": "層級",
"branding": "品牌",
"customizations": "自訂項目",
"custom_assets": "已設定自訂檔案",
"defaults": "使用預設值",
"company": "公司名稱",
"password_title": "選擇管理員密碼",
"password_hint": "你將用它在 {path} 登入。至少 8 個字元。",
"new_password": "新密碼",
"confirm": "確認",
"too_short": "至少 8 個字元。",
"mismatch": "兩次輸入的密碼不一致。",
"looks_good": "沒問題。",
"advanced": "進階",
"lock": "設定後鎖定設定",
"lock_hint": "建立一個標記檔案。完成後請以唯讀方式重新掛載設定磁碟區,應用程式將拒絕之後的設定寫入。稽核記錄和登入狀態在狀態磁碟區中仍可寫入。",
"applying": "套用中…",
"finish": "套用並完成"
}
},
"errors": {
"page_error_title": "出了點問題",
"page_error_description": "發生未預期的錯誤。請再試一次或返回首頁。",
+254 -5
View File
@@ -1109,6 +1109,7 @@
"settings": {
"title": "设置",
"back_to_mail": "返回邮件",
"back_to_settings": "返回设置",
"save_success": "设置保存成功",
"import_success": "设置导入成功",
"import_error": "导入设置失败",
@@ -1345,6 +1346,7 @@
"id_exists": "此标签ID已存在",
"edit": "编辑标签",
"delete": "删除标签",
"deleted": "标签已删除",
"save": "保存",
"add": "添加",
"cancel": "取消",
@@ -1849,6 +1851,7 @@
"delete": "Delete saved list",
"cancel": "Cancel",
"delete_confirm": "Delete the saved account list from the server?",
"delete_message": "其他设备将无法再从中恢复账户。",
"password": "Backup password",
"password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.",
"restore_hint": "The password chosen when the list was saved.",
@@ -1944,6 +1947,8 @@
"removed": "应用密码已删除",
"add_error": "创建应用密码失败",
"remove_error": "无法删除应用密码",
"delete_title": "删除应用密码?",
"delete_message": "使用 {name} 登录的应用将无法再登录。",
"none": "未配置应用密码",
"done": "完成",
"expires_label": "过期时间(可选)",
@@ -1962,6 +1967,8 @@
"removed": "API 密钥已删除",
"add_error": "创建 API 密钥失败",
"remove_error": "删除 API 密钥失败",
"delete_title": "删除 API 密钥?",
"delete_message": "使用 {name} 的脚本和集成将失去访问权限。",
"none": "未配置 API 密钥"
},
"encryption": {
@@ -1981,6 +1988,9 @@
"configure_title": "使用此密钥启用加密",
"enable_button": "启用",
"disable_tooltip": "禁用加密",
"disable_title": "关闭静态加密?",
"disable_message": "从现在起,新邮件将不加密存储。",
"disable_button": "关闭",
"enabled_success": "静态加密已启用"
},
"email_client": {
@@ -2029,7 +2039,14 @@
"name_placeholder": "例如:我的密钥",
"key_label": "公钥(S/MIME 或 PGP ASCII 格式)",
"key_placeholder": "在此粘贴您的公钥",
"none": "未配置公钥"
"none": "未配置公钥",
"title": "公钥",
"added": "已添加公钥",
"removed": "已删除公钥",
"add_error": "无法添加公钥",
"remove_error": "无法删除公钥",
"delete_title": "删除公钥?",
"delete_message": "{name} 将从服务器中删除。"
},
"sections": {
"devices": "其他应用和设备",
@@ -2089,6 +2106,7 @@
},
"save": "保存更改",
"saving": "正在保存…",
"saved": "已保存",
"warnings": {
"end_before_start": "结束日期必须晚于开始日期",
"invalid_date": "请输入带时间的有效开始和结束日期,或将该字段留空",
@@ -2155,12 +2173,17 @@
"new_folder_name": "文件夹名称",
"rename": "重命名",
"change_icon": "更改图标",
"actions": "{name} 的操作",
"expand": "展开 {name}",
"collapse": "折叠 {name}",
"counter": "计数",
"count_mode": "{name} 的计数",
"count_mode_default": "计数:默认",
"count_mode_unread": "仅未读",
"count_mode_all": "未读和总数",
"count_mode_none": "不显示计数",
"delete": "删除",
"delete_title": "删除文件夹?",
"confirm_delete": "您确定要删除\"{name}\"吗?此文件夹中的邮件将被移至\"已删除\"。",
"create": "创建",
"cancel": "取消",
@@ -2180,7 +2203,8 @@
"advanced": {
"debug_mode": {
"label": "调试模式",
"description": "启用详细日志记录以进行故障排除"
"description": "启用详细日志记录以进行故障排除",
"toggle": "详细日志"
},
"debug_categories": {
"description": "选择要记录的类别。禁用不需要的类别以减少控制台噪音。",
@@ -2225,12 +2249,15 @@
"refresh_cache": {
"label": "Refresh cached data",
"description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.",
"button": "Refresh"
"button": "Refresh",
"confirm_title": "刷新缓存数据?"
},
"reset_settings": {
"label": "重置设置",
"description": "将所有设置恢复为默认值",
"button": "重置为默认值"
"button": "重置为默认值",
"confirm_title": "重置所有设置?",
"done": "设置已重置"
},
"export_settings": {
"label": "导出设置",
@@ -2302,11 +2329,19 @@
"categories_title": "类别",
"categories_description": "重命名联系人类别",
"no_categories": "未找到类别",
"delete_category": "删除类别",
"category_deleted": "类别已删除",
"category_delete_failed": "无法删除类别",
"group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题",
"group_by_letter_label": "按首字母分组",
"sort_by_last_name_label": "按姓氏排序",
"sort_by_last_name_description": "按姓氏排列联系人列表,使家庭成员显示在一起"
},
"calendar_management": {
"delete_title": "删除日历?",
"clear_title": "清除所有日程?",
"unsubscribe_title": "取消订阅此日历?"
},
"downloads": {
"title": "下载",
"description": "自定义下载的邮件和附件的命名方式。",
@@ -2371,7 +2406,7 @@
"validate": "验证",
"validation_success": "脚本有效",
"validation_error": "脚本有错误",
"save": "保存规则",
"save": "保存",
"saving": "保存中...",
"saved": "过滤器保存成功",
"save_failed": "无法保存过滤器",
@@ -2385,6 +2420,8 @@
"add_condition": "添加条件",
"actions": "操作",
"add_action": "添加操作",
"remove_condition": "移除条件",
"remove_action": "移除操作",
"stop_processing": "停止处理后续规则",
"include_spam": "同时移动被标记为垃圾邮件的邮件",
"attachment_type_placeholder": "例如:pdf、doc、jpg",
@@ -2461,6 +2498,7 @@
"match_type": "匹配类型",
"reset_to_visual": "重置为可视化编辑器",
"reset_warning": "这将丢弃当前脚本并重新开始。",
"reset_title": "重置为可视化编辑器?",
"confirm_reset": "重置",
"validation_empty_name": "规则名称为必填项",
"validation_empty_conditions": "至少需要一个具有值的条件",
@@ -2651,6 +2689,217 @@
}
}
},
"setup": {
"title": "Bulwark Webmail 设置",
"subtitle": "在浏览器中配置你的网页邮箱实例。",
"loading": "正在加载…",
"back": "返回",
"next": "下一步",
"saving": "正在保存…",
"show": "显示",
"hide": "隐藏",
"remove": "移除",
"on": "开",
"off": "关",
"dismiss": "关闭",
"unknown_error": "未知错误",
"steps": {
"welcome": "欢迎",
"server": "服务器",
"auth": "认证",
"security": "安全",
"logging": "日志",
"branding": "品牌",
"review": "检查"
},
"read_only": {
"title": "配置为只读",
"body": "配置卷以只读方式挂载,或设置了 {variable}。请以可写方式重新挂载或取消该变量,然后重启容器。"
},
"done": {
"title": "一切就绪",
"body": "{appName} 已配置完成,可以使用了。",
"admin_sign_in": "登录管理面板",
"webmail_login": "打开网页邮箱登录页",
"redirecting": "正在前往管理面板…"
},
"already": {
"title": "设置已完成",
"body": "{appName} 已配置。请登录以继续。"
},
"insecure": {
"title": "你正在通过未加密的 HTTP 运行设置",
"body": "你在这里输入的设置令牌和管理员密码将以明文传输。请尽量使用 HTTPS:在容器或其前方的反向代理上终止 TLS。",
"try_https": "尝试 HTTPS",
"continue_http": "继续使用 HTTP"
},
"errors": {
"token_expired": "该设置令牌已失效。重启容器即可在日志中获取新令牌。",
"session_expired": "设置会话已过期。请重新粘贴设置令牌以继续。",
"token_required": "请粘贴容器日志中显示的设置令牌以继续。",
"already_finished": "设置已结束。请刷新页面登录。",
"token_rejected": "令牌被拒绝 (HTTP {status})",
"step_failed": "无法保存此步骤 (HTTP {status})",
"upload_failed": "上传失败 (HTTP {status})",
"finish_failed": "无法完成设置 (HTTP {status})"
},
"welcome": {
"title": "欢迎",
"body": "请粘贴容器日志中显示的设置令牌以继续。令牌 1 小时后过期。",
"token_label": "设置令牌",
"token_placeholder": "32 字节十六进制令牌",
"verifying": "正在验证…",
"continue": "继续"
},
"server": {
"title": "服务器",
"subtitle": "你的邮件所在之处。",
"app_name": "应用名称",
"url_label": "JMAP 服务器 URL",
"url_hint": "用户默认连接的服务器。例如:https://mail.example.com",
"test": "测试",
"testing": "正在测试…",
"probe_ok": "已连接:这看起来是 JMAP 服务器。",
"probe_not_jmap": "已连上服务器,但它看起来不是 JMAP 端点。",
"probe_invalid": "该 URL 无效。必须以 http:// 或 https:// 开头。",
"probe_unreachable": "无法连接到该地址。请检查 URL 以及服务器是否在线。",
"not_jmap_hint": "当反向代理在同一域名下单独路由 JMAP 时可能出现这种情况。否则通常是 URL 有误。",
"not_jmap_confirm": "我确定这个 URL 正确,仍然继续。",
"http_title": "此 URL 使用未加密的 HTTP。",
"http_body": "密码和邮件内容将在用户与服务器之间以未加密方式传输。生产环境请使用 {https}:在邮件服务器或其前方的反向代理上终止 TLS。",
"local_title": "此 URL 只能在本地访问。",
"local_body": "邮件直接由用户的浏览器获取,因此 JMAP URL 必须能从用户登录的任何地方访问,而不仅是本机或局域网。生产环境请使用公共主机名(例如 {example})。",
"additional_title": "其他 JMAP 服务器",
"additional_hint": "可选。在登录列表中提供多个服务器,适合运行多个 Stalwart 实例的托管方。",
"additional_show": "显示 ({count})",
"add": "添加",
"row_title": "服务器 {index}",
"id_label": "ID",
"id_hint": "唯一的短名称,例如 eu-1",
"label_label": "标签",
"label_hint": "向用户显示",
"row_url": "URL",
"domains_label": "域名",
"domains_hint": "以逗号分隔。用于登录时按邮箱域名选择此服务器。",
"add_server": "添加服务器",
"auto_pick": "按邮箱域名选择服务器",
"auto_pick_hint": "用户输入邮箱地址时,会从上方列表中选择对应的服务器。",
"row_id_required": "服务器 {index}:ID 为必填项",
"row_id_invalid": "服务器 {index}:ID 只能包含字母、数字、- 和 _,并且必须以字母或数字开头",
"row_id_duplicate": "服务器 {index}:ID“{id}”重复",
"row_url_required": "服务器 {index}:URL 为必填项",
"row_url_scheme": "服务器 {index}:URL 必须以 http:// 或 https:// 开头",
"stalwart": "启用 Stalwart 专属功能",
"stalwart_hint": "添加修改密码和 Sieve 过滤器管理。在其他服务器上启用也没有问题。"
},
"auth": {
"title": "认证",
"subtitle": "密码登录始终可用。OAuth/OIDC 为可选项。",
"oauth": "启用 OAuth2 / OpenID Connect",
"oauth_only": "仅限 OAuth(隐藏密码表单)",
"client_id": "OAuth 客户端 ID",
"client_secret": "OAuth 客户端密钥",
"client_secret_hint": "对于只使用 PKCE 的公共客户端,请留空。",
"client_secret_placeholder": "粘贴密钥",
"issuer": "OAuth 签发者 URL",
"issuer_hint": "用于外部身份提供商(Keycloak、Authentik、Entra ID 等)。"
},
"security": {
"title": "安全与会话",
"subtitle": "会话密钥用于启用“记住我”和设置同步。",
"generated": "已生成会话密钥",
"customize": "自定义",
"generated_hint": "已为你创建 32 字节的密钥。除非有特定原因,否则无需更改。",
"secret_label": "会话密钥",
"secret_hint": "粘贴你自己的值,或重新生成。",
"regenerate": "重新生成",
"sync": "在设备之间同步用户设置",
"sync_hint": "将用户偏好保存在服务器上,并用会话密钥加密。",
"telemetry": "发送匿名使用统计以帮助改进 Bulwark",
"telemetry_hint": "默认关闭。每天发送一次匿名心跳,包含版本、平台和已启用的功能;绝不包含邮箱地址、主机名或 IP。你可以随时在管理设置中更改。"
},
"logging": {
"title": "日志",
"subtitle": "应用日志的格式和详细程度。",
"format": "格式",
"format_text": "text:彩色,易读",
"format_json": "json:结构化,便于日志汇总",
"level": "级别",
"level_info": "info(推荐)"
},
"branding": {
"title": "品牌",
"subtitle": "所有字段均为可选。上传文件或粘贴 URL;跳过的部分将使用默认值。",
"company": "公司或组织名称",
"favicon": "网站图标",
"favicon_hint": "浏览器标签页图标。推荐 SVG。",
"login_light": "登录徽标(浅色模式)",
"login_light_hint": "显示在登录页的浅色背景上。",
"login_dark": "登录徽标(深色模式)",
"login_dark_hint": "显示在登录页的深色背景上。",
"sidebar_light": "侧边栏徽标(浅色模式)",
"sidebar_light_hint": "登录后显示。留空则不显示。",
"sidebar_dark": "侧边栏徽标(深色模式)",
"sidebar_dark_hint": "侧边栏徽标的深色模式版本。",
"website": "网站 URL",
"imprint": "法律信息 URL",
"privacy": "隐私政策 URL",
"drop": "点击或拖放到此处",
"uploading": "正在上传…",
"uploaded": "已上传的文件",
"formats": "SVG、PNG、JPEG、WebP 或 ICO · 最大 2 MB",
"use_url": "使用 URL",
"hide_url": "隐藏 URL",
"url_placeholder": "https://… 或 /branding/file.svg"
},
"review": {
"title": "快完成了",
"subtitle": "检查设置,选择管理员密码并应用。",
"server": "服务器",
"app_name": "应用名称",
"jmap_server": "JMAP 服务器",
"additional": "其他服务器",
"additional_count": "已配置 {count} 个",
"additional_auto_pick": "已配置 {count} 个 · 按域名选择",
"stalwart": "Stalwart 功能",
"auth": "认证",
"method": "方式",
"oauth_only": "仅 OAuth",
"password_oauth": "密码 + OAuth",
"password_only": "仅密码",
"client_id": "OAuth 客户端 ID",
"security": "安全",
"secret": "会话密钥",
"configured": "已配置",
"not_set": "未设置",
"remember_me": "记住我",
"available": "可用",
"disabled": "已禁用",
"sync": "设置同步",
"needs_secret": "需要会话密钥",
"telemetry": "匿名遥测",
"logging": "日志",
"format": "格式",
"level": "级别",
"branding": "品牌",
"customizations": "自定义项",
"custom_assets": "已配置自定义文件",
"defaults": "使用默认值",
"company": "公司名称",
"password_title": "选择管理员密码",
"password_hint": "你将用它在 {path} 登录。至少 8 个字符。",
"new_password": "新密码",
"confirm": "确认",
"too_short": "至少 8 个字符。",
"mismatch": "两次输入的密码不一致。",
"looks_good": "没问题。",
"advanced": "高级",
"lock": "设置后锁定配置",
"lock_hint": "创建一个标记文件。完成后请以只读方式重新挂载配置卷,应用将拒绝之后的配置写入。审计日志和登录状态在状态卷中仍可写入。",
"applying": "正在应用…",
"finish": "应用并完成"
}
},
"errors": {
"page_error_title": "出了点问题",
"page_error_description": "我们遇到了意外错误。请重试或返回主页。",

Some files were not shown because too many files have changed in this diff Show More