diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index d2b560bae..98ad51957 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -52,6 +52,7 @@ const RESTRICTABLE_SETTINGS = [ { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' }, { key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] }, { key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' }, + { key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'] }, { key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' }, { key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' }, { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' }, diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts new file mode 100644 index 000000000..e562ba44c --- /dev/null +++ b/app/api/auth/verify/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; +import { configManager } from '@/lib/admin/config-manager'; +import { isPublicHttpUrl } from '@/lib/security/url-guard'; +import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; + +/** + * Server-side Basic-auth pre-check for the login form (#969). + * + * When the browser itself probes the JMAP session URL with wrong credentials, + * the server answers 401 + `WWW-Authenticate: Basic`, and on a same-origin + * deployment (JMAP reverse-proxied under the webmail's own host) the browser + * pops its native "This site requires authentication" dialog before the + * login form can show its own error. Probing from here first means a wrong + * password never reaches the browser as a 401 with a Basic challenge: the + * answer comes back as JSON from our own origin. + * + * The result is only *authoritative* for a definitive upstream 401. Anything + * else - the JMAP server unreachable from this container, a timeout, 5xx, a + * TOTP challenge (402), an unconfigured or disallowed URL - is reported as + * `inconclusive` so the browser-side connect keeps handling it exactly as + * before. Some deployments can't resolve the JMAP host from inside the + * container at all; those must keep logging in. + */ +export type VerifyResult = 'ok' | 'unauthorized' | 'inconclusive'; + +function respond(result: VerifyResult) { + return NextResponse.json({ result }, { headers: { 'Cache-Control': 'no-store' } }); +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => null); + const serverUrl = body?.serverUrl; + const username = body?.username; + const password = body?.password; + if (typeof serverUrl !== 'string' || typeof username !== 'string' || typeof password !== 'string' + || !serverUrl || !username || !password) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + await configManager.ensureLoaded(); + const oauthEnabled = configManager.get('oauthEnabled', false); + const oauthOnly = configManager.get('oauthOnly', false); + if (oauthEnabled && oauthOnly) { + return respond('inconclusive'); + } + + // Same upstream pinning as /api/auth/session: an unauthenticated caller + // must not be able to point this route at arbitrary internal hosts. + const configuredServerUrl = + configManager.get('jmapServerUrl', '') || + process.env.JMAP_SERVER_URL || + process.env.NEXT_PUBLIC_JMAP_SERVER_URL || + ''; + const allowCustomEndpoint = configManager.get('allowCustomJmapEndpoint', false); + const serverList = parseJmapServers(configManager.get('jmapServers', [])); + const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList); + + let upstreamUrl: string; + let upstreamTrusted: boolean; + if (trustedUrl) { + upstreamUrl = trustedUrl; + upstreamTrusted = true; + } else if (allowCustomEndpoint && (await isPublicHttpUrl(serverUrl))) { + upstreamUrl = serverUrl; + upstreamTrusted = false; + } else { + return respond('inconclusive'); + } + + const authHeader = 'Basic ' + Buffer.from(username + ':' + password).toString('base64'); + try { + await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted }); + return respond('ok'); + } catch (error) { + if (error instanceof JmapAuthVerificationError && error.upstreamStatus === 401) { + return respond('unauthorized'); + } + logger.debug('Login pre-check inconclusive', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return respond('inconclusive'); + } + } catch (error) { + logger.error('Login pre-check error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return respond('inconclusive'); + } +} diff --git a/app/globals.css b/app/globals.css index 76d6f1522..a3a4d3ab3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -187,6 +187,22 @@ body { "calt" 1; } +/* Native updateSetting('replyIdentityMatch', value as ReplyIdentityMatch)} + options={[ + { value: 'exact', label: t('reply_identity_match.exact') }, + { value: 'domain', label: t('reply_identity_match.domain') }, + ]} + /> + + )} + s.groupContactsByLetter); + const sortContactsByLastName = useSettingsStore((s) => s.sortContactsByLastName); const updateSetting = useSettingsStore((s) => s.updateSetting); const [showImport, setShowImport] = useState(false); @@ -68,6 +69,16 @@ export function ContactsSettings() { /> + + updateSetting("sortContactsByLastName", checked)} + /> + + = { const tabGroupOrder: TabGroup[] = ['general', 'appearance', 'mail', 'privacy', 'apps', 'advanced']; -// Translation paths per tab. Tabs that share a namespace (email_behavior, -// appearance) explicitly list the subkeys they actually render so sub-results -// are attributed to the correct tab. Tabs with their own namespace just point -// at the namespace root. -const tabSearchPaths: Record = { - account: [ - 'settings.account.name_label', - 'settings.account.username_label', - 'settings.account.account_type_label', - 'settings.account.auth_method_label', - 'settings.account.email', - 'settings.account.server', - 'settings.account.storage', - 'settings.account.accounts', - ], - language: ['settings.appearance.language'], - notifications: ['settings.notifications'], - appearance: [ - 'settings.appearance.theme', - 'settings.appearance.font_size', - 'settings.appearance.list_density', - 'settings.appearance.animations', - ], - layout: [ - 'settings.appearance.toolbar_position', - 'settings.appearance.toolbar_labels', - 'settings.appearance.hide_account_switcher', - 'settings.appearance.show_rail_account_list', - 'settings.appearance.unified_mailbox', - 'settings.appearance.all_mail', - 'settings.appearance.colorful_sidebar_icons', - 'settings.email_behavior.mail_layout', - ], - reading: [ - 'settings.email_behavior.mark_read', - 'settings.email_behavior.archive_mode', - 'settings.email_behavior.delete_action', - 'settings.email_behavior.attachment_click_action', - 'settings.email_behavior.attachment_image_previews', - 'settings.email_behavior.attachment_position', - 'settings.email_behavior.disable_threading', - 'settings.email_behavior.emails_per_page', - 'settings.email_behavior.hide_inline_image_attachments', - 'settings.email_behavior.hover_actions', - 'settings.email_behavior.permanently_delete_junk', - 'settings.email_behavior.plain_text_font', - 'settings.email_behavior.show_preview', - ], - composing: [ - 'settings.email_behavior.attachment_reminder', - 'settings.email_behavior.auto_select_reply_identity', - 'settings.email_behavior.plain_text_mode', - 'settings.email_behavior.default_mail_program', - 'settings.email_behavior.empty_subject_warning', - 'settings.email_behavior.signature_position', - 'settings.email_behavior.sub_address_delimiter', - ], - downloads: ['settings.downloads'], - identities: ['settings.identities'], - vacation: ['settings.vacation'], - filters: ['settings.filters'], - templates: ['settings.templates'], - folders: ['settings.folders'], - keywords: ['settings.keywords'], - security: ['settings.security'], - content_senders: [ - 'settings.email_behavior.always_light_mode', - 'settings.email_behavior.external_content', - 'settings.email_behavior.trusted_senders', - ], - calendar: ['calendar.settings', 'calendar.management'], - contacts: ['settings.contacts', 'contacts'], - files: ['settings.files'], - protocol_handlers: ['protocol_handlers'], - sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'], - about_data: ['settings.advanced'], - themes: [], - plugins: [], - debug: ['settings.advanced'], -}; - -// Extra English keywords per tab so common search terms hit even when the -// translation doesn't contain the literal word. -const tabKeywords: Record = { - account: 'profile email password user signin signout reorder rearrange drag dropdown switcher multi-account', - language: 'locale region timezone date time format', - notifications: 'sound alert push badge', - appearance: 'theme dark light font size accent color animation density', - layout: 'toolbar sidebar account switcher unified mailbox icons rail', - reading: 'mark read preview thread conversation archive delete attachment open monospace mono font plain text', - composing: 'editor signature plain text reply forward draft compose', - downloads: 'download filename template eml attachment save export', - identities: 'from address signature email', - vacation: 'auto reply away out of office holiday responder', - filters: 'sieve rules block junk forward', - templates: 'snippet quick reply', - folders: 'mailbox subscribe', - keywords: 'tags labels colors', - security: 'password 2fa two-factor passkey app password mfa', - content_senders: 'block sender remote images privacy tracking', - calendar: 'event schedule appointment meeting timezone', - contacts: 'address book contact', - files: 'attachments cloud drive storage upload', - protocol_handlers: 'mailto webcal links default app protocol handler', - sidebar_apps: 'apps webview iframe', - about_data: 'export import storage quota privacy backup', - themes: 'custom theme css skin appearance', - plugins: 'extensions addons', - debug: 'logs developer console diagnostic', -}; - -function flattenStrings(node: unknown, sink: string[]): void { - if (typeof node === 'string') { - sink.push(node); - return; - } - if (Array.isArray(node)) { - for (const item of node) flattenStrings(item, sink); - return; - } - if (node && typeof node === 'object') { - for (const value of Object.values(node)) flattenStrings(value, sink); - } -} - -interface SubResult { - label: string; - description?: string; - // For plugin setting fields: the id of the plugin whose card needs to be - // expanded before the field becomes visible in the DOM. - pluginId?: string; -} - -// Walk a translation subtree and emit sub-results for renderable settings. -// Picks up: -// - bare string leaves (when a tab path points directly at a flat label) -// - objects with a `label` or `title` field (the standard pattern) -// - flat `*_label` string keys at any object level (e.g. `name_label`) -function collectSubResults(node: unknown, sink: SubResult[]): void { - if (typeof node === 'string') { - sink.push({ label: node }); - return; - } - if (!node || typeof node !== 'object' || Array.isArray(node)) return; - const obj = node as Record; - const label = typeof obj.label === 'string' ? obj.label : (typeof obj.title === 'string' ? obj.title : undefined); - if (label) { - sink.push({ - label, - description: typeof obj.description === 'string' ? obj.description : undefined, - }); - } - for (const [key, value] of Object.entries(obj)) { - if (typeof value === 'string' && key !== 'label' && key !== 'title' && key.endsWith('_label')) { - sink.push({ label: value }); - } - } - for (const value of Object.values(obj)) { - if (value && typeof value === 'object' && !Array.isArray(value)) { - collectSubResults(value, sink); - } - } -} - -function getByPath(obj: unknown, path: string): unknown { - let cur: unknown = obj; - for (const key of path.split('.')) { - if (cur && typeof cur === 'object' && key in (cur as Record)) { - cur = (cur as Record)[key]; - } else { - return undefined; - } - } - return cur; -} - // Map legacy tab IDs to current ones; runs once on read of localStorage. const LEGACY_TAB_MAP: Record = { email: 'reading', diff --git a/lib/__tests__/auth-verify-route.test.ts b/lib/__tests__/auth-verify-route.test.ts new file mode 100644 index 000000000..9216ed2ce --- /dev/null +++ b/lib/__tests__/auth-verify-route.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('next/server', () => ({ + NextResponse: { + json: (data: unknown, init?: { status?: number }) => ({ + json: async () => data, + status: init?.status ?? 200, + }), + }, +})); + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +const config: Record = {}; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback), + }, +})); + +const TRUSTED = 'https://mail.example.com'; + +function mockRequest(body: unknown): unknown { + return { + json: async () => body, + headers: { get: () => null }, + nextUrl: { searchParams: new URLSearchParams() }, + }; +} + +async function callRoute(body: unknown) { + const { POST } = await import('@/app/api/auth/verify/route'); + const res = (await POST(mockRequest(body) as Parameters[0])) as unknown as { + json: () => Promise<{ result?: string; error?: string }>; + status: number; + }; + return { status: res.status, body: await res.json() }; +} + +function upstream(status: number, body: unknown = {}) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: async () => body, + }; +} + +describe('POST /api/auth/verify (#969 login pre-check)', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + for (const k of Object.keys(config)) delete config[k]; + config.jmapServerUrl = TRUSTED; + vi.resetModules(); + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('reports a definitive 401 from the JMAP server as unauthorized', async () => { + fetchSpy.mockResolvedValue(upstream(401) as unknown as Response); + + const { status, body } = await callRoute({ serverUrl: TRUSTED, username: 'alice', password: 'wrong' }); + + expect(status).toBe(200); + expect(body).toEqual({ result: 'unauthorized' }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${TRUSTED}/.well-known/jmap`); + expect((init.headers as Record).Authorization) + .toBe(`Basic ${Buffer.from('alice:wrong').toString('base64')}`); + }); + + it('reports ok when the session fetch succeeds', async () => { + fetchSpy.mockResolvedValue(upstream(200, { apiUrl: `${TRUSTED}/jmap`, accounts: { a: {} } }) as unknown as Response); + + const { body } = await callRoute({ serverUrl: TRUSTED, username: 'alice', password: 'right' }); + + expect(body).toEqual({ result: 'ok' }); + }); + + it.each([ + ['network error', () => Promise.reject(new TypeError('fetch failed'))], + ['5xx outage', () => Promise.resolve(upstream(503) as unknown as Response)], + ['TOTP challenge (402)', () => Promise.resolve(upstream(402, { title: 'MFA code required' }) as unknown as Response)], + ['403', () => Promise.resolve(upstream(403) as unknown as Response)], + ])('is inconclusive on %s so the browser-side connect still decides', async (_label, impl) => { + fetchSpy.mockImplementation(impl as typeof fetch); + + const { status, body } = await callRoute({ serverUrl: TRUSTED, username: 'alice', password: 'pw' }); + + expect(status).toBe(200); + expect(body).toEqual({ result: 'inconclusive' }); + }); + + it('is inconclusive for a server URL that is neither configured nor allowed as custom', async () => { + const { body } = await callRoute({ serverUrl: 'https://other.example.net', username: 'alice', password: 'pw' }); + + expect(body).toEqual({ result: 'inconclusive' }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('is inconclusive when only OAuth logins are allowed', async () => { + config.oauthEnabled = true; + config.oauthOnly = true; + + const { body } = await callRoute({ serverUrl: TRUSTED, username: 'alice', password: 'pw' }); + + expect(body).toEqual({ result: 'inconclusive' }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects requests with missing fields', async () => { + const { status } = await callRoute({ serverUrl: TRUSTED, username: 'alice' }); + expect(status).toBe(400); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/__tests__/jmap-client-resilience.test.ts b/lib/__tests__/jmap-client-resilience.test.ts index 9daf7da9c..5f2fdefee 100644 --- a/lib/__tests__/jmap-client-resilience.test.ts +++ b/lib/__tests__/jmap-client-resilience.test.ts @@ -46,19 +46,41 @@ function mockFetchResponseWithHeaders(status: number, headers: Record { + return Promise.reject(new Error(`Unmocked fetch: ${String(url)}`)); +} + describe('JMAPClient resilience', () => { let fetchSpy: ReturnType; + // Every client a test connected. Their keep-alive intervals and rate-limit + // timers are stopped in afterEach so nothing from one test can fire inside + // the next one's fake clock. + const liveClients: JMAPClient[] = []; beforeEach(() => { - fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(unmockedFetch); vi.useFakeTimers({ shouldAdvanceTime: true }); }); afterEach(() => { + for (const client of liveClients.splice(0)) client.disconnect(); fetchSpy.mockRestore(); vi.useRealTimers(); }); + /** Drop queued one-shot responses and call history; keep the unmocked-fetch guard. */ + function resetFetch() { + fetchSpy.mockReset(); + fetchSpy.mockImplementation(unmockedFetch); + } + /** * Helper: create a connected basic-auth client by mocking the connect() flow */ @@ -70,7 +92,8 @@ describe('JMAPClient resilience', () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session)); const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123'); await client.connect(); - fetchSpy.mockReset(); + liveClients.push(client); + resetFetch(); return client; } @@ -78,7 +101,8 @@ describe('JMAPClient resilience', () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session)); const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com'); await client.connect(); - fetchSpy.mockReset(); + liveClients.push(client); + resetFetch(); return client; } @@ -236,7 +260,8 @@ describe('JMAPClient resilience', () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session)); const client = JMAPClient.withBearer('https://mail.example.com', 'old-token', 'user@test.com', tokenRefresh); await client.connect(); - fetchSpy.mockReset(); + liveClients.push(client); + resetFetch(); const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] }; @@ -276,12 +301,14 @@ describe('JMAPClient resilience', () => { await expect(client.ping()).rejects.toThrow('Rate limited by server'); expect(fetchSpy).not.toHaveBeenCalled(); + // The keep-alive ping goes out again the instant the window closes, which + // is inside this advance - serve it as well, or it would be an unmocked + // call (see unmockedFetch). + const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] }; + fetchSpy.mockImplementation(() => Promise.resolve(mockFetchResponse(200, echoResponse))); await vi.advanceTimersByTimeAsync(120_000); fetchSpy.mockClear(); - const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] }; - fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, echoResponse)); - await expect(client.ping()).resolves.toBeUndefined(); expect(fetchSpy).toHaveBeenCalledTimes(1); }); @@ -292,7 +319,8 @@ describe('JMAPClient resilience', () => { fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, makeSession(session))); const client = new JMAPClient(serverUrl, 'user@test.com', 'pass123'); await client.connect(); - fetchSpy.mockReset(); + liveClients.push(client); + resetFetch(); return client; } @@ -357,7 +385,7 @@ describe('JMAPClient resilience', () => { await client.ping(); // After refresh, subsequent requests should go to the new apiUrl - fetchSpy.mockReset(); + resetFetch(); fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, echoResponse)); await client.ping(); @@ -727,7 +755,8 @@ describe('JMAPClient resilience', () => { (async () => { // Connect first with valid session, then clear downloadUrl via re-connect with empty await client.connect(); - fetchSpy.mockReset(); + liveClients.push(client); + resetFetch(); // Now reconnect with empty downloadUrl to simulate the issue fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, makeSession({ downloadUrl: '' }))); // Force session refresh to pick up empty downloadUrl diff --git a/lib/__tests__/native-select-options.test.ts b/lib/__tests__/native-select-options.test.ts new file mode 100644 index 000000000..b3e303397 --- /dev/null +++ b/lib/__tests__/native-select-options.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import path from 'path'; +import { BUILTIN_THEMES } from '@/lib/builtin-themes'; + +/** + * The browser paints a native option colours (#999)', () => { + it('pins option backgrounds and text to the popover tokens', () => { + const body = ruleBody('option'); + expect(body).toMatch(/background-color:\s*var\(--color-popover\)/); + expect(body).toMatch(/(?:^|[^-])color:\s*var\(--color-popover-foreground\)/); + }); + + it('asks the browser for a dark popup in dark mode', () => { + expect(ruleBody('.dark select')).toMatch(/color-scheme:\s*dark/); + }); + + it('keeps the base light and dark popover surfaces opaque', () => { + const values = popoverValues(css); + expect(values.length).toBeGreaterThanOrEqual(2); + for (const v of values) expect(isOpaque(v), `globals.css --color-popover: ${v}`).toBe(true); + }); + + it.each(BUILTIN_THEMES.map((t) => [t.name, t] as const))( + '%s defines an opaque popover in every variant', + (_name, theme) => { + const values = popoverValues(theme.css); + expect(values.length, 'theme css should set --color-popover').toBeGreaterThanOrEqual( + theme.variants.length + ); + for (const v of values) expect(isOpaque(v), `--color-popover: ${v}`).toBe(true); + } + ); +}); diff --git a/lib/__tests__/reply-identity.test.ts b/lib/__tests__/reply-identity.test.ts index 106852021..6829e8bb1 100644 --- a/lib/__tests__/reply-identity.test.ts +++ b/lib/__tests__/reply-identity.test.ts @@ -189,6 +189,21 @@ describe('resolveReplyFrom', () => { expect(result).toEqual({ identityId: 'primary' }); }); + // #1000: a domain whose extra addresses are distribution lists, not + // catch-all aliases. Exact mode keeps the identity matching but never + // surfaces a From override. + it('returns null instead of a catch-all override in exact mode', () => { + expect(resolveReplyFrom(identities, { to: [{ email: 'stripe@primary.com', name: 'Stripe' }] }, 'exact')) + .toBeNull(); + }); + + it('still matches configured identities (exact and +tag) in exact mode', () => { + expect(resolveReplyFrom(identities, { to: [{ email: 'harry@secondary.com' }] }, 'exact')) + .toEqual({ identityId: 'secondary' }); + expect(resolveReplyFrom(identities, { to: [{ email: 'harry+news@primary.com' }] }, 'exact')) + .toEqual({ identityId: 'primary' }); + }); + it('returns null when recipients are on foreign domains', () => { expect(resolveReplyFrom(identities, { to: [{ email: 'nobody@elsewhere.com' }] })) .toBeNull(); diff --git a/lib/__tests__/settings-search.test.ts b/lib/__tests__/settings-search.test.ts new file mode 100644 index 000000000..223660ad6 --- /dev/null +++ b/lib/__tests__/settings-search.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import en from '@/locales/en/common.json'; +import { + type SettingsSearchTab, + type SubResult, + collectSubResults, + flattenStrings, + getByPath, + tabSearchPaths, +} from '../settings-search'; + +function subResultsFor(tab: SettingsSearchTab): SubResult[] { + const list: SubResult[] = []; + for (const path of tabSearchPaths[tab]) { + collectSubResults(getByPath(en, path), list); + } + return list; +} + +function haystackFor(tab: SettingsSearchTab): string { + const strings: string[] = []; + for (const path of tabSearchPaths[tab]) { + flattenStrings(getByPath(en, path), strings); + } + return strings.join(' ').toLowerCase(); +} + +function labelsFor(tab: SettingsSearchTab): string[] { + return subResultsFor(tab).map((r) => r.label); +} + +describe('collectSubResults', () => { + it('emits flat `key` / `key_desc` string pairs as a labelled sub-result', () => { + const list: SubResult[] = []; + collectSubResults( + { + title: 'Calendar settings', + free_scroll: 'Free scrolling', + free_scroll_desc: 'Scroll continuously', + hover_preview_off: 'Disabled', + }, + list + ); + expect(list).toEqual([ + { label: 'Calendar settings', description: undefined }, + { label: 'Free scrolling', description: 'Scroll continuously' }, + ]); + }); + + it('still emits label/description objects and *_label keys', () => { + const list: SubResult[] = []; + collectSubResults( + { + name_label: 'Name', + nested: { label: 'Theme', description: 'Pick one', dark: 'Dark' }, + }, + list + ); + expect(list).toEqual([ + { label: 'Name' }, + { label: 'Theme', description: 'Pick one' }, + ]); + }); +}); + +describe('settings search index (English messages)', () => { + it('every tab path resolves to a translation subtree', () => { + const missing: string[] = []; + for (const paths of Object.values(tabSearchPaths)) { + for (const path of paths) { + if (getByPath(en, path) === undefined) missing.push(path); + } + } + expect(missing).toEqual([]); + }); + + it('lists the calendar toggles as sub-results', () => { + const labels = labelsFor('calendar'); + expect(labels).toContain('Free scrolling'); + expect(labels).toContain('Show week numbers'); + expect(labels).toContain('Organize invitations as'); + expect(labels).toContain('Contact birthday calendar'); + // Option values are not settings of their own. + expect(labels).not.toContain('Disabled'); + }); + + it('finds the newer mail settings on their tabs', () => { + expect(labelsFor('reading')).toContain('Clear search when switching folders'); + expect(labelsFor('reading')).toContain('Swipe right action (mobile)'); + expect(labelsFor('composing')).toContain('Undo send / send delay'); + expect(labelsFor('composing')).toContain('Request read receipts by default'); + expect(labelsFor('appearance')).toContain('Message list order'); + expect(labelsFor('layout')).toContain('Pro Interface (Experimental)'); + expect(labelsFor('language')).toContain('Time zone'); + }); + + it('matches the calendar tab for a free-scroll query', () => { + expect(haystackFor('calendar')).toContain('free scrolling'); + expect(haystackFor('language')).toContain('time zone'); + }); +}); diff --git a/lib/auth/verify-jmap-auth.ts b/lib/auth/verify-jmap-auth.ts index 9c31c6ab6..71c00896c 100644 --- a/lib/auth/verify-jmap-auth.ts +++ b/lib/auth/verify-jmap-auth.ts @@ -10,11 +10,19 @@ const MAX_REDIRECTS = 3; export class JmapAuthVerificationError extends Error { status: number; - - constructor(message: string, status: number) { + /** + * HTTP status the JMAP server itself answered with, when the failure came + * from an upstream response rather than URL validation, a timeout or a + * network error. Lets callers tell a definitive credential rejection (401) + * apart from everything else that happens to map to the same `status`. + */ + upstreamStatus?: number; + + constructor(message: string, status: number, upstreamStatus?: number) { super(message); this.name = 'JmapAuthVerificationError'; this.status = status; + this.upstreamStatus = upstreamStatus; } } @@ -140,6 +148,7 @@ export async function verifyJmapAuth( ? 'Authentication failed' : 'Failed to verify JMAP session', response.status === 401 || response.status === 403 ? 401 : 502, + response.status, ); } diff --git a/lib/reply-identity.ts b/lib/reply-identity.ts index 52522c29b..a5109b104 100644 --- a/lib/reply-identity.ts +++ b/lib/reply-identity.ts @@ -198,6 +198,16 @@ export function findDraftIdentityId( return base?.id ?? null; } +/** + * How far `resolveReplyFrom` goes when matching received addresses: + * `exact` stops at the user's configured identities (steps 1-2 below), + * `domain` also takes the same-domain catch-all step (3), which rewrites + * `From:` to an address the user has not configured. Deployments where the + * extra addresses on a domain are distribution lists rather than aliases + * want `exact` (#1000). + */ +export type ReplyIdentityMatchMode = 'exact' | 'domain'; + export interface ReplyFromResolution { /** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */ identityId: string; @@ -223,12 +233,14 @@ export interface ReplyFromResolution { * sub-addressing, reply as that identity with no override. * 3. Else if a recipient address is on a domain that one of the identities * uses, treat that recipient as a catch-all alias: return the matching - * identity + the recipient as a header-From override. + * identity + the recipient as a header-From override. Skipped in + * `exact` match mode. * 4. Else return `null` (caller falls back to primary identity). */ export function resolveReplyFrom( identities: Identity[], recipients?: ReplyRecipients, + matchMode: ReplyIdentityMatchMode = 'domain', ): ReplyFromResolution | null { if (identities.length === 0 || !recipients) { return null; @@ -265,6 +277,10 @@ export function resolveReplyFrom( return { identityId: baseIdentity.id }; } + if (matchMode !== 'domain') { + return null; + } + const ownedDomains = new Set(identities.map((i) => domainOf(i.email)).filter(Boolean)); const catchAll = received.find((r) => { diff --git a/lib/settings-search.ts b/lib/settings-search.ts new file mode 100644 index 000000000..646da5d1b --- /dev/null +++ b/lib/settings-search.ts @@ -0,0 +1,236 @@ +// Settings search index helpers: which translation subtrees each settings tab +// renders, extra keywords per tab, and the walker that turns those subtrees +// into clickable sub-results. Kept free of React so it can be unit-tested. + +export type SettingsSearchTab = + | 'account' + | 'language' + | 'notifications' + | 'appearance' + | 'layout' + | 'reading' + | 'composing' + | 'downloads' + | 'identities' + | 'vacation' + | 'filters' + | 'templates' + | 'folders' + | 'keywords' + | 'security' + | 'content_senders' + | 'calendar' + | 'contacts' + | 'files' + | 'protocol_handlers' + | 'sidebar_apps' + | 'about_data' + | 'themes' + | 'plugins' + | 'debug'; + +type Tab = SettingsSearchTab; + +// Translation paths per tab. Tabs that share a namespace (email_behavior, +// appearance) explicitly list the subkeys they actually render so sub-results +// are attributed to the correct tab. Tabs with their own namespace just point +// at the namespace root. +export const tabSearchPaths: Record = { + account: [ + 'settings.account.name_label', + 'settings.account.username_label', + 'settings.account.account_type_label', + 'settings.account.auth_method_label', + 'settings.account.email', + 'settings.account.server', + 'settings.account.storage', + 'settings.account.accounts', + ], + language: ['settings.language_region'], + notifications: ['settings.notifications'], + appearance: [ + 'settings.appearance.theme', + 'settings.appearance.font_size', + 'settings.appearance.list_density', + 'settings.appearance.animations', + 'settings.appearance.message_list_order', + 'settings.advanced.sender_favicons', + 'settings.advanced.show_avatars_in_junk', + ], + layout: [ + 'settings.appearance.toolbar_position', + 'settings.appearance.toolbar_labels', + 'settings.appearance.hide_account_switcher', + 'settings.appearance.show_rail_account_list', + 'settings.appearance.unified_mailbox', + 'settings.appearance.cross_unread', + 'settings.appearance.cross_starred', + 'settings.appearance.cross_all', + 'settings.appearance.all_mail', + 'settings.appearance.colorful_sidebar_icons', + 'settings.appearance.tint_list_rows', + 'settings.appearance.show_folder_total_count', + 'settings.appearance.favicon_unread_badge', + 'settings.appearance.pro_interface', + 'settings.email_behavior.mail_layout', + ], + reading: [ + 'settings.email_behavior.mark_read', + 'settings.email_behavior.message_spacing', + 'settings.email_behavior.archive_mode', + 'settings.email_behavior.delete_action', + 'settings.email_behavior.attachment_click_action', + 'settings.email_behavior.attachment_image_previews', + 'settings.email_behavior.attachment_position', + 'settings.email_behavior.disable_threading', + 'settings.email_behavior.emails_per_page', + 'settings.email_behavior.hide_inline_image_attachments', + 'settings.email_behavior.hover_actions', + 'settings.email_behavior.permanently_delete_junk', + 'settings.email_behavior.plain_text_font', + 'settings.email_behavior.show_preview', + 'settings.email_behavior.return_to_list_after_action', + 'settings.email_behavior.swipe_right_action', + 'settings.email_behavior.swipe_left_action', + 'settings.email_behavior.clear_search_on_folder_change', + ], + composing: [ + 'settings.email_behavior.attachment_reminder', + 'settings.email_behavior.auto_select_reply_identity', + 'settings.email_behavior.reply_identity_match', + 'settings.email_behavior.plain_text_mode', + 'settings.email_behavior.rtl_editing', + 'settings.email_behavior.default_mail_program', + 'settings.email_behavior.empty_subject_warning', + 'settings.email_behavior.send_delay', + 'settings.email_behavior.signature_position', + 'settings.email_behavior.signature_separator', + 'settings.email_behavior.request_read_receipt', + 'settings.email_behavior.read_receipt_response', + 'settings.email_behavior.sub_address_delimiter', + ], + downloads: ['settings.downloads'], + identities: ['settings.identities'], + vacation: ['settings.vacation'], + filters: ['settings.filters'], + templates: ['settings.templates'], + folders: ['settings.folders'], + keywords: ['settings.keywords'], + security: ['settings.security'], + content_senders: [ + 'settings.email_behavior.always_light_mode', + 'settings.email_behavior.external_content', + 'settings.email_behavior.trusted_senders', + ], + calendar: ['calendar.settings', 'calendar.management'], + contacts: ['settings.contacts', 'contacts'], + files: ['settings.files'], + protocol_handlers: ['protocol_handlers'], + sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'], + about_data: ['settings.advanced'], + themes: [], + plugins: [], + debug: ['settings.advanced'], +}; + +// Extra English keywords per tab so common search terms hit even when the +// translation doesn't contain the literal word. +export const tabKeywords: Record = { + account: 'profile email password user signin signout reorder rearrange drag dropdown switcher multi-account', + language: 'locale region timezone date time format', + notifications: 'sound alert push badge', + appearance: 'theme dark light font size accent color animation density', + layout: 'toolbar sidebar account switcher unified mailbox icons rail', + reading: 'mark read preview thread conversation archive delete attachment open monospace mono font plain text', + composing: 'editor signature plain text reply forward draft compose', + downloads: 'download filename template eml attachment save export', + identities: 'from address signature email', + vacation: 'auto reply away out of office holiday responder', + filters: 'sieve rules block junk forward', + templates: 'snippet quick reply', + folders: 'mailbox subscribe', + keywords: 'tags labels colors', + security: 'password 2fa two-factor passkey app password mfa', + content_senders: 'block sender remote images privacy tracking', + calendar: 'event schedule appointment meeting timezone', + contacts: 'address book contact', + files: 'attachments cloud drive storage upload', + protocol_handlers: 'mailto webcal links default app protocol handler', + sidebar_apps: 'apps webview iframe', + about_data: 'export import storage quota privacy backup', + themes: 'custom theme css skin appearance', + plugins: 'extensions addons', + debug: 'logs developer console diagnostic', +}; + +export function flattenStrings(node: unknown, sink: string[]): void { + if (typeof node === 'string') { + sink.push(node); + return; + } + if (Array.isArray(node)) { + for (const item of node) flattenStrings(item, sink); + return; + } + if (node && typeof node === 'object') { + for (const value of Object.values(node)) flattenStrings(value, sink); + } +} + +export interface SubResult { + label: string; + description?: string; + // For plugin setting fields: the id of the plugin whose card needs to be + // expanded before the field becomes visible in the DOM. + pluginId?: string; +} + +// Walk a translation subtree and emit sub-results for renderable settings. +// Picks up: +// - bare string leaves (when a tab path points directly at a flat label) +// - objects with a `label` or `title` field (the standard pattern) +// - flat `*_label` string keys at any object level (e.g. `name_label`) +// - flat `foo` / `foo_desc` string pairs (the calendar.settings pattern) +export function collectSubResults(node: unknown, sink: SubResult[]): void { + if (typeof node === 'string') { + sink.push({ label: node }); + return; + } + if (!node || typeof node !== 'object' || Array.isArray(node)) return; + const obj = node as Record; + const label = typeof obj.label === 'string' ? obj.label : (typeof obj.title === 'string' ? obj.title : undefined); + if (label) { + sink.push({ + label, + description: typeof obj.description === 'string' ? obj.description : undefined, + }); + } + for (const [key, value] of Object.entries(obj)) { + if (typeof value !== 'string' || key === 'label' || key === 'title') continue; + if (key.endsWith('_label')) { + sink.push({ label: value }); + continue; + } + const desc = obj[`${key}_desc`]; + if (typeof desc === 'string') { + sink.push({ label: value, description: desc }); + } + } + for (const value of Object.values(obj)) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + collectSubResults(value, sink); + } + } +} + +export function getByPath(obj: unknown, path: string): unknown { + let cur: unknown = obj; + for (const key of path.split('.')) { + if (cur && typeof cur === 'object' && key in (cur as Record)) { + cur = (cur as Record)[key]; + } else { + return undefined; + } + } + return cur; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 7ce66df4e..355251c92 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1335,6 +1335,12 @@ "label": "الرد من العنوان المستلَم إليه", "description": "عند الرد، الإرسال من العنوان الذي أُرسلت إليه الرسالة أصلًا. يطابق الهويات أولًا؛ وبالنسبة لعمليات التسليم الشاملة للنطاق، يعيد كتابة ترويسة \"من\" إلى العنوان البديل مع الإرسال عبر هويتك الأساسية." }, + "reply_identity_match": { + "label": "مطابقة عنوان الاستلام", + "description": "أي عناوين الاستلام تُعدّ عناوينك. «العنوان المطابق فقط» يختار إحدى هوياتك المضبوطة فقط؛ أما «نفس النطاق» فيعامل أيضًا أي عنوان آخر على نطاقات هوياتك كاسم مستعار شامل ويستبدل به ترويسة المرسل. اختر العنوان المطابق إذا كانت تلك العناوين قوائم توزيع.", + "exact": "العنوان المطابق فقط", + "domain": "أي عنوان على نطاقاتي" + }, "signature_position": { "label": "موضع التوقيع", "description": "أين يُدرج توقيعك في الردود وإعادة التوجيه. أعلى النص المقتبس يُقرأ بشكل طبيعي كخاتمة للرد؛ وأسفله يُبقي الرسالة الأصلية متصلة.", @@ -1892,6 +1898,8 @@ "title": "جهات الاتصال", "description": "استيراد وتصدير جهات اتصالك", "group_by_letter_label": "التجميع حسب الحرف الأول", + "sort_by_last_name_label": "الترتيب حسب اسم العائلة", + "sort_by_last_name_description": "ترتيب قائمة جهات الاتصال حسب اسم العائلة بحيث يظهر أفراد العائلة معًا", "group_by_letter_description": "إظهار عناوين أقسام أبجدية في قائمة جهات الاتصال", "import_label": "استيراد جهات الاتصال", "import_description": "استيراد جهات الاتصال من ملف vCard (‎.vcf)", @@ -2740,7 +2748,10 @@ "any_month": "أي شهر", "has_email": "لديه بريد إلكتروني", "has_phone": "لديه هاتف", - "has_photo": "لديه صورة" + "has_photo": "لديه صورة", + "sort_by": "الترتيب حسب", + "sort_first_name": "الاسم الأول", + "sort_last_name": "اسم العائلة" } }, "calendar": { diff --git a/locales/ca/common.json b/locales/ca/common.json index d3bceb0dd..34f8e722b 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1335,6 +1335,12 @@ "label": "Respon des de l'adreça de recepció", "description": "En respondre, envia des de l'adreça a la qual es va enviar originalment el missatge. Primer intenta coincidir amb les identitats; per als lliuraments de captura general de domini, reescriu la capçalera «De» amb l'àlies mentre envia a través de la identitat principal." }, + "reply_identity_match": { + "label": "Coincidència de l'adreça de recepció", + "description": "Quines adreces de recepció compten com a teves. «Només l'adreça exacta» tria únicament una de les teves identitats configurades; «mateix domini» també tracta qualsevol altra adreça dels teus dominis d'identitat com un àlies catch-all i hi reescriu la capçalera De. Tria l'adreça exacta si aquestes adreces són llistes de distribució.", + "exact": "Només l'adreça exacta", + "domain": "Qualsevol adreça dels meus dominis" + }, "signature_position": { "label": "Posició de la signatura", "description": "On inserir la signatura a les respostes i reenviaments. Abans del text citat es llegeix de manera natural com a tancament de la resposta; després manté el missatge original contigu.", @@ -1892,6 +1898,8 @@ "title": "Contactes", "description": "Importeu i exporteu els contactes", "group_by_letter_label": "Agrupa per lletra inicial", + "sort_by_last_name_label": "Ordena per cognom", + "sort_by_last_name_description": "Ordena la llista de contactes per cognom perquè els membres d'una família apareguin junts", "group_by_letter_description": "Mostra capçaleres de secció alfabètiques a la llista de contactes", "import_label": "Importa contactes", "import_description": "Importeu contactes des d'un fitxer vCard (.vcf)", @@ -2740,7 +2748,10 @@ "any_month": "Qualsevol mes", "has_email": "Té correu electrònic", "has_phone": "Té telèfon", - "has_photo": "Té foto" + "has_photo": "Té foto", + "sort_by": "Ordena per", + "sort_first_name": "Nom", + "sort_last_name": "Cognom" } }, "calendar": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 7367db8ef..68dd0de47 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1332,6 +1332,12 @@ "label": "Automaticky vybírat adresu pro odpověď", "description": "Při odpovídání automaticky přepnout adresu odesílatele na identitu, která původně obdržela zprávu" }, + "reply_identity_match": { + "label": "Porovnávání přijímací adresy", + "description": "Které přijímací adresy se počítají jako vaše. „Pouze přesná adresa“ vybere jen jednu z vašich nastavených identit; „stejná doména“ navíc považuje jakoukoli jinou adresu na doménách vašich identit za catch-all alias a přepíše na ni hlavičku Od. Pokud jsou tyto adresy distribuční seznamy, zvolte přesnou adresu.", + "exact": "Pouze přesná adresa", + "domain": "Jakákoli adresa na mých doménách" + }, "signature_position": { "label": "Pozice podpisu", "description": "Kam vložit podpis v odpovědích a přeposláních. Nad citovaným textem působí přirozeně jako zakončení odpovědi; pod ním zachovává původní zprávu vcelku.", @@ -1885,6 +1891,8 @@ "title": "Kontakty", "description": "Import a export kontaktů", "group_by_letter_label": "Seskupit podle prvního písmena", + "sort_by_last_name_label": "Řadit podle příjmení", + "sort_by_last_name_description": "Seřadit seznam kontaktů podle příjmení, aby členové rodiny byli pohromadě", "group_by_letter_description": "Zobrazit záhlaví sekcí podle abecedy v seznamu kontaktů", "import_label": "Importovat kontakty", "import_description": "Importovat kontakty ze souboru vCard (.vcf)", @@ -2739,7 +2747,10 @@ "any_month": "Kterýkoli měsíc", "has_email": "Má e-mail", "has_phone": "Má telefon", - "has_photo": "Má fotku" + "has_photo": "Má fotku", + "sort_by": "Řadit podle", + "sort_first_name": "Jméno", + "sort_last_name": "Příjmení" }, "open_categories": "Otevřít kategorie" }, diff --git a/locales/da/common.json b/locales/da/common.json index 1a60ef4eb..95b7a623e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1335,6 +1335,12 @@ "label": "Svar fra modtaget adresse", "description": "Når du svarer, send fra den adresse som beskeden oprindeligt blev sendt til. Matcher først identiteter; for domæne catch-all-leveringer omskriver Fra-headeren til aliaset, mens der sendes gennem din primære identitet." }, + "reply_identity_match": { + "label": "Matchning af modtageradresse", + "description": "Hvilke modtageradresser der tæller som dine. „Kun præcis adresse“ vælger udelukkende en af dine opsatte identiteter; „samme domæne“ behandler desuden enhver anden adresse på dine identitetsdomæner som et catch-all-alias og omskriver Fra-headeren til den. Vælg præcis adresse, hvis disse adresser er distributionslister.", + "exact": "Kun præcis adresse", + "domain": "Enhver adresse på mine domæner" + }, "signature_position": { "label": "Signaturplacering", "description": "Hvor din signatur indsættes i svar og videresendelser. Før citeret tekst læses naturligt som en afslutning på svaret; under bevarer den oprindelige besked sammenhængende.", @@ -1888,6 +1894,8 @@ "title": "Kontakter", "description": "Importér og eksportér dine kontakter", "group_by_letter_label": "Gruppér efter første bogstav", + "sort_by_last_name_label": "Sortér efter efternavn", + "sort_by_last_name_description": "Sortér kontaktlisten efter efternavn, så familiemedlemmer står samlet", "group_by_letter_description": "Vis alfabetiske sektionsoverskrifter i kontaktlisten", "import_label": "Importér kontakter", "import_description": "Importér kontakter fra en vCard-fil (.vcf)", @@ -2739,7 +2747,10 @@ "any_month": "Enhver måned", "has_email": "Har e-mail", "has_phone": "Har telefon", - "has_photo": "Har billede" + "has_photo": "Har billede", + "sort_by": "Sortér efter", + "sort_first_name": "Fornavn", + "sort_last_name": "Efternavn" }, "open_categories": "Åbn kategorier" }, diff --git a/locales/de/common.json b/locales/de/common.json index 22ccb6591..578aae1c5 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1332,6 +1332,12 @@ "label": "Antwortadresse automatisch wählen", "description": "Beim Antworten die Absenderadresse automatisch auf die Identität umstellen, die die ursprüngliche Nachricht erhalten hat" }, + "reply_identity_match": { + "label": "Abgleich der Empfangsadresse", + "description": "Welche Empfangsadressen als eigene gelten. „Nur exakte Adresse“ wählt ausschließlich eine Ihrer eingerichteten Identitäten. „Gleiche Domain“ behandelt zusätzlich jede andere Adresse auf einer Ihrer Identitäts-Domains als Catch-all-Alias und ersetzt den Absender-Header damit. Wählen Sie die exakte Adresse, wenn solche Adressen Verteilerlisten und keine Aliasse sind.", + "exact": "Nur exakte Adresse", + "domain": "Jede Adresse auf meinen Domains" + }, "signature_position": { "label": "Signaturposition", "description": "Wo Ihre Signatur in Antworten und Weiterleitungen eingefügt wird. Über dem zitierten Text liest sie sich natürlich als Abschluss der Antwort; darunter bleibt die ursprüngliche Nachricht zusammenhängend.", @@ -1895,7 +1901,9 @@ "categories_description": "Kontaktkategorien umbenennen", "no_categories": "Keine Kategorien gefunden", "group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen", - "group_by_letter_label": "Nach Anfangsbuchstaben gruppieren" + "group_by_letter_label": "Nach Anfangsbuchstaben gruppieren", + "sort_by_last_name_label": "Nach Nachname sortieren", + "sort_by_last_name_description": "Kontaktliste nach Nachnamen ordnen, damit Familienmitglieder zusammenstehen" }, "downloads": { "title": "Downloads", @@ -2739,7 +2747,10 @@ "any_month": "Beliebiger Monat", "has_email": "Mit E-Mail", "has_phone": "Mit Telefon", - "has_photo": "Mit Foto" + "has_photo": "Mit Foto", + "sort_by": "Sortieren nach", + "sort_first_name": "Vorname", + "sort_last_name": "Nachname" }, "open_categories": "Kategorien öffnen" }, diff --git a/locales/en/common.json b/locales/en/common.json index cf57ed107..b568a1f00 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1333,7 +1333,13 @@ }, "auto_select_reply_identity": { "label": "Reply From Received Address", - "description": "When replying, send from the address the message was originally sent to. Matches identities first; for domain catch-all deliveries, rewrites the From header to the alias while sending through your primary identity." + "description": "When replying, send from the address the message was originally sent to. Your configured identities always match; whether other addresses on your domains count too (catch-all) is set by “Received Address Matching” below." + }, + "reply_identity_match": { + "label": "Received Address Matching", + "description": "Which received addresses count as yours. Exact address only picks one of your configured identities. Same domain also treats any other address on one of your identity domains as a catch-all alias and rewrites the From header to it. Choose exact address if those addresses are distribution lists rather than aliases.", + "exact": "Exact address only", + "domain": "Any address on my domains" }, "signature_position": { "label": "Signature Position", @@ -1893,6 +1899,8 @@ "description": "Import and export your contacts", "group_by_letter_label": "Group by first letter", "group_by_letter_description": "Show alphabetical section headers in the contact list", + "sort_by_last_name_label": "Sort by last name", + "sort_by_last_name_description": "Order the contact list by surname so family members appear together", "import_label": "Import Contacts", "import_description": "Import contacts from a vCard (.vcf) file", "export_label": "Export Contacts", @@ -2740,7 +2748,10 @@ "any_month": "Any month", "has_email": "Has email", "has_phone": "Has phone", - "has_photo": "Has photo" + "has_photo": "Has photo", + "sort_by": "Sort by", + "sort_first_name": "First name", + "sort_last_name": "Last name" } }, "calendar": { diff --git a/locales/es/common.json b/locales/es/common.json index f9e47b094..70e4b71cf 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1327,6 +1327,12 @@ "label": "Seleccionar dirección de respuesta automáticamente", "description": "Al responder, cambia automáticamente la dirección del remitente a la identidad que recibió el mensaje original" }, + "reply_identity_match": { + "label": "Coincidencia de la dirección de recepción", + "description": "Qué direcciones de recepción cuentan como tuyas. «Solo la dirección exacta» elige únicamente una de tus identidades configuradas; «mismo dominio» también trata cualquier otra dirección de tus dominios de identidad como un alias catch-all y reescribe la cabecera De con ella. Elige la dirección exacta si esas direcciones son listas de distribución.", + "exact": "Solo la dirección exacta", + "domain": "Cualquier dirección de mis dominios" + }, "signature_position": { "label": "Posición de la firma", "description": "Dónde insertar tu firma en respuestas y reenvíos. Encima del texto citado se lee de forma natural como cierre de la respuesta; debajo mantiene el mensaje original contiguo.", @@ -1895,7 +1901,9 @@ "categories_description": "Renombrar categorías de contactos", "no_categories": "No se encontraron categorías", "group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos", - "group_by_letter_label": "Agrupar por primera letra" + "group_by_letter_label": "Agrupar por primera letra", + "sort_by_last_name_label": "Ordenar por apellido", + "sort_by_last_name_description": "Ordenar la lista de contactos por apellido para que los miembros de una familia aparezcan juntos" }, "downloads": { "title": "Descargas", @@ -2739,7 +2747,10 @@ "any_month": "Cualquier mes", "has_email": "Con correo", "has_phone": "Con teléfono", - "has_photo": "Con foto" + "has_photo": "Con foto", + "sort_by": "Ordenar por", + "sort_first_name": "Nombre", + "sort_last_name": "Apellido" }, "open_categories": "Abrir categorías" }, diff --git a/locales/fa/common.json b/locales/fa/common.json index 30ae80711..1ecf4882a 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1335,6 +1335,12 @@ "label": "پاسخ از آدرس دریافتی", "description": "ارسال با آدرس دریافت شده" }, + "reply_identity_match": { + "label": "تطبیق نشانی دریافت", + "description": "کدام نشانی‌های دریافت به‌عنوان نشانی شما در نظر گرفته شوند. «فقط نشانی دقیق» تنها یکی از هویت‌های پیکربندی‌شدهٔ شما را انتخاب می‌کند؛ «همان دامنه» هر نشانی دیگری در دامنه‌های هویت شما را نیز نام مستعار catch-all در نظر می‌گیرد و سرآیند فرستنده را با آن بازنویسی می‌کند. اگر این نشانی‌ها فهرست توزیع هستند، نشانی دقیق را انتخاب کنید.", + "exact": "فقط نشانی دقیق", + "domain": "هر نشانی در دامنه‌های من" + }, "signature_position": { "label": "موقعیت امضا", "description": "محل درج امضا", @@ -1892,6 +1898,8 @@ "title": "مخاطبین", "description": "وارد کردن و خروجی مخاطبین", "group_by_letter_label": "گروه‌بندی بر اساس حرف اول", + "sort_by_last_name_label": "مرتب‌سازی بر اساس نام خانوادگی", + "sort_by_last_name_description": "لیست مخاطبین را بر اساس نام خانوادگی مرتب کنید تا اعضای خانواده کنار هم نمایش داده شوند", "group_by_letter_description": "نمایش هدرهای الفبایی در لیست مخاطبین", "import_label": "وارد کردن مخاطبین", "import_description": "وارد کردن مخاطبین از فایل vCard (.vcf)", @@ -2740,7 +2748,10 @@ "any_month": "هر ماه", "has_email": "دارای ایمیل", "has_phone": "دارای تلفن", - "has_photo": "دارای عکس" + "has_photo": "دارای عکس", + "sort_by": "مرتب‌سازی بر اساس", + "sort_first_name": "نام", + "sort_last_name": "نام خانوادگی" } }, "calendar": { diff --git a/locales/fr/common.json b/locales/fr/common.json index b35db2eee..788bcc9db 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1327,6 +1327,12 @@ "label": "Sélection automatique de l'adresse de réponse", "description": "Lors d'une réponse, bascule automatiquement l'adresse d'expédition vers l'identité qui a reçu le message d'origine" }, + "reply_identity_match": { + "label": "Correspondance de l'adresse de réception", + "description": "Quelles adresses de réception comptent comme les vôtres. « Adresse exacte uniquement » ne choisit qu'une de vos identités configurées ; « même domaine » traite aussi toute autre adresse de vos domaines d'identité comme un alias catch-all et y réécrit l'en-tête De. Choisissez l'adresse exacte si ces adresses sont des listes de diffusion.", + "exact": "Adresse exacte uniquement", + "domain": "Toute adresse de mes domaines" + }, "signature_position": { "label": "Position de la signature", "description": "Où insérer votre signature dans les réponses et les transferts. Au-dessus du texte cité, elle se lit naturellement comme la conclusion de la réponse ; en dessous, elle garde le message d'origine contigu.", @@ -1895,7 +1901,9 @@ "categories_description": "Renommer les catégories de contacts", "no_categories": "Aucune catégorie trouvée", "group_by_letter_description": "Afficher des en-têtes alphabétiques dans la liste de contacts", - "group_by_letter_label": "Grouper par première lettre" + "group_by_letter_label": "Grouper par première lettre", + "sort_by_last_name_label": "Trier par nom de famille", + "sort_by_last_name_description": "Classer la liste des contacts par nom de famille pour regrouper les membres d'une même famille" }, "downloads": { "title": "Téléchargements", @@ -2739,7 +2747,10 @@ "any_month": "Tous les mois", "has_email": "Avec e-mail", "has_phone": "Avec téléphone", - "has_photo": "Avec photo" + "has_photo": "Avec photo", + "sort_by": "Trier par", + "sort_first_name": "Prénom", + "sort_last_name": "Nom de famille" }, "open_categories": "Ouvrir les catégories" }, diff --git a/locales/he/common.json b/locales/he/common.json index 8e4086695..62e2cc565 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1286,6 +1286,12 @@ "label": "בחירה אוטומטית בכתובת תשובה", "description": "בעת תשובה, העבר אוטומטית את הכתובת מאת לזהות שקיבלה את ההודעה במקור" }, + "reply_identity_match": { + "label": "התאמת כתובת הקבלה", + "description": "אילו כתובות קבלה נחשבות לשלך. „כתובת מדויקת בלבד“ בוחרת רק אחת מהזהויות שהגדרת; „אותו דומיין“ מתייחסת גם לכל כתובת אחרת בדומייני הזהויות שלך ככינוי catch-all ומשכתבת אליה את כותרת השולח. בחר בכתובת מדויקת אם כתובות אלה הן רשימות תפוצה.", + "exact": "כתובת מדויקת בלבד", + "domain": "כל כתובת בדומיינים שלי" + }, "attachment_click_action": { "label": "קובץ מצורף לחץ על פעולה", "description": "בחר אם לחיצה על קובץ מצורף תציג אותו בתצוגה מקדימה או תוריד אותו מיד", @@ -1861,6 +1867,8 @@ "categories_description": "שנה שם של קטגוריות אנשי קשר", "no_categories": "לא נמצאו קטגוריות", "group_by_letter_label": "קבוצה לפי אות ראשונה", + "sort_by_last_name_label": "מיון לפי שם משפחה", + "sort_by_last_name_description": "סידור רשימת אנשי הקשר לפי שם משפחה כך שבני אותה משפחה יופיעו יחד", "group_by_letter_description": "הצג כותרות חלקים אלפביתיים ברשימת אנשי הקשר" }, "filters": { @@ -2664,7 +2672,10 @@ "any_month": "כל חודש", "has_email": "יש דוא״ל", "has_phone": "יש טלפון", - "has_photo": "יש תמונה" + "has_photo": "יש תמונה", + "sort_by": "מיון לפי", + "sort_first_name": "שם פרטי", + "sort_last_name": "שם משפחה" } }, "calendar": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 6366ece1e..770c08b28 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1335,6 +1335,12 @@ "label": "Válasz a fogadott címről", "description": "Válaszküldéskor arról a címről küldjön, amelyre az üzenet eredetileg érkezett. Először identitásokat keres; tartományi catch-all kézbesítések esetén átírja a Feladó fejlécet az aliasra, miközben az elsődleges identitáson keresztül küld." }, + "reply_identity_match": { + "label": "Fogadó cím egyeztetése", + "description": "Mely fogadó címek számítanak a sajátjának. A „Csak pontos cím” kizárólag a beállított identitásai közül választ; az „azonos domain” az identitás-domainjein lévő bármely más címet is catch-all aliasként kezeli, és arra írja át a Feladó fejlécet. Válassza a pontos címet, ha ezek a címek terjesztési listák.", + "exact": "Csak pontos cím", + "domain": "Bármely cím a domainjeimen" + }, "signature_position": { "label": "Aláírás pozíciója", "description": "Hova kerüljön az aláírás a válaszokban és továbbításokban. Az idézett szöveg felett természetes lezárásként olvasható; alatta az eredeti üzenet marad egybefüggő.", @@ -1888,6 +1894,8 @@ "title": "Névjegyek", "description": "Névjegyek importálása és exportálása", "group_by_letter_label": "Csoportosítás kezdőbetű szerint", + "sort_by_last_name_label": "Rendezés vezetéknév szerint", + "sort_by_last_name_description": "A névjegylista vezetéknév szerinti rendezése, hogy a családtagok egymás mellett legyenek", "group_by_letter_description": "ABC sorrendű fejléc mutatása a névjegy listában", "import_label": "Névjegyek importálása", "import_description": "Névjegyek importálása vCard (.vcf) fájlból", @@ -2740,7 +2748,10 @@ "any_month": "Bármely hónap", "has_email": "Van e-mail", "has_phone": "Van telefon", - "has_photo": "Van fotó" + "has_photo": "Van fotó", + "sort_by": "Rendezés", + "sort_first_name": "Keresztnév", + "sort_last_name": "Vezetéknév" } }, "calendar": { diff --git a/locales/it/common.json b/locales/it/common.json index f6a3017a5..87fb129a0 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1327,6 +1327,12 @@ "label": "Seleziona automaticamente l'indirizzo di risposta", "description": "Quando rispondi, passa automaticamente l'indirizzo mittente all'identità che ha ricevuto il messaggio originale" }, + "reply_identity_match": { + "label": "Corrispondenza dell'indirizzo di ricezione", + "description": "Quali indirizzi di ricezione contano come tuoi. «Solo indirizzo esatto» sceglie soltanto una delle tue identità configurate; «stesso dominio» tratta anche qualsiasi altro indirizzo dei tuoi domini di identità come alias catch-all e riscrive con esso l'intestazione Da. Scegli l'indirizzo esatto se quegli indirizzi sono liste di distribuzione.", + "exact": "Solo indirizzo esatto", + "domain": "Qualsiasi indirizzo dei miei domini" + }, "signature_position": { "label": "Posizione della firma", "description": "Dove inserire la tua firma nelle risposte e negli inoltri. Sopra il testo citato si legge in modo naturale come chiusura della risposta; sotto mantiene il messaggio originale contiguo.", @@ -1895,7 +1901,9 @@ "categories_description": "Rinomina le categorie dei contatti", "no_categories": "Nessuna categoria trovata", "group_by_letter_description": "Mostra intestazioni alfabetiche nell'elenco dei contatti", - "group_by_letter_label": "Raggruppa per prima lettera" + "group_by_letter_label": "Raggruppa per prima lettera", + "sort_by_last_name_label": "Ordina per cognome", + "sort_by_last_name_description": "Ordina l'elenco dei contatti per cognome così i familiari compaiono insieme" }, "downloads": { "title": "Download", @@ -2739,7 +2747,10 @@ "any_month": "Qualsiasi mese", "has_email": "Con email", "has_phone": "Con telefono", - "has_photo": "Con foto" + "has_photo": "Con foto", + "sort_by": "Ordina per", + "sort_first_name": "Nome", + "sort_last_name": "Cognome" }, "open_categories": "Apri categorie" }, diff --git a/locales/ja/common.json b/locales/ja/common.json index 147cc1fab..9285aa99a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1327,6 +1327,12 @@ "label": "返信元アドレスを自動選択", "description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます" }, + "reply_identity_match": { + "label": "受信アドレスの照合", + "description": "どの受信アドレスを自分のものとみなすか。「完全一致のみ」は設定済みのアイデンティティからのみ選択します。「同じドメイン」はアイデンティティのドメイン上の他のアドレスもキャッチオールのエイリアスとして扱い、From ヘッダーをそのアドレスに書き換えます。それらのアドレスが配布リストの場合は完全一致を選択してください。", + "exact": "完全一致のみ", + "domain": "自分のドメイン上の任意のアドレス" + }, "signature_position": { "label": "署名の位置", "description": "返信や転送で署名を挿入する位置。引用テキストの上は返信の締めとして自然に読めます。下は元のメッセージを続けて表示します。", @@ -1895,7 +1901,9 @@ "categories_description": "連絡先カテゴリの名前を変更", "no_categories": "カテゴリが見つかりません", "group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示", - "group_by_letter_label": "頭文字でグループ化" + "group_by_letter_label": "頭文字でグループ化", + "sort_by_last_name_label": "姓で並べ替え", + "sort_by_last_name_description": "家族が並んで表示されるように、連絡先一覧を姓の順に並べ替えます" }, "downloads": { "title": "ダウンロード", @@ -2739,7 +2747,10 @@ "any_month": "すべての月", "has_email": "メールあり", "has_phone": "電話あり", - "has_photo": "写真あり" + "has_photo": "写真あり", + "sort_by": "並べ替え", + "sort_first_name": "名", + "sort_last_name": "姓" }, "open_categories": "カテゴリを開く" }, diff --git a/locales/ko/common.json b/locales/ko/common.json index 5acb161f4..c5dc24de6 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1332,6 +1332,12 @@ "label": "답장 시 보내는 사람 자동 선택", "description": "답장할 때 메일을 받았던 주소로 보내는 사람을 자동으로 변경해요" }, + "reply_identity_match": { + "label": "수신 주소 일치 방식", + "description": "어떤 수신 주소를 내 주소로 간주할지 정합니다. '정확한 주소만'은 설정된 ID 중에서만 선택합니다. '같은 도메인'은 ID 도메인의 다른 주소도 캐치올 별칭으로 간주하여 보낸 사람 헤더를 해당 주소로 바꿉니다. 해당 주소가 배포 목록이라면 정확한 주소를 선택하세요.", + "exact": "정확한 주소만", + "domain": "내 도메인의 모든 주소" + }, "signature_position": { "label": "서명 위치", "description": "답장과 전달에서 서명을 삽입할 위치. 인용된 텍스트 위에 두면 답장의 마무리처럼 자연스럽게 읽히고, 아래에 두면 원본 메시지가 이어져 보입니다.", @@ -1895,7 +1901,9 @@ "categories_description": "연락처 카테고리 이름 변경", "no_categories": "카테고리를 찾을 수 없음", "group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시", - "group_by_letter_label": "첫 글자로 그룹화" + "group_by_letter_label": "첫 글자로 그룹화", + "sort_by_last_name_label": "성으로 정렬", + "sort_by_last_name_description": "가족 구성원이 함께 표시되도록 연락처 목록을 성 기준으로 정렬합니다" }, "downloads": { "title": "다운로드", @@ -2739,7 +2747,10 @@ "any_month": "모든 달", "has_email": "이메일 있음", "has_phone": "전화번호 있음", - "has_photo": "사진 있음" + "has_photo": "사진 있음", + "sort_by": "정렬 기준", + "sort_first_name": "이름", + "sort_last_name": "성" }, "open_categories": "카테고리 열기" }, diff --git a/locales/lv/common.json b/locales/lv/common.json index a2133d779..8ce4eda39 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1327,6 +1327,12 @@ "label": "Automātiski izvēlēties atbildes adresi", "description": "Atbildot automātiski izmantot to kontu, uz kuru vēstule tika saņemta" }, + "reply_identity_match": { + "label": "Saņemšanas adreses atbilstība", + "description": "Kuras saņemšanas adreses tiek uzskatītas par jūsu. „Tikai precīza adrese“ izvēlas vienīgi kādu no jūsu konfigurētajām identitātēm; „tas pats domēns“ arī jebkuru citu adresi jūsu identitāšu domēnos uzskata par catch-all aizstājvārdu un pārraksta ar to galveni „No“. Izvēlieties precīzu adresi, ja šīs adreses ir izplatīšanas saraksti.", + "exact": "Tikai precīza adrese", + "domain": "Jebkura adrese manos domēnos" + }, "signature_position": { "label": "Paraksta novietojums", "description": "Kur ievietot jūsu parakstu atbildēs un pārsūtīšanā. Virs citētā teksta tas dabiski lasās kā atbildes noslēgums; zem tā saglabā oriģinālo ziņojumu vienkopus.", @@ -1895,7 +1901,9 @@ "categories_description": "Pārdēvēt kontaktu kategorijas", "no_categories": "Kategorijas nav atrastas", "group_by_letter_description": "Rādīt alfabētiskos sadaļu virsrakstus kontaktu sarakstā", - "group_by_letter_label": "Grupēt pēc pirmā burta" + "group_by_letter_label": "Grupēt pēc pirmā burta", + "sort_by_last_name_label": "Kārtot pēc uzvārda", + "sort_by_last_name_description": "Kārtot kontaktu sarakstu pēc uzvārda, lai ģimenes locekļi būtu kopā" }, "downloads": { "title": "Lejupielādes", @@ -2739,7 +2747,10 @@ "any_month": "Jebkurš mēnesis", "has_email": "Ar e-pastu", "has_phone": "Ar tālruni", - "has_photo": "Ar foto" + "has_photo": "Ar foto", + "sort_by": "Kārtot pēc", + "sort_first_name": "Vārds", + "sort_last_name": "Uzvārds" }, "open_categories": "Atvērt kategorijas" }, diff --git a/locales/mn/common.json b/locales/mn/common.json index 28e281cd8..dcf70e5fb 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1335,6 +1335,12 @@ "label": "Хүлээн авсан хаягаас хариу бичих", "description": "Хариу бичихдээ мессежийг анх илгээсэн хаягаар нь илгээнэ үү. Эхлээд таних тэмдэгтэй таарах; Домэйн бүх хүргэлтийн хувьд, таны үндсэн таниулбараар дамжуулан илгээхдээ From толгой хэсгийг өөр нэр рүү дахин бичнэ." }, + "reply_identity_match": { + "label": "Хүлээн авах хаягийн тохирол", + "description": "Хүлээн авах ямар хаягуудыг таных гэж үзэх. „Зөвхөн яг таарах хаяг“ нь зөвхөн таны тохируулсан identity-уудаас сонгоно; „ижил домэйн“ нь таны identity-ийн домэйн дээрх бусад хаягийг ч catch-all нэр болгон үзэж, Илгээгч толгойг тэр хаягаар солино. Эдгээр хаяг нь түгээлтийн жагсаалт бол яг таарах хаягийг сонгоно уу.", + "exact": "Зөвхөн яг таарах хаяг", + "domain": "Миний домэйн дээрх дурын хаяг" + }, "signature_position": { "label": "Гарын үсэг зурах албан тушаал", "description": "Хариулт болон дамжуулалтад гарын үсгээ хаана оруулах вэ. Иш татсан бичвэрийн дээрх хариултыг хаах нь ойлгомжтой; доор нь эх мессежийг залгаж хадгална.", @@ -1892,6 +1898,8 @@ "title": "Харилцах утас", "description": "Харилцагчаа импортлох, экспортлох", "group_by_letter_label": "Эхний үсгээр бүлэглэх", + "sort_by_last_name_label": "Овгоор эрэмбэлэх", + "sort_by_last_name_description": "Гэр бүлийн гишүүд хамт харагдахаар харилцах хүмүүсийн жагсаалтыг овгоор эрэмбэлэх", "group_by_letter_description": "Харилцах хүмүүсийн жагсаалтад цагаан толгойн үсгийн дарааллаар хэсгийн толгойг харуулах", "import_label": "Харилцагчдыг импортлох", "import_description": "vCard (.vcf) файлаас харилцагчдыг импортлох", @@ -2740,7 +2748,10 @@ "any_month": "Ямар ч сар", "has_email": "Имэйл хаягтай", "has_phone": "Утастай", - "has_photo": "Зурагтай" + "has_photo": "Зурагтай", + "sort_by": "Эрэмбэлэх", + "sort_first_name": "Нэр", + "sort_last_name": "Овог" } }, "calendar": { diff --git a/locales/nb/common.json b/locales/nb/common.json index e37def473..4b4f2a917 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1335,6 +1335,12 @@ "label": "Svar fra mottatt adresse", "description": "Når du svarer, bruker Bulwark adressen meldingen opprinnelig ble sendt til. Programmet ser først etter en samsvarende identitet. For meldinger til et oppsamlingsdomene brukes aliaset i Fra-feltet, mens sendingen går gjennom hovedidentiteten din." }, + "reply_identity_match": { + "label": "Samsvar for mottaksadresse", + "description": "Hvilke mottaksadresser som regnes som dine. «Kun nøyaktig adresse» velger bare en av dine oppsatte identiteter; «samme domene» behandler i tillegg enhver annen adresse på identitetsdomenene dine som et catch-all-alias og skriver om Fra-headeren til den. Velg nøyaktig adresse hvis disse adressene er distribusjonslister.", + "exact": "Kun nøyaktig adresse", + "domain": "Enhver adresse på mine domener" + }, "signature_position": { "label": "Signaturposisjon", "description": "Velg hvor signaturen skal settes inn i svar og videresendinger. Over den siterte teksten fungerer den som en naturlig avslutning på svaret. Under den siterte teksten holdes den opprinnelige meldingen samlet.", @@ -1892,6 +1898,8 @@ "title": "Kontakter", "description": "Importer og eksporter kontaktene dine", "group_by_letter_label": "Grupper etter første bokstav", + "sort_by_last_name_label": "Sorter etter etternavn", + "sort_by_last_name_description": "Sorter kontaktlisten etter etternavn slik at familiemedlemmer står samlet", "group_by_letter_description": "Vis alfabetiske overskrifter i kontaktlisten", "import_label": "Importer kontakter", "import_description": "Importer kontakter fra en vCard-fil (.vcf).", @@ -2740,7 +2748,10 @@ "any_month": "Alle måneder", "has_email": "Har e-postadresse", "has_phone": "Har telefonnummer", - "has_photo": "Har bilde" + "has_photo": "Har bilde", + "sort_by": "Sorter etter", + "sort_first_name": "Fornavn", + "sort_last_name": "Etternavn" } }, "calendar": { diff --git a/locales/nl/common.json b/locales/nl/common.json index f27bbb48f..25eb18b30 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1327,6 +1327,12 @@ "label": "Antwoordadres automatisch selecteren", "description": "Schakel bij het beantwoorden automatisch het Van-adres om naar de identiteit die het oorspronkelijke bericht ontving" }, + "reply_identity_match": { + "label": "Overeenkomst van ontvangstadres", + "description": "Welke ontvangstadressen als de uwe gelden. „Alleen exact adres“ kiest uitsluitend een van uw ingestelde identiteiten; „zelfde domein“ behandelt ook elk ander adres op uw identiteitsdomeinen als catch-all-alias en herschrijft de Van-header ernaar. Kies exact adres als die adressen distributielijsten zijn.", + "exact": "Alleen exact adres", + "domain": "Elk adres op mijn domeinen" + }, "signature_position": { "label": "Positie van handtekening", "description": "Waar je handtekening in antwoorden en doorgestuurde berichten moet worden ingevoegd. Boven de geciteerde tekst leest natuurlijk als afsluiting van het antwoord; eronder houdt het originele bericht aaneengesloten.", @@ -1895,7 +1901,9 @@ "categories_description": "Contactcategorieën hernoemen", "no_categories": "Geen categorieën gevonden", "group_by_letter_description": "Toon alfabetische sectiekoppen in de contactenlijst", - "group_by_letter_label": "Groeperen op eerste letter" + "group_by_letter_label": "Groeperen op eerste letter", + "sort_by_last_name_label": "Sorteren op achternaam", + "sort_by_last_name_description": "Sorteer de contactenlijst op achternaam zodat gezinsleden bij elkaar staan" }, "downloads": { "title": "Downloads", @@ -2739,7 +2747,10 @@ "any_month": "Elke maand", "has_email": "Met e-mail", "has_phone": "Met telefoon", - "has_photo": "Met foto" + "has_photo": "Met foto", + "sort_by": "Sorteren op", + "sort_first_name": "Voornaam", + "sort_last_name": "Achternaam" }, "open_categories": "Categorieën openen" }, diff --git a/locales/pl/common.json b/locales/pl/common.json index 69b0cb99f..a84af2437 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1332,6 +1332,12 @@ "label": "Automatycznie wybieraj adres odpowiedzi", "description": "Podczas odpowiadania automatycznie przełączaj adres nadawcy na tożsamość, która pierwotnie otrzymała wiadomość" }, + "reply_identity_match": { + "label": "Dopasowanie adresu odbioru", + "description": "Które adresy odbioru są uznawane za Twoje. „Tylko dokładny adres” wybiera wyłącznie jedną ze skonfigurowanych tożsamości; „ta sama domena” traktuje dodatkowo każdy inny adres w domenach Twoich tożsamości jako alias catch-all i przepisuje na niego nagłówek Od. Wybierz dokładny adres, jeśli te adresy to listy dystrybucyjne.", + "exact": "Tylko dokładny adres", + "domain": "Dowolny adres w moich domenach" + }, "signature_position": { "label": "Pozycja podpisu", "description": "Gdzie wstawić podpis w odpowiedziach i wiadomościach przekazanych dalej. Nad cytowanym tekstem brzmi naturalnie jako zakończenie odpowiedzi; pod nim zachowuje oryginalną wiadomość w całości.", @@ -1895,7 +1901,9 @@ "categories_description": "Zmień nazwy kategorii kontaktów", "no_categories": "Nie znaleziono kategorii", "group_by_letter_description": "Pokaż alfabetyczne nagłówki sekcji na liście kontaktów", - "group_by_letter_label": "Grupuj według pierwszej litery" + "group_by_letter_label": "Grupuj według pierwszej litery", + "sort_by_last_name_label": "Sortuj według nazwiska", + "sort_by_last_name_description": "Uporządkuj listę kontaktów według nazwiska, aby członkowie rodziny byli obok siebie" }, "downloads": { "title": "Pobrane", @@ -2739,7 +2747,10 @@ "any_month": "Dowolny miesiąc", "has_email": "Z e-mailem", "has_phone": "Z telefonem", - "has_photo": "Ze zdjęciem" + "has_photo": "Ze zdjęciem", + "sort_by": "Sortuj według", + "sort_first_name": "Imię", + "sort_last_name": "Nazwisko" }, "open_categories": "Otwórz kategorie" }, diff --git a/locales/pt/common.json b/locales/pt/common.json index d1970d94f..eed73ebdb 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1327,6 +1327,12 @@ "label": "Selecionar automaticamente o endereço de resposta", "description": "Ao responder, muda automaticamente o endereço do remetente para a identidade que recebeu a mensagem original" }, + "reply_identity_match": { + "label": "Correspondência do endereço de receção", + "description": "Quais endereços de receção contam como seus. «Apenas endereço exato» escolhe somente uma das suas identidades configuradas; «mesmo domínio» trata também qualquer outro endereço dos seus domínios de identidade como alias catch-all e reescreve o cabeçalho De com ele. Escolha o endereço exato se esses endereços forem listas de distribuição.", + "exact": "Apenas endereço exato", + "domain": "Qualquer endereço dos meus domínios" + }, "signature_position": { "label": "Posição da assinatura", "description": "Onde inserir a sua assinatura em respostas e encaminhamentos. Acima do texto citado lê-se naturalmente como fecho da resposta; abaixo mantém a mensagem original contígua.", @@ -1895,7 +1901,9 @@ "categories_description": "Renomear categorias de contatos", "no_categories": "Nenhuma categoria encontrada", "group_by_letter_description": "Mostrar cabeçalhos de seção alfabéticos na lista de contatos", - "group_by_letter_label": "Agrupar pela primeira letra" + "group_by_letter_label": "Agrupar pela primeira letra", + "sort_by_last_name_label": "Ordenar por sobrenome", + "sort_by_last_name_description": "Ordenar a lista de contatos por sobrenome para que os membros da família apareçam juntos" }, "downloads": { "title": "Downloads", @@ -2739,7 +2747,10 @@ "any_month": "Qualquer mês", "has_email": "Com e-mail", "has_phone": "Com telefone", - "has_photo": "Com foto" + "has_photo": "Com foto", + "sort_by": "Ordenar por", + "sort_first_name": "Nome", + "sort_last_name": "Sobrenome" }, "open_categories": "Abrir categorias" }, diff --git a/locales/ro/common.json b/locales/ro/common.json index f5f9dd539..ca6b20557 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1335,6 +1335,12 @@ "label": "Răspunde de la adresa de la care a fost primit mesajul", "description": "Când răspundeți, trimiteți mesajul de la adresa la care a fost trimis inițial. Se potrivesc mai întâi identitățile; pentru livrările de tip „catch-all” ale domeniului, antetul „De la” este rescris cu aliasul, în timp ce trimiterea se face prin identitatea dvs. principală." }, + "reply_identity_match": { + "label": "Potrivirea adresei de primire", + "description": "Care adrese de primire sunt considerate ale tale. „Doar adresa exactă” alege numai una dintre identitățile configurate; „același domeniu” tratează și orice altă adresă de pe domeniile identităților tale ca alias catch-all și rescrie antetul De la cu ea. Alege adresa exactă dacă aceste adrese sunt liste de distribuție.", + "exact": "Doar adresa exactă", + "domain": "Orice adresă de pe domeniile mele" + }, "signature_position": { "label": "Poziția semnăturii", "description": "Unde să inserați semnătura în răspunsuri și redirecționări. Deasupra textului citat, semnătura se citește în mod natural ca o încheiere a răspunsului; sub text, mesajul original rămâne contiguu.", @@ -1892,6 +1898,8 @@ "title": "Contacte", "description": "Importați și exportați contactele", "group_by_letter_label": "Grupați după prima literă", + "sort_by_last_name_label": "Sortare după nume de familie", + "sort_by_last_name_description": "Ordonați lista de contacte după numele de familie, astfel încât membrii familiei să apară împreună", "group_by_letter_description": "Afișați titlurile secțiunilor în ordine alfabetică în lista de contacte", "import_label": "Importați contactele", "import_description": "Importați contactele dintr-un fișier „vCard” (.vcf)", @@ -2740,7 +2748,10 @@ "any_month": "Orice lună", "has_email": "Are e-mail", "has_phone": "Are telefon", - "has_photo": "Are fotografie" + "has_photo": "Are fotografie", + "sort_by": "Sortare după", + "sort_first_name": "Prenume", + "sort_last_name": "Nume de familie" } }, "calendar": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 3a51f6e36..867a2c7a8 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1327,6 +1327,12 @@ "label": "Автоматически выбирать адрес для ответа", "description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение" }, + "reply_identity_match": { + "label": "Сопоставление адреса получения", + "description": "Какие адреса получения считать вашими. «Только точный адрес» выбирает исключительно одну из настроенных личностей; «тот же домен» также считает любой другой адрес на доменах ваших личностей catch-all-псевдонимом и подставляет его в заголовок «От». Выберите точный адрес, если такие адреса являются списками рассылки.", + "exact": "Только точный адрес", + "domain": "Любой адрес на моих доменах" + }, "signature_position": { "label": "Положение подписи", "description": "Куда вставлять подпись в ответах и пересылке. Над цитируемым текстом она читается естественно как завершение ответа; под ним сохраняет целостность исходного сообщения.", @@ -1895,7 +1901,9 @@ "categories_description": "Переименование категорий контактов", "no_categories": "Категории не найдены", "group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов", - "group_by_letter_label": "Группировать по первой букве" + "group_by_letter_label": "Группировать по первой букве", + "sort_by_last_name_label": "Сортировать по фамилии", + "sort_by_last_name_description": "Упорядочить список контактов по фамилии, чтобы члены семьи шли подряд" }, "downloads": { "title": "Загрузки", @@ -2739,7 +2747,10 @@ "any_month": "Любой месяц", "has_email": "С эл. почтой", "has_phone": "С телефоном", - "has_photo": "С фото" + "has_photo": "С фото", + "sort_by": "Сортировать по", + "sort_first_name": "Имя", + "sort_last_name": "Фамилия" }, "open_categories": "Открыть категории" }, diff --git a/locales/sk/common.json b/locales/sk/common.json index 38a9537ed..adbebc270 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1335,6 +1335,12 @@ "label": "Odpovedať z prijatej adresy", "description": "Pri odpovedaní automaticky prepnúť adresu odosielateľa na identitu, ktorá pôvodne prijala správu" }, + "reply_identity_match": { + "label": "Porovnávanie prijímacej adresy", + "description": "Ktoré prijímacie adresy sa považujú za vaše. „Iba presná adresa“ vyberie len jednu z vašich nastavených identít; „rovnaká doména“ navyše považuje akúkoľvek inú adresu na doménach vašich identít za catch-all alias a prepíše na ňu hlavičku Od. Ak sú tieto adresy distribučné zoznamy, zvoľte presnú adresu.", + "exact": "Iba presná adresa", + "domain": "Akákoľvek adresa na mojich doménach" + }, "signature_position": { "label": "Pozícia podpisu", "description": "Kam vložiť podpis v odpovediach a preposlaniach.", @@ -1892,6 +1898,8 @@ "title": "Kontakty", "description": "Import a export kontaktov", "group_by_letter_label": "Zoskupiť podľa prvého písmena", + "sort_by_last_name_label": "Zoradiť podľa priezviska", + "sort_by_last_name_description": "Zoradiť zoznam kontaktov podľa priezviska, aby členovia rodiny boli pri sebe", "group_by_letter_description": "Zobraziť abecedné záhlavia sekcií v zozname kontaktov", "import_label": "Importovať kontakty", "import_description": "Importovať kontakty zo súboru vCard (.vcf)", @@ -2740,7 +2748,10 @@ "any_month": "Ktorýkoľvek mesiac", "has_email": "Má e-mail", "has_phone": "Má telefón", - "has_photo": "Má fotku" + "has_photo": "Má fotku", + "sort_by": "Zoradiť podľa", + "sort_first_name": "Meno", + "sort_last_name": "Priezvisko" } }, "calendar": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 8df0a36a7..0ad4e09b9 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1332,6 +1332,12 @@ "label": "Yanıt Adresini Otomatik Seç", "description": "Yanıtlarken, Kimden adresini iletiyi başlangıçta alan kimliğe otomatik olarak değiştir" }, + "reply_identity_match": { + "label": "Alıcı adresi eşleştirme", + "description": "Hangi alıcı adreslerinin sizin sayılacağı. „Yalnızca tam adres“ sadece yapılandırılmış kimliklerinizden birini seçer; „aynı alan adı“ ayrıca kimlik alan adlarınızdaki diğer tüm adresleri catch-all takma adı olarak değerlendirir ve Kimden başlığını buna göre yeniden yazar. Bu adresler dağıtım listesiyse tam adresi seçin.", + "exact": "Yalnızca tam adres", + "domain": "Alan adlarımdaki herhangi bir adres" + }, "signature_position": { "label": "İmza konumu", "description": "Yanıtlarda ve iletmelerde imzanızın nereye ekleneceği. Alıntılanan metnin üzerinde, yanıt için doğal bir kapanış olarak okunur; altında ise orijinal mesajı bir bütün hâlinde tutar.", @@ -1885,6 +1891,8 @@ "title": "Kişiler", "description": "Kişilerinizi içe ve dışa aktarın", "group_by_letter_label": "İlk harfe göre grupla", + "sort_by_last_name_label": "Soyada göre sırala", + "sort_by_last_name_description": "Aile üyeleri bir arada görünsün diye kişi listesini soyada göre sıralar", "group_by_letter_description": "Kişi listesinde alfabetik bölüm başlıkları göster", "import_label": "Kişileri İçe Aktar", "import_description": "Kişileri bir vCard (.vcf) dosyasından içe aktarın", @@ -2739,7 +2747,10 @@ "any_month": "Herhangi bir ay", "has_email": "E-postası var", "has_phone": "Telefonu var", - "has_photo": "Fotoğrafı var" + "has_photo": "Fotoğrafı var", + "sort_by": "Sıralama", + "sort_first_name": "Ad", + "sort_last_name": "Soyad" }, "open_categories": "Kategorileri aç" }, diff --git a/locales/uk/common.json b/locales/uk/common.json index 1910a3c77..3d1e45ac1 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1332,6 +1332,12 @@ "label": "Автоматичний вибір адреси для відповіді", "description": "Під час відповіді автоматично змінюйте адресу відправника на особу, яка спочатку отримала повідомлення" }, + "reply_identity_match": { + "label": "Зіставлення адреси отримання", + "description": "Які адреси отримання вважати вашими. «Лише точна адреса» вибирає виключно одну з налаштованих ідентичностей; «той самий домен» також вважає будь-яку іншу адресу на доменах ваших ідентичностей catch-all-псевдонімом і підставляє її в заголовок «Від». Виберіть точну адресу, якщо такі адреси є списками розсилки.", + "exact": "Лише точна адреса", + "domain": "Будь-яка адреса на моїх доменах" + }, "signature_position": { "label": "Розташування підпису", "description": "Куди вставляти підпис у відповідях і пересиланнях. Над цитованим текстом читається природно як завершення відповіді; під ним зберігає цілісність оригінального повідомлення.", @@ -1895,7 +1901,9 @@ "categories_description": "Перейменувати категорії контактів", "no_categories": "Категорії не знайдено", "group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів", - "group_by_letter_label": "Групувати за першою літерою" + "group_by_letter_label": "Групувати за першою літерою", + "sort_by_last_name_label": "Сортувати за прізвищем", + "sort_by_last_name_description": "Упорядкувати список контактів за прізвищем, щоб члени родини були поруч" }, "downloads": { "title": "Завантаження", @@ -2739,7 +2747,10 @@ "any_month": "Будь-який місяць", "has_email": "З ел. поштою", "has_phone": "З телефоном", - "has_photo": "З фото" + "has_photo": "З фото", + "sort_by": "Сортувати за", + "sort_first_name": "Ім'я", + "sort_last_name": "Прізвище" }, "open_categories": "Відкрити категорії" }, diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b2bdfc864..6bc11c2d1 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1335,6 +1335,12 @@ "label": "使用收件地址回覆", "description": "回覆時,使用原始郵件的收件地址寄送。系統會先比對寄件身分;若是網域 catch-all 收件,則透過主要寄件身分傳送,但將 From 標頭改寫為別名地址。" }, + "reply_identity_match": { + "label": "接收地址比對", + "description": "哪些接收地址算作您的地址。「僅精確地址」只從已設定的身分中選擇;「相同網域」還會將身分網域下的其他任何地址視為 catch-all 別名,並將寄件者標頭改寫為該地址。如果這些地址是通訊群組清單,請選擇精確地址。", + "exact": "僅精確地址", + "domain": "我的網域下的任意地址" + }, "signature_position": { "label": "簽名檔位置", "description": "在回覆與轉寄中插入簽名檔的位置。放在引用文字上方可自然作為回覆結尾;放在下方則能保持原始郵件連續。", @@ -1892,6 +1898,8 @@ "title": "聯絡人", "description": "匯入與匯出聯絡人", "group_by_letter_label": "依首字分組", + "sort_by_last_name_label": "依姓氏排序", + "sort_by_last_name_description": "依姓氏排列聯絡人清單,讓家庭成員顯示在一起", "group_by_letter_description": "在聯絡人清單中顯示依字母排列的分節標題", "import_label": "匯入聯絡人", "import_description": "從 vCard(.vcf)檔案匯入聯絡人", @@ -2740,7 +2748,10 @@ "any_month": "任何月份", "has_email": "有電子郵件地址", "has_phone": "有電話", - "has_photo": "有照片" + "has_photo": "有照片", + "sort_by": "排序方式", + "sort_first_name": "名", + "sort_last_name": "姓" } }, "calendar": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 1eeba3c9c..4b25e437d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1332,6 +1332,12 @@ "label": "自动选择回复地址", "description": "回复时自动将发件人地址切换为最初收到该邮件的身份" }, + "reply_identity_match": { + "label": "接收地址匹配", + "description": "哪些接收地址算作您的地址。“仅精确地址”只从已配置的身份中选择;“相同域名”还会将身份域名下的其他任何地址视为 catch-all 别名,并将发件人标头改写为该地址。如果这些地址是分发列表,请选择精确地址。", + "exact": "仅精确地址", + "domain": "我的域名下的任意地址" + }, "signature_position": { "label": "签名位置", "description": "在回复和转发中插入签名的位置。位于引用文本上方时,可作为回复的自然结尾;位于下方时,保持原始邮件连贯。", @@ -1895,7 +1901,9 @@ "categories_description": "重命名联系人类别", "no_categories": "未找到类别", "group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题", - "group_by_letter_label": "按首字母分组" + "group_by_letter_label": "按首字母分组", + "sort_by_last_name_label": "按姓氏排序", + "sort_by_last_name_description": "按姓氏排列联系人列表,使家庭成员显示在一起" }, "downloads": { "title": "下载", @@ -2739,7 +2747,10 @@ "any_month": "任意月份", "has_email": "有邮箱", "has_phone": "有电话", - "has_photo": "有照片" + "has_photo": "有照片", + "sort_by": "排序方式", + "sort_first_name": "名", + "sort_last_name": "姓" }, "open_categories": "打开分类" }, diff --git a/stores/__tests__/auth-store-login-precheck.test.ts b/stores/__tests__/auth-store-login-precheck.test.ts new file mode 100644 index 000000000..8e4ab7b96 --- /dev/null +++ b/stores/__tests__/auth-store-login-precheck.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '@/lib/jmap/client'; +import { useAuthStore } from '../auth-store'; +import { useAccountStore } from '../account-store'; + +type FetchInput = Parameters[0]; +type FetchInit = Parameters[1]; + +const SERVER = 'https://mail.example.com'; + +// The login form's Basic-auth path asks /api/auth/verify first (#969) so a +// wrong password is rejected by our own origin as JSON instead of by the JMAP +// server as 401 + WWW-Authenticate: Basic, which would make the browser open +// its native login dialog on same-origin deployments. +describe('auth-store login Basic-auth pre-check (#969)', () => { + let connectSpy: ReturnType; + + beforeEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + window.history.pushState({}, '', '/en/login'); + + useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null }); + useAuthStore.setState({ + isAuthenticated: false, + isLoading: false, + error: null, + serverUrl: null, + username: null, + client: null, + identities: [], + primaryIdentity: null, + authMode: 'basic', + rememberMe: false, + accessToken: null, + tokenExpiresAt: null, + connectionLost: false, + activeAccountId: null, + }); + + // Whatever the pre-check decides, a browser-side connect in these tests + // ends the login with a server error so we never reach the post-connect + // identity/settings machinery. + connectSpy = vi.spyOn(JMAPClient.prototype, 'connect') + .mockRejectedValue(new Error('Failed to get session: 503')); + }); + + afterEach(() => { + connectSpy.mockRestore(); + }); + + function stubVerify(handler: (init?: FetchInit) => Promise) { + const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => { + const url = String(input); + if (url === '/api/auth/verify') return handler(init); + throw new Error(`unexpected fetch ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; + } + + it('rejects wrong credentials from the backend without touching the JMAP server from the browser', async () => { + const fetchMock = stubVerify(async () => ({ ok: true, json: async () => ({ result: 'unauthorized' }) })); + + const ok = await useAuthStore.getState().login(SERVER, 'alice', 'wrong'); + + expect(ok).toBe(false); + expect(connectSpy).not.toHaveBeenCalled(); + expect(useAuthStore.getState().error).toBe('invalid_credentials'); + expect(useAuthStore.getState().isLoading).toBe(false); + + const [, init] = fetchMock.mock.calls[0]; + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toEqual({ serverUrl: SERVER, username: 'alice', password: 'wrong' }); + }); + + it('falls through to the browser-side connect when the pre-check is inconclusive', async () => { + stubVerify(async () => ({ ok: true, json: async () => ({ result: 'inconclusive' }) })); + + const ok = await useAuthStore.getState().login(SERVER, 'alice', 'pw'); + + expect(ok).toBe(false); + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(useAuthStore.getState().error).toBe('server_error'); + }); + + it('falls through when the pre-check route itself fails or is missing', async () => { + stubVerify(async () => ({ ok: false, status: 404, json: async () => ({}) })); + await useAuthStore.getState().login(SERVER, 'alice', 'pw'); + expect(connectSpy).toHaveBeenCalledTimes(1); + + connectSpy.mockClear(); + stubVerify(async () => { throw new TypeError('Failed to fetch'); }); + await useAuthStore.getState().login(SERVER, 'alice', 'pw'); + expect(connectSpy).toHaveBeenCalledTimes(1); + }); + + it('skips the pre-check for app-relative (mock) servers', async () => { + const fetchMock = stubVerify(async () => ({ ok: true, json: async () => ({ result: 'unauthorized' }) })); + + await useAuthStore.getState().login('/api/dev-jmap', 'alice', 'pw'); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(connectSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/stores/__tests__/contact-sort-name.test.ts b/stores/__tests__/contact-sort-name.test.ts new file mode 100644 index 000000000..96e82f323 --- /dev/null +++ b/stores/__tests__/contact-sort-name.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { getContactSortName } from '../contact-store'; +import type { ContactCard } from '@/lib/jmap/types'; + +const make = (overrides: Partial): ContactCard => ({ + id: 'c1', + addressBookIds: {}, + ...overrides, +}); + +const structured = make({ + name: { + components: [ + { kind: 'given', value: 'Alice' }, + { kind: 'middle', value: 'Jane' }, + { kind: 'surname', value: 'Smith' }, + ], + isOrdered: true, + }, +}); + +describe('getContactSortName (#963)', () => { + it('returns the display name when not sorting by last name', () => { + expect(getContactSortName(structured, false)).toBe('Alice Smith'); + }); + + it('leads with the surname when sorting by last name', () => { + expect(getContactSortName(structured, true)).toBe('Smith, Alice Jane'); + }); + + it('returns just the surname when no given name exists', () => { + const c = make({ name: { components: [{ kind: 'surname', value: 'Smith' }], isOrdered: true } }); + expect(getContactSortName(c, true)).toBe('Smith'); + }); + + it('uses the last word of name.full when there are no components', () => { + const c = make({ name: { full: 'Jean Pierre Dupont' } }); + expect(getContactSortName(c, true)).toBe('Dupont, Jean Pierre'); + }); + + it('keeps a single-word name.full as-is', () => { + const c = make({ name: { full: 'Madonna' } }); + expect(getContactSortName(c, true)).toBe('Madonna'); + }); + + it('does not split organization or email fallbacks into a surname', () => { + const org = make({ organizations: { o1: { name: 'Acme Corp' } } }); + expect(getContactSortName(org, true)).toBe('Acme Corp'); + const mail = make({ emails: { e0: { address: 'someone@example.com' } } }); + expect(getContactSortName(mail, true)).toBe('someone@example.com'); + }); + + it('falls back to the display name for a given-only name (e.g. a group)', () => { + const c = make({ kind: 'group', name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true } }); + expect(getContactSortName(c, true)).toBe('Team'); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index e76b326cf..fcace4e8e 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -90,6 +90,34 @@ function isRateLimitError(error: unknown): error is RateLimitError { return error instanceof RateLimitError; } +/** + * Ask our own backend to try the Basic credentials before the browser does + * (#969). A wrong password answered straight from the JMAP server arrives as + * 401 + `WWW-Authenticate: Basic`, which makes the browser open its native + * login dialog on top of our form when the JMAP server shares our origin + * (reverse-proxied under the same host). Rejecting wrong credentials via a + * JSON reply from our origin sidesteps that. Only a definitive + * `unauthorized` short-circuits; anything else (route missing, backend can't + * reach the JMAP server, TOTP challenge, ...) falls through to the regular + * browser-side connect so no deployment loses the ability to log in. + */ +async function precheckBasicCredentials(serverUrl: string, username: string, password: string): Promise { + // App-relative servers (the dev mock) never send a Basic challenge. + if (serverUrl.startsWith('/')) return false; + try { + const res = await apiFetch('/api/auth/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ serverUrl, username, password }), + }); + if (!res.ok) return false; + const body = await res.json().catch(() => null); + return body?.result === 'unauthorized'; + } catch { + return false; + } +} + // An auth/session endpoint answered with a server-side error (5xx) - an // outage, not a rejection of our credentials. class TransientAuthError extends Error { @@ -725,6 +753,9 @@ export const useAuthStore = create()( } else { // Legacy fallback for pre-0.16 Stalwart, which accepts the TOTP // appended to the password over basic auth. + if (await precheckBasicCredentials(serverUrl, username, `${password}$${totp}`)) { + throw new Error('Invalid username or password'); + } client = new JMAPClient(serverUrl, username, `${password}$${totp}`); await client.connect(); const { useTotpReauthStore } = await import('@/stores/totp-reauth-store'); @@ -732,6 +763,9 @@ export const useAuthStore = create()( debug.log('auth', 'TOTP re-auth enabled (legacy basic-auth path)'); } } else { + if (await precheckBasicCredentials(serverUrl, username, password)) { + throw new Error('Invalid username or password'); + } client = new JMAPClient(serverUrl, username, password); await client.connect(); } diff --git a/stores/contact-store.ts b/stores/contact-store.ts index cbeef8e69..77e2e51f4 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -149,6 +149,34 @@ export function getContactDisplayName(contact: ContactCard): string { return ''; } +// Name used to order (and letter-group) the contact list. With `byLastName` +// the surname leads ("Smith, Alice") so family members sit together (#963). +// Contacts without a structured surname fall back to the last word of +// `name.full`; everything else (nickname, org, email) keeps the display name. +export function getContactSortName(contact: ContactCard, byLastName: boolean): string { + const display = getContactDisplayName(contact); + if (!byLastName) return display; + const components = contact.name?.components; + if (components && components.length > 0) { + const pick = (...kinds: string[]) => + components.filter(c => kinds.includes(c.kind) && c.value).map(c => c.value).join(' '); + const surname = pick('surname', 'surname2'); + if (surname) { + const rest = pick('given', 'given2', 'middle', 'additional'); + return rest ? `${surname}, ${rest}` : surname; + } + } + const full = contact.name?.full; + if (full && display === full) { + const words = full.trim().split(/\s+/); + if (words.length > 1) { + const last = words[words.length - 1]; + return `${last}, ${words.slice(0, -1).join(' ')}`; + } + } + return display; +} + export function getContactPrimaryEmail(contact: ContactCard): string { if (!contact.emails) return ''; return Object.values(contact.emails)[0]?.address || ''; diff --git a/stores/settings-store.ts b/stores/settings-store.ts index fb5840653..75d8372c6 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -102,6 +102,7 @@ export type ListDensity = Density; export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent'; export type ReplyMode = 'reply' | 'replyAll'; export type SignaturePosition = 'above_quote' | 'below_quote'; +export type ReplyIdentityMatch = 'exact' | 'domain'; /** How to handle an incoming Disposition-Notification-To (read-receipt) request. */ export type ReadReceiptResponse = 'ask' | 'always' | 'never'; export type DateFormat = 'smart' | 'relative' | 'full'; @@ -343,6 +344,7 @@ interface SettingsState { sendConfirmation: boolean; defaultReplyMode: ReplyMode; autoSelectReplyIdentity: boolean; + replyIdentityMatch: ReplyIdentityMatch; // With autoSelectReplyIdentity on: 'exact' = configured identities only, 'domain' = also same-domain catch-all addresses (rewrites From) #1000 plainTextMode: boolean; // Send plain text only (no rich text editor) rtlEditingSupport: boolean; // Show a per-paragraph LTR/RTL direction control in the composer (Gmail-style) subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@") @@ -386,6 +388,9 @@ interface SettingsState { // Contacts Display groupContactsByLetter: boolean; + // Sort (and group) the contact list by surname instead of given name so + // family members sit together (#963). + sortContactsByLastName: boolean; // Email Notifications emailNotificationsEnabled: boolean; @@ -582,6 +587,7 @@ const DEFAULT_SETTINGS = { sendConfirmation: false, defaultReplyMode: 'reply' as ReplyMode, autoSelectReplyIdentity: false, + replyIdentityMatch: 'domain' as ReplyIdentityMatch, plainTextMode: false, rtlEditingSupport: false, subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER, @@ -617,6 +623,7 @@ const DEFAULT_SETTINGS = { // Contacts Display groupContactsByLetter: true, + sortContactsByLastName: false, // Email Notifications emailNotificationsEnabled: true, @@ -808,6 +815,7 @@ export const useSettingsStore = create()( sendConfirmation: state.sendConfirmation, defaultReplyMode: state.defaultReplyMode, autoSelectReplyIdentity: state.autoSelectReplyIdentity, + replyIdentityMatch: state.replyIdentityMatch, plainTextMode: state.plainTextMode, rtlEditingSupport: state.rtlEditingSupport, subAddressDelimiter: state.subAddressDelimiter, @@ -831,6 +839,7 @@ export const useSettingsStore = create()( birthdayCalendarColor: state.birthdayCalendarColor, sharedCalendarColors: state.sharedCalendarColors, groupContactsByLetter: state.groupContactsByLetter, + sortContactsByLastName: state.sortContactsByLastName, expandedFilterView: state.expandedFilterView, showTimeInMonthView: state.showTimeInMonthView, showWeekNumbers: state.showWeekNumbers, @@ -1390,8 +1399,10 @@ if (typeof window !== 'undefined') { }; // Ensure template-store is loaded (and the bridge registered) even before // any UI component imports it, so the first sync push already carries the - // templates. - void import('./template-store'); + // templates. Best effort: the UI imports the store itself when it needs it, + // and under vitest a short test file can finish (and tear its environment + // down) before this chain has loaded, which rejects the import. + import('./template-store').catch(() => {}); } /**