mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
Merge pull request #1201 from lucamzanon/feat/account-archive-list
feat: optional encrypted backup of the account list
This commit is contained in:
@@ -168,7 +168,8 @@ function LoginPageContent() {
|
||||
: undefined;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
// An account restored from the list backup opens this form with its address.
|
||||
username: isAddAccountMode ? searchParams.get("username") ?? "" : "",
|
||||
password: "",
|
||||
});
|
||||
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
||||
|
||||
@@ -38,6 +38,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
|
||||
accountArchiveEnabled: { label: 'Account List Backup', description: 'Let users save their list of accounts (address, server, label, colour) on this server, encrypted in the browser with a password of their choice, and restore it on another device. No mailbox passwords are stored. Archives live in SETTINGS_DATA_DIR/account-archives; requires SESSION_SECRET.' },
|
||||
};
|
||||
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { assertBasicAuthMatchesUsername, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { archiveIdentity, parseArchiveEnvelope, ARCHIVE_MAX_BYTES, type ArchiveOwner } from '@/lib/account-archive';
|
||||
import { deleteArchive, loadArchive, saveArchive } from '@/lib/account-archive-storage';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const reply = (data: unknown, status = 200) => NextResponse.json(data, {
|
||||
status, headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
|
||||
/**
|
||||
* The owner whose archive this request may touch, or the response refusing it.
|
||||
*
|
||||
* A session cookie alone is not enough: cookies for admin-configured servers
|
||||
* can outlive the credential inside them. So the credential in the matching
|
||||
* slot is checked against the mail server on every call, and listing is held
|
||||
* to the same rule as writing, since the ciphertext is what an offline
|
||||
* password guess would need.
|
||||
*/
|
||||
async function verifiedOwner(request: NextRequest): Promise<ArchiveOwner | NextResponse> {
|
||||
await configManager.ensureLoaded();
|
||||
if (!configManager.getPolicy().features.accountArchiveEnabled || !hasSessionSecret()) return reply({ error: 'disabled' }, 404);
|
||||
const denied = reply({ error: 'owner_signin_required' }, 403);
|
||||
let claimed: string;
|
||||
try {
|
||||
claimed = archiveIdentity({
|
||||
username: request.headers.get('x-archive-username') ?? '',
|
||||
serverUrl: request.headers.get('x-archive-server') ?? '',
|
||||
});
|
||||
} catch { return denied; }
|
||||
const store = await cookies();
|
||||
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||
const ctx = readStalwartAuthContextFromStore(store, slot);
|
||||
if (!ctx) continue;
|
||||
try { if (archiveIdentity(ctx) !== claimed) continue; } catch { continue; }
|
||||
try {
|
||||
// The cookie's own username, not the header's: the two may differ in case.
|
||||
assertBasicAuthMatchesUsername(ctx.authHeader, ctx.username);
|
||||
const trusted = resolveTrustedJmapUrl(ctx.serverUrl,
|
||||
configManager.get<string>('jmapServerUrl', ''),
|
||||
parseJmapServers(configManager.get<unknown>('jmapServers', [])));
|
||||
await verifyJmapAuth(ctx.serverUrl, ctx.authHeader, { trusted: !!trusted });
|
||||
} catch { return denied; }
|
||||
return { username: ctx.accountName ?? ctx.username, serverUrl: ctx.serverUrl };
|
||||
}
|
||||
return denied;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const owner = await verifiedOwner(request);
|
||||
if (owner instanceof NextResponse) return owner;
|
||||
try { return reply({ envelope: await loadArchive(owner) }); }
|
||||
catch { return reply({ error: 'storage_failed' }, 500); }
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
const owner = await verifiedOwner(request);
|
||||
if (owner instanceof NextResponse) return owner;
|
||||
if (Number(request.headers.get('content-length') ?? 0) > ARCHIVE_MAX_BYTES) return reply({ error: 'too_large' }, 413);
|
||||
let envelope;
|
||||
try {
|
||||
const text = await request.text();
|
||||
if (text.length > ARCHIVE_MAX_BYTES) return reply({ error: 'too_large' }, 413);
|
||||
envelope = parseArchiveEnvelope((JSON.parse(text) as { envelope?: unknown })?.envelope);
|
||||
} catch { return reply({ error: 'invalid_archive' }, 400); }
|
||||
try { await saveArchive(owner, envelope); return reply({ ok: true }); }
|
||||
catch { return reply({ error: 'storage_failed' }, 500); }
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
const owner = await verifiedOwner(request);
|
||||
if (owner instanceof NextResponse) return owner;
|
||||
try { await deleteArchive(owner); return reply({ ok: true }); }
|
||||
catch { return reply({ error: 'storage_failed' }, 500); }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AccountArchiveSettings } from '../account-archive-settings';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), stored: null as unknown }));
|
||||
|
||||
vi.mock('next-intl', () => {
|
||||
const t = (key: string, values?: Record<string, unknown>) => values ? `${key} ${JSON.stringify(values)}` : key;
|
||||
return { useTranslations: () => t, useLocale: () => 'en' };
|
||||
});
|
||||
vi.mock('@/lib/browser-navigation', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/browser-navigation')>()),
|
||||
apiFetch: mocks.apiFetch,
|
||||
}));
|
||||
|
||||
const owner = { username: 'owner@x.test', serverUrl: 'https://mail.x.test' };
|
||||
const account = {
|
||||
id: 'owner@x.test@mail.x.test', label: 'Work', ...owner, authMode: 'basic' as const, rememberMe: true, cookieSlot: 0,
|
||||
displayName: 'Work', email: owner.username, isConnected: true, hasError: false, lastLoginAt: 1, avatarColor: '#2563eb', isDefault: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.stored = null;
|
||||
mocks.apiFetch.mockReset().mockImplementation(async (_url: string, init: RequestInit = {}) => {
|
||||
if (init.method === 'PUT') mocks.stored = JSON.parse(String(init.body)).envelope;
|
||||
if (init.method === 'DELETE') mocks.stored = null;
|
||||
return new Response(JSON.stringify(init.method ? { ok: true } : { envelope: mocks.stored }), { status: 200 });
|
||||
});
|
||||
useAuthStore.setState(owner);
|
||||
useAccountStore.setState({ accounts: [account] as never, activeAccountId: account.id, defaultAccountId: account.id });
|
||||
});
|
||||
|
||||
const fill = (label: string, value: string) => fireEvent.change(screen.getByLabelText(label), { target: { value } });
|
||||
|
||||
describe('AccountArchiveSettings', () => {
|
||||
it('saves the list encrypted, then restores it on a browser that lacks the accounts', async () => {
|
||||
render(<AccountArchiveSettings />);
|
||||
await screen.findByText(/^status_none/);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'save' }));
|
||||
fill('password', 'a long backup password');
|
||||
fill('confirm', 'a different password!');
|
||||
fireEvent.submit(screen.getByLabelText('confirm').closest('form')!);
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('errors.mismatch');
|
||||
expect(mocks.apiFetch).not.toHaveBeenCalledWith('/api/account-archive', expect.objectContaining({ method: 'PUT' }));
|
||||
|
||||
fill('confirm', 'a long backup password');
|
||||
fireEvent.submit(screen.getByLabelText('confirm').closest('form')!);
|
||||
expect(await screen.findByRole('status', {}, { timeout: 5000 })).toHaveTextContent('saved {"count":1}');
|
||||
const [, init] = mocks.apiFetch.mock.calls.find(([, i]) => i?.method === 'PUT')!;
|
||||
expect(init.headers).toMatchObject({ 'x-archive-username': owner.username, 'x-archive-server': owner.serverUrl });
|
||||
expect(String(init.body)).not.toContain('x.test');
|
||||
expect(String(init.body)).not.toContain('Work');
|
||||
|
||||
// Another browser: same owner signed in, the other account not there yet.
|
||||
useAccountStore.setState({ accounts: [{ ...account, label: 'Renamed', avatarColor: '#000000' }] as never });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'restore' }));
|
||||
fill('password', 'a long backup password');
|
||||
fireEvent.submit(screen.getByLabelText('password').closest('form')!);
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('restored'), { timeout: 5000 });
|
||||
expect(useAccountStore.getState().accounts[0]).toMatchObject({ label: 'Work', avatarColor: '#2563eb' });
|
||||
});
|
||||
|
||||
it('reports a wrong restore password', async () => {
|
||||
render(<AccountArchiveSettings />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'save' }));
|
||||
fill('password', 'a long backup password');
|
||||
fill('confirm', 'a long backup password');
|
||||
fireEvent.submit(screen.getByLabelText('confirm').closest('form')!);
|
||||
await screen.findByRole('status', {}, { timeout: 5000 });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'restore' }));
|
||||
fill('password', 'not the password');
|
||||
fireEvent.submit(screen.getByLabelText('password').closest('form')!);
|
||||
expect(await screen.findByRole('alert', {}, { timeout: 5000 })).toHaveTextContent('errors.unlock_failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { decryptArchive, encryptArchive, ARCHIVE_MIN_PASSWORD, type ArchiveOwner } from '@/lib/account-archive';
|
||||
import { collectAccounts, fetchArchive, putArchive, removeArchive, restoreAccounts } from '@/lib/account-archive-client';
|
||||
import { toUnicodeEmail } from '@/lib/idn';
|
||||
import { SettingsSection } from './settings-section';
|
||||
|
||||
const KNOWN_ERRORS = ['mismatch', 'password_length', 'unlock_failed', 'owner_signin_required', 'https_required'];
|
||||
|
||||
/** 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 id = useId();
|
||||
const username = useAuthStore(s => s.username);
|
||||
const serverUrl = useAuthStore(s => s.serverUrl);
|
||||
const [saved, setSaved] = useState<boolean | null>(null);
|
||||
const [mode, setMode] = useState<'save' | 'restore' | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmation, setConfirmation] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState<{ error: boolean; text: string } | null>(null);
|
||||
const owner: ArchiveOwner | null = username && serverUrl ? { username, serverUrl } : null;
|
||||
|
||||
useEffect(() => {
|
||||
setSaved(null);
|
||||
if (!username || !serverUrl) return;
|
||||
let cancelled = false;
|
||||
fetchArchive({ username, serverUrl })
|
||||
.then(envelope => { if (!cancelled) setSaved(envelope !== null); })
|
||||
.catch(() => { if (!cancelled) setSaved(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [username, serverUrl]);
|
||||
|
||||
if (!owner) return null;
|
||||
|
||||
const open = (next: 'save' | 'restore' | null) => {
|
||||
setMode(next); setPassword(''); setConfirmation(''); setMessage(null);
|
||||
};
|
||||
const run = async (action: () => Promise<string>) => {
|
||||
setBusy(true); setMessage(null);
|
||||
try {
|
||||
const text = await action();
|
||||
open(null);
|
||||
setMessage({ error: false, text });
|
||||
}
|
||||
catch (err) {
|
||||
const code = err instanceof Error ? err.message : '';
|
||||
setMessage({ error: true, text: t(`errors.${KNOWN_ERRORS.includes(code) ? code : 'storage_failed'}`) });
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const save = () => run(async () => {
|
||||
if (password !== confirmation) throw new Error('mismatch');
|
||||
const accounts = collectAccounts();
|
||||
await putArchive(owner, await encryptArchive(owner, accounts, password));
|
||||
setSaved(true);
|
||||
return t('saved', { count: accounts.length });
|
||||
});
|
||||
const restore = () => run(async () => {
|
||||
const envelope = await fetchArchive(owner);
|
||||
if (!envelope) { setSaved(false); throw new Error('storage_failed'); }
|
||||
const result = restoreAccounts(await decryptArchive(owner, envelope, password));
|
||||
return [t('restored', result), result.skipped ? t('skipped', { count: result.skipped }) : ''].join(' ').trim();
|
||||
});
|
||||
const remove = () => {
|
||||
if (!window.confirm(t('delete_confirm'))) return;
|
||||
void run(async () => { await removeArchive(owner); setSaved(false); return t('deleted'); });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{saved === null ? '…' : t(saved ? 'status_saved' : 'status_none', { account: toUnicodeEmail(owner.username) })}
|
||||
</p>
|
||||
{mode === null ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" onClick={() => open('save')}>{t('save')}</Button>
|
||||
{saved && <Button type="button" size="sm" variant="outline" onClick={() => open('restore')}>{t('restore')}</Button>}
|
||||
{saved && <Button type="button" size="sm" variant="ghost" disabled={busy} onClick={remove}>{t('delete')}</Button>}
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-3 max-w-md"
|
||||
onSubmit={e => { e.preventDefault(); void (mode === 'save' ? save() : restore()); }}
|
||||
>
|
||||
<label className="block text-sm font-medium" htmlFor={`${id}-password`}>{t('password')}</label>
|
||||
<Input
|
||||
id={`${id}-password`} type="password" value={password} required autoFocus
|
||||
minLength={mode === 'save' ? ARCHIVE_MIN_PASSWORD : undefined}
|
||||
autoComplete={mode === 'save' ? 'new-password' : 'current-password'}
|
||||
aria-describedby={`${id}-hint`} onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<p id={`${id}-hint`} className="text-xs text-muted-foreground">
|
||||
{t(mode === 'save' ? 'password_hint' : 'restore_hint')}
|
||||
</p>
|
||||
{mode === 'save' && (
|
||||
<>
|
||||
<label className="block text-sm font-medium" htmlFor={`${id}-confirm`}>{t('confirm')}</label>
|
||||
<Input
|
||||
id={`${id}-confirm`} type="password" value={confirmation} required autoComplete="new-password"
|
||||
onChange={e => setConfirmation(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" size="sm" disabled={busy}>{t(mode === 'save' ? 'save' : 'restore')}</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={() => open(null)}>{t('cancel')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{message && (
|
||||
<p role={message.error ? 'alert' : 'status'} className={message.error ? 'text-sm text-destructive' : 'text-sm text-foreground'}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import { useRouter } from '@/i18n/navigation';
|
||||
import { getMaxAccounts } from '@/lib/account-utils';
|
||||
import { formatFileSize, cn } from '@/lib/utils';
|
||||
import { toUnicodeDomain, toUnicodeEmail } from '@/lib/idn';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { AccountArchiveSettings } from './account-archive-settings';
|
||||
|
||||
function hostnameOf(serverUrl: string): string {
|
||||
try { return toUnicodeDomain(new URL(serverUrl).hostname); } catch { return serverUrl; }
|
||||
@@ -38,6 +41,7 @@ export function AccountSettings() {
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount);
|
||||
const archiveEnabled = usePolicyStore((s) => s.isFeatureEnabled('accountArchiveEnabled')) && !IS_LITE && !isDemoMode;
|
||||
|
||||
// Shared/group accounts delegated to this session (excludes the user's own
|
||||
// primary account). These can be drilled into for scoped settings editing.
|
||||
@@ -235,6 +239,8 @@ export function AccountSettings() {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{archiveEnabled && <AccountArchiveSettings />}
|
||||
|
||||
{/* Shared / group accounts delegated to this session. Clicking one drills
|
||||
into a scoped settings view (filters, vacation, calendars, contacts). */}
|
||||
{sharedAccounts.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
enabled: true, contexts: new Map<number, unknown>(), verify: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({ configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
get: (_key: string, fallback: unknown) => fallback,
|
||||
getPolicy: () => ({ features: { accountArchiveEnabled: mocks.enabled } }),
|
||||
} }));
|
||||
vi.mock('@/lib/auth/session-secret', () => ({ hasSessionSecret: () => true }));
|
||||
vi.mock('next/headers', () => ({ cookies: async () => ({}) }));
|
||||
vi.mock('@/lib/stalwart/auth-context', () => ({
|
||||
readStalwartAuthContextFromStore: (_: unknown, slot: number) => mocks.contexts.get(slot) ?? null,
|
||||
}));
|
||||
vi.mock('@/lib/auth/verify-jmap-auth', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@/lib/auth/verify-jmap-auth')>(), verifyJmapAuth: mocks.verify,
|
||||
}));
|
||||
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), 'bw-account-archive-'));
|
||||
process.env.SETTINGS_DATA_DIR = dataDir;
|
||||
afterAll(() => rmSync(dataDir, { recursive: true, force: true }));
|
||||
|
||||
import { DELETE, GET, PUT } from '@/app/api/account-archive/route';
|
||||
|
||||
const owner = { username: 'owner@example.com', serverUrl: 'https://mail.example.com' };
|
||||
const envelope = { version: 1, iterations: 600000, salt: 'A'.repeat(22) + '==', iv: 'A'.repeat(16), ciphertext: 'A'.repeat(43) + '=' };
|
||||
const basic = (user: string) => 'Basic ' + Buffer.from(`${user}:secret`).toString('base64');
|
||||
|
||||
function request(method: string, body?: unknown, headers: Record<string, string> = {}) {
|
||||
return new NextRequest('https://webmail.example.com/api/account-archive', {
|
||||
method,
|
||||
headers: { 'x-archive-username': owner.username, 'x-archive-server': owner.serverUrl, 'content-type': 'application/json', ...headers },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
}
|
||||
const signIn = (username = owner.username, slot = 3) => {
|
||||
mocks.contexts.set(slot, { username, serverUrl: owner.serverUrl, authHeader: basic(username) });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.enabled = true;
|
||||
mocks.contexts.clear();
|
||||
mocks.verify.mockReset().mockResolvedValue(owner.serverUrl);
|
||||
});
|
||||
|
||||
describe('account archive API', () => {
|
||||
it('is off unless the admin enables it', async () => {
|
||||
mocks.enabled = false;
|
||||
signIn();
|
||||
expect((await GET(request('GET'))).status).toBe(404);
|
||||
expect((await PUT(request('PUT', { envelope }))).status).toBe(404);
|
||||
expect((await DELETE(request('DELETE'))).status).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses every method without a verified session of the owner', async () => {
|
||||
for (const call of [() => GET(request('GET')), () => PUT(request('PUT', { envelope })), () => DELETE(request('DELETE'))]) {
|
||||
expect((await call()).status).toBe(403);
|
||||
}
|
||||
// Another user's session, or a credential that does not match its cookie.
|
||||
mocks.contexts.set(0, { ...owner, username: 'someone@example.com', authHeader: basic('someone@example.com') });
|
||||
mocks.contexts.set(1, { ...owner, authHeader: basic('someone@example.com') });
|
||||
expect((await GET(request('GET'))).status).toBe(403);
|
||||
// The owner's cookie whose credential the mail server no longer accepts.
|
||||
mocks.contexts.clear();
|
||||
signIn();
|
||||
mocks.verify.mockRejectedValue(new Error('401'));
|
||||
expect((await GET(request('GET'))).status).toBe(403);
|
||||
expect((await PUT(request('PUT', { envelope }))).status).toBe(403);
|
||||
});
|
||||
|
||||
it('stores, returns and deletes the envelope for the verified owner, in any case', async () => {
|
||||
signIn('Owner@Example.com');
|
||||
let res = await GET(request('GET'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('Cache-Control')).toBe('no-store');
|
||||
expect((await res.json()).envelope).toBeNull();
|
||||
|
||||
res = await PUT(request('PUT', { envelope: { ...envelope, password: 'forbidden' }, accounts: ['forbidden'] }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(mocks.verify).toHaveBeenCalledWith(owner.serverUrl, basic('Owner@Example.com'), { trusted: false });
|
||||
expect((await (await GET(request('GET'))).json()).envelope).toEqual(envelope);
|
||||
|
||||
expect((await DELETE(request('DELETE'))).status).toBe(200);
|
||||
expect((await (await GET(request('GET'))).json()).envelope).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects cross-site writes, malformed envelopes and oversized bodies', async () => {
|
||||
signIn();
|
||||
expect((await PUT(request('PUT', { envelope }, { 'sec-fetch-site': 'cross-site' }))).status).toBe(403);
|
||||
expect((await DELETE(request('DELETE', undefined, { 'sec-fetch-site': 'cross-site' }))).status).toBe(403);
|
||||
expect((await PUT(request('PUT', { envelope: { ...envelope, iterations: 1 } }))).status).toBe(400);
|
||||
expect((await PUT(request('PUT', { envelope: { ...envelope, ciphertext: 'A'.repeat(70000) } }))).status).toBe(413);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
archiveIdentity, decryptArchive, encryptArchive, parseArchiveEnvelope, parseArchivedAccounts, type ArchivedAccount,
|
||||
} from '@/lib/account-archive';
|
||||
|
||||
const owner = { username: 'owner@example.com', serverUrl: 'https://mail.example.com' };
|
||||
const accounts: ArchivedAccount[] = [
|
||||
{ username: 'owner@example.com', serverUrl: 'https://mail.example.com', label: 'Work', avatarColor: '#2563eb' },
|
||||
{ username: 'me@other.example', serverUrl: 'https://jmap.other.example/', label: 'Home', avatarColor: '#16a34a' },
|
||||
];
|
||||
const password = 'correct horse battery';
|
||||
|
||||
describe('account archive encryption', () => {
|
||||
it('round-trips the account list and leaves only ciphertext in the envelope', async () => {
|
||||
const envelope = await encryptArchive(owner, accounts, password);
|
||||
expect(JSON.stringify(envelope)).not.toContain('example');
|
||||
expect(JSON.stringify(envelope)).not.toContain('Work');
|
||||
expect(await decryptArchive(owner, envelope, password)).toEqual([
|
||||
accounts[0], { ...accounts[1], serverUrl: 'https://jmap.other.example' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a wrong password and an envelope moved to another owner', async () => {
|
||||
const envelope = await encryptArchive(owner, accounts, password);
|
||||
await expect(decryptArchive(owner, envelope, 'wrong password!')).rejects.toThrow('unlock_failed');
|
||||
await expect(decryptArchive({ ...owner, username: 'thief@example.com' }, envelope, password)).rejects.toThrow('unlock_failed');
|
||||
});
|
||||
|
||||
it('treats the owner username without regard to case', async () => {
|
||||
const envelope = await encryptArchive({ ...owner, username: 'Owner@Example.com' }, accounts, password);
|
||||
expect(await decryptArchive(owner, envelope, password)).toHaveLength(2);
|
||||
expect(archiveIdentity({ ...owner, username: 'OWNER@example.com' })).toBe(archiveIdentity(owner));
|
||||
expect(archiveIdentity({ ...owner, serverUrl: 'https://mail.example.com/' })).toBe(archiveIdentity(owner));
|
||||
});
|
||||
|
||||
it('demands a password of at least 10 characters', async () => {
|
||||
await expect(encryptArchive(owner, accounts, 'short')).rejects.toThrow('password_length');
|
||||
});
|
||||
|
||||
it('never carries a password or any other extra field', async () => {
|
||||
const withSecrets = accounts.map(a => ({ ...a, password: 'mailbox-secret', token: 't' }));
|
||||
const envelope = await encryptArchive(owner, withSecrets, password);
|
||||
const restored = await decryptArchive(owner, envelope, password);
|
||||
expect(JSON.stringify(restored)).not.toContain('mailbox-secret');
|
||||
expect(Object.keys(restored[0]).sort()).toEqual(['avatarColor', 'label', 'serverUrl', 'username']);
|
||||
});
|
||||
|
||||
it('rejects malformed lists and envelopes', async () => {
|
||||
expect(() => parseArchivedAccounts([])).toThrow('invalid_archive');
|
||||
expect(() => parseArchivedAccounts([{ ...accounts[0], avatarColor: 'red' }])).toThrow('invalid_archive');
|
||||
const envelope = await encryptArchive(owner, accounts, password);
|
||||
expect(parseArchiveEnvelope({ ...envelope, extra: 'x' })).toEqual(envelope);
|
||||
expect(() => parseArchiveEnvelope({ ...envelope, iterations: 1000 })).toThrow('invalid_archive');
|
||||
expect(() => parseArchiveEnvelope({ ...envelope, iv: 'AAAA' })).toThrow('invalid_archive');
|
||||
});
|
||||
});
|
||||
@@ -103,13 +103,14 @@ describe('applyLitePolicy (policy.json for the static build)', () => {
|
||||
|
||||
it('honours deployer gates and restrictions but never re-enables plugins', () => {
|
||||
const policy = applyLitePolicy({
|
||||
features: { pluginsEnabled: true, sidebarAppsEnabled: true, calendarEnabled: false },
|
||||
features: { pluginsEnabled: true, sidebarAppsEnabled: true, calendarEnabled: false, accountArchiveEnabled: true },
|
||||
restrictions: { theme: { locked: true, value: 'dark' } },
|
||||
forceEnabledPlugins: ['evil'],
|
||||
defaultSidebarApps: [{ id: 'x' }],
|
||||
});
|
||||
expect(policy.features.pluginsEnabled).toBe(false);
|
||||
expect(policy.features.sidebarAppsEnabled).toBe(false);
|
||||
expect(policy.features.accountArchiveEnabled).toBe(false);
|
||||
expect(policy.features.calendarEnabled).toBe(false);
|
||||
expect(policy.restrictions.theme).toEqual({ locked: true, value: 'dark' });
|
||||
expect(policy.forceEnabledPlugins).toEqual([]);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { generateAccountId, getMaxAccounts } from '@/lib/account-utils';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import type { ArchiveEnvelope, ArchiveOwner, ArchivedAccount } from './account-archive';
|
||||
|
||||
async function request(owner: ArchiveOwner, init: RequestInit = {}): Promise<{ envelope?: ArchiveEnvelope | null }> {
|
||||
// The archive lives on this app's server; the static Lite build has none.
|
||||
if (IS_LITE) throw new Error('disabled');
|
||||
const res = await apiFetch('/api/account-archive', {
|
||||
cache: 'no-store', ...init,
|
||||
headers: {
|
||||
'x-archive-username': owner.username, 'x-archive-server': owner.serverUrl,
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(typeof body.error === 'string' ? body.error : 'storage_failed');
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function fetchArchive(owner: ArchiveOwner): Promise<ArchiveEnvelope | null> {
|
||||
return (await request(owner)).envelope ?? null;
|
||||
}
|
||||
export async function putArchive(owner: ArchiveOwner, envelope: ArchiveEnvelope): Promise<void> {
|
||||
await request(owner, { method: 'PUT', body: JSON.stringify({ envelope }) });
|
||||
}
|
||||
export async function removeArchive(owner: ArchiveOwner): Promise<void> {
|
||||
await request(owner, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/** This browser's accounts as the archive keeps them: no credentials of any kind. */
|
||||
export function collectAccounts(): ArchivedAccount[] {
|
||||
return useAccountStore.getState().accounts.map(a => ({
|
||||
username: a.username, serverUrl: a.serverUrl, label: a.label, avatarColor: a.avatarColor,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the archived accounts this browser lacks, each waiting for its own
|
||||
* sign-in, and gives the ones it has their archived label and colour.
|
||||
*/
|
||||
export function restoreAccounts(archived: ArchivedAccount[]): { added: number; updated: number; skipped: number } {
|
||||
const store = useAccountStore.getState();
|
||||
let added = 0, updated = 0, skipped = 0;
|
||||
for (const entry of archived) {
|
||||
const id = generateAccountId(entry.username, entry.serverUrl);
|
||||
if (store.getAccountById(id)) {
|
||||
store.updateAccount(id, { label: entry.label, avatarColor: entry.avatarColor });
|
||||
updated++;
|
||||
continue;
|
||||
}
|
||||
if (useAccountStore.getState().accounts.length >= getMaxAccounts()) { skipped++; continue; }
|
||||
store.addAccount({
|
||||
username: entry.username, serverUrl: entry.serverUrl, authMode: 'basic', rememberMe: false,
|
||||
label: entry.label, displayName: entry.label, email: entry.username, lastLoginAt: 0,
|
||||
isConnected: false, hasError: true, isDefault: false, awaitingSignIn: true,
|
||||
});
|
||||
store.updateAccount(id, { avatarColor: entry.avatarColor });
|
||||
added++;
|
||||
}
|
||||
return { added, updated, skipped };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { archiveIdentity, parseArchiveEnvelope, type ArchiveEnvelope, type ArchiveOwner } from './account-archive';
|
||||
|
||||
/** One file per owner: `SETTINGS_DATA_DIR/account-archives/<sha256 of the owner>.json`. */
|
||||
function archiveDir(): string {
|
||||
return path.join(process.env.SETTINGS_DATA_DIR || path.join(process.cwd(), 'data', 'settings'), 'account-archives');
|
||||
}
|
||||
function archivePath(owner: ArchiveOwner): string {
|
||||
return path.join(archiveDir(), createHash('sha256').update(archiveIdentity(owner)).digest('hex') + '.json');
|
||||
}
|
||||
|
||||
export async function loadArchive(owner: ArchiveOwner): Promise<ArchiveEnvelope | null> {
|
||||
try { return parseArchiveEnvelope(JSON.parse(await readFile(archivePath(owner), 'utf8'))); }
|
||||
catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the owner's archive atomically: a reader sees the old file or the new one, never half of it. */
|
||||
export async function saveArchive(owner: ArchiveOwner, envelope: ArchiveEnvelope): Promise<void> {
|
||||
const target = archivePath(owner);
|
||||
const temporary = `${target}.${randomBytes(8).toString('hex')}.tmp`;
|
||||
await mkdir(archiveDir(), { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
const file = await open(temporary, 'wx', 0o600);
|
||||
try { await file.writeFile(JSON.stringify(parseArchiveEnvelope(envelope))); await file.sync(); }
|
||||
finally { await file.close(); }
|
||||
await rename(temporary, target);
|
||||
} finally {
|
||||
await unlink(temporary).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteArchive(owner: ArchiveOwner): Promise<void> {
|
||||
await unlink(archivePath(owner)).catch(err => {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Encrypted list of a user's accounts, kept on the server so another browser
|
||||
* can bring the same accounts back. Encryption happens in the browser with a
|
||||
* password the user chooses; the server only ever sees the envelope.
|
||||
*
|
||||
* The list holds no mailbox passwords: each restored account is signed in to
|
||||
* by hand.
|
||||
*/
|
||||
|
||||
export interface ArchiveOwner { username: string; serverUrl: string }
|
||||
export interface ArchivedAccount extends ArchiveOwner { label: string; avatarColor: string }
|
||||
export interface ArchiveEnvelope {
|
||||
version: 1;
|
||||
iterations: typeof ARCHIVE_ITERATIONS;
|
||||
salt: string;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
export const ARCHIVE_ITERATIONS = 600000;
|
||||
export const ARCHIVE_MAX_BYTES = 64 * 1024;
|
||||
export const ARCHIVE_MAX_ACCOUNTS = 50;
|
||||
export const ARCHIVE_MIN_PASSWORD = 10;
|
||||
|
||||
const trimUrl = (url: string) => url.replace(/\/+$/, '');
|
||||
|
||||
/**
|
||||
* One owner however the username is cased: `Lu@example.com` and
|
||||
* `lu@example.com` sign in to the same mailbox, so they share the archive.
|
||||
*/
|
||||
export function archiveIdentity(owner: ArchiveOwner): string {
|
||||
if (typeof owner?.username !== 'string' || !owner.username.trim() || owner.username.length > 320
|
||||
|| typeof owner.serverUrl !== 'string' || !owner.serverUrl || owner.serverUrl.length > 2048) {
|
||||
throw new Error('invalid_archive');
|
||||
}
|
||||
return JSON.stringify([owner.username.trim().toLowerCase(), trimUrl(owner.serverUrl)]);
|
||||
}
|
||||
|
||||
/** Strict allowlist: nothing but the envelope fields is stored or echoed. */
|
||||
export function parseArchiveEnvelope(value: unknown): ArchiveEnvelope {
|
||||
const v = value as ArchiveEnvelope | null;
|
||||
const base64 = (s: unknown, bytes: (n: number) => boolean): s is string => {
|
||||
if (typeof s !== 'string' || s.length > ARCHIVE_MAX_BYTES || !/^[A-Za-z0-9+/]+={0,2}$/.test(s) || s.length % 4) return false;
|
||||
try { return bytes(atob(s).length); } catch { return false; }
|
||||
};
|
||||
if (!v || typeof v !== 'object' || v.version !== 1 || v.iterations !== ARCHIVE_ITERATIONS
|
||||
|| !base64(v.salt, n => n === 16) || !base64(v.iv, n => n === 12) || !base64(v.ciphertext, n => n > 16)) {
|
||||
throw new Error('invalid_archive');
|
||||
}
|
||||
return { version: 1, iterations: ARCHIVE_ITERATIONS, salt: v.salt, iv: v.iv, ciphertext: v.ciphertext };
|
||||
}
|
||||
|
||||
export function parseArchivedAccounts(value: unknown): ArchivedAccount[] {
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > ARCHIVE_MAX_ACCOUNTS) throw new Error('invalid_archive');
|
||||
return value.map((a: ArchivedAccount) => {
|
||||
archiveIdentity(a);
|
||||
if (typeof a.label !== 'string' || a.label.length > 320
|
||||
|| typeof a.avatarColor !== 'string' || !/^#[0-9a-f]{6}$/i.test(a.avatarColor)) throw new Error('invalid_archive');
|
||||
// Rebuilt field by field, so nothing else (a password, say) survives a round trip.
|
||||
return { username: a.username.trim(), serverUrl: trimUrl(a.serverUrl), label: a.label, avatarColor: a.avatarColor };
|
||||
});
|
||||
}
|
||||
|
||||
const encode = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes));
|
||||
const decode = (text: string) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
|
||||
// The owner is sealed in: an envelope copied to another owner's slot will not open there.
|
||||
const additionalData = (owner: ArchiveOwner) => new TextEncoder().encode(`bulwark-account-archive:1:${archiveIdentity(owner)}`);
|
||||
|
||||
async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
|
||||
if (!globalThis.crypto?.subtle) throw new Error('https_required');
|
||||
const material = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
|
||||
return crypto.subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: ARCHIVE_ITERATIONS },
|
||||
material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
export async function encryptArchive(owner: ArchiveOwner, accounts: ArchivedAccount[], password: string): Promise<ArchiveEnvelope> {
|
||||
if (password.length < ARCHIVE_MIN_PASSWORD || password.length > 1024) throw new Error('password_length');
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify({ accounts: parseArchivedAccounts(accounts) }));
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveKey(password, salt);
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: additionalData(owner) }, key, plaintext);
|
||||
return parseArchiveEnvelope({ version: 1, iterations: ARCHIVE_ITERATIONS, salt: encode(salt), iv: encode(iv),
|
||||
ciphertext: encode(new Uint8Array(ciphertext)) });
|
||||
}
|
||||
|
||||
export async function decryptArchive(owner: ArchiveOwner, envelope: ArchiveEnvelope, password: string): Promise<ArchivedAccount[]> {
|
||||
const clean = parseArchiveEnvelope(envelope);
|
||||
if (password.length > 1024) throw new Error('unlock_failed');
|
||||
const key = await deriveKey(password, decode(clean.salt));
|
||||
let plaintext: ArrayBuffer;
|
||||
try {
|
||||
plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: decode(clean.iv), additionalData: additionalData(owner) },
|
||||
key, decode(clean.ciphertext));
|
||||
} catch { throw new Error('unlock_failed'); }
|
||||
return parseArchivedAccounts((JSON.parse(new TextDecoder().decode(plaintext)) as { accounts?: unknown }).accounts);
|
||||
}
|
||||
@@ -67,6 +67,8 @@ export interface FeatureGates {
|
||||
crossStarredViewEnabled: boolean;
|
||||
crossAllViewEnabled: boolean;
|
||||
unifiedCrossAccountEnabled: boolean;
|
||||
/** Encrypted account list kept on the server, restorable on another device. */
|
||||
accountArchiveEnabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
@@ -94,6 +96,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||
crossStarredViewEnabled: false,
|
||||
crossAllViewEnabled: false,
|
||||
unifiedCrossAccountEnabled: false,
|
||||
accountArchiveEnabled: false,
|
||||
};
|
||||
|
||||
export interface ThemePolicy {
|
||||
|
||||
@@ -162,6 +162,8 @@ export function applyLitePolicy(raw: unknown): SettingsPolicy {
|
||||
pluginsEnabled: false,
|
||||
pluginsUploadEnabled: false,
|
||||
sidebarAppsEnabled: false,
|
||||
// Kept by this app's server, which Lite does not have.
|
||||
accountArchiveEnabled: false,
|
||||
},
|
||||
forceEnabledPlugins: [],
|
||||
approvedPlugins: [],
|
||||
|
||||
@@ -1666,6 +1666,33 @@
|
||||
"drag_handle": "اسحب لإعادة الترتيب",
|
||||
"add": "إضافة حساب"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "مشترك معي",
|
||||
"description": "الحسابات الجماعية والمشتركة التي يمكنك إدارتها. اختر واحدًا لتعديل مرشحاته، والرد التلقائي أثناء الغياب، والتقويمات، وجهات الاتصال.",
|
||||
|
||||
@@ -1666,6 +1666,33 @@
|
||||
"drag_handle": "Arrossegueu per reordenar",
|
||||
"add": "Afegeix un compte"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Compartit amb mi",
|
||||
"description": "Comptes de grup i compartits que podeu gestionar. Seleccioneu-ne un per editar-ne els filtres, la resposta automàtica, els calendaris i els contactes.",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "Přetažením změňte pořadí",
|
||||
"add": "Přidat účet"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Sdíleno se mnou",
|
||||
"description": "Skupinové a sdílené účty, které můžete spravovat. Vyberte jeden pro úpravu jeho filtrů, odpovědi v nepřítomnosti, kalendářů a kontaktů.",
|
||||
|
||||
@@ -1666,6 +1666,33 @@
|
||||
"drag_handle": "Træk for at omarrangere",
|
||||
"add": "Tilføj konto"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Delt med mig",
|
||||
"description": "Gruppe- og delte konti, du kan administrere. Vælg en for at redigere dens filtre, autosvar ved fravær, kalendere og kontakter.",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "Zum Sortieren ziehen",
|
||||
"add": "Konto hinzufügen"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Sicherung der Kontoliste",
|
||||
"description": "Speichert die Liste der Konten in diesem Browser auf dem Server, verschlüsselt mit einem Passwort, das nur Sie kennen, und stellt sie auf einem anderen Gerät wieder her. Postfachpasswörter werden nicht gespeichert: Sie melden sich bei jedem wiederhergestellten Konto selbst an.",
|
||||
"status_none": "Für {account} ist keine Liste gespeichert.",
|
||||
"status_saved": "Für {account} ist eine Liste gespeichert.",
|
||||
"save": "Liste speichern",
|
||||
"restore": "Liste wiederherstellen",
|
||||
"delete": "Gespeicherte Liste löschen",
|
||||
"cancel": "Abbrechen",
|
||||
"delete_confirm": "Die gespeicherte Kontoliste vom Server löschen?",
|
||||
"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.",
|
||||
"confirm": "Passwort bestätigen",
|
||||
"saved": "Liste gespeichert ({count, plural, one {# Konto} other {# Konten}}).",
|
||||
"restored": "Hinzugefügte Konten: {added}. Bereits vorhandene, aktualisierte Konten: {updated}. Öffnen Sie jedes neue Konto in der Kontoliste, um sich anzumelden.",
|
||||
"skipped": "{count, plural, one {# Konto wurde} other {# Konten wurden}} nicht hinzugefügt: Die Höchstzahl an Konten ist erreicht.",
|
||||
"deleted": "Gespeicherte Liste gelöscht.",
|
||||
"errors": {
|
||||
"mismatch": "Die Passwörter stimmen nicht überein.",
|
||||
"password_length": "Verwenden Sie mindestens 10 Zeichen.",
|
||||
"unlock_failed": "Falsches Passwort, oder die Liste wurde für ein anderes Konto gespeichert.",
|
||||
"owner_signin_required": "Melden Sie sich erneut bei diesem Konto an und versuchen Sie es dann noch einmal.",
|
||||
"https_required": "Dafür ist eine sichere (HTTPS-)Verbindung nötig.",
|
||||
"storage_failed": "Der Server konnte die Liste nicht lesen oder speichern. Versuchen Sie es später erneut."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Mit mir geteilt",
|
||||
"description": "Gruppen- und freigegebene Konten, die Sie verwalten können. Wählen Sie eines aus, um dessen Filter, Abwesenheitsnotiz, Kalender und Kontakte zu bearbeiten.",
|
||||
|
||||
@@ -1666,6 +1666,33 @@
|
||||
"drag_handle": "Drag to reorder",
|
||||
"add": "Add account"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Shared with me",
|
||||
"description": "Group and shared accounts you can manage. Select one to edit its filters, vacation responder, calendars, and contacts.",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "Arrastra para reordenar",
|
||||
"add": "Añadir cuenta"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Copia de la lista de cuentas",
|
||||
"description": "Guarda en el servidor la lista de cuentas de este navegador, cifrada con una contraseña que solo tú conoces, y recupérala en otro dispositivo. Las contraseñas de los buzones no se guardan: inicias sesión tú en cada cuenta restaurada.",
|
||||
"status_none": "No hay ninguna lista guardada para {account}.",
|
||||
"status_saved": "Hay una lista guardada para {account}.",
|
||||
"save": "Guardar lista",
|
||||
"restore": "Restaurar lista",
|
||||
"delete": "Eliminar lista guardada",
|
||||
"cancel": "Cancelar",
|
||||
"delete_confirm": "¿Eliminar del servidor la lista de cuentas guardada?",
|
||||
"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.",
|
||||
"confirm": "Confirmar contraseña",
|
||||
"saved": "Lista guardada ({count, plural, one {# cuenta} other {# cuentas}}).",
|
||||
"restored": "Cuentas añadidas: {added}. Cuentas ya presentes y actualizadas: {updated}. Abre cada cuenta nueva desde la lista de cuentas para iniciar sesión.",
|
||||
"skipped": "{count, plural, one {# cuenta no se ha añadido} other {# cuentas no se han añadido}}: se ha alcanzado el límite de cuentas.",
|
||||
"deleted": "Lista guardada eliminada.",
|
||||
"errors": {
|
||||
"mismatch": "Las contraseñas no coinciden.",
|
||||
"password_length": "Usa al menos 10 caracteres.",
|
||||
"unlock_failed": "Contraseña incorrecta, o la lista se guardó para otra cuenta.",
|
||||
"owner_signin_required": "Vuelve a iniciar sesión en esta cuenta y reinténtalo.",
|
||||
"https_required": "Se necesita una conexión segura (HTTPS).",
|
||||
"storage_failed": "El servidor no ha podido leer ni guardar la lista. Inténtalo más tarde."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Compartido conmigo",
|
||||
"description": "Cuentas de grupo y compartidas que puedes gestionar. Selecciona una para editar sus filtros, respuesta de vacaciones, calendarios y contactos.",
|
||||
|
||||
@@ -1666,6 +1666,33 @@
|
||||
"drag_handle": "کشیدن",
|
||||
"add": "افزودن"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "اشتراکی با من",
|
||||
"description": "حسابهای گروهی",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "Faire glisser pour réorganiser",
|
||||
"add": "Ajouter un compte"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Sauvegarde de la liste des comptes",
|
||||
"description": "Conservez sur le serveur la liste des comptes de ce navigateur, chiffrée avec un mot de passe que vous seul connaissez, et retrouvez-la sur un autre appareil. Les mots de passe des boîtes ne sont pas enregistrés : vous vous connectez vous-même à chaque compte restauré.",
|
||||
"status_none": "Aucune liste enregistrée pour {account}.",
|
||||
"status_saved": "Une liste est enregistrée pour {account}.",
|
||||
"save": "Enregistrer la liste",
|
||||
"restore": "Restaurer la liste",
|
||||
"delete": "Supprimer la liste enregistrée",
|
||||
"cancel": "Annuler",
|
||||
"delete_confirm": "Supprimer du serveur la liste de comptes enregistrée ?",
|
||||
"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.",
|
||||
"confirm": "Confirmer le mot de passe",
|
||||
"saved": "Liste enregistrée ({count, plural, one {# compte} other {# comptes}}).",
|
||||
"restored": "Comptes ajoutés : {added}. Comptes déjà présents et mis à jour : {updated}. Ouvrez chaque nouveau compte depuis la liste des comptes pour vous connecter.",
|
||||
"skipped": "{count, plural, one {# compte n'a pas été ajouté} other {# comptes n'ont pas été ajoutés}} : la limite de comptes est atteinte.",
|
||||
"deleted": "Liste enregistrée supprimée.",
|
||||
"errors": {
|
||||
"mismatch": "Les mots de passe ne correspondent pas.",
|
||||
"password_length": "Utilisez au moins 10 caractères.",
|
||||
"unlock_failed": "Mot de passe incorrect, ou la liste a été enregistrée pour un autre compte.",
|
||||
"owner_signin_required": "Reconnectez-vous à ce compte, puis réessayez.",
|
||||
"https_required": "Une connexion sécurisée (HTTPS) est nécessaire.",
|
||||
"storage_failed": "Le serveur n'a pas pu lire ou enregistrer la liste. Réessayez plus tard."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Partagé avec moi",
|
||||
"description": "Comptes de groupe et partagés que vous pouvez gérer. Sélectionnez-en un pour modifier ses filtres, son répondeur d'absence, ses calendriers et ses contacts.",
|
||||
|
||||
@@ -1628,6 +1628,33 @@
|
||||
"drag_handle": "גרור לסדר",
|
||||
"add": "הוסף חשבון"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "משותף איתי",
|
||||
"description": "חשבונות קבוצתיים ומשותפים שאתה יכול לנהל. בחר אחד כדי לערוך את המסננים, המגיב לחופשה, לוחות השנה והאנשים שלו.",
|
||||
|
||||
@@ -1666,6 +1666,33 @@
|
||||
"drag_handle": "Húzd át a sorrend módosításához",
|
||||
"add": "Fiók hozzáadása"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Velem megosztva",
|
||||
"description": "Csoportos és megosztott fiókok, amelyeket kezelhet. Válasszon egyet a szűrői, távolléti válasza, naptárai és névjegyei szerkesztéséhez.",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "Trascina per riordinare",
|
||||
"add": "Aggiungi account"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup dell'elenco account",
|
||||
"description": "Conserva sul server l'elenco degli account di questo browser, cifrato con una password che conosci solo tu, e ripristinalo su un altro dispositivo. Le password delle caselle non vengono salvate: accedi tu a ogni account ripristinato.",
|
||||
"status_none": "Nessun elenco salvato per {account}.",
|
||||
"status_saved": "C'è un elenco salvato per {account}.",
|
||||
"save": "Salva elenco",
|
||||
"restore": "Ripristina elenco",
|
||||
"delete": "Elimina elenco salvato",
|
||||
"cancel": "Annulla",
|
||||
"delete_confirm": "Eliminare dal server l'elenco account salvato?",
|
||||
"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.",
|
||||
"confirm": "Conferma password",
|
||||
"saved": "Elenco salvato ({count, plural, one {# account} other {# account}}).",
|
||||
"restored": "Account aggiunti: {added}. Account già presenti e aggiornati: {updated}. Apri ogni nuovo account dall'elenco account per accedere.",
|
||||
"skipped": "{count, plural, one {# account non è stato aggiunto} other {# account non sono stati aggiunti}}: è stato raggiunto il limite di account.",
|
||||
"deleted": "Elenco salvato eliminato.",
|
||||
"errors": {
|
||||
"mismatch": "Le password non coincidono.",
|
||||
"password_length": "Usa almeno 10 caratteri.",
|
||||
"unlock_failed": "Password errata, oppure l'elenco è stato salvato per un altro account.",
|
||||
"owner_signin_required": "Accedi di nuovo a questo account, poi riprova.",
|
||||
"https_required": "Serve una connessione sicura (HTTPS).",
|
||||
"storage_failed": "Il server non è riuscito a leggere o salvare l'elenco. Riprova più tardi."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Condivisi con me",
|
||||
"description": "Account di gruppo e condivisi che puoi gestire. Selezionane uno per modificarne filtri, risponditore automatico, calendari e contatti.",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "ドラッグして並べ替え",
|
||||
"add": "アカウントを追加"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "自分と共有",
|
||||
"description": "管理できるグループアカウントと共有アカウントです。1つ選択して、フィルター、不在応答、カレンダー、連絡先を編集します。",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "끌어서 순서 변경",
|
||||
"add": "계정 추가"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "나와 공유됨",
|
||||
"description": "관리할 수 있는 그룹 및 공유 계정입니다. 하나를 선택하여 필터, 부재 중 자동 응답, 캘린더, 연락처를 편집하세요.",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "Velciet, lai pārkārtotu",
|
||||
"add": "Pievienot kontu"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Kopīgots ar mani",
|
||||
"description": "Grupas un kopīgotie konti, kurus varat pārvaldīt. Atlasiet vienu, lai rediģētu tā filtrus, prombūtnes atbildētāju, kalendārus un kontaktus.",
|
||||
|
||||
@@ -1666,6 +1666,33 @@
|
||||
"drag_handle": "Дахин эрэмбэлэхийн тулд чирнэ үү",
|
||||
"add": "Бүртгэл нэмэх"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Надтай хуваалцсан",
|
||||
"description": "Бүлэг болон хуваалцсан акаунтуудыг удирдах боломжтой. Шүүлтүүр, амралтын хариулагч, хуанли, харилцагчдыг засахын тулд нэгийг нь сонгоно уу.",
|
||||
|
||||
@@ -1666,6 +1666,33 @@
|
||||
"drag_handle": "Dra for å omorganisere",
|
||||
"add": "Legg til konto"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Delt med meg",
|
||||
"description": "Gruppekontoer og delte kontoer du kan administrere. Velg en konto for å redigere filtre, feriesvar, kalendere og kontakter.",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "Sleep om volgorde aan te passen",
|
||||
"add": "Account toevoegen"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Gedeeld met mij",
|
||||
"description": "Groeps- en gedeelde accounts die u kunt beheren. Selecteer er een om de filters, afwezigheidsassistent, agenda's en contacten te bewerken.",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "Przeciągnij, aby zmienić kolejność",
|
||||
"add": "Dodaj konto"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Udostępnione mnie",
|
||||
"description": "Konta grupowe i udostępnione, którymi możesz zarządzać. Wybierz jedno, aby edytować jego filtry, odpowiedź wakacyjną, kalendarze i kontakty.",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "Arraste para reordenar",
|
||||
"add": "Adicionar conta"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Compartilhado comigo",
|
||||
"description": "Contas de grupo e compartilhadas que você pode gerenciar. Selecione uma para editar seus filtros, resposta de ausência, calendários e contatos.",
|
||||
|
||||
@@ -1666,6 +1666,33 @@
|
||||
"drag_handle": "Trageți pentru a reordona",
|
||||
"add": "Adăugare cont"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Partajat cu mine",
|
||||
"description": "Conturi de grup și conturi partajate pe care le puteți gestiona. Selectați unul pentru a-i edita filtrele, răspunsul automat de vacanță, calendarele și contactele.",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "Перетащите, чтобы изменить порядок",
|
||||
"add": "Добавить учётную запись"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Доступные мне",
|
||||
"description": "Групповые и общие учётные записи, которыми вы можете управлять. Выберите одну, чтобы изменить её фильтры, автоответчик, календари и контакты.",
|
||||
|
||||
@@ -1666,6 +1666,33 @@
|
||||
"drag_handle": "Presuňte na zmenu poradia",
|
||||
"add": "Pridať účet"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Zdieľané so mnou",
|
||||
"description": "Skupinové a zdieľané účty, ktoré môžete spravovať.",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "Sıralamak için sürükleyin",
|
||||
"add": "Hesap ekle"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Benimle paylaşılanlar",
|
||||
"description": "Yönetebileceğiniz grup ve paylaşılan hesaplar. Filtrelerini, tatil yanıtlayıcısını, takvimlerini ve kişilerini düzenlemek için birini seçin.",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "Перетягніть, щоб змінити порядок",
|
||||
"add": "Додати обліковий запис"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "Доступні мені",
|
||||
"description": "Групові та спільні облікові записи, якими ви можете керувати. Виберіть один, щоб змінити його фільтри, автовідповідач, календарі та контакти.",
|
||||
|
||||
@@ -1666,6 +1666,33 @@
|
||||
"drag_handle": "拖曳以重新排序",
|
||||
"add": "新增帳號"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "與我共用",
|
||||
"description": "您可以管理的群組與共用帳號。選取一個帳號,即可編輯其篩選規則、休假自動回覆、行事曆與聯絡人。",
|
||||
|
||||
@@ -1663,6 +1663,33 @@
|
||||
"drag_handle": "拖动以重新排序",
|
||||
"add": "添加账户"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Account list backup",
|
||||
"description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.",
|
||||
"status_none": "No list is saved for {account}.",
|
||||
"status_saved": "A list is saved for {account}.",
|
||||
"save": "Save list",
|
||||
"restore": "Restore list",
|
||||
"delete": "Delete saved list",
|
||||
"cancel": "Cancel",
|
||||
"delete_confirm": "Delete the saved account list from the server?",
|
||||
"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.",
|
||||
"confirm": "Confirm password",
|
||||
"saved": "List saved ({count, plural, one {# account} other {# accounts}}).",
|
||||
"restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.",
|
||||
"skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.",
|
||||
"deleted": "Saved list deleted.",
|
||||
"errors": {
|
||||
"mismatch": "The passwords do not match.",
|
||||
"password_length": "Use at least 10 characters.",
|
||||
"unlock_failed": "Wrong password, or the list was saved for another account.",
|
||||
"owner_signin_required": "Sign in to this account again, then retry.",
|
||||
"https_required": "This needs a secure (HTTPS) connection.",
|
||||
"storage_failed": "The server could not read or store the list. Try again later."
|
||||
}
|
||||
},
|
||||
"shared_accounts": {
|
||||
"title": "与我共享",
|
||||
"description": "您可以管理的群组和共享账户。选择一个以编辑其筛选器、休假自动回复、日历和联系人。",
|
||||
|
||||
@@ -105,6 +105,8 @@ export const LITE_API_STRING_ALLOWLIST = [
|
||||
// Settings/policy - replaced by config.json / policy.json.
|
||||
"/api/config",
|
||||
"/api/admin/policy",
|
||||
// lib/account-archive-client.ts - the feature is pinned off in Lite.
|
||||
"/api/account-archive",
|
||||
// WOPI (#425) - use-wopi-status returns disabled in Lite.
|
||||
"/api/wopi/status",
|
||||
"/api/wopi/launch",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { beforeAll, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { createConfig } from '@/lib/__tests__/fixtures/config';
|
||||
|
||||
// Accounts restored from the account list backup carry no credentials. They
|
||||
// must stay listed until signed in, and opening one must lead to the sign-in
|
||||
// form instead of dropping it like an account whose session ran out.
|
||||
vi.mock('@/lib/jmap/client', () => {
|
||||
class JMAPClient {
|
||||
constructor(public serverUrl: string, public username: string, public password: string) {}
|
||||
static withBearer() { throw new Error('not used'); }
|
||||
async connect() {}
|
||||
getSessionUsername() { return this.username; }
|
||||
getUsername() { return this.username; }
|
||||
async getIdentities() { return [{ id: 'a', email: this.username, name: this.username, mayDelete: false }]; }
|
||||
getAuthHeader() { return 'Basic eA=='; }
|
||||
onConnectionChange() {}
|
||||
onRateLimit() {}
|
||||
getRateLimitRemainingMs() { return 0; }
|
||||
supportsContacts() { return false; }
|
||||
supportsPrincipals() { return false; }
|
||||
supportsVacationResponse() { return false; }
|
||||
supportsCalendars() { return false; }
|
||||
supportsSieve() { return false; }
|
||||
disconnect() {}
|
||||
getAccountId() { return 'acct'; }
|
||||
}
|
||||
class RateLimitError extends Error {}
|
||||
return { JMAPClient, RateLimitError };
|
||||
});
|
||||
vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: async () => null }));
|
||||
|
||||
import * as nav from '@/lib/browser-navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { collectAccounts, restoreAccounts } from '@/lib/account-archive-client';
|
||||
|
||||
const signedIn = {
|
||||
id: 'bob@x.test@mail.x.test', label: 'Bob', serverUrl: 'https://mail.x.test', username: 'bob@x.test', authMode: 'basic' as const,
|
||||
rememberMe: true, cookieSlot: 0, displayName: 'Bob', email: 'bob@x.test', isConnected: false, hasError: false,
|
||||
lastLoginAt: 1, avatarColor: '#000000', isDefault: true,
|
||||
};
|
||||
const archived = [
|
||||
{ username: 'bob@x.test', serverUrl: 'https://mail.x.test', label: 'Bob at work', avatarColor: '#2563eb' },
|
||||
{ username: 'alice@x.test', serverUrl: 'https://mail.x.test', label: 'Alice', avatarColor: '#16a34a' },
|
||||
];
|
||||
const redirect = vi.fn();
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: unknown) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/auth/session')) {
|
||||
return new Response(JSON.stringify({ serverUrl: signedIn.serverUrl, username: signedIn.username, password: 'x' }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
|
||||
return new Response('{}', { status: 200 });
|
||||
}));
|
||||
vi.spyOn(nav, 'replaceWindowLocation').mockImplementation(redirect);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
redirect.mockClear();
|
||||
useAccountStore.setState({ accounts: [signedIn] as never, activeAccountId: signedIn.id, defaultAccountId: signedIn.id });
|
||||
});
|
||||
|
||||
it('adds missing accounts awaiting sign-in and relabels the ones already here', () => {
|
||||
expect(collectAccounts()).toEqual([
|
||||
{ username: signedIn.username, serverUrl: signedIn.serverUrl, label: 'Bob', avatarColor: '#000000' },
|
||||
]);
|
||||
expect(restoreAccounts(archived)).toEqual({ added: 1, updated: 1, skipped: 0 });
|
||||
const [bob, alice] = useAccountStore.getState().accounts;
|
||||
expect(bob).toMatchObject({ label: 'Bob at work', avatarColor: '#2563eb', isDefault: true });
|
||||
expect(bob.awaitingSignIn).toBeUndefined();
|
||||
expect(alice).toMatchObject({ id: 'alice@x.test@mail.x.test', label: 'Alice', avatarColor: '#16a34a', awaitingSignIn: true, rememberMe: false });
|
||||
});
|
||||
|
||||
it('keeps a restored account through a reload and sends it to the sign-in form', async () => {
|
||||
restoreAccounts(archived);
|
||||
await useAuthStore.getState().checkAuth();
|
||||
expect(useAuthStore.getState().activeAccountId).toBe(signedIn.id);
|
||||
expect(useAccountStore.getState().accounts.map(a => a.id)).toEqual([signedIn.id, 'alice@x.test@mail.x.test']);
|
||||
|
||||
await useAuthStore.getState().switchAccount('alice@x.test@mail.x.test');
|
||||
expect(redirect).toHaveBeenCalledWith(expect.stringMatching(/\/login\?mode=add-account&username=alice%40x\.test$/));
|
||||
expect(useAccountStore.getState().accounts).toHaveLength(2);
|
||||
expect(useAuthStore.getState().activeAccountId).toBe(signedIn.id);
|
||||
});
|
||||
|
||||
it('stops waiting once the account is signed in', () => {
|
||||
restoreAccounts(archived);
|
||||
useAccountStore.getState().addAccount({
|
||||
username: 'alice@x.test', serverUrl: 'https://mail.x.test', authMode: 'basic', rememberMe: true, label: 'alice',
|
||||
displayName: 'alice', email: 'alice@x.test', lastLoginAt: 2, isConnected: true, hasError: false, isDefault: false,
|
||||
});
|
||||
const alice = useAccountStore.getState().getAccountById('alice@x.test@mail.x.test');
|
||||
expect(alice).toMatchObject({ label: 'Alice', avatarColor: '#16a34a', isConnected: true });
|
||||
expect(alice?.awaitingSignIn).toBeUndefined();
|
||||
});
|
||||
@@ -49,6 +49,11 @@ export interface AccountEntry {
|
||||
errorMessage?: string;
|
||||
/** Whether this is the default account (loaded on app start) */
|
||||
isDefault: boolean;
|
||||
/**
|
||||
* Brought back from the account list backup and not signed in on this
|
||||
* device yet. Kept across reloads, and opening it leads to the sign-in form.
|
||||
*/
|
||||
awaitingSignIn?: boolean;
|
||||
}
|
||||
|
||||
interface AccountState {
|
||||
@@ -93,6 +98,8 @@ export const useAccountStore = create<AccountState>()(
|
||||
errorMessage: undefined,
|
||||
lastLoginAt: entry.lastLoginAt,
|
||||
authMode: entry.authMode,
|
||||
// Any sign-in ends the wait, whichever way it went.
|
||||
awaitingSignIn: entry.awaitingSignIn,
|
||||
}
|
||||
: a
|
||||
),
|
||||
|
||||
@@ -2279,6 +2279,12 @@ export const useAuthStore = create<AuthState>()(
|
||||
const accountStore = useAccountStore.getState();
|
||||
const targetAccount = accountStore.getAccountById(accountId);
|
||||
if (!targetAccount) return;
|
||||
// Restored from the account list backup and never signed in here:
|
||||
// there is no session to resume, so ask for the password instead.
|
||||
if (targetAccount.awaitingSignIn && !clients.has(accountId)) {
|
||||
replaceWindowLocation(`${getLocaleLoginPath()}?mode=add-account&username=${encodeURIComponent(targetAccount.username)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// A switch between two already-connected accounts is done seamlessly:
|
||||
// the current account's client and mail stay on screen while we verify
|
||||
@@ -2562,6 +2568,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
// overlaps the connect instead of running after it.
|
||||
const restoreAccount = async (account: (typeof accounts)[number]) => {
|
||||
if (clients.has(account.id)) return; // Already connected
|
||||
// Restored from the account list backup: listed until signed in.
|
||||
if (account.awaitingSignIn) return;
|
||||
|
||||
// A password or access token login without rememberMe leaves
|
||||
// nothing to restore - the user logged in without persisting
|
||||
|
||||
Reference in New Issue
Block a user