From f5fb7f9db917e914cc3b3612c04116954b4cb44c Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 2 Oct 2026 11:29:32 +0200 Subject: [PATCH 01/35] feat: out of office forwarding and recipients, filter rule periods Filter rules can be limited to a period: the rule dialog takes a start and an end on the user's clock, the rule list shows whether a rule is planned, active or expired, and the script tests the moment of delivery with RFC 5260 currentdate in UTC. A rule is never written without its period. The vacation card becomes an out of office page: the period on top, then the automatic reply and forwarding, each with its own switch. Forwarding runs with or without the auto-reply, within the period, never for spam, and keeps a copy here or not. Against the server's redirect limit it counts as a rule ahead of all others, one that stops the message unless it keeps a copy. The auto-reply can go to internal or external senders only, told apart by the envelope sender against the account's own domains. Both run from the filters script that includes Stalwart's vacation script, which is put back in charge when the server's own script takes over. A script that uses any of this says metadata version 2, which older builds leave alone instead of writing it back without these fields. --- .../__tests__/filter-rule-modal-dst.test.tsx | 53 ++ .../filter-rule-modal-period.test.tsx | 263 ++++++++++ .../__tests__/quick-rule-host.test.tsx | 113 +++++ components/filters/filter-rule-modal.tsx | 126 ++++- components/filters/quick-rule-host.tsx | 29 ++ .../__tests__/filter-settings-save.test.tsx | 187 +++++++ .../__tests__/vacation-forward.test.tsx | 464 +++++++++++++++++ components/settings/filter-settings.tsx | 56 ++- components/settings/vacation-settings.tsx | 268 ++++++++-- lib/__tests__/timezone.test.ts | 10 + .../__tests__/forward-limit-vacation.test.ts | 59 +++ lib/filters/__tests__/quick-rules.test.ts | 9 + lib/filters/__tests__/retroactive.test.ts | 8 + lib/filters/__tests__/sieve-mock.ts | 47 ++ lib/filters/account-filters.ts | 4 +- lib/filters/forward-limit.ts | 12 +- lib/filters/quick-rules.ts | 6 +- lib/filters/retroactive.ts | 10 +- lib/jmap/sieve-types.ts | 51 +- lib/sieve/__tests__/generator-compat.test.ts | 161 ++++++ lib/sieve/__tests__/parser.test.ts | 2 +- lib/sieve/__tests__/period.test.ts | 242 +++++++++ lib/sieve/__tests__/vacation-audience.test.ts | 124 +++++ lib/sieve/__tests__/vacation-forward.test.ts | 170 +++++++ lib/sieve/generator.ts | 95 +++- lib/sieve/parser.ts | 31 +- lib/sieve/period.ts | 87 ++++ lib/sieve/vacation-audience.ts | 30 ++ lib/sieve/vacation-forward.ts | 55 +++ lib/timezone.ts | 14 + locales/ar/common.json | 57 ++- locales/ca/common.json | 57 ++- locales/cs/common.json | 57 ++- locales/da/common.json | 57 ++- locales/de/common.json | 57 ++- locales/en/common.json | 57 ++- locales/es/common.json | 57 ++- locales/fa/common.json | 57 ++- locales/fr/common.json | 57 ++- locales/he/common.json | 57 ++- locales/hu/common.json | 57 ++- locales/it/common.json | 57 ++- locales/ja/common.json | 57 ++- locales/ko/common.json | 57 ++- locales/lv/common.json | 57 ++- locales/mn/common.json | 57 ++- locales/nb/common.json | 57 ++- locales/nl/common.json | 53 +- locales/pl/common.json | 57 ++- locales/pt/common.json | 57 ++- locales/ro/common.json | 57 ++- locales/ru/common.json | 57 ++- locales/sk/common.json | 57 ++- locales/tr/common.json | 57 ++- locales/uk/common.json | 57 ++- locales/zh-TW/common.json | 57 ++- locales/zh/common.json | 57 ++- stores/__tests__/filter-store.test.ts | 6 +- stores/__tests__/vacation-audience.test.ts | 168 +++++++ stores/__tests__/vacation-forward.test.ts | 465 ++++++++++++++++++ stores/filter-store.ts | 201 +++++++- stores/vacation-store.ts | 186 +++++-- 62 files changed, 4881 insertions(+), 466 deletions(-) create mode 100644 components/filters/__tests__/filter-rule-modal-dst.test.tsx create mode 100644 components/filters/__tests__/filter-rule-modal-period.test.tsx create mode 100644 components/filters/__tests__/quick-rule-host.test.tsx create mode 100644 components/settings/__tests__/filter-settings-save.test.tsx create mode 100644 components/settings/__tests__/vacation-forward.test.tsx create mode 100644 lib/filters/__tests__/forward-limit-vacation.test.ts create mode 100644 lib/sieve/__tests__/generator-compat.test.ts create mode 100644 lib/sieve/__tests__/period.test.ts create mode 100644 lib/sieve/__tests__/vacation-audience.test.ts create mode 100644 lib/sieve/__tests__/vacation-forward.test.ts create mode 100644 lib/sieve/period.ts create mode 100644 lib/sieve/vacation-audience.ts create mode 100644 lib/sieve/vacation-forward.ts create mode 100644 stores/__tests__/vacation-audience.test.ts create mode 100644 stores/__tests__/vacation-forward.test.ts diff --git a/components/filters/__tests__/filter-rule-modal-dst.test.tsx b/components/filters/__tests__/filter-rule-modal-dst.test.tsx new file mode 100644 index 000000000..0924bf93c --- /dev/null +++ b/components/filters/__tests__/filter-rule-modal-dst.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { FilterRule } from '@/lib/jmap/sieve-types'; +import { getBrowserTimeZone } from '@/lib/timezone'; +import { useSettingsStore } from '@/stores/settings-store'; +import { FilterRuleModal } from '../filter-rule-modal'; + +vi.mock('@/stores/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })); + +// The browser runs on Berlin time, the user's setting says New York. Set +// before the imports run: the app reads the browser zone once and keeps it. +const ORIGINAL_TZ = vi.hoisted(() => { + const original = process.env.TZ; + process.env.TZ = 'Europe/Berlin'; + return original; +}); +beforeAll(() => { + expect(getBrowserTimeZone()).toBe('Europe/Berlin'); +}); +afterAll(() => { + if (ORIGINAL_TZ === undefined) delete process.env.TZ; + else process.env.TZ = ORIGINAL_TZ; +}); + +const initialSettings = useSettingsStore.getState(); +beforeEach(() => { + useSettingsStore.setState({ timeZone: 'America/New_York' }); +}); +afterEach(() => { + useSettingsStore.setState(initialSettings, true); +}); + +const rule: FilterRule = { + id: 'r1', + name: 'Urlaub', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: '@' }], + actions: [{ type: 'forward', value: 'kollege@example.com' }], + stopProcessing: false, +}; + +it('reads a time the browser zone skips at its DST change on the clock of the user zone', () => { + const onSave = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText('period_toggle')); + // 02:30 on 29 March does not exist in Berlin; in New York it is 02:30 EDT. + fireEvent.change(screen.getByLabelText('period_end'), { target: { value: '2026-03-29T02:30' } }); + fireEvent.click(screen.getByText('save')); + expect(onSave).toHaveBeenCalledTimes(1); + expect((onSave.mock.calls[0][0] as FilterRule).activeUntil).toBe('2026-03-29T06:30:00.000Z'); +}); diff --git a/components/filters/__tests__/filter-rule-modal-period.test.tsx b/components/filters/__tests__/filter-rule-modal-period.test.tsx new file mode 100644 index 000000000..0109630bb --- /dev/null +++ b/components/filters/__tests__/filter-rule-modal-period.test.tsx @@ -0,0 +1,263 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { FilterRule } from '@/lib/jmap/sieve-types'; +import { getBrowserTimeZone } from '@/lib/timezone'; +import { useSettingsStore } from '@/stores/settings-store'; +import { FilterRuleModal } from '../filter-rule-modal'; +import { RuleSummary, VisualRuleSummary } from '@/components/settings/filter-settings'; + +const toastError = vi.hoisted(() => vi.fn()); +vi.mock('@/stores/toast-store', () => ({ toast: { error: toastError, success: vi.fn(), info: vi.fn() } })); + +const rule = (extra: Partial = {}): FilterRule => ({ + id: 'r1', + name: 'Urlaub', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: '@' }], + actions: [{ type: 'forward', value: 'kollege@example.com' }, { type: 'discard' }], + stopProcessing: false, + ...extra, +}); + +// The browser reports UTC while the user's time zone setting says Berlin, so +// the times show which of the two the dialog and the list follow. Set before +// the imports run: the app reads the browser zone once and keeps it. +const ORIGINAL_TZ = vi.hoisted(() => { + const original = process.env.TZ; + process.env.TZ = 'UTC'; + return original; +}); +beforeAll(() => { + expect(getBrowserTimeZone()).toBe('UTC'); +}); +afterAll(() => { + if (ORIGINAL_TZ === undefined) delete process.env.TZ; + else process.env.TZ = ORIGINAL_TZ; +}); + +const initialSettings = useSettingsStore.getState(); +beforeEach(() => { + toastError.mockClear(); + useSettingsStore.setState({ timeZone: 'Europe/Berlin', dateLocale: 'en-GB', timeFormat: '24h' }); +}); +afterEach(() => { + useSettingsStore.setState(initialSettings, true); + vi.useRealTimers(); +}); + +function openModal(props: Partial> = {}) { + const onSave = vi.fn(); + render(); + return { onSave, saved: () => onSave.mock.calls[0][0] as FilterRule }; +} + +const toggle = () => screen.getByLabelText('period_toggle') as HTMLInputElement; +const startInput = () => screen.getByLabelText('period_start') as HTMLInputElement; +const endInput = () => screen.getByLabelText('period_end') as HTMLInputElement; +const save = () => fireEvent.click(screen.getByText('save')); + +describe('filter rule period (dialog)', () => { + it('is offered only where the server supports it', () => { + openModal({ rule: rule() }); + expect(screen.queryByLabelText('period_toggle')).toBeNull(); + }); + + it('saves the period as the UTC moments of the times entered in the user time zone', () => { + const { onSave, saved } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + expect(screen.getByText('period_hint')).toBeInTheDocument(); + // Berlin: CEST (+02:00) on 5 Oct, CET (+01:00) after the switch on 25 Oct. + fireEvent.change(startInput(), { target: { value: '2026-10-05T08:00' } }); + fireEvent.change(endInput(), { target: { value: '2026-10-26T18:00' } }); + save(); + expect(onSave).toHaveBeenCalledTimes(1); + expect(saved()).toMatchObject({ + activeFrom: '2026-10-05T06:00:00.000Z', + activeUntil: '2026-10-26T17:00:00.000Z', + }); + }); + + it('keeps the start open', () => { + const { saved } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + fireEvent.change(endInput(), { target: { value: '2026-10-16T18:00' } }); + save(); + expect(saved().activeUntil).toBe('2026-10-16T16:00:00.000Z'); + expect(saved().activeFrom).toBeUndefined(); + }); + + it('keeps the end open', () => { + const { saved } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + fireEvent.change(startInput(), { target: { value: '2026-10-05T08:00' } }); + save(); + expect(saved().activeFrom).toBe('2026-10-05T06:00:00.000Z'); + expect(saved().activeUntil).toBeUndefined(); + }); + + it('asks for a start or an end when the period is on but empty', () => { + const { onSave } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + save(); + expect(onSave).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith('validation_period_empty'); + }); + + it('will not take a date without its time for an open end', () => { + const { onSave } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + fireEvent.change(startInput(), { target: { value: '2026-10-05T08:00' } }); + // A browser reads a field it holds half filled in as empty, often without + // an event; jsdom never holds one, so the field says so here. + Object.defineProperty(endInput(), 'validity', { value: { badInput: true }, configurable: true }); + save(); + expect(onSave).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith('validation_period_invalid'); + }); + + it('refuses a year it cannot store, rather than leave that end open', () => { + const { onSave } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + fireEvent.change(startInput(), { target: { value: '2026-10-05T08:00' } }); + fireEvent.change(endInput(), { target: { value: '20266-10-16T18:00' } }); + save(); + expect(onSave).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith('validation_period_invalid'); + + // The last minute of 9999 in New York is year 10000 in UTC already. + toastError.mockClear(); + useSettingsStore.setState({ timeZone: 'America/New_York' }); + fireEvent.change(endInput(), { target: { value: '9999-12-31T23:59' } }); + save(); + expect(onSave).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith('validation_period_invalid'); + }); + + it('offers no year past 9999', () => { + openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + expect(startInput().max).toBe('9999-12-31T23:59'); + expect(endInput().max).toBe('9999-12-31T23:59'); + }); + + it('refuses an end that is not after the start', () => { + const { onSave } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + fireEvent.change(startInput(), { target: { value: '2026-10-16T18:00' } }); + fireEvent.change(endInput(), { target: { value: '2026-10-16T18:00' } }); + save(); + expect(onSave).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith('validation_period_order'); + }); + + it('shows a saved period in the user time zone', () => { + openModal({ + rule: rule({ activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-26T17:00:00.000Z' }), + periodsSupported: true, + }); + expect(toggle().checked).toBe(true); + expect(startInput().value).toBe('2026-10-05T08:00'); + expect(endInput().value).toBe('2026-10-26T18:00'); + }); + + it('keeps a saved boundary exactly while it is left as shown', () => { + // 08:00:30, shown as 08:00; and 02:30 CEST on the night the clocks go + // back, when 02:30 comes twice. + const period = { activeFrom: '2026-10-05T06:00:30.000Z', activeUntil: '2026-10-25T00:30:00.000Z' }; + const { saved } = openModal({ rule: rule(period), periodsSupported: true }); + expect(startInput().value).toBe('2026-10-05T08:00'); + expect(endInput().value).toBe('2026-10-25T02:30'); + save(); + expect(saved()).toMatchObject(period); + }); + + it('reads a changed boundary from the input and keeps the other one', () => { + const { saved } = openModal({ + rule: rule({ activeFrom: '2026-10-05T06:00:30.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }), + periodsSupported: true, + }); + fireEvent.change(endInput(), { target: { value: '2026-10-18T12:00' } }); + save(); + expect(saved()).toMatchObject({ + activeFrom: '2026-10-05T06:00:30.000Z', + activeUntil: '2026-10-18T10:00:00.000Z', + }); + }); + + it('drops the period when it is switched off', () => { + const { saved } = openModal({ rule: rule({ activeUntil: '2026-10-16T16:00:00.000Z' }), periodsSupported: true }); + fireEvent.click(toggle()); + save(); + expect(saved().activeFrom).toBeUndefined(); + expect(saved().activeUntil).toBeUndefined(); + }); + + it('shows a saved period where the server does not offer periods, so it can be removed', () => { + const { saved } = openModal({ rule: rule({ activeUntil: '2026-10-16T16:00:00.000Z' }) }); + expect(toggle().checked).toBe(true); + fireEvent.click(toggle()); + // The switch stays where it was, so a slip can be undone. + expect(toggle().checked).toBe(false); + save(); + expect(saved().activeUntil).toBeUndefined(); + }); + + it('does not run a rule with a period on existing messages', () => { + openModal({ + initialRule: rule({ actions: [{ type: 'mark_read' }] }), + offerApplyToExisting: true, + periodsSupported: true, + }); + const applyExisting = screen.getByLabelText('apply_existing') as HTMLInputElement; + expect(applyExisting.disabled).toBe(false); + fireEvent.click(toggle()); + expect(applyExisting.disabled).toBe(true); + expect(screen.getByText('apply_existing_period')).toBeInTheDocument(); + }); +}); + +describe('filter rule period (list)', () => { + // 14:00 in Berlin + beforeEach(() => vi.useFakeTimers({ now: new Date('2026-10-10T12:00:00.000Z'), toFake: ['Date'] })); + + const periods = [ + ['scheduled', { activeFrom: '2026-10-12T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }, '12 Oct 2026, 08:00 – 16 Oct 2026, 18:00'], + ['active', { activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }, '5 Oct 2026, 08:00 – 16 Oct 2026, 18:00'], + ['expired', { activeFrom: '2026-10-01T06:00:00.000Z', activeUntil: '2026-10-09T16:00:00.000Z' }, '1 Oct 2026, 08:00 – 9 Oct 2026, 18:00'], + ['active', { activeUntil: '2026-10-16T16:00:00.000Z' }, '… – 16 Oct 2026, 18:00'], + ['scheduled', { activeFrom: '2026-10-12T06:00:00.000Z' }, '12 Oct 2026, 08:00 – …'], + ] as const; + + it.each(periods)('shows a %s period in the detailed view', (status, period, range) => { + render(); + expect(screen.getByText('period_title')).toBeInTheDocument(); + expect(screen.getByText(range)).toBeInTheDocument(); + expect(screen.getByText(`(period_status_${status})`)).toBeInTheDocument(); + }); + + it.each(periods)('shows a %s period in the compact view', (status, period, range) => { + render(); + expect(screen.getByText(`· ${range} (period_status_${status})`)).toBeInTheDocument(); + }); + + it('shows the period of a rule that is off without saying it is active', () => { + const off = rule({ enabled: false, activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }); + const { unmount } = render(); + expect(screen.getByText('5 Oct 2026, 08:00 – 16 Oct 2026, 18:00')).toBeInTheDocument(); + expect(screen.queryByText(/period_status_/)).toBeNull(); + unmount(); + render(); + expect(screen.getByText('· 5 Oct 2026, 08:00 – 16 Oct 2026, 18:00')).toBeInTheDocument(); + expect(screen.queryByText(/period_status_/)).toBeNull(); + }); + + it('shows no period for a rule without one', () => { + const { unmount } = render(); + expect(screen.queryByText('period_title')).toBeNull(); + unmount(); + const { container } = render(); + expect(container.textContent).not.toContain('·'); + }); +}); diff --git a/components/filters/__tests__/quick-rule-host.test.tsx b/components/filters/__tests__/quick-rule-host.test.tsx new file mode 100644 index 000000000..1d6d55acf --- /dev/null +++ b/components/filters/__tests__/quick-rule-host.test.tsx @@ -0,0 +1,113 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { FilterRule, VacationForward } from '@/lib/jmap/sieve-types'; +import { generateScript } from '@/lib/sieve/generator'; +import { mockStalwartAccount } from '@/lib/filters/__tests__/sieve-mock'; +import { useQuickRuleStore } from '@/stores/quick-rule-store'; +import { QuickRuleHost } from '../quick-rule-host'; + +vi.mock('@/stores/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })); + +const EXTENSIONS = ['fileinto', 'imap4flags', 'include', 'envelope', 'copy', 'date', 'relational', 'spamtestplus', 'comparator-i;ascii-numeric']; + +const forwardRule = (id: string): FilterRule => ({ + id, + name: `Rule ${id}`, + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: 'chef@example.com' }], + actions: [{ type: 'forward', value: 'kollege@example.com' }], + stopProcessing: false, +}); + +/** An account that allows one redirect per message, as Stalwart does. */ +const account = (rules: FilterRule[], vacationForward?: VacationForward) => mockStalwartAccount('b', [ + { name: 'filters', content: generateScript(rules, undefined, { extensions: EXTENSIONS, vacationForward }), isActive: true }, +], EXTENSIONS); + +function openEditor(server: ReturnType, mode: 'prefill' | 'edit', rule: FilterRule) { + useQuickRuleStore.getState().openEditor({ + mode, + target: { + client: server.client, + clientAccountId: null, + key: 'login/b', + accountId: 'b', + sieveAccountId: 'b', + shared: false, + supportsSieve: true, + mailboxes: [], + }, + rule, + suggestions: [], + sourceMailboxId: null, + }); + render(); +} + +/** Once the account's script has been read and counted. */ +async function counted(server: ReturnType) { + await waitFor(() => expect(server.client.getSieveScriptContent).toHaveBeenCalled()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +afterEach(() => { + useQuickRuleStore.getState().closeEditor(); +}); + +describe('a rule made from a message, against the redirect limit', () => { + it('counts the vacation forwarding that keeps a copy', async () => { + const server = account([], { enabled: true, to: 'vertretung@example.com', keepCopy: true }); + openEditor(server, 'prefill', forwardRule('new')); + expect(await screen.findByText('forward_limit')).toBeInTheDocument(); + }); + + it('leaves out vacation forwarding without a copy: it stops the message before any rule', async () => { + const server = account([], { enabled: true, to: 'vertretung@example.com', keepCopy: false }); + openEditor(server, 'prefill', forwardRule('new')); + await counted(server); + expect(screen.queryByText('forward_limit')).toBeNull(); + }); + + it('leaves out the forwards of the rule being edited', async () => { + const edited = forwardRule('edited'); + const server = account([edited]); + openEditor(server, 'edit', edited); + await counted(server); + expect(screen.queryByText('forward_limit')).toBeNull(); + }); + + it('counts the forwards of the other rules', async () => { + const edited = forwardRule('edited'); + const server = account([edited, forwardRule('other')]); + openEditor(server, 'edit', edited); + expect(await screen.findByText('forward_limit')).toBeInTheDocument(); + }); + + it('leaves out a rule above that forwards and stops: its messages never get here', async () => { + const edited = forwardRule('edited'); + const server = account([{ ...forwardRule('above'), stopProcessing: true }, edited]); + openEditor(server, 'edit', edited); + await counted(server); + expect(screen.queryByText('forward_limit')).toBeNull(); + }); + + it('puts a new rule first, ahead of a rule that forwards and stops', async () => { + const server = account([{ ...forwardRule('below'), stopProcessing: true }]); + openEditor(server, 'prefill', forwardRule('new')); + // A message both match gets the new rule's forward first ... + expect(await screen.findByText('forward_limit')).toBeInTheDocument(); + // ... unless the new rule stops. + fireEvent.click(screen.getByLabelText('stop_processing')); + expect(screen.queryByText('forward_limit')).toBeNull(); + }); + + it('takes an edited rule no longer there for one saved first again', async () => { + const server = account([{ ...forwardRule('below'), stopProcessing: true }]); + openEditor(server, 'edit', forwardRule('gone')); + expect(await screen.findByText('forward_limit')).toBeInTheDocument(); + }); +}); diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index bf4703127..62ad70431 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -20,6 +20,8 @@ import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID, c import type { RuleSuggestion } from "@/lib/filters/quick-rules"; import { retroactiveSupport } from "@/lib/filters/retroactive"; import { ruleForwards, ruleStops } from "@/lib/filters/forward-limit"; +import { hasPeriod, isPeriodBoundary } from "@/lib/sieve/period"; +import { fromWallClock, getEffectiveTimeZone, getWallClock } from "@/lib/timezone"; import { useSettingsStore } from "@/stores/settings-store"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; @@ -44,6 +46,8 @@ interface FilterRuleModalProps { */ forwardsBefore?: number; forwardsAfter?: number; + /** The server can confine a rule to a period (Sieve "date" and "relational"). */ + periodsSupported?: boolean; onSave: (rule: FilterRule, options?: { applyToExisting: boolean }) => void; onClose: () => void; } @@ -115,6 +119,37 @@ function makeEmptyAction(): FilterAction { return { type: "move", value: "" }; } +// A period boundary as the "yyyy-mm-ddThh:mm" a datetime-local input shows, +// read on the clock of the user's time zone - the one dates are shown in. +function toPeriodInput(boundary: string | undefined): string { + if (!isPeriodBoundary(boundary)) return ""; + const w = getWallClock(new Date(boundary), getEffectiveTimeZone()); + const pad = (n: number, len = 2) => String(n).padStart(len, "0"); + return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}`; +} + +// The latest moment the period fields offer: a later year has no room in +// the four digits a stored boundary has. +const PERIOD_INPUT_MAX = "9999-12-31T23:59"; + +// The input's wall-clock time in the user's time zone, as the UTC moment the +// rule stores: undefined for an empty field, null for one that holds no +// usable moment (a five-digit year). A boundary left as the dialog showed it +// is kept exactly: the input shows whole minutes, so reading it back would +// drop a saved second, or pick the other one of the two moments in the hour +// a DST switch repeats. +function fromPeriodInput(value: string, saved: string | undefined): string | null | undefined { + if (!value) return undefined; + if (saved !== undefined && value === toPeriodInput(saved)) return saved; + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value); + if (!m) return null; + const [year, month, day, hour, minute, second] = m.slice(1).map((part) => Number(part ?? 0)); + const moment = fromWallClock({ year, month, day, hour, minute, second }, getEffectiveTimeZone()); + if (Number.isNaN(moment.getTime())) return null; + const boundary = moment.toISOString(); + return isPeriodBoundary(boundary) ? boundary : null; +} + export function FilterRuleModal({ rule, initialRule, @@ -124,6 +159,7 @@ export function FilterRuleModal({ maxRedirects, forwardsBefore = 0, forwardsAfter = 0, + periodsSupported = false, onSave, onClose, }: FilterRuleModalProps) { @@ -145,6 +181,12 @@ export function FilterRuleModal({ const [includeSpam, setIncludeSpam] = useState(start?.includeSpam ?? false); const [usedSuggestions, setUsedSuggestions] = useState>(new Set()); const [applyToExisting, setApplyToExisting] = useState(false); + const hadPeriod = !!start && hasPeriod(start); + const [periodOn, setPeriodOn] = useState(hadPeriod); + const [periodStart, setPeriodStart] = useState(toPeriodInput(start?.activeFrom)); + const [periodEnd, setPeriodEnd] = useState(toPeriodInput(start?.activeUntil)); + const periodStartRef = useRef(null); + const periodEndRef = useRef(null); const modalRef = useFocusTrap({ isActive: true, onEscape: onClose }); @@ -217,9 +259,12 @@ export function FilterRuleModal({ // the conditions decide. const canApplyToExisting = useMemo(() => { if (!offerApplyToExisting) return false; + // A rule with a period acts on mail as it arrives within it, which says + // nothing about the mail already there. + if (periodOn) return false; const checkActions: FilterAction[] = validActions.length > 0 ? validActions : [{ type: "mark_read" }]; return retroactiveSupport({ conditions: validConditions, actions: checkActions }).ok; - }, [offerApplyToExisting, validConditions, validActions]); + }, [offerApplyToExisting, validConditions, validActions, periodOn]); const handleSave = useCallback(() => { const trimmedName = name.trim(); @@ -238,6 +283,24 @@ export function FilterRuleModal({ return; } + const activeFrom = periodOn ? fromPeriodInput(periodStart, start?.activeFrom) : undefined; + const activeUntil = periodOn ? fromPeriodInput(periodEnd, start?.activeUntil) : undefined; + // A field the browser holds half filled in (a date without its time) + // reads as empty, and must not pass for an open end of the period. + const halfFilled = !!periodStartRef.current?.validity.badInput || !!periodEndRef.current?.validity.badInput; + if (activeFrom === null || activeUntil === null || (periodOn && halfFilled)) { + toast.error(t("validation_period_invalid")); + return; + } + if (periodOn && !activeFrom && !activeUntil) { + toast.error(t("validation_period_empty")); + return; + } + if (activeFrom && activeUntil && Date.parse(activeUntil) <= Date.parse(activeFrom)) { + toast.error(t("validation_period_order")); + return; + } + onSave({ id: start?.id || generateUUID(), name: trimmedName, @@ -246,9 +309,14 @@ export function FilterRuleModal({ conditions: validConditions, actions: validActions, stopProcessing, - ...(includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? { includeSpam: true } : {}), + // Each optional field this dialog controls is set, to undefined when it + // is off: the settings page merges the result into the stored rule, and + // a field left out would keep its old value there. + includeSpam: includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? true : undefined, + activeFrom, + activeUntil, }, { applyToExisting: applyToExisting && canApplyToExisting }); - }, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, start, onSave, t, applyToExisting, canApplyToExisting]); + }, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, periodStart, periodEnd, start, onSave, t, applyToExisting, canApplyToExisting]); const visibleSuggestions = suggestions.filter((s) => !usedSuggestions.has(s.id)); @@ -687,6 +755,54 @@ export function FilterRuleModal({ )} + {/* Offered where the server supports it; a rule that already has a + period always shows it, so it can be removed. */} + {(periodsSupported || hadPeriod) && ( +
+
+ setPeriodOn(e.target.checked)} + className="rounded border-input" + /> + +
+ {periodOn && ( +
+
+ + +
+

{t("period_hint")}

+
+ )} +
+ )} + {offerApplyToExisting && (
@@ -708,7 +824,7 @@ export function FilterRuleModal({
{!canApplyToExisting && (

- {t("apply_existing_unsupported")} + {periodOn ? t("apply_existing_period") : t("apply_existing_unsupported")}

)}
diff --git a/components/filters/quick-rule-host.tsx b/components/filters/quick-rule-host.tsx index 2f9f251d2..74640e5e9 100644 --- a/components/filters/quick-rule-host.tsx +++ b/components/filters/quick-rule-host.tsx @@ -1,11 +1,15 @@ "use client"; +import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { FilterRuleModal } from "@/components/filters/filter-rule-modal"; import { PromptDialog } from "@/components/ui/prompt-dialog"; import { useQuickRuleText } from "@/components/email/rules-menu"; import { useQuickRuleStore } from "@/stores/quick-rule-store"; +import { readAccountFilters } from "@/lib/filters/account-filters"; +import { forwardsAround, inRunOrder } from "@/lib/filters/forward-limit"; import { createFolderAndRunPreset, saveEditorRule } from "@/lib/filters/quick-rule-flow"; +import { supportsPeriods } from "@/lib/sieve/period"; /** * Draws what a rule made from a message opens: the rule editor ("Create @@ -21,6 +25,28 @@ export function QuickRuleHost() { const closeEditor = useQuickRuleStore((s) => s.closeEditor); const closeNewFolder = useQuickRuleStore((s) => s.closeNewFolder); + // Where the rule runs among the account's rules, for the server's redirect + // limit: "Create rule…" puts it first, an edit keeps it in place (one no + // longer there goes first again), and the out of office forwarding runs + // ahead of them all. See lib/filters/forward-limit.ts. + const [forwards, setForwards] = useState({ before: 0, after: 0 }); + useEffect(() => { + setForwards({ before: 0, after: 0 }); + if (!editor) return; + let current = true; + readAccountFilters(editor.target.client, editor.target.sieveAccountId) + .then(({ parsed }) => { + if (!current || parsed.isOpaque) return; + const runOrder = inRunOrder(parsed.rules, parsed.vacationForward); + const index = editor.mode === "edit" ? parsed.rules.findIndex((r) => r.id === editor.rule.id) : -1; + setForwards(forwardsAround(runOrder, runOrder.length - parsed.rules.length + Math.max(index, 0), index >= 0)); + }) + .catch(() => {}); + return () => { + current = false; + }; + }, [editor]); + return ( <> {editor && ( @@ -32,6 +58,9 @@ export function QuickRuleHost() { offerApplyToExisting={editor.mode === "prefill" && !!editor.sourceMailboxId} mailboxes={editor.target.mailboxes} maxRedirects={editor.target.client.getSieveCapabilities(editor.target.sieveAccountId)?.maxNumberRedirects} + forwardsBefore={forwards.before} + forwardsAfter={forwards.after} + periodsSupported={supportsPeriods(editor.target.client.getSieveCapabilities(editor.target.sieveAccountId)?.sieveExtensions)} onSave={(rule, options) => { closeEditor(); void saveEditorRule(editor, rule, options?.applyToExisting ?? false, text); diff --git a/components/settings/__tests__/filter-settings-save.test.tsx b/components/settings/__tests__/filter-settings-save.test.tsx new file mode 100644 index 000000000..1b0a25062 --- /dev/null +++ b/components/settings/__tests__/filter-settings-save.test.tsx @@ -0,0 +1,187 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { FilterRule, SieveCapabilities } from '@/lib/jmap/sieve-types'; +import { getBrowserTimeZone } from '@/lib/timezone'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useFilterStore } from '@/stores/filter-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { FilterSettings } from '../filter-settings'; + +// Editing a rule on the settings page: the real page, rule dialog, filter +// store and script generator; only the server write is caught. The dialog +// hands back the whole rule and the page merges it into the stored one, so +// whatever the dialog switches off has to come through that merge. + +const ORIGINAL_TZ = vi.hoisted(() => { + const original = process.env.TZ; + process.env.TZ = 'UTC'; + return original; +}); +beforeAll(() => { + expect(getBrowserTimeZone()).toBe('UTC'); +}); +afterAll(() => { + if (ORIGINAL_TZ === undefined) delete process.env.TZ; + else process.env.TZ = ORIGINAL_TZ; +}); + +const toastError = vi.hoisted(() => vi.fn()); +vi.mock('@/stores/toast-store', () => ({ toast: { error: toastError, success: vi.fn(), info: vi.fn() } })); + +const CAPABILITIES: SieveCapabilities = { + implementation: 'Stalwart', + maxSizeScript: 102400, + sieveExtensions: ['fileinto', 'mailbox', 'mailboxid', 'imap4flags', 'date', 'relational', 'spamtest', 'spamtestplus', 'comparator-i;ascii-numeric'], + notificationMethods: [], + externalLists: [], + maxNumberRedirects: 1, +}; + +const rule = (extra: Partial = {}): FilterRule => ({ + id: 'r1', + name: 'ZRTEST', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: '@' }], + actions: [{ type: 'star' }], + stopProcessing: false, + ...extra, +}); + +const initial = { + auth: useAuthStore.getState(), + email: useEmailStore.getState(), + filter: useFilterStore.getState(), + settings: useSettingsStore.getState(), +}; + +let updateSieveScript: ReturnType; + +beforeEach(() => { + toastError.mockClear(); + updateSieveScript = vi.fn(async () => {}); + useSettingsStore.setState({ timeZone: 'Europe/Berlin', dateLocale: 'en-GB', timeFormat: '24h' }); + useAuthStore.setState({ client: { updateSieveScript, getSieveAccountId: () => 'primary' } as never }); + useEmailStore.setState({ fetchMailboxes: vi.fn(async () => {}) as never }); +}); + +afterEach(() => { + useAuthStore.setState(initial.auth, true); + useEmailStore.setState(initial.email, true); + useFilterStore.setState(initial.filter, true); + useSettingsStore.setState(initial.settings, true); +}); + +function showSettings(stored: FilterRule) { + useFilterStore.setState({ + rules: [stored], + isSupported: true, + isLoading: false, + isOpaque: false, + activeScriptId: 'script-1', + selectedAccountId: 'primary', + sieveCapabilities: CAPABILITIES, + selectAccount: vi.fn(async () => {}), + }); + render(); +} + +function openSettings(stored: FilterRule) { + showSettings(stored); + fireEvent.click(screen.getByText(stored.name)); + return within(screen.getByRole('dialog')); +} + +async function saveAndReadBack(dialog: ReturnType) { + fireEvent.click(dialog.getByText('save')); + await waitFor(() => expect(updateSieveScript).toHaveBeenCalledTimes(1)); + expect(toastError).not.toHaveBeenCalled(); + return { stored: useFilterStore.getState().rules[0], script: updateSieveScript.mock.calls[0][1] as string }; +} + +describe('saving an edited rule on the settings page', () => { + it('drops an end that was cleared, along with a moved start', async () => { + // 1 Oct 05:14 - 07:14 in Berlin; then the start goes to 2 Oct 05:14 and + // the end is cleared in the picker. + const dialog = openSettings(rule({ activeFrom: '2026-10-01T03:14:00.000Z', activeUntil: '2026-10-01T05:14:00.000Z' })); + fireEvent.change(dialog.getByLabelText('period_start'), { target: { value: '2026-10-02T05:14' } }); + fireEvent.change(dialog.getByLabelText('period_end'), { target: { value: '' } }); + const { stored, script } = await saveAndReadBack(dialog); + + expect(stored.activeFrom).toBe('2026-10-02T03:14:00.000Z'); + expect(stored.activeUntil).toBeUndefined(); + expect(script).toContain('currentdate :zone "+0000" :value "ge" "time" "03:14:00"'); + expect(script).not.toContain('"le" "time"'); + expect(script).not.toContain('activeUntil'); + expect(screen.getByText(/2 Oct 2026, 05:14 – …/)).toBeInTheDocument(); + }); + + it('removes the period when it is switched off', async () => { + const dialog = openSettings(rule({ activeFrom: '2026-10-01T03:14:00.000Z', activeUntil: '2026-10-01T05:14:00.000Z' })); + fireEvent.click(dialog.getByLabelText('period_toggle')); + const { stored, script } = await saveAndReadBack(dialog); + + expect(stored.activeFrom).toBeUndefined(); + expect(stored.activeUntil).toBeUndefined(); + expect(script).not.toContain('currentdate'); + expect(script).not.toContain('"date"'); + }); + + it('lets a folder rule stop taking spam out of Junk again', async () => { + const dialog = openSettings(rule({ + actions: [{ type: 'move', value: 'News', mailboxId: 'mb-news' }], + includeSpam: true, + })); + fireEvent.click(dialog.getByLabelText('include_spam')); + const { stored, script } = await saveAndReadBack(dialog); + + expect(stored.includeSpam).toBeUndefined(); + expect(script).toContain('not spamtest :percent :value "ge"'); + expect(script).not.toContain('includeSpam'); + }); + + it('counts the vacation forwarding that keeps a copy against the redirect limit', () => { + const forwarding = rule({ actions: [{ type: 'forward', value: 'chef@example.com' }] }); + const vacationForward = { enabled: true, to: 'kollege@example.com', keepCopy: true }; + // CAPABILITIES allows one redirect per message. + const warningFor = (state: Record) => { + useFilterStore.setState(state); + const dialog = openSettings(forwarding); + const shown = dialog.queryByText('forward_limit') !== null; + cleanup(); + return shown; + }; + // With the auto-reply or without: forwarding runs either way. + expect(warningFor({ includeVacation: true, vacationForward })).toBe(true); + expect(warningFor({ includeVacation: false, vacationForward })).toBe(true); + // Not when only forwarded (the rules do not run then), nor while it is off. + expect(warningFor({ includeVacation: true, vacationForward: { ...vacationForward, keepCopy: false } })).toBe(false); + expect(warningFor({ includeVacation: true, vacationForward: { ...vacationForward, enabled: false } })).toBe(false); + }); + + it('counts the vacation forwarding that keeps a copy in the list of rules too', () => { + const forwarding = rule({ actions: [{ type: 'forward', value: 'chef@example.com' }] }); + const vacationForward = { enabled: true, to: 'kollege@example.com', keepCopy: true }; + const listWarns = (state: Record) => { + useFilterStore.setState(state); + showSettings(forwarding); + const shown = screen.queryByText('forward_limit') !== null; + cleanup(); + return shown; + }; + expect(listWarns({ vacationForward })).toBe(true); + // Only forwarded, a message never reaches the rules. + expect(listWarns({ vacationForward: { ...vacationForward, keepCopy: false } })).toBe(false); + expect(listWarns({ vacationForward: { ...vacationForward, enabled: false } })).toBe(false); + }); + + it('keeps what the dialog left alone', async () => { + const period = { activeFrom: '2026-10-01T03:14:30.000Z', activeUntil: '2026-10-01T05:14:00.000Z' }; + const dialog = openSettings(rule({ ...period, actions: [{ type: 'move', value: 'News', mailboxId: 'mb-news' }], includeSpam: true })); + const { stored } = await saveAndReadBack(dialog); + + expect(stored).toMatchObject({ ...period, includeSpam: true, name: 'ZRTEST', enabled: true }); + }); +}); diff --git a/components/settings/__tests__/vacation-forward.test.tsx b/components/settings/__tests__/vacation-forward.test.tsx new file mode 100644 index 000000000..ffc1cc568 --- /dev/null +++ b/components/settings/__tests__/vacation-forward.test.tsx @@ -0,0 +1,464 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { FilterRule } from '@/lib/jmap/sieve-types'; +import { generateScript } from '@/lib/sieve/generator'; +import { parseScript } from '@/lib/sieve/parser'; +import { STALWART_VACATION_SCRIPT, mockStalwartAccount } from '@/lib/filters/__tests__/sieve-mock'; +import { useAuthStore } from '@/stores/auth-store'; +import { useFilterStore } from '@/stores/filter-store'; +import { useIdentityStore } from '@/stores/identity-store'; +import { useVacationStore } from '@/stores/vacation-store'; +import { VacationSettings } from '../vacation-settings'; + +// The vacation card as it is used: the real card, vacation store, filter +// sync and script generator, against a server that behaves like Stalwart +// (turning the auto-reply on activates its own script and switches every +// other script off). + +const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast })); + +// next-intl hands out the same translate function on every render; the card's +// validation effect depends on it, so the shared mock's fresh function per +// call would make the effect run forever. +vi.mock('next-intl', () => { + const t = (key: string, values?: Record) => (values ? `${key} ${Object.values(values).join(' ')}` : key); + return { useTranslations: () => t, useLocale: () => 'en' }; +}); + +const EXTENSIONS = ['fileinto', 'mailbox', 'mailboxid', 'imap4flags', 'include', 'envelope', 'copy', 'date', 'relational', 'spamtestplus', 'comparator-i;ascii-numeric']; + +const rule = (id: string, extra: Partial = {}): FilterRule => ({ + id, + name: `Rule ${id}`, + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: `${id}@example.net` }], + actions: [{ type: 'mark_read' }], + stopProcessing: false, + ...extra, +}); + +/** A Stalwart-like account whose active filters script holds `rules`, away 5-16 Oct. */ +const stalwart = (rules: FilterRule[], options: { extensions?: string[]; maxNumberRedirects?: number } = {}) => { + const extensions = options.extensions ?? EXTENSIONS; + return mockStalwartAccount('b', [ + { name: 'filters', content: generateScript(rules, undefined, { extensions }), isActive: true }, + ], extensions, { + // Left out, the server allows one redirect, as Stalwart does by default. + ...(options.maxNumberRedirects !== undefined ? { maxNumberRedirects: options.maxNumberRedirects } : {}), + vacation: { + fromDate: '2026-10-05T06:00:00Z', + toDate: '2026-10-16T16:00:00Z', + subject: 'Abwesend', + textBody: 'Ich bin nicht da.', + }, + }); +}; + +const initial = { + auth: useAuthStore.getState(), + vacation: useVacationStore.getState(), + filter: useFilterStore.getState(), + identity: useIdentityStore.getState(), +}; +beforeEach(() => { + toast.error.mockClear(); + toast.success.mockClear(); +}); +afterEach(() => { + useAuthStore.setState(initial.auth, true); + useVacationStore.setState(initial.vacation, true); + useFilterStore.setState(initial.filter, true); + useIdentityStore.setState(initial.identity, true); +}); + +async function openCard(server: ReturnType) { + useAuthStore.setState({ client: server.client as never }); + useVacationStore.setState({ isSupported: true }); + render(); + await screen.findByText('status.label'); +} + +const forwardSwitch = () => screen.getByRole('switch', { name: 'forward.enabled_label' }); +const keepSwitch = () => screen.getByRole('switch', { name: 'forward.keep_label' }); +const addressInput = () => screen.getByLabelText('forward.to_label') as HTMLInputElement; +const saveButton = () => screen.getByText('save').closest('button') as HTMLButtonElement; + +async function save(server: ReturnType) { + fireEvent.click(saveButton()); + await waitFor(() => expect(server.client.setVacationResponse).toHaveBeenCalled()); + await waitFor(() => expect(toast.success.mock.calls.length + toast.error.mock.calls.length).toBe(1)); +} + +describe('forwarding in the vacation card', () => { + it('is offered only where the server can run it next to the auto-reply', async () => { + await openCard(stalwart([rule('a')], { extensions: ['fileinto', 'imap4flags'] })); + expect(screen.queryByText('forward.title')).toBeNull(); + }); + + it('turns the auto-reply on with forwarding: only forwarded, in its period, ahead of the rules', async () => { + const server = stalwart([rule('a')]); + await openCard(server); + expect(screen.getByText('forward.title')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('switch', { name: 'status.label' })); + fireEvent.click(forwardSwitch()); + fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } }); + await save(server); + + expect(toast.success).toHaveBeenCalledWith('vacation_saved'); + expect(server.client.setVacationResponse.mock.calls[0][0]).not.toHaveProperty('forward'); + expect(server.active()).toBe('filters'); + const written = server.content('filters'); + expect(parseScript(written).vacationForward).toEqual({ + enabled: true, + to: 'kollege@example.com', + keepCopy: false, + activeFrom: '2026-10-05T06:00:00Z', + activeUntil: '2026-10-16T16:00:00Z', + }); + expect(written).toContain('include :personal :optional "vacation";'); + expect(written).toMatch(/# Vacation forwarding\nif allof\(.*"2026-10-05".*"2026-10-16".*not spamtest[^\n]*\) \{\n {4}redirect "kollege@example\.com";\n {4}stop;\n\}/); + expect(written.indexOf('# Vacation forwarding')).toBeLessThan(written.indexOf('# Rule: Rule a')); + }); + + it('forwards without sending any auto-reply', async () => { + const server = stalwart([rule('a')]); + await openCard(server); + // The auto-reply stays off; only forwarding is switched on. + fireEvent.click(forwardSwitch()); + fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } }); + await save(server); + + expect(toast.success).toHaveBeenCalledWith('vacation_saved'); + expect(server.client.setVacationResponse.mock.calls[0][0]).toMatchObject({ isEnabled: false }); + expect(server.scripts.some((s) => s.name === 'vacation' && s.isActive)).toBe(false); + expect(server.active()).toBe('filters'); + const written = server.content('filters'); + expect(written).not.toContain('include'); + expect(written).toMatch(/# Vacation forwarding\n[^\n]*\n {4}redirect "kollege@example\.com";\n {4}stop;\n\}/); + }); + + it('keeps forwarding through a later save, also without any filter rule', async () => { + const server = stalwart([]); + await openCard(server); + fireEvent.click(screen.getByRole('switch', { name: 'status.label' })); + fireEvent.click(forwardSwitch()); + fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } }); + await save(server); + expect(server.active()).toBe('filters'); + toast.success.mockClear(); + + // Saving again turns Stalwart's own vacation script back on. + fireEvent.change(screen.getByPlaceholderText('message.subject_placeholder'), { target: { value: 'Bis bald' } }); + fireEvent.click(saveButton()); + await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1)); + expect(server.client.setVacationResponse).toHaveBeenCalledTimes(2); + expect(server.active()).toBe('filters'); + expect(server.content('filters')).toContain('# Vacation forwarding'); + }); + + it('keeps a copy here when asked to', async () => { + const server = stalwart([rule('a')]); + await openCard(server); + fireEvent.click(screen.getByRole('switch', { name: 'status.label' })); + fireEvent.click(forwardSwitch()); + fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } }); + fireEvent.click(keepSwitch()); + await save(server); + expect(server.content('filters')).toContain(' redirect :copy "kollege@example.com";'); + expect(server.content('filters')).not.toContain(' stop;'); + }); + + it('will not save forwarding without a usable address', async () => { + const server = stalwart([rule('a')]); + await openCard(server); + fireEvent.click(forwardSwitch()); + fireEvent.change(addressInput(), { target: { value: 'kollege' } }); + expect(screen.getByText('warnings.forward_address')).toBeInTheDocument(); + expect(saveButton().disabled).toBe(true); + fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } }); + expect(screen.queryByText('warnings.forward_address')).toBeNull(); + expect(saveButton().disabled).toBe(false); + }); + + it('does not count an address typed and switched off again as a change', async () => { + const server = stalwart([rule('a')]); + await openCard(server); + fireEvent.click(forwardSwitch()); + fireEvent.change(addressInput(), { target: { value: 'kollege' } }); + fireEvent.click(forwardSwitch()); + // Nothing a save would store is different, so there is nothing to save. + expect(saveButton().disabled).toBe(true); + }); + + it('warns that a forwarding rule is skipped when a copy is kept and the server allows one redirect', async () => { + const server = stalwart([rule('f', { actions: [{ type: 'forward', value: 'chef@example.com' }] })], { maxNumberRedirects: 1 }); + await openCard(server); + fireEvent.click(forwardSwitch()); + fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } }); + expect(screen.queryByText(/^forward_limit/)).toBeNull(); + fireEvent.click(keepSwitch()); + expect(screen.getByText('forward_limit 1')).toBeInTheDocument(); + }); + + it('shows the stored forwarding, stops it with the switch and keeps the address', async () => { + const server = stalwart([rule('a')]); + await openCard(server); + fireEvent.click(screen.getByRole('switch', { name: 'status.label' })); + fireEvent.click(forwardSwitch()); + fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } }); + await save(server); + toast.success.mockClear(); + + // Back as stored after the save, as the server has it now. + expect(useVacationStore.getState().forward).toMatchObject({ enabled: true, to: 'kollege@example.com' }); + expect(forwardSwitch().getAttribute('aria-checked')).toBe('true'); + expect(addressInput().value).toBe('kollege@example.com'); + expect(saveButton().disabled).toBe(true); + + fireEvent.click(forwardSwitch()); + fireEvent.click(saveButton()); + await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1)); + const written = server.content('filters'); + expect(written).not.toContain('# Vacation forwarding'); + expect(written).not.toContain('redirect'); + expect(parseScript(written).vacationForward).toMatchObject({ enabled: false, to: 'kollege@example.com' }); + }); + + it('opens with the forwarding the server has', async () => { + const server = stalwart([rule('a')]); + const forward = { enabled: true, to: 'kollege@example.com', keepCopy: true }; + await server.client.updateSieveScript( + server.scripts[0].id, + generateScript([rule('a')], undefined, { extensions: EXTENSIONS, vacationForward: forward }), + true, + 'b', + ); + await openCard(server); + expect(forwardSwitch().getAttribute('aria-checked')).toBe('true'); + expect(addressInput().value).toBe('kollege@example.com'); + expect(keepSwitch().getAttribute('aria-checked')).toBe('true'); + expect(saveButton().disabled).toBe(true); + }); + + it('says so when the auto-reply was saved but the forwarding was not', async () => { + const server = stalwart([rule('a')]); + await openCard(server); + server.client.updateSieveScript.mockRejectedValueOnce(new Error('server said no')); + fireEvent.click(screen.getByRole('switch', { name: 'status.label' })); + fireEvent.click(forwardSwitch()); + fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } }); + await save(server); + expect(toast.error).toHaveBeenCalledWith('vacation_filters_save_failed'); + expect(toast.success).not.toHaveBeenCalled(); + // What was set stays, to be saved again. + expect(forwardSwitch().getAttribute('aria-checked')).toBe('true'); + expect(saveButton().disabled).toBe(false); + }); + + it('saves only what was changed here, not forwarding another tab changed since', async () => { + const server = stalwart([rule('a')]); + const stored = (enabled: boolean) => generateScript([rule('a')], undefined, { + extensions: EXTENSIONS, + vacationForward: { enabled, to: 'kollege@example.com', keepCopy: false }, + }); + await server.client.updateSieveScript(server.scripts[0].id, stored(true), true, 'b'); + await openCard(server); + expect(forwardSwitch().getAttribute('aria-checked')).toBe('true'); + + // Switched off elsewhere; here only the subject changes. + await server.client.updateSieveScript(server.scripts[0].id, stored(false), true, 'b'); + fireEvent.change(screen.getByPlaceholderText('message.subject_placeholder'), { target: { value: 'Bis bald' } }); + await save(server); + expect(toast.success).toHaveBeenCalledWith('vacation_saved'); + expect(parseScript(server.content('filters')).vacationForward).toMatchObject({ enabled: false }); + expect(server.content('filters')).not.toContain('redirect'); + }); + + it('says when stored forwarding does not run, and a save sets it right', async () => { + const server = mockStalwartAccount('b', [ + { + name: 'filters', + content: generateScript([rule('a')], undefined, { + extensions: EXTENSIONS, + includeVacation: true, + vacationForward: { enabled: true, to: 'kollege@example.com', keepCopy: false }, + }), + isActive: false, + }, + // Stalwart's own script took over: it answers, nothing is forwarded. + { name: 'vacation', content: STALWART_VACATION_SCRIPT, isActive: true }, + ], EXTENSIONS); + await openCard(server); + expect(screen.getByText('warnings.not_running')).toBeInTheDocument(); + expect(saveButton().disabled).toBe(false); + + await save(server); + expect(toast.success).toHaveBeenCalledWith('vacation_saved'); + expect(server.active()).toBe('filters'); + expect(server.content('filters')).toContain('# Vacation forwarding'); + expect(screen.queryByText('warnings.not_running')).toBeNull(); + expect(saveButton().disabled).toBe(true); + }); + + it('starts over on another account, even where both store the same', async () => { + const server = stalwart([rule('a')]); + await openCard(server); + fireEvent.click(forwardSwitch()); + fireEvent.change(addressInput(), { target: { value: 'chef@a.example' } }); + expect(saveButton().disabled).toBe(false); + + // Neither account has forwarding stored, so no stored value changes. + useAuthStore.setState({ activeAccountId: 'other-login' }); + await waitFor(() => expect(forwardSwitch().getAttribute('aria-checked')).toBe('false')); + expect(saveButton().disabled).toBe(true); + fireEvent.click(forwardSwitch()); + expect(addressInput().value).toBe(''); + }); +}); + +describe('the vacation period in the card', () => { + const dateField = (index: number) => document.querySelectorAll('input[type="datetime-local"]')[index] as HTMLInputElement; + // jsdom never holds a field half filled in: let it say so, as a browser does. + const halfFilledIn = (input: HTMLInputElement, half: boolean) => + Object.defineProperty(input, 'validity', { value: { badInput: half }, configurable: true }); + + it('will not take a date without its time for no date at all', async () => { + const server = stalwart([rule('a')]); + await openCard(server); + // A browser reports the end as empty once its time is cleared. + halfFilledIn(dateField(1), true); + fireEvent.change(dateField(1), { target: { value: '' } }); + expect(screen.getByText('warnings.invalid_date')).toBeInTheDocument(); + expect(saveButton().disabled).toBe(true); + + halfFilledIn(dateField(1), false); + fireEvent.change(dateField(1), { target: { value: '2026-10-17T18:00' } }); + expect(screen.queryByText('warnings.invalid_date')).toBeNull(); + expect(saveButton().disabled).toBe(false); + }); + + it('checks the fields again on save, which a field filled in only half may never have told', async () => { + const server = stalwart([rule('a')]); + await openCard(server); + fireEvent.change(dateField(1), { target: { value: '' } }); + halfFilledIn(dateField(1), true); + fireEvent.click(saveButton()); + expect(await screen.findByText('warnings.invalid_date')).toBeInTheDocument(); + expect(server.client.setVacationResponse).not.toHaveBeenCalled(); + }); + + it('will not save a year with more than four digits', async () => { + const server = stalwart([rule('a')]); + await openCard(server); + fireEvent.change(dateField(1), { target: { value: '20266-10-16T18:00' } }); + expect(dateField(1).value).toBe('20266-10-16T18:00'); + expect(screen.getByText('warnings.invalid_date')).toBeInTheDocument(); + expect(saveButton().disabled).toBe(true); + }); + + it('keeps the dates the server has while their fields are left alone', async () => { + const server = stalwart([rule('a')]); + await openCard(server); + fireEvent.change(screen.getByPlaceholderText('message.subject_placeholder'), { target: { value: 'Bis bald' } }); + await save(server); + expect(server.client.setVacationResponse.mock.calls[0][0]).toMatchObject({ + fromDate: '2026-10-05T06:00:00Z', + toDate: '2026-10-16T16:00:00Z', + }); + }); +}); + +describe('who gets the auto-reply, in the vacation card', () => { + const identities = ['max@example.com', 'info@example.com', 'shop@example.org']; + const withIdentities = () => + useIdentityStore.setState({ identities: identities.map((email, i) => ({ id: `i${i}`, name: '', email })) as never }); + const audienceSelect = () => screen.getByRole('combobox', { name: 'audience.label' }) as HTMLSelectElement; + + it('is offered with the own domains taken from the identities', async () => { + withIdentities(); + await openCard(stalwart([rule('a')])); + expect(audienceSelect().value).toBe('all'); + expect(screen.getByText('audience.description example.com, example.org')).toBeInTheDocument(); + }); + + it('is not offered without identities to tell the own domains', async () => { + await openCard(stalwart([rule('a')])); + expect(screen.queryByRole('combobox', { name: 'audience.label' })).toBeNull(); + }); + + it('answers external senders only', async () => { + withIdentities(); + const server = stalwart([rule('a')]); + await openCard(server); + fireEvent.click(screen.getByRole('switch', { name: 'status.label' })); + fireEvent.change(audienceSelect(), { target: { value: 'external' } }); + await save(server); + + expect(toast.success).toHaveBeenCalledWith('vacation_saved'); + expect(server.active()).toBe('filters'); + const written = server.content('filters'); + expect(written).toContain([ + '# Vacation auto-reply', + 'if not envelope :domain :is "from" ["example.com", "example.org"] {', + ' include :personal :optional "vacation";', + '}', + ].join('\n')); + expect(parseScript(written).vacationAudience).toEqual({ only: 'external', domains: ['example.com', 'example.org'] }); + }); + + it('opens with the stored choice and goes back to everyone', async () => { + withIdentities(); + const server = stalwart([rule('a')]); + await server.client.updateSieveScript( + server.scripts[0].id, + generateScript([rule('a')], undefined, { + extensions: EXTENSIONS, + includeVacation: true, + vacationAudience: { only: 'internal', domains: ['example.com', 'example.org'] }, + }), + true, + 'b', + ); + // The auto-reply is on, run from the filters script. + await server.client.createSieveScript('vacation', STALWART_VACATION_SCRIPT, false, 'b'); + await openCard(server); + expect(screen.getByRole('switch', { name: 'status.label' }).getAttribute('aria-checked')).toBe('true'); + expect(audienceSelect().value).toBe('internal'); + expect(saveButton().disabled).toBe(true); + + fireEvent.change(audienceSelect(), { target: { value: 'all' } }); + await save(server); + expect(server.active()).toBe('filters'); + const written = server.content('filters'); + expect(written).not.toContain('envelope :domain'); + expect(written).toContain('# Vacation auto-reply\ninclude :personal :optional "vacation";'); + expect(parseScript(written).vacationAudience).toBeUndefined(); + }); +}); + +describe('layout of the out-of-office card', () => { + const follows = (a: Node, b: Node) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0; + + it('puts the period first, then the auto-reply and forwarding, each with its own switch and state', async () => { + await openCard(stalwart([rule('a')])); + const [page, reply, forwarding] = ['title', 'auto_reply.title', 'forward.title'].map((key) => screen.getByText(key)); + const start = screen.getByText('date_range.start'); + expect(follows(page, start)).toBe(true); + expect(follows(start, reply)).toBe(true); + expect(follows(reply, forwarding)).toBe(true); + expect(screen.queryByText('date_range.title')).toBeNull(); + + // Two switches, each saying whether it is on, independent of the other. + const replyRow = screen.getByRole('switch', { name: 'status.label' }).parentElement!; + const forwardRow = forwardSwitch().parentElement!; + expect(within(replyRow).getByText('status.inactive')).toBeInTheDocument(); + expect(within(forwardRow).getByText('status.inactive')).toBeInTheDocument(); + fireEvent.click(forwardSwitch()); + expect(within(forwardRow).getByText('status.active')).toBeInTheDocument(); + expect(within(replyRow).getByText('status.inactive')).toBeInTheDocument(); + }); +}); diff --git a/components/settings/filter-settings.tsx b/components/settings/filter-settings.tsx index 855515c61..1b1d01548 100644 --- a/components/settings/filter-settings.tsx +++ b/components/settings/filter-settings.tsx @@ -13,7 +13,9 @@ import { useSettingsStore } from "@/stores/settings-store"; import { toast } from "@/stores/toast-store"; import type { FilterRule } from "@/lib/jmap/sieve-types"; import type { Mailbox } from "@/lib/jmap/types"; -import { forwardsAround, worstCaseForwards } from "@/lib/filters/forward-limit"; +import { forwardsAround, inRunOrder, worstCaseForwards } from "@/lib/filters/forward-limit"; +import { isPeriodBoundary, periodStatus, supportsPeriods } from "@/lib/sieve/period"; +import { formatDateTime } from "@/lib/utils"; import { useVacationStore } from "@/stores/vacation-store"; import { useManagedAccountStore } from "@/stores/managed-account-store"; import { @@ -33,8 +35,29 @@ function isReadonlyRule(r: FilterRule): boolean { return r.origin === "external" || r.origin === "opaque"; } +/** + * The rule's period ("5 Oct 2026, 08:00 – …") and, for a rule that is on, + * where now falls in it; null without a period. + */ +function usePeriodLabel(rule: FilterRule): { range: string; status: string | null } | null { + const t = useTranslations("settings.filters"); + const timeFormat = useSettingsStore((s) => s.timeFormat); + const status = periodStatus(rule); + if (!status) return null; + const format = (boundary: string | undefined) => isPeriodBoundary(boundary) + ? formatDateTime(boundary, timeFormat, { day: "numeric", month: "short", year: "numeric" }) + : "…"; + return { + range: `${format(rule.activeFrom)} – ${format(rule.activeUntil)}`, + // A rule that is off does nothing in any period; "active" next to it + // would say otherwise. + status: rule.enabled ? t(`period_status_${status}`) : null, + }; +} + export function RuleSummary({ rule }: { rule: FilterRule }) { const t = useTranslations("settings.filters"); + const period = usePeriodLabel(rule); const conditions = rule.conditions.slice(0, 2).map((c) => { const field = t(`condition_fields.${c.field}`); @@ -85,12 +108,14 @@ export function RuleSummary({ rule }: { rule: FilterRule }) { ))} + {period && · {period.range}{period.status && ` (${period.status})`}} ); } export function VisualRuleSummary({ rule }: { rule: FilterRule }) { const t = useTranslations("settings.filters"); + const period = usePeriodLabel(rule); const joiner = rule.matchType === "all" ? t("and") : t("or"); const matchLabel = rule.matchType === "all" ? t("match_all_conditions") : t("match_any_condition"); @@ -157,6 +182,20 @@ export function VisualRuleSummary({ rule }: { rule: FilterRule }) { ); })} + + {period && ( +
+ + {t("period_title")} + + + {period.range} + + {period.status && ( + ({period.status}) + )} +
+ )} ); } @@ -180,6 +219,7 @@ export function FilterSettings() { rawScript, vacationSettings, includeVacation, + vacationForward, sieveCapabilities, selectAccount, saveFilters, @@ -254,16 +294,19 @@ export function FilterSettings() { const draggedIndexRef = useRef(null); // A message can collect only so many forwards on this server. The rules run - // in the order listed, and one that stops ends the script for its messages, - // so the order counts (see lib/filters/forward-limit.ts). + // in the order listed, behind the out of office forwarding, and one that + // stops ends the script for its messages, so the order counts (see + // lib/filters/forward-limit.ts). const redirectLimit = sieveCapabilities?.maxNumberRedirects; + const runOrder = inRunOrder(rules, vacationForward); const tooManyForwards = - typeof redirectLimit === "number" && redirectLimit > 0 && worstCaseForwards(rules) > redirectLimit; + typeof redirectLimit === "number" && redirectLimit > 0 && worstCaseForwards(runOrder) > redirectLimit; // The edited rule where it is; a new one goes below Bulwark's own rules. const editedIndex = editingRule ? rules.findIndex((r) => r.id === editingRule.id) : -1; const forwardsOfEdited = forwardsAround( - rules, - editedIndex >= 0 ? editedIndex : rules.filter((r) => !isReadonlyRule(r)).length, + runOrder, + runOrder.length - rules.length + + (editedIndex >= 0 ? editedIndex : rules.filter((r) => !isReadonlyRule(r)).length), editedIndex >= 0, ); @@ -727,6 +770,7 @@ export function FilterSettings() { rule={editingRule} mailboxes={mailboxes} maxRedirects={sieveCapabilities?.maxNumberRedirects} + periodsSupported={supportsPeriods(sieveCapabilities?.sieveExtensions)} forwardsBefore={forwardsOfEdited.before} forwardsAfter={forwardsOfEdited.after} onSave={handleSaveRule} diff --git a/components/settings/vacation-settings.tsx b/components/settings/vacation-settings.tsx index 9c44855f9..20c48a0f9 100644 --- a/components/settings/vacation-settings.tsx +++ b/components/settings/vacation-settings.tsx @@ -1,15 +1,20 @@ 'use client'; -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useTranslations } from 'next-intl'; -import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; +import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section'; import { Button } from '@/components/ui/button'; import { RichTextEditor } from '@/components/email/rich-text-editor'; -import { useVacationStore } from '@/stores/vacation-store'; +import { VacationFiltersError, useVacationStore, type VacationForwardSettings } from '@/stores/vacation-store'; import { useAuthStore } from '@/stores/auth-store'; +import { useIdentityStore } from '@/stores/identity-store'; import { useManagedAccountStore } from '@/stores/managed-account-store'; +import type { VacationForward } from '@/lib/jmap/sieve-types'; +import { isPeriodBoundary } from '@/lib/sieve/period'; +import { ownDomains } from '@/lib/sieve/vacation-audience'; import { sanitizeEmailHtml } from '@/lib/email-sanitization'; import { htmlToPlainText } from '@/lib/html-to-text'; +import { isValidEmail } from '@/lib/validation'; import { Loader2, AlertTriangle, Eye, EyeOff } from '@/components/icons'; import { toast } from '@/stores/toast-store'; @@ -19,17 +24,66 @@ function byteLength(value: string): number { return new TextEncoder().encode(value).length; } +/** A switch with its state written out next to it ("Active" / "Inactive"). */ +function StatusSwitch({ checked, onChange, activeLabel, inactiveLabel }: { + checked: boolean; + onChange: (checked: boolean) => void; + activeLabel: string; + inactiveLabel: string; +}) { + return ( +
+ + {checked ? activeLabel : inactiveLabel} + + +
+ ); +} + function utcToLocalDatetime(utcIso: string): string { const d = new Date(utcIso); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } +// The latest moment the date fields offer: a later year has no room in the +// four digits a stored date has. +const DATE_INPUT_MAX = '9999-12-31T23:59'; + +/** + * The moment a date field holds: '' when it is empty, null when it holds none + * that can be stored (a five-digit year). A field left as it was loaded keeps + * the stored value exactly. + */ +function fieldMoment(input: string, stored: string | null): string | null { + if (!input) return ''; + if (stored && input === utcToLocalDatetime(stored)) return stored; + const moment = new Date(input); + if (Number.isNaN(moment.getTime())) return null; + const iso = moment.toISOString(); + return isPeriodBoundary(iso) ? iso : null; +} + +function sameForward(a: VacationForwardSettings | null, b: VacationForward | null): boolean { + if (!a || !b) return a === b; + return a.enabled === b.enabled && a.to === b.to && a.keepCopy === b.keepCopy; +} + export function VacationSettings() { const t = useTranslations('settings.vacation'); + const tFilters = useTranslations('settings.filters'); const tNotifications = useTranslations('notifications'); const { client } = useAuthStore(); + const activeAccountId = useAuthStore((s) => s.activeAccountId); const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); + // What is typed here belongs to the account it was typed for: on a switch + // the fields start over, even where both accounts store the same values. + const accountKey = `${activeAccountId ?? ''}/${managedAccountId ?? ''}`; const { isEnabled, fromDate, @@ -37,6 +91,12 @@ export function VacationSettings() { subject, textBody, htmlBody, + forward, + forwardAvailable, + audience, + audienceAvailable, + notRunning, + otherForwards, isLoading, isSaving, error, @@ -46,14 +106,31 @@ export function VacationSettings() { } = useVacationStore(); const [localEnabled, setLocalEnabled] = useState(isEnabled); - const [localFromDate, setLocalFromDate] = useState(fromDate || ''); - const [localToDate, setLocalToDate] = useState(toDate || ''); + // The date fields as typed ("yyyy-mm-ddThh:mm" on the browser's clock). + const [fromInput, setFromInput] = useState(() => (fromDate ? utcToLocalDatetime(fromDate) : '')); + const [toInput, setToInput] = useState(() => (toDate ? utcToLocalDatetime(toDate) : '')); + // A field the browser holds half filled in (a date without its time) reads + // as empty, without an event when it was empty before: told apart here, so + // it does not pass for "no date", which leaves that end of the period open. + const [halfFilled, setHalfFilled] = useState({ from: false, to: false }); + const fromRef = useRef(null); + const toRef = useRef(null); + const localFromDate = fieldMoment(fromInput, fromDate); + const localToDate = fieldMoment(toInput, toDate); + const datesUnusable = localFromDate === null || localToDate === null || halfFilled.from || halfFilled.to; const [localSubject, setLocalSubject] = useState(subject); const [localTextBody, setLocalTextBody] = useState(textBody); const [htmlEnabled, setHtmlEnabled] = useState(!!htmlBody); const [localHtmlBody, setLocalHtmlBody] = useState(htmlBody || ''); const [showPreview, setShowPreview] = useState(false); const [validationWarnings, setValidationWarnings] = useState([]); + const [forwardEnabled, setForwardEnabled] = useState(forward?.enabled ?? false); + const [forwardTo, setForwardTo] = useState(forward?.to ?? ''); + const [forwardKeep, setForwardKeep] = useState(forward?.keepCopy ?? false); + const [audienceOnly, setAudienceOnly] = useState<'all' | 'internal' | 'external'>(audience?.only ?? 'all'); + // "Internal" are the account's own domains, as its identities give them. + const identities = useIdentityStore((s) => s.identities); + const domains = useMemo(() => ownDomains(identities.map((i) => i.email)), [identities]); useEffect(() => { if (client && isSupported) { @@ -63,13 +140,60 @@ export function VacationSettings() { useEffect(() => { setLocalEnabled(isEnabled); - setLocalFromDate(fromDate || ''); - setLocalToDate(toDate || ''); + setFromInput(fromDate ? utcToLocalDatetime(fromDate) : ''); + setToInput(toDate ? utcToLocalDatetime(toDate) : ''); + setHalfFilled({ from: false, to: false }); setLocalSubject(subject); setLocalTextBody(textBody); setHtmlEnabled(!!htmlBody); setLocalHtmlBody(htmlBody || ''); - }, [isEnabled, fromDate, toDate, subject, textBody, htmlBody]); + }, [accountKey, isEnabled, fromDate, toDate, subject, textBody, htmlBody]); + + useEffect(() => { + setForwardEnabled(forward?.enabled ?? false); + setForwardTo(forward?.to ?? ''); + setForwardKeep(forward?.keepCopy ?? false); + }, [accountKey, forward]); + + useEffect(() => { + setAudienceOnly(audience?.only ?? 'all'); + }, [accountKey, audience]); + + // Reads a date field as the browser holds it, half filled in or not. + const readDateField = (field: 'from' | 'to', input: HTMLInputElement) => { + (field === 'from' ? setFromInput : setToInput)(input.value); + const half = input.validity.badInput; + setHalfFilled((current) => (current[field] === half ? current : { ...current, [field]: half })); + }; + + // Who gets the auto-reply as a save stores it: everyone, or the senders + // from (or not from) the account's domains as they are now. + const canNarrow = audienceAvailable && domains.length > 0; + const audienceSettings = !canNarrow + ? undefined + : audienceOnly === 'all' ? null : { only: audienceOnly, domains }; + const audienceChanged = canNarrow && ( + audienceOnly !== (audience?.only ?? 'all') || + (audienceOnly !== 'all' && domains.join(',') !== (audience?.domains ?? []).join(',')) + ); + + // Forwarding as a save stores it: an address that is off is kept for next + // time if it is usable, and dropped otherwise. + const forwardInvalid = forwardAvailable && forwardEnabled && !isValidEmail(forwardTo); + const forwardSettings = !forwardAvailable + ? undefined + : forwardEnabled || isValidEmail(forwardTo) + ? { enabled: forwardEnabled, to: forwardTo, keepCopy: forwardKeep } + : null; + // Only what was changed here is sent: the rest stays as the server has it, + // which another tab or device may have changed since this one loaded. + const forwardChanged = forwardSettings !== undefined && !sameForward(forwardSettings, forward); + // Kept here, the message goes on through the filter rules, whose forwards + // share the server's limit with this one. + const forwardLimit = client?.getSieveCapabilities(managedAccountId ?? undefined)?.maxNumberRedirects; + const forwardOverLimit = + forwardAvailable && forwardEnabled && forwardKeep && + typeof forwardLimit === 'number' && forwardLimit > 0 && 1 + otherForwards > forwardLimit; // What a save sends: the sanitized HTML and a plain-text part, derived // from the HTML when the user left it blank (for clients without HTML). @@ -100,6 +224,9 @@ export function VacationSettings() { const validate = useCallback(() => { const warnings: string[] = []; + if (datesUnusable) { + warnings.push(t('warnings.invalid_date')); + } if (localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate)) { warnings.push(t('warnings.end_before_start')); } @@ -122,9 +249,19 @@ export function VacationSettings() { warnings.push(t('warnings.body_too_long', { max: sizeLimits.body })); } + if (forwardInvalid) { + warnings.push(t('warnings.forward_address')); + } + if (forwardOverLimit) { + warnings.push(tFilters('forward_limit', { count: forwardLimit })); + } + if (notRunning) { + warnings.push(t('warnings.not_running')); + } + setValidationWarnings(warnings); return warnings; - }, [localFromDate, localToDate, localEnabled, localTextBody, htmlEnabled, localHtmlBody, oversize, sizeLimits, t]); + }, [datesUnusable, localFromDate, localToDate, localEnabled, localTextBody, htmlEnabled, localHtmlBody, oversize, sizeLimits, forwardInvalid, forwardOverLimit, forwardLimit, notRunning, t, tFilters]); useEffect(() => { validate(); @@ -136,14 +273,25 @@ export function VacationSettings() { (localToDate || null) !== (toDate || null) || localSubject !== subject || localTextBody !== textBody || - (htmlEnabled ? localHtmlBody : '') !== (htmlBody || ''); + (htmlEnabled ? localHtmlBody : '') !== (htmlBody || '') || + forwardChanged || + audienceChanged || + // Saving sets right what is stored but does not run. + notRunning; const hasBlockingError = + datesUnusable || !!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate)) || - oversize.subject || oversize.body; + oversize.subject || oversize.body || forwardInvalid; const handleSave = async () => { if (!client) return; + // Half filled in without a single event: only the field itself knows. + const half = { from: !!fromRef.current?.validity.badInput, to: !!toRef.current?.validity.badInput }; + if (half.from || half.to) { + setHalfFilled(half); + return; + } validate(); if (hasBlockingError) return; @@ -157,12 +305,16 @@ export function VacationSettings() { subject: localSubject, textBody, htmlBody: sanitizedHtml, + forward: forwardChanged ? forwardSettings : undefined, + audience: audienceChanged ? audienceSettings : undefined, }, managedAccountId ?? undefined); toast.success(tNotifications('vacation_saved')); } catch (error) { console.error('Failed to save vacation response:', error); - toast.error(tNotifications('vacation_save_failed')); + toast.error(tNotifications( + error instanceof VacationFiltersError ? 'vacation_filters_save_failed' : 'vacation_save_failed', + )); } }; @@ -199,33 +351,20 @@ export function VacationSettings() { return (
+ {/* The period is the absence: the auto-reply and forwarding both keep to it. */} - -
- - {localEnabled ? t('status.active') : t('status.inactive')} - - -
-
-
- - setLocalFromDate(e.target.value ? new Date(e.target.value).toISOString() : '')} + max={DATE_INPUT_MAX} + value={fromInput} + onChange={(e) => readDateField('from', e.currentTarget)} + onBlur={(e) => readDateField('from', e.currentTarget)} + aria-invalid={localFromDate === null || halfFilled.from} className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground" /> @@ -234,15 +373,43 @@ export function VacationSettings() { description={t('date_range.end_description')} > setLocalToDate(e.target.value ? new Date(e.target.value).toISOString() : '')} + max={DATE_INPUT_MAX} + value={toInput} + onChange={(e) => readDateField('to', e.currentTarget)} + onBlur={(e) => readDateField('to', e.currentTarget)} + aria-invalid={localToDate === null || halfFilled.to} className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground" /> - + + + + + {canNarrow && ( + + setForwardTo(e.target.value)} + placeholder={t('forward.to_placeholder')} + aria-label={t('forward.to_label')} + aria-invalid={forwardInvalid} + className="w-64 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground" + /> + + + + + + )} + + )} + {validationWarnings.length > 0 && (
{validationWarnings.map((warning, i) => ( diff --git a/lib/__tests__/timezone.test.ts b/lib/__tests__/timezone.test.ts index 5fcc01f18..c73da6ee7 100644 --- a/lib/__tests__/timezone.test.ts +++ b/lib/__tests__/timezone.test.ts @@ -3,6 +3,7 @@ import { AUTO_TIME_ZONE, displayNow, fromDisplayDate, + fromWallClock, fromZonedDisplayDate, getBrowserTimeZone, getTimeZoneOffsetMs, @@ -114,6 +115,15 @@ describe('wall-clock helpers', () => { expect(isNaN(toZonedDisplayDate(bad, 'Europe/Berlin').getTime())).toBe(true); expect(isNaN(fromZonedDisplayDate(bad, 'Europe/Berlin').getTime())).toBe(true); }); + + it('reads a wall-clock in a zone straight from its fields', () => { + const at = (year: number, month: number, day: number, hour: number, minute: number, zone: string) => + fromWallClock({ year, month, day, hour, minute, second: 0 }, zone).toISOString(); + expect(at(2026, 10, 5, 8, 0, 'Europe/Berlin')).toBe('2026-10-05T06:00:00.000Z'); + expect(at(2026, 10, 26, 18, 0, 'Europe/Berlin')).toBe('2026-10-26T17:00:00.000Z'); + // 02:30 on 29 March is skipped in Berlin, but exists in New York. + expect(at(2026, 3, 29, 2, 30, 'America/New_York')).toBe('2026-03-29T06:30:00.000Z'); + }); }); describe('store-aware display dates', () => { diff --git a/lib/filters/__tests__/forward-limit-vacation.test.ts b/lib/filters/__tests__/forward-limit-vacation.test.ts new file mode 100644 index 000000000..b0c094d29 --- /dev/null +++ b/lib/filters/__tests__/forward-limit-vacation.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import type { FilterAction, FilterRule, VacationForward } from '@/lib/jmap/sieve-types'; +import { generateScript } from '@/lib/sieve/generator'; +import { VACATION_FORWARD_MARKER } from '@/lib/sieve/vacation-forward'; +import { forwardsAround, inRunOrder, ruleForwards, ruleStops, worstCaseForwards } from '../forward-limit'; + +const forward: FilterAction = { type: 'forward', value: 'office@example.com' }; +const rule = (actions: FilterAction[], stopProcessing = false): FilterRule => ({ + id: `r-${Math.random()}`, + name: 'R', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], + actions, + stopProcessing, +}); +const vacationForward = (keepCopy: boolean, enabled = true): VacationForward => ({ + enabled, + to: 'deputy@example.com', + keepCopy, +}); + +describe('the out of office forwarding in the run order', () => { + const rules = [rule([forward])]; + + it('goes ahead of the rules, and stops unless it keeps a copy', () => { + expect(inRunOrder(rules, vacationForward(true))).toEqual([ + { enabled: true, actions: [{ type: 'forward', value: 'deputy@example.com' }], stopProcessing: false }, + ...rules, + ]); + expect(inRunOrder(rules, vacationForward(false))[0].stopProcessing).toBe(true); + }); + + it('is left out while it is off', () => { + expect(inRunOrder(rules, vacationForward(true, false))).toBe(rules); + expect(inRunOrder(rules, null)).toBe(rules); + expect(inRunOrder(rules, undefined)).toBe(rules); + }); + + it('agrees with the script: one redirect ahead of every rule, and a stop unless a copy is kept', () => { + for (const keepCopy of [true, false]) { + const script = generateScript(rules, undefined, { vacationForward: vacationForward(keepCopy) }); + expect(script.indexOf(VACATION_FORWARD_MARKER)).toBeGreaterThan(-1); + expect(script.indexOf(VACATION_FORWARD_MARKER)).toBeLessThan(script.indexOf('# Rule:')); + const lines = script.split(VACATION_FORWARD_MARKER)[1].split('# Rule:')[0].split('\n').map((line) => line.trim()); + const [ahead] = inRunOrder([], vacationForward(keepCopy)); + expect(lines.filter((line) => line.startsWith('redirect ')).length, `keepCopy ${keepCopy}`).toBe(ruleForwards(ahead)); + expect(lines.includes('stop;'), `keepCopy ${keepCopy}`).toBe(ruleStops(ahead)); + } + }); + + it('adds its forward to every rule only when it keeps a copy', () => { + // A new rule goes behind Bulwark's rules, here behind the one rule. + expect(forwardsAround(inRunOrder(rules, vacationForward(true)), 2, false)).toEqual({ before: 2, after: 0 }); + expect(forwardsAround(inRunOrder(rules, vacationForward(false)), 2, false)).toEqual({ before: 1, after: 0 }); + expect(worstCaseForwards(inRunOrder(rules, vacationForward(true)))).toBe(2); + expect(worstCaseForwards(inRunOrder(rules, vacationForward(false)))).toBe(1); + }); +}); diff --git a/lib/filters/__tests__/quick-rules.test.ts b/lib/filters/__tests__/quick-rules.test.ts index c3070c574..6f3161e5a 100644 --- a/lib/filters/__tests__/quick-rules.test.ts +++ b/lib/filters/__tests__/quick-rules.test.ts @@ -240,6 +240,15 @@ describe('applyQuickRule', () => { } }); + it('leaves a rule with a period alone, since the new values would only act within it', () => { + for (const period of [{ activeFrom: '2026-10-05T06:00:00.000Z' }, { activeUntil: '2026-10-16T16:00:00.000Z' }]) { + const existing = [rule({ id: 'target', ...period })]; + // Neither merged into it nor counted as covered by it. + expect(applyQuickRule(existing, candidate('bob@acme.com')).kind).toBe('created'); + expect(applyQuickRule(existing, candidate('anna@acme.com')).kind).toBe('created'); + } + }); + it('merges List-Id rules by header name, whatever its case', () => { const existing = [rule({ id: 'lists', conditions: [{ field: 'header', headerName: 'list-id', comparator: 'contains', value: 'a.example.org' }] })]; const outcome = applyQuickRule(existing, rule({ diff --git a/lib/filters/__tests__/retroactive.test.ts b/lib/filters/__tests__/retroactive.test.ts index daecf9176..1f9955aa1 100644 --- a/lib/filters/__tests__/retroactive.test.ts +++ b/lib/filters/__tests__/retroactive.test.ts @@ -144,6 +144,14 @@ describe('retroactiveSupport', () => { } }); + it('never runs a rule with a period on old mail', () => { + for (const period of [{ activeFrom: '2026-10-05T06:00:00.000Z' }, { activeUntil: '2026-10-16T16:00:00.000Z' }]) { + const r = rule([{ field: 'from', comparator: 'address_is', value: 'anna@acme.com' }], [{ type: 'mark_read' }], period); + expect(retroactiveSupport(r)).toEqual({ ok: false, reason: 'period' }); + expect(planRetroactive(r, [message()])).toEqual({ ids: [], steps: [] }); + } + }); + it('refuses conditions it cannot evaluate like Sieve', () => { for (const condition of [ { field: 'body', comparator: 'contains', value: 'x' }, diff --git a/lib/filters/__tests__/sieve-mock.ts b/lib/filters/__tests__/sieve-mock.ts index 265bfbb27..bac77da20 100644 --- a/lib/filters/__tests__/sieve-mock.ts +++ b/lib/filters/__tests__/sieve-mock.ts @@ -80,3 +80,50 @@ export function mockSieveAccount( }, }; } + +export const STALWART_VACATION_SCRIPT = 'require "vacation";\nvacation "Ich bin nicht da.";\n'; + +interface VacationFields { + fromDate: string | null; + toDate: string | null; + subject: string; + textBody: string; + htmlBody: string | null; +} + +/** + * A Sieve account that behaves like Stalwart for VacationResponse: turning + * the auto-reply on activates its own "vacation" script, which switches + * every other script off, and it reads as on only while that script is the + * active one. Like Stalwart, an update that leaves isEnabled out switches + * that script off, and one redirect per message is the default limit. + */ +export function mockStalwartAccount( + accountId: string, + initial: Parameters[1], + extensions: string[], + options: { maxNumberRedirects?: number; vacation?: Partial } = {}, +) { + const account = mockSieveAccount(accountId, initial, extensions); + let vacation: VacationFields = { fromDate: null, toDate: null, subject: '', textBody: 'away', htmlBody: null, ...options.vacation }; + const maxNumberRedirects = 'maxNumberRedirects' in options ? options.maxNumberRedirects : 1; + const client = Object.assign(account.client, { + supportsSieve: () => true, + hasAccountCapability: () => false, + getSieveAccounts: () => [{ id: accountId, name: 'Me', isPrimary: true }], + getSieveCapabilities: () => ({ sieveExtensions: extensions, maxNumberRedirects }) as never, + getVacationResponse: vi.fn(async () => ({ ...vacation, isEnabled: account.active() === 'vacation' })), + setVacationResponse: vi.fn(async (updates: Record) => { + const { isEnabled, ...fields } = updates; + vacation = { ...vacation, ...fields }; + if (isEnabled === true) { + const own = account.scripts.find((s) => s.name === 'vacation'); + if (own) await client.activateSieveScript(own.id, accountId); + else await client.createSieveScript('vacation', STALWART_VACATION_SCRIPT, true, accountId); + } else if (account.active() === 'vacation') { + await client.deactivateSieveScript(accountId); + } + }), + }); + return { ...account, client }; +} diff --git a/lib/filters/account-filters.ts b/lib/filters/account-filters.ts index 88c71abd4..449bc8d50 100644 --- a/lib/filters/account-filters.ts +++ b/lib/filters/account-filters.ts @@ -94,7 +94,7 @@ export async function readAccountFilters(client: IJMAPClient, accountId: string) }; } -/** The script for `rules`, keeping the account's vacation and external requires. */ +/** The script for `rules`, keeping the account's vacation, its forwarding and external requires. */ export function renderFiltersScript( rules: FilterRule[], filters: Pick, @@ -102,6 +102,8 @@ export function renderFiltersScript( return generateScript(rules, filters.parsed.vacation, { externalRequires: filters.parsed.externalRequires, includeVacation: filters.includeVacation, + vacationForward: filters.parsed.vacationForward, + vacationAudience: filters.parsed.vacationAudience, extensions: filters.capabilities?.sieveExtensions, }); } diff --git a/lib/filters/forward-limit.ts b/lib/filters/forward-limit.ts index 227132bf0..2a3674c6d 100644 --- a/lib/filters/forward-limit.ts +++ b/lib/filters/forward-limit.ts @@ -1,4 +1,4 @@ -import type { FilterRule } from '@/lib/jmap/sieve-types'; +import type { FilterRule, VacationForward } from '@/lib/jmap/sieve-types'; /* * A server lets one message trigger only so many redirects @@ -61,3 +61,13 @@ export function forwardsAround( const after = worstCaseForwards(rules.slice(replaces ? index + 1 : index)); return { before, after }; } + +/** + * The rules in the order the script runs them: forwarding from the out of + * office card goes ahead of every rule, and ends the script unless it keeps + * a copy here. + */ +export function inRunOrder(rules: RuleForwards[], forward: VacationForward | null | undefined): RuleForwards[] { + if (!forward?.enabled) return rules; + return [{ enabled: true, actions: [{ type: 'forward', value: forward.to }], stopProcessing: !forward.keepCopy }, ...rules]; +} diff --git a/lib/filters/quick-rules.ts b/lib/filters/quick-rules.ts index 7f14ccbca..ceee0da9a 100644 --- a/lib/filters/quick-rules.ts +++ b/lib/filters/quick-rules.ts @@ -1,5 +1,6 @@ import type { Email, Mailbox } from '@/lib/jmap/types'; import type { FilterAction, FilterCondition, FilterRule } from '@/lib/jmap/sieve-types'; +import { hasPeriod } from '@/lib/sieve/period'; /** * Rules made from a message: "Always move messages from Anna to…", "Block @@ -350,7 +351,9 @@ export type QuickRuleOutcome = /** * Save a one-click rule without piling up near-duplicates: an enabled * Bulwark rule with a single condition of the same kind and the same actions - * takes the new values into its value list and keeps its name and place. + * takes the new values into its value list and keeps its name and place. A + * rule with a period only acts within it, and the new values would too, so + * it is never the one. * `added` is the part of the rule that is new (the candidate itself, or the * merged rule narrowed to the added values), which is what the "apply to * existing messages" pass runs. @@ -361,6 +364,7 @@ export function applyQuickRule(rules: FilterRule[], candidate: FilterRule): Quic ? rules.find(r => isBulwarkRule(r) && r.enabled + && !hasPeriod(r) && r.conditions.length === 1 && sameConditionKind(r.conditions[0], condition) && sameActions(r.actions, candidate.actions)) diff --git a/lib/filters/retroactive.ts b/lib/filters/retroactive.ts index ebb7ac6d5..006e2b291 100644 --- a/lib/filters/retroactive.ts +++ b/lib/filters/retroactive.ts @@ -1,5 +1,6 @@ import type { EmailAddress } from '@/lib/jmap/types'; import type { FilterAction, FilterCondition, FilterRule } from '@/lib/jmap/sieve-types'; +import { hasPeriod } from '@/lib/sieve/period'; import { unfoldHeader } from './quick-rules'; /** @@ -38,7 +39,9 @@ export type RetroSupport = /** A condition the client cannot evaluate like Sieve does (body, size, attachment). */ | { ok: false; reason: 'condition' } /** An action that must never run on old mail (forward, reject, discard) or has no meaning there (keep). */ - | { ok: false; reason: 'action' }; + | { ok: false; reason: 'action' } + /** A period: the rule acts on mail as it arrives within it, which says nothing about the mail already there. */ + | { ok: false; reason: 'period' }; /** A header condition the client can evaluate: which header it reads. */ function conditionHeader(condition: FilterCondition): string | null { @@ -55,7 +58,10 @@ function usesAddressTest(condition: FilterCondition): boolean { && ADDRESS_FIELDS.has(condition.field); } -export function retroactiveSupport(rule: Pick): RetroSupport { +export function retroactiveSupport( + rule: Pick, +): RetroSupport { + if (hasPeriod(rule)) return { ok: false, reason: 'period' }; if (rule.conditions.length === 0) return { ok: false, reason: 'condition' }; for (const condition of rule.conditions) { if (!TEXT_COMPARATORS.has(condition.comparator) || !conditionHeader(condition)) { diff --git a/lib/jmap/sieve-types.ts b/lib/jmap/sieve-types.ts index 53ea76e1b..8713f585f 100644 --- a/lib/jmap/sieve-types.ts +++ b/lib/jmap/sieve-types.ts @@ -92,6 +92,15 @@ export interface FilterRule { * folder rule does not pull spam out of Junk. */ includeSpam?: boolean; + /** + * The rule only acts on mail that arrives from `activeFrom` until + * `activeUntil`, both inclusive, either one open-ended. Absolute moments + * as ISO 8601 UTC ("2026-10-05T06:00:00.000Z"), so the script does not + * depend on the time zone of whoever saves it next. Compiled to Sieve + * `currentdate` tests (RFC 5260), which need "date" and "relational". + */ + activeFrom?: string; + activeUntil?: string; origin?: FilterOrigin; originLabel?: string; rawBlock?: string; @@ -103,10 +112,50 @@ export interface VacationSieveConfig { textBody: string; } +/** + * Forwarding set up in the vacation card: mail goes on to one address, with + * or without the auto-reply, in the vacation's period. It is not a filter + * rule and the filter list does not show it; it runs ahead of the rules. + */ +export interface VacationForward { + /** The forwarding switch, independent of the auto-reply's. */ + enabled: boolean; + /** The one address mail is forwarded to. */ + to: string; + /** + * Keep the message in this mailbox too (`redirect :copy`). Otherwise it + * is only forwarded, and no filter rule runs on it. + */ + keepCopy: boolean; + /** The auto-reply's period when it was saved, as for FilterRule.activeFrom. */ + activeFrom?: string; + activeUntil?: string; +} + +/** + * Who gets the auto-reply when not everyone should: only senders from the + * account's own domains, or only senders from elsewhere. It is decided on + * the From address while the auto-reply runs from the filters script. + */ +export interface VacationAudience { + only: 'internal' | 'external'; + /** The account's own domains (from its identities) when it was saved. */ + domains: string[]; +} + export interface FilterMetadata { - version: 1; + /** + * 2 once a rule has a period or the vacation's forwarding or recipients are + * stored. Older builds only take version 1 as their own and would write + * such a script back without these fields; anything else they leave alone. + */ + version: 1 | 2; rules: FilterRule[]; vacation?: VacationSieveConfig; + /** See VacationAudience; absent when everyone gets the auto-reply. */ + vacationAudience?: VacationAudience; + /** See VacationForward; kept while switched off, so the card remembers it. */ + vacationForward?: VacationForward; /** * The script runs the server-managed "vacation" script via `include`. * Servers like Stalwart keep one active script, so a VacationResponse diff --git a/lib/sieve/__tests__/generator-compat.test.ts b/lib/sieve/__tests__/generator-compat.test.ts new file mode 100644 index 000000000..ace7e3026 --- /dev/null +++ b/lib/sieve/__tests__/generator-compat.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; +import type { FilterRule, VacationSieveConfig } from '@/lib/jmap/sieve-types'; +import { generateScript, type GenerateOptions } from '../generator'; +import { parseScript } from '../parser'; + +const EXTENSIONS = [ + 'fileinto', 'mailbox', 'mailboxid', 'imap4flags', 'include', 'copy', 'date', 'relational', + 'spamtestplus', 'comparator-i;ascii-numeric', 'body', 'vacation', 'envelope', 'reject', +]; + +const rules: FilterRule[] = [ + { + id: 'r1', name: 'Boersen', enabled: true, matchType: 'any', + conditions: [ + { field: 'from', comparator: 'contains', value: ['autoscout24', 'mobile.de'] }, + { field: 'subject', comparator: 'starts_with', value: 'Anfrage' }, + ], + actions: [{ type: 'move', value: 'INBOX/Boersen', mailboxId: 'mb-1' }, { type: 'mark_read' }], + stopProcessing: true, + }, + { + id: 'r2', name: 'Chef', enabled: true, matchType: 'all', + conditions: [{ field: 'from', comparator: 'address_is', value: 'chef@example.com' }], + actions: [{ type: 'forward', value: 'kollege@example.com', keepCopy: true }, { type: 'star' }], + stopProcessing: false, + }, + { + id: 'r3', name: 'Gross', enabled: false, matchType: 'all', + conditions: [{ field: 'size', comparator: 'greater_than', value: '1000000' }], + actions: [{ type: 'discard' }], + stopProcessing: false, includeSpam: true, + }, +]; +const external: FilterRule = { + id: 'ext-1', name: 'Hand', enabled: true, matchType: 'all', conditions: [], actions: [], stopProcessing: false, + origin: 'external', rawBlock: '# Hand\nif header :contains "subject" "hand" {\n keep;\n}', +}; +const vacation: VacationSieveConfig = { isEnabled: true, subject: 'Weg', textBody: 'Bin weg' }; + +const RULE_BLOCKS = [ + '# Rule: Boersen', + 'if allof(anyof(header :contains "From" ["autoscout24", "mobile.de"], header :matches "Subject" "Anfrage*"), not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {', + ' addflag "\\\\Seen";', + ' fileinto :mailboxid "mb-1" "INBOX/Boersen";', + ' stop;', + '}', + '', + '# Rule: Chef', + 'if address :is "From" "chef@example.com" {', + ' addflag "\\\\Flagged";', + ' redirect :copy "kollege@example.com";', + '}', + '', +]; + +// What the generator wrote for these before rules had periods and the +// vacation card had forwarding and reply recipients, taken from it as it was +// then (0409bde8). Every account's script is rewritten on its next save, so a +// script without the new fields must still come out byte for byte the same. +const BEFORE: Record = { + 'rules next to the vacation include': { + args: [rules, undefined, { includeVacation: true, extensions: EXTENSIONS }], + script: [ + '/* @metadata:begin', + JSON.stringify({ version: 1, rules, includeVacation: true }), + '@metadata:end */', + '', + 'require ["comparator-i;ascii-numeric", "copy", "fileinto", "imap4flags", "include", "mailbox", "mailboxid", "relational", "spamtestplus"];', + '', + '# Vacation auto-reply', + 'include :personal :optional "vacation";', + '', + ...RULE_BLOCKS, + ].join('\n'), + }, + 'rules, an external block and the vacation written in': { + args: [[...rules, external], vacation, { externalRequires: ['body'], extensions: EXTENSIONS }], + script: [ + '/* @metadata:begin', + JSON.stringify({ version: 1, rules, vacation }), + '@metadata:end */', + '', + 'require ["body", "comparator-i;ascii-numeric", "copy", "fileinto", "imap4flags", "mailbox", "mailboxid", "relational", "spamtestplus", "vacation"];', + '', + '# Vacation auto-reply', + 'vacation :subject "Weg" "Bin weg";', + '', + ...RULE_BLOCKS, + '# --- External rules (managed outside Bulwark) ---', + '# Hand', + 'if header :contains "subject" "hand" {', + ' keep;', + '}', + '', + ].join('\n'), + }, + 'only the vacation include': { + args: [[], undefined, { includeVacation: true, extensions: EXTENSIONS }], + script: [ + '/* @metadata:begin', + '{"version":1,"rules":[],"includeVacation":true}', + '@metadata:end */', + '', + 'require ["include"];', + '', + '# Vacation auto-reply', + 'include :personal :optional "vacation";', + '', + ].join('\n'), + }, +}; + +describe('a script without periods, forwarding or reply recipients', () => { + it('comes out exactly as before these existed', () => { + for (const [name, { args: [r, v, options], script }] of Object.entries(BEFORE)) { + expect(generateScript(r, v, options), name).toBe(script); + // The stores pass "none" for both. + expect(generateScript(r, v, { ...options, vacationForward: null, vacationAudience: null }), name).toBe(script); + } + }); +}); + +describe('the metadata version', () => { + const period = { activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }; + const versionOf = (script: string) => (JSON.parse(script.split('\n')[1]) as { version: number }).version; + const generate = (r: FilterRule[], options: GenerateOptions = {}) => + generateScript(r, undefined, { extensions: EXTENSIONS, ...options }); + + it('is 2 once an older build would drop something, so that it leaves the script alone', () => { + // An older build takes version 1 as its own and writes it back without + // what it does not know: the rule would forward for good, the auto-reply + // would go to everyone. + expect(versionOf(generate([{ ...rules[1], ...period }]))).toBe(2); + // A rule that is off keeps its period for when it is on again. + expect(versionOf(generate([{ ...rules[2], activeUntil: period.activeUntil }]))).toBe(2); + // Forwarding is remembered while it is off. + expect(versionOf(generate([], { vacationForward: { enabled: false, to: 'kollege@example.com', keepCopy: false } }))).toBe(2); + expect(versionOf(generate([], { includeVacation: true, vacationAudience: { only: 'internal', domains: ['example.com'] } }))).toBe(2); + expect(versionOf(generate(rules, { includeVacation: true }))).toBe(1); + }); + + it('reads version 2 back as its own', () => { + const options: GenerateOptions = { + includeVacation: true, + vacationForward: { enabled: true, to: 'kollege@example.com', keepCopy: true, ...period }, + vacationAudience: { only: 'external', domains: ['example.com'] }, + }; + const script = generate([{ ...rules[1], ...period }], options); + const parsed = parseScript(script); + expect(parsed.isOpaque).toBe(false); + expect(generate(parsed.rules, { + includeVacation: parsed.includeVacation, + vacationForward: parsed.vacationForward, + vacationAudience: parsed.vacationAudience, + })).toBe(script); + }); + + it('takes a version it does not know for a script edited by hand', () => { + expect(parseScript('/* @metadata:begin\n{"version":3,"rules":[]}\n@metadata:end */').isOpaque).toBe(true); + }); +}); diff --git a/lib/sieve/__tests__/parser.test.ts b/lib/sieve/__tests__/parser.test.ts index ddd49f3d4..18fee1b15 100644 --- a/lib/sieve/__tests__/parser.test.ts +++ b/lib/sieve/__tests__/parser.test.ts @@ -43,7 +43,7 @@ describe('parseScript', () => { }); it('returns isOpaque for version mismatch', () => { - const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */'; + const script = '/* @metadata:begin\n{"version":3,"rules":[]}\n@metadata:end */'; const result = parseScript(script); expect(result.isOpaque).toBe(true); expect(result.rules).toEqual([]); diff --git a/lib/sieve/__tests__/period.test.ts b/lib/sieve/__tests__/period.test.ts new file mode 100644 index 000000000..948f8ff98 --- /dev/null +++ b/lib/sieve/__tests__/period.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; +import type { FilterRule } from '@/lib/jmap/sieve-types'; +import { generateScript } from '../generator'; +import { parseScript } from '../parser'; +import { isPeriodBoundary, periodStatus, periodTests, supportsPeriods } from '../period'; + +/** + * Evaluates the Sieve tests periodTests() writes, following RFC 5260: in zone + * "+0000" the "date" part is yyyy-mm-dd and "time" is hh:mm:ss, and :value + * compares them as strings (the default comparator). It reads only that + * subset, so it checks the logic, not a server's parser - the scripts were + * also run through Stalwart's own interpreter (sieve-rs) by hand. + */ +function evaluate(test: string, now: Date): boolean { + const split = (inner: string) => { + const parts: string[] = []; + let depth = 0; + let quoted = false; + let start = 0; + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]; + if (ch === '"') quoted = !quoted; + else if (!quoted && ch === '(') depth++; + else if (!quoted && ch === ')') depth--; + else if (!quoted && depth === 0 && ch === ',') { + parts.push(inner.slice(start, i).trim()); + start = i + 1; + } + } + parts.push(inner.slice(start).trim()); + return parts; + }; + const group = test.match(/^(anyof|allof)\((.*)\)$/); + if (group) { + const results = split(group[2]).map((t) => evaluate(t, now)); + return group[1] === 'anyof' ? results.some(Boolean) : results.every(Boolean); + } + const current = test.match(/^currentdate :zone "\+0000" (?::is|:value "(gt|ge|lt|le)") "(date|time)" "([^"]+)"$/); + if (!current) throw new Error(`not a period test: ${test}`); + const [, op, part, key] = current; + const iso = now.toISOString(); + const value = part === 'date' ? iso.slice(0, 10) : iso.slice(11, 19); + switch (op) { + case undefined: return value === key; + case 'gt': return value > key; + case 'ge': return value >= key; + case 'lt': return value < key; + default: return value <= key; + } +} + +const inPeriod = (period: Pick, at: string) => + periodTests(period)!.every((t) => evaluate(t, new Date(at))); + +describe('period boundaries', () => { + it('accepts ISO moments with a zone only', () => { + expect(isPeriodBoundary('2026-10-05T06:00:00.000Z')).toBe(true); + expect(isPeriodBoundary('2026-10-05T08:00+02:00')).toBe(true); + // Without a zone, Date.parse would read local time: browser-dependent. + expect(isPeriodBoundary('2026-10-05T08:00')).toBe(false); + expect(isPeriodBoundary('2026-10-05')).toBe(false); + expect(isPeriodBoundary('2026-13-45T08:00:00Z')).toBe(false); + expect(isPeriodBoundary('')).toBe(false); + expect(isPeriodBoundary(null)).toBe(false); + expect(isPeriodBoundary(1_790_000_000_000)).toBe(false); + }); + + it('refuses a moment whose year in UTC has no room in four digits', () => { + // Year 10000 in UTC: Sieve's "date" part would read "+010000-01-01". + expect(isPeriodBoundary('9999-12-31T23:59:59-01:00')).toBe(false); + expect(isPeriodBoundary('+010000-01-01T00:59:59.000Z')).toBe(false); + expect(isPeriodBoundary('9999-12-31T23:59:59Z')).toBe(true); + }); + + it('needs the date and relational extensions', () => { + expect(supportsPeriods(['fileinto', 'date', 'relational'])).toBe(true); + expect(supportsPeriods(['fileinto', 'date'])).toBe(false); + expect(supportsPeriods(undefined)).toBe(false); + }); + + it('gives no tests without a period and null for an unusable boundary', () => { + expect(periodTests({})).toEqual([]); + expect(periodTests({ activeFrom: '2026-10-05T08:00' })).toBeNull(); + expect(periodTests({ activeFrom: '2026-10-05T06:00:00Z', activeUntil: 'soon' })).toBeNull(); + }); + + it('writes the start and end as UTC date and time tests', () => { + expect(periodTests({ activeFrom: '2026-10-05T08:00:00+02:00', activeUntil: '2026-10-16T16:00:00.000Z' })).toEqual([ + 'anyof(currentdate :zone "+0000" :value "gt" "date" "2026-10-05", allof(currentdate :zone "+0000" :is "date" "2026-10-05", currentdate :zone "+0000" :value "ge" "time" "06:00:00"))', + 'anyof(currentdate :zone "+0000" :value "lt" "date" "2026-10-16", allof(currentdate :zone "+0000" :is "date" "2026-10-16", currentdate :zone "+0000" :value "le" "time" "16:00:00"))', + ]); + }); +}); + +describe('period logic (RFC 5260 semantics)', () => { + const period = { activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }; + + it.each([ + ['one second before the start', '2026-10-05T05:59:59Z', false], + ['the day before, later than the start time', '2026-10-04T23:00:00Z', false], + ['the start', '2026-10-05T06:00:00Z', true], + ['later on the first day', '2026-10-05T23:59:59Z', true], + ['the next day, earlier than the start time', '2026-10-06T00:00:00Z', true], + ['the middle', '2026-10-10T12:00:00Z', true], + ['the last day, earlier than the end time', '2026-10-16T05:00:00Z', true], + ['the end', '2026-10-16T16:00:00Z', true], + ['one second after the end', '2026-10-16T16:00:01Z', false], + ['the day after, earlier than the end time', '2026-10-17T00:00:00Z', false], + ])('%s: %s -> %s', (_label, at, expected) => { + expect(inPeriod(period, at)).toBe(expected); + }); + + it('leaves either end open', () => { + expect(inPeriod({ activeFrom: period.activeFrom }, '2099-01-01T00:00:00Z')).toBe(true); + expect(inPeriod({ activeFrom: period.activeFrom }, '2026-10-05T05:59:59Z')).toBe(false); + expect(inPeriod({ activeUntil: period.activeUntil }, '2000-01-01T00:00:00Z')).toBe(true); + expect(inPeriod({ activeUntil: period.activeUntil }, '2026-10-16T16:00:01Z')).toBe(false); + }); + + it('spans a new year', () => { + const newYear = { activeFrom: '2026-12-31T23:30:00Z', activeUntil: '2027-01-01T00:30:00Z' }; + expect(inPeriod(newYear, '2026-12-31T23:29:59Z')).toBe(false); + expect(inPeriod(newYear, '2026-12-31T23:30:00Z')).toBe(true); + expect(inPeriod(newYear, '2027-01-01T00:00:00Z')).toBe(true); + expect(inPeriod(newYear, '2027-01-01T00:30:00Z')).toBe(true); + expect(inPeriod(newYear, '2027-01-01T00:30:01Z')).toBe(false); + }); + + it('spans the switch from summer to winter time (Berlin, 25 Oct 2026)', () => { + // 24 Oct 22:00 CEST until 26 Oct 08:00 CET, written with their offsets. + const overSwitch = { activeFrom: '2026-10-24T22:00:00+02:00', activeUntil: '2026-10-26T08:00:00+01:00' }; + expect(inPeriod(overSwitch, '2026-10-24T19:59:59Z')).toBe(false); + expect(inPeriod(overSwitch, '2026-10-24T20:00:00Z')).toBe(true); + expect(inPeriod(overSwitch, '2026-10-25T01:30:00Z')).toBe(true); + expect(inPeriod(overSwitch, '2026-10-26T07:00:00Z')).toBe(true); + expect(inPeriod(overSwitch, '2026-10-26T07:00:01Z')).toBe(false); + }); + + it('never acts when the period ends before it starts', () => { + const inverted = { activeFrom: '2026-10-16T00:00:00Z', activeUntil: '2026-10-05T00:00:00Z' }; + for (const at of ['2026-10-01T00:00:00Z', '2026-10-10T00:00:00Z', '2026-10-20T00:00:00Z']) { + expect(inPeriod(inverted, at)).toBe(false); + } + }); + + it('reports where a moment falls', () => { + const now = Date.parse('2026-10-10T12:00:00Z'); + expect(periodStatus({}, now)).toBeNull(); + expect(periodStatus(period, now)).toBe('active'); + expect(periodStatus({ activeFrom: '2026-10-11T00:00:00Z' }, now)).toBe('scheduled'); + expect(periodStatus({ activeUntil: '2026-10-09T00:00:00Z' }, now)).toBe('expired'); + }); +}); + +const rule = (extra: Partial = {}): FilterRule => ({ + id: 'r1', + name: 'Urlaub', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: '@' }], + actions: [{ type: 'forward', value: 'kollege@example.com' }, { type: 'discard' }], + stopProcessing: false, + ...extra, +}); + +const PERIOD = { activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }; +const EXTENSIONS = ['fileinto', 'mailbox', 'mailboxid', 'spamtestplus', 'relational', 'comparator-i;ascii-numeric', 'date', 'copy']; + +describe('generator with periods', () => { + it('confines the rule to its period and requires date and relational', () => { + const script = generateScript([rule(PERIOD)], undefined, { extensions: EXTENSIONS }); + const [from, until] = periodTests(PERIOD)!; + expect(script).toContain('require ["date", "relational"];'); + expect(script).toContain(`if allof(${from}, ${until}, header :contains "From" "@") {`); + expect(script).toContain(' redirect "kollege@example.com";'); + }); + + it('keeps the conditions and the spam guard intact inside the period', () => { + const any = generateScript([rule({ + ...PERIOD, + matchType: 'any', + conditions: [ + { field: 'from', comparator: 'contains', value: 'a' }, + { field: 'subject', comparator: 'contains', value: ['b', 'c'] }, + ], + actions: [{ type: 'move', value: 'F', mailboxId: 'mb' }], + })], undefined, { extensions: EXTENSIONS }); + const [from, until] = periodTests(PERIOD)!; + expect(any).toContain(`if allof(${from}, ${until}, allof(anyof(header :contains "From" "a", header :contains "Subject" ["b", "c"]), not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50")) {`); + expect(any).toContain('require ["comparator-i;ascii-numeric", "date", "fileinto", "mailbox", "mailboxid", "relational", "spamtestplus"];'); + }); + + it('writes an open-ended period as one test', () => { + const script = generateScript([rule({ activeUntil: PERIOD.activeUntil })]); + const [until] = periodTests({ activeUntil: PERIOD.activeUntil })!; + expect(script).toContain(`if allof(${until}, header :contains "From" "@") {`); + }); + + it('drops a rule whose period is unusable instead of running it for good', () => { + const script = generateScript([ + rule({ activeUntil: '2026-10-16T16:00' }), + rule({ id: 'r2', name: 'Other', actions: [{ type: 'star' }] }), + ]); + expect(script).not.toContain('redirect'); + expect(script).toContain('# Rule: Other'); + expect(script).not.toContain('"date"'); + }); + + it('adds no requirement for a disabled rule with a period', () => { + const script = generateScript([rule({ ...PERIOD, enabled: false })]); + expect(script).not.toContain('require'); + expect(script).not.toContain('currentdate'); + }); + + it('stores the period in the metadata and reads it back unchanged', () => { + const rules = [rule(PERIOD), rule({ id: 'r2', name: 'Open', activeFrom: PERIOD.activeFrom })]; + const script = generateScript(rules, undefined, { extensions: EXTENSIONS }); + const parsed = parseScript(script); + expect(parsed.isOpaque).toBe(false); + expect(parsed.rules).toEqual(rules); + // Writing the rules read back gives the same script, byte for byte. + expect(generateScript(parsed.rules, undefined, { extensions: EXTENSIONS })).toBe(script); + }); +}); + +describe('parser with periods', () => { + const withMetadata = (r: Record) => + `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [r] })}\n@metadata:end */\n`; + + it('leaves a script with an unreadable period alone (treated as hand-edited)', () => { + for (const activeUntil of ['2026-10-16T16:00', '', null, 42]) { + const parsed = parseScript(withMetadata({ ...rule(), activeUntil })); + expect(parsed.isOpaque).toBe(true); + } + }); + + it('reads rules without a period as before', () => { + const parsed = parseScript(withMetadata({ ...rule() })); + expect(parsed.isOpaque).toBe(false); + expect(parsed.rules[0]).not.toHaveProperty('activeFrom'); + }); +}); diff --git a/lib/sieve/__tests__/vacation-audience.test.ts b/lib/sieve/__tests__/vacation-audience.test.ts new file mode 100644 index 000000000..d8858472d --- /dev/null +++ b/lib/sieve/__tests__/vacation-audience.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import type { FilterRule, VacationAudience } from '@/lib/jmap/sieve-types'; +import { generateScript } from '../generator'; +import { parseScript } from '../parser'; +import { ownDomains } from '../vacation-audience'; + +const EXTENSIONS = ['fileinto', 'imap4flags', 'include', 'envelope']; +const INCLUDE = 'include :personal :optional "vacation";'; + +const rule: FilterRule = { + id: 'r1', + name: 'Boersen', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: 'autoscout24' }], + actions: [{ type: 'mark_read' }], + stopProcessing: false, +}; + +const audience = (only: VacationAudience['only'], domains = ['example.com', 'example.org']): VacationAudience => ({ only, domains }); + +const generate = (vacationAudience: VacationAudience | null, includeVacation = true) => + generateScript([rule], undefined, { includeVacation, vacationAudience, extensions: EXTENSIONS }); + +/** The auto-reply block: its marker comment up to the first rule. */ +function replyBlock(script: string): string { + const start = script.indexOf('# Vacation auto-reply'); + return script.slice(start, script.indexOf('\n\n', start)); +} + +describe('who gets the auto-reply (generator)', () => { + it('answers only senders from the own domains', () => { + // Judged by the envelope sender, whom the reply goes to: a From header + // naming a colleague on mail from outside gets no reply meant for colleagues. + const script = generate(audience('internal')); + expect(replyBlock(script)).toBe([ + '# Vacation auto-reply', + 'if envelope :domain :is "from" ["example.com", "example.org"] {', + ` ${INCLUDE}`, + '}', + ].join('\n')); + expect(script).toMatch(/^require \["envelope", "imap4flags", "include"\];$/m); + }); + + it('answers only senders from elsewhere', () => { + expect(replyBlock(generate(audience('external')))).toBe([ + '# Vacation auto-reply', + 'if not envelope :domain :is "from" ["example.com", "example.org"] {', + ` ${INCLUDE}`, + '}', + ].join('\n')); + }); + + it('answers everyone when no one is singled out', () => { + // Byte for byte as before the choice existed: see generator-compat.test.ts. + const script = generate(null); + expect(replyBlock(script)).toBe(['# Vacation auto-reply', INCLUDE].join('\n')); + expect(script).not.toContain('vacationAudience'); + }); + + it('keeps the choice while the auto-reply is off', () => { + const script = generate(audience('external'), false); + expect(script).not.toContain('include'); + expect(script).not.toContain('envelope'); + expect(parseScript(script).vacationAudience).toEqual(audience('external')); + }); + + it('never writes a choice it cannot read back', () => { + for (const unusable of [ + audience('external', []), + audience('external', ['example.com"; discard; "']), + { only: 'everyone', domains: ['example.com'] } as unknown as VacationAudience, + ]) { + const script = generate(unusable); + expect(replyBlock(script)).toBe(['# Vacation auto-reply', INCLUDE].join('\n')); + expect(script).not.toContain('vacationAudience'); + } + }); +}); + +describe('who gets the auto-reply (parser)', () => { + it('reads its own narrowed include back as its own', () => { + const readAndWrite = (script: string) => { + const parsed = parseScript(script); + return { + parsed, + script: generateScript(parsed.rules, parsed.vacation, { + includeVacation: parsed.includeVacation, + vacationAudience: parsed.vacationAudience, + externalRequires: parsed.externalRequires, + extensions: EXTENSIONS, + }), + }; + }; + const first = readAndWrite(generate(audience('internal'))); + expect(first.parsed.isOpaque).toBe(false); + expect(first.parsed.includeVacation).toBe(true); + expect(first.parsed.vacationAudience).toEqual(audience('internal')); + expect(first.parsed.rules.map((r) => r.origin ?? 'bulwark')).toEqual(['bulwark']); + expect(readAndWrite(first.script).script).toBe(first.script); + expect(first.script.split(INCLUDE).length).toBe(2); + }); + + it('treats a choice it cannot read as a hand-edited script', () => { + const script = generate(audience('internal')); + for (const [from, to] of [ + ['"only":"internal"', '"only":"intern"'], + ['"domains":["example.com","example.org"]', '"domains":[]'], + ['"domains":["example.com","example.org"]', '"domains":["example com"]'], + ]) { + expect(script).toContain(from); + expect(parseScript(script.replace(from, to)).isOpaque).toBe(true); + } + }); +}); + +describe('ownDomains', () => { + it('takes each domain of the addresses once, lower-cased, skipping what is not one', () => { + expect(ownDomains(['Max@Example.com', 'info@example.com', 'shop@example.org', 'broken', 'x@localhost'])).toEqual([ + 'example.com', + 'example.org', + ]); + }); +}); diff --git a/lib/sieve/__tests__/vacation-forward.test.ts b/lib/sieve/__tests__/vacation-forward.test.ts new file mode 100644 index 000000000..b12c9d18b --- /dev/null +++ b/lib/sieve/__tests__/vacation-forward.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import type { FilterRule, VacationForward } from '@/lib/jmap/sieve-types'; +import { generateScript } from '../generator'; +import { parseScript } from '../parser'; +import { periodTests } from '../period'; + +const EXTENSIONS = ['fileinto', 'imap4flags', 'include', 'copy', 'date', 'relational', 'spamtestplus', 'comparator-i;ascii-numeric']; +const SPAM_GUARD = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"'; + +const forward = (extra: Partial = {}): VacationForward => ({ + enabled: true, + to: 'kollege@example.com', + keepCopy: false, + activeFrom: '2026-10-05T06:00:00.000Z', + activeUntil: '2026-10-16T16:00:00.000Z', + ...extra, +}); + +const rule: FilterRule = { + id: 'r1', + name: 'Boersen', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: 'autoscout24' }], + actions: [{ type: 'mark_read' }], + stopProcessing: false, +}; + +const generate = (vacationForward: VacationForward | null, includeVacation = true, extensions = EXTENSIONS) => + generateScript([rule], undefined, { includeVacation, vacationForward, extensions }); + +/** The lines of the forwarding block, from its marker to its closing brace. */ +function forwardBlock(script: string): string[] | null { + const lines = script.split('\n'); + const start = lines.indexOf('# Vacation forwarding'); + if (start === -1) return null; + return lines.slice(start, lines.indexOf('}', start) + 1); +} + +describe('vacation forwarding (generator)', () => { + it('forwards ahead of the rules, within the period, without spam, and stops', () => { + const script = generate(forward()); + const [from, until] = periodTests(forward())!; + expect(forwardBlock(script)).toEqual([ + '# Vacation forwarding', + `if allof(${from}, ${until}, ${SPAM_GUARD}) {`, + ' redirect "kollege@example.com";', + ' stop;', + '}', + ]); + // After the auto-reply runs, before any rule can file the message. + expect(script.indexOf('include :personal :optional "vacation";')).toBeLessThan(script.indexOf('# Vacation forwarding')); + expect(script.indexOf('# Vacation forwarding')).toBeLessThan(script.indexOf('# Rule: Boersen')); + expect(script).toMatch(/^require \["comparator-i;ascii-numeric", "date", "imap4flags", "include", "relational", "spamtestplus"\];$/m); + }); + + it('keeps a copy and lets the rules run when asked to', () => { + const script = generate(forward({ keepCopy: true })); + const block = forwardBlock(script)!; + expect(block).toContain(' redirect :copy "kollege@example.com";'); + expect(block).not.toContain(' stop;'); + expect(script).toMatch(/^require \[.*"copy".*\];$/m); + }); + + it('forwards without a period when the vacation has none', () => { + expect(forwardBlock(generate(forward({ activeFrom: undefined, activeUntil: undefined })))![1]).toBe(`if ${SPAM_GUARD} {`); + const noSpamTest = generate(forward({ activeFrom: undefined, activeUntil: undefined }), true, ['include']); + expect(forwardBlock(noSpamTest)![1]).toBe('if true {'); + }); + + it('forwards without the auto-reply too', () => { + const script = generate(forward(), false); + expect(script).not.toContain('include'); + expect(forwardBlock(script)).toEqual(forwardBlock(generate(forward()))); + expect(script.indexOf('# Vacation forwarding')).toBeLessThan(script.indexOf('# Rule: Boersen')); + expect(parseScript(script).vacationForward).toEqual(forward()); + }); + + it('does not forward while forwarding is switched off', () => { + const script = generate(forward({ enabled: false })); + expect(forwardBlock(script)).toBeNull(); + expect(script).not.toContain('redirect'); + expect(parseScript(script).vacationForward).toEqual(forward({ enabled: false })); + }); + + it('never writes forwarding it cannot read back, and never forwards without its period', () => { + for (const unusable of [ + forward({ to: 'not an address' }), + forward({ activeUntil: '16.10.2026' }), + { ...forward(), keepCopy: 'yes' } as unknown as VacationForward, + ]) { + const script = generate(unusable); + expect(forwardBlock(script)).toBeNull(); + expect(script).not.toContain('redirect'); + expect(script).not.toContain('vacationForward'); + } + }); + + it('writes no forwarding when there is none', () => { + // Byte for byte as before forwarding existed: see generator-compat.test.ts. + const script = generate(null); + expect(forwardBlock(script)).toBeNull(); + expect(script).not.toContain('redirect'); + expect(script).not.toContain('vacationForward'); + }); +}); + +describe('vacation forwarding (parser)', () => { + it('reads its own block back as its own, not as an external rule', () => { + const external = '# Hand\nif header :contains "subject" "hand" {\n keep;\n}\n'; + const readAndWrite = (script: string) => { + const parsed = parseScript(script); + return { + parsed, + script: generateScript(parsed.rules, parsed.vacation, { + includeVacation: parsed.includeVacation, + vacationForward: parsed.vacationForward, + externalRequires: parsed.externalRequires, + extensions: EXTENSIONS, + }), + }; + }; + const first = readAndWrite(`${generate(forward())}\n${external}`); + expect(first.parsed.isOpaque).toBe(false); + expect(first.parsed.vacationForward).toEqual(forward()); + expect(first.parsed.rules.map((r) => r.origin ?? 'bulwark')).toEqual(['bulwark', 'external']); + // Read and written again, nothing is lost or doubled. (Each pass adds two + // blank lines before the external rules; that is older than forwarding.) + const second = readAndWrite(first.script); + const blankRuns = (script: string) => script.replace(/\n{3,}/g, '\n\n'); + expect(blankRuns(second.script)).toBe(blankRuns(first.script)); + expect(second.parsed.vacationForward).toEqual(forward()); + expect(second.script.split('# Vacation forwarding').length).toBe(2); + expect(second.script.split('if header :contains "subject" "hand"').length).toBe(2); + }); + + it('treats forwarding it cannot read as a hand-edited script', () => { + const script = generate(forward()); + for (const [from, to] of [ + ['"to":"kollege@example.com"', '"to":"kollege"'], + ['"activeUntil":"2026-10-16T16:00:00.000Z"', '"activeUntil":"bald"'], + ['"enabled":true', '"enabled":"true"'], + ]) { + expect(script).toContain(from); + expect(parseScript(script.replace(from, to)).isOpaque).toBe(true); + } + }); + + it('keeps a block with the marker as an external rule when the metadata has no forwarding', () => { + const handWritten = '# Vacation forwarding\nif true {\n redirect "x@example.com";\n}\n'; + const parsed = parseScript(`${generate(null)}\n${handWritten}`); + expect(parsed.vacationForward).toBeUndefined(); + // Kept as someone else's block (it reads as neither rule nor Bulwark's). + expect(parsed.rules.some((r) => r.origin === 'opaque' && r.rawBlock?.includes('redirect "x@example.com"'))).toBe(true); + }); + + it('keeps a block of someone else that only looks like its own', () => { + const team = '# Vacation forwarding of the team inbox (set up by IT)\nif address :is "to" "team@example.com" {\n redirect :copy "archive@example.com";\n}\n'; + const exact = '# Vacation forwarding\nif true {\n redirect "x@example.com";\n}\n'; + const kept = (script: string, raw: string) => + parseScript(script).rules.some((r) => r.origin !== undefined && r.origin !== 'bulwark' && r.rawBlock?.includes(raw)); + // A comment that only begins like the marker, while forwarding is on. + expect(kept(`${generate(forward())}\n${team}`, 'archive@example.com')).toBe(true); + // The exact marker while forwarding is off: Bulwark wrote no block then. + expect(kept(`${generate(forward({ enabled: false }))}\n${exact}`, 'x@example.com')).toBe(true); + // Its own block it still takes back, and writes once. + const own = parseScript(generate(forward())); + expect(own.rules.every((r) => (r.origin ?? 'bulwark') === 'bulwark')).toBe(true); + }); +}); diff --git a/lib/sieve/generator.ts b/lib/sieve/generator.ts index ebe02b016..dcc326fc6 100644 --- a/lib/sieve/generator.ts +++ b/lib/sieve/generator.ts @@ -1,5 +1,8 @@ -import type { FilterRule, FilterCondition, FilterAction, FilterMetadata, VacationSieveConfig } from '@/lib/jmap/sieve-types'; +import type { FilterRule, FilterCondition, FilterAction, FilterMetadata, VacationAudience, VacationForward, VacationSieveConfig } from '@/lib/jmap/sieve-types'; import { debug } from '@/lib/debug'; +import { PERIOD_REQUIRES, hasPeriod, periodTests } from '@/lib/sieve/period'; +import { VACATION_FORWARD_MARKER, isValidVacationForward, normalizeVacationForward } from '@/lib/sieve/vacation-forward'; +import { isValidVacationAudience, normalizeVacationAudience } from '@/lib/sieve/vacation-audience'; const HEADER_MAP: Record = { from: 'From', @@ -162,7 +165,7 @@ function generateActions(actions: FilterAction[], useMailboxId: boolean): string const SPAM_GUARD = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"'; const SPAM_GUARD_REQUIRES = ['spamtestplus', 'relational', 'comparator-i;ascii-numeric']; -function supportsSpamGuard(extensions: string[] | undefined): boolean { +export function supportsSpamGuard(extensions: string[] | undefined): boolean { return ['spamtestplus', 'relational'].every(e => extensions?.includes(e)); } @@ -194,6 +197,9 @@ function computeRequires( if (needsSpamGuard(rule, serverExtensions)) { for (const e of SPAM_GUARD_REQUIRES) extensions.add(e); } + if (periodTests(rule)?.length) { + for (const e of PERIOD_REQUIRES) extensions.add(e); + } for (const condition of rule.conditions) { if (condition.field === 'body') extensions.add('body'); if (condition.field === 'attachment') extensions.add('mime'); @@ -241,6 +247,8 @@ function stripRuleForMetadata(r: FilterRule): Omit 0) bulwarkRequires.push(...PERIOD_REQUIRES); + if (forwardSpamGuard) bulwarkRequires.push(...SPAM_GUARD_REQUIRES); + } const externalRequires = options.externalRequires ?? []; const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort(); @@ -316,7 +367,20 @@ export function generateScript( // the parser does not re-import it as an external rule. lines.push(''); lines.push('# Vacation auto-reply'); - lines.push(`include :personal :optional "${VACATION_SCRIPT_NAME}";`); + const include = `include :personal :optional "${VACATION_SCRIPT_NAME}";`; + const audience = metadata.vacationAudience; + if (audience) { + // Only senders from (or not from) the account's own domains. The reply + // goes to the envelope sender, so that is the address to judge: a From + // header can name a colleague on mail from anywhere, and the reply + // meant for colleagues would go to whoever sent it. + const domains = audience.domains.map(d => `"${escapeString(d)}"`).join(', '); + lines.push(`if ${audience.only === 'external' ? 'not ' : ''}envelope :domain :is "from" [${domains}] {`); + lines.push(` ${include}`); + lines.push('}'); + } else { + lines.push(include); + } } if (vacation?.isEnabled) { @@ -330,6 +394,19 @@ export function generateScript( lines.push(`vacation ${vacationParts.join(' ')};`); } + if (forward && forwardPeriod) { + // Spam is not passed on: it stays here for the server's own handling. + const tests = [...forwardPeriod, ...(forwardSpamGuard ? [SPAM_GUARD] : [])]; + const condition = tests.length === 0 ? 'true' : tests.length === 1 ? tests[0] : `allof(${tests.join(', ')})`; + lines.push(''); + lines.push(VACATION_FORWARD_MARKER); + lines.push(`if ${condition} {`); + lines.push(` redirect ${forward.keepCopy ? ':copy ' : ''}"${escapeString(forward.to)}";`); + // Without a copy kept here, no rule below may file it either. + if (!forward.keepCopy) lines.push(' stop;'); + lines.push('}'); + } + const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled); for (const rule of enabledBulwarkRules) { @@ -337,6 +414,13 @@ export function generateScript( debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`); continue; } + // Without its period a rule would act all the time - a forwarding rule + // would forward for good - so one whose period is unusable is dropped. + const period = periodTests(rule); + if (period === null) { + debug.warn('filters', `Skipping rule "${rule.name}": unusable period`); + continue; + } lines.push(''); // A line break in the name would end the comment and start a command. @@ -360,6 +444,11 @@ export function generateScript( : `allof(${conditionStr}, ${SPAM_GUARD})`; } + // The period comes first: outside it, nothing else is evaluated. + if (period.length > 0) { + conditionStr = `allof(${[...period, conditionStr].join(', ')})`; + } + const actionLines = generateActions(rule.actions, useMailboxId); // discard and reject only cancel the implicit keep (RFC 5228 4.4, diff --git a/lib/sieve/parser.ts b/lib/sieve/parser.ts index f6580f8e4..847f57dcc 100644 --- a/lib/sieve/parser.ts +++ b/lib/sieve/parser.ts @@ -5,9 +5,14 @@ import type { FilterConditionField, FilterMetadata, FilterRule, + VacationAudience, + VacationForward, VacationSieveConfig, } from '@/lib/jmap/sieve-types'; import { debug } from '@/lib/debug'; +import { isPeriodBoundary } from '@/lib/sieve/period'; +import { VACATION_FORWARD_MARKER_RE, isValidVacationForward } from '@/lib/sieve/vacation-forward'; +import { isValidVacationAudience } from '@/lib/sieve/vacation-audience'; export interface ParseResult { rules: FilterRule[]; @@ -16,6 +21,10 @@ export interface ParseResult { externalRequires: string[]; /** The script runs the server's "vacation" script via `include`. */ includeVacation?: boolean; + /** Forwarding from the vacation card, on or off. */ + vacationForward?: VacationForward; + /** Who gets the auto-reply, when not everyone. */ + vacationAudience?: VacationAudience; } const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] }; @@ -64,6 +73,12 @@ function isValidRule(rule: unknown): rule is FilterRule { typeof r.stopProcessing !== 'boolean' ) return false; + // A period that cannot be read would be dropped on the next save and the + // rule would act all the time; treating the script as hand-edited leaves + // it untouched instead. + if (r.activeFrom !== undefined && !isPeriodBoundary(r.activeFrom)) return false; + if (r.activeUntil !== undefined && !isPeriodBoundary(r.activeUntil)) return false; + return r.conditions.every(isValidCondition) && r.actions.every(isValidAction); } @@ -812,12 +827,21 @@ export function parseScript(content: string): ParseResult { return OPAQUE; } - if (!metadata || metadata.version !== 1) return OPAQUE; + // Version 2 only marks fields older builds do not know (see FilterMetadata). + if (!metadata || (metadata.version !== 1 && metadata.version !== 2)) return OPAQUE; if (!Array.isArray(metadata.rules)) return OPAQUE; for (const rule of metadata.rules) { if (!isValidRule(rule)) return OPAQUE; } + // Forwarding that cannot be read could be written back without its + // period; as with a rule, the script then counts as edited by hand. + if (metadata.vacationForward !== undefined && !isValidVacationForward(metadata.vacationForward)) { + return OPAQUE; + } + if (metadata.vacationAudience !== undefined && !isValidVacationAudience(metadata.vacationAudience)) { + return OPAQUE; + } // Scan the portion AFTER the metadata block for external rules. A prior // Bulwark save may have emitted its "External rules" header here; strip @@ -858,6 +882,9 @@ export function parseScript(content: string): ParseResult { if (bulwarkRules.some(b => oneLine(b.name) === name)) return false; } if (/#\s*Vacation auto-reply/i.test(raw)) return false; + // The generator writes the forwarding block from the metadata again. + // Only written while switched on, so only then is such a block Bulwark's. + if (metadata.vacationForward?.enabled && VACATION_FORWARD_MARKER_RE.test(raw)) return false; return true; }); @@ -875,6 +902,8 @@ export function parseScript(content: string): ParseResult { vacation: metadata.vacation, externalRequires, ...(metadata.includeVacation === true ? { includeVacation: true } : {}), + ...(metadata.vacationForward ? { vacationForward: metadata.vacationForward } : {}), + ...(metadata.vacationAudience ? { vacationAudience: metadata.vacationAudience } : {}), }; } diff --git a/lib/sieve/period.ts b/lib/sieve/period.ts new file mode 100644 index 000000000..750cdb1f1 --- /dev/null +++ b/lib/sieve/period.ts @@ -0,0 +1,87 @@ +import type { FilterRule } from '@/lib/jmap/sieve-types'; + +/** + * Time periods for filter rules: "only act on mail that arrives from ... + * until ...". Sieve has no rule scheduling of its own; the date extension + * (RFC 5260) lets a rule test the moment the message is being filtered. + */ + +/** Sieve extensions a period needs: `currentdate` and `:value` comparisons. */ +export const PERIOD_REQUIRES = ['date', 'relational'] as const; + +export function supportsPeriods(extensions: readonly string[] | undefined | null): boolean { + return PERIOD_REQUIRES.every((e) => extensions?.includes(e)); +} + +// A moment with its zone spelled out. Without one, Date.parse reads the value +// as local time, which would make the script depend on the browser saving it. +const ISO_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/; + +/** Whether `value` is a usable period boundary (see FilterRule.activeFrom). */ +export function isPeriodBoundary(value: unknown): value is string { + if (typeof value !== 'string' || !ISO_WITH_ZONE.test(value)) return false; + const time = Date.parse(value); + // In UTC the zone can push it past year 9999 ("9999-12-31T23:59-01:00"), + // which the four-digit year of Sieve's "date" part cannot hold. + return !Number.isNaN(time) && /^\d{4}-/.test(new Date(time).toISOString()); +} + +export function hasPeriod(rule: Pick): boolean { + return rule.activeFrom !== undefined || rule.activeUntil !== undefined; +} + +/** + * The boundary as RFC 5260 "date" (yyyy-mm-dd) and "time" (hh:mm:ss) parts in + * UTC. The RFC fixes both formats, so comparing them as strings works on any + * server - unlike "iso8601", whose zone suffix is up to the implementation. + */ +function utcParts(boundary: string): { date: string; time: string } { + const iso = new Date(Date.parse(boundary)).toISOString(); + return { date: iso.slice(0, 10), time: iso.slice(11, 19) }; +} + +const NOW = 'currentdate :zone "+0000"'; + +/** now >= boundary: a later day, or the same day at or after the time. */ +function atOrAfter(boundary: string): string { + const { date, time } = utcParts(boundary); + return `anyof(${NOW} :value "gt" "date" "${date}", allof(${NOW} :is "date" "${date}", ${NOW} :value "ge" "time" "${time}"))`; +} + +/** now <= boundary: an earlier day, or the same day at or before the time. */ +function atOrBefore(boundary: string): string { + const { date, time } = utcParts(boundary); + return `anyof(${NOW} :value "lt" "date" "${date}", allof(${NOW} :is "date" "${date}", ${NOW} :value "le" "time" "${time}"))`; +} + +/** + * The Sieve tests that confine `rule` to its period: none without a period, + * null when a boundary is unusable. A caller must then drop the rule rather + * than run it without its period - a forwarding rule would otherwise forward + * for good. + */ +export function periodTests(rule: Pick): string[] | null { + const tests: string[] = []; + if (rule.activeFrom !== undefined) { + if (!isPeriodBoundary(rule.activeFrom)) return null; + tests.push(atOrAfter(rule.activeFrom)); + } + if (rule.activeUntil !== undefined) { + if (!isPeriodBoundary(rule.activeUntil)) return null; + tests.push(atOrBefore(rule.activeUntil)); + } + return tests; +} + +export type PeriodStatus = 'scheduled' | 'active' | 'expired'; + +/** Where `now` falls in the rule's period; null for a rule without one. */ +export function periodStatus( + rule: Pick, + now: number = Date.now(), +): PeriodStatus | null { + if (!hasPeriod(rule)) return null; + if (isPeriodBoundary(rule.activeFrom) && now < Date.parse(rule.activeFrom)) return 'scheduled'; + if (isPeriodBoundary(rule.activeUntil) && now > Date.parse(rule.activeUntil)) return 'expired'; + return 'active'; +} diff --git a/lib/sieve/vacation-audience.ts b/lib/sieve/vacation-audience.ts new file mode 100644 index 000000000..65b987d69 --- /dev/null +++ b/lib/sieve/vacation-audience.ts @@ -0,0 +1,30 @@ +import type { VacationAudience } from '@/lib/jmap/sieve-types'; + +// A host name in the form the identities give it: labels of letters, digits +// and inner hyphens, at least two of them. +const DOMAIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/; + +/** Whether `value` is an audience Bulwark can write back as it reads. */ +export function isValidVacationAudience(value: unknown): value is VacationAudience { + if (!value || typeof value !== 'object') return false; + const a = value as Record; + if (a.only !== 'internal' && a.only !== 'external') return false; + return Array.isArray(a.domains) && a.domains.length > 0 + && a.domains.every((d) => typeof d === 'string' && DOMAIN.test(d)); +} + +/** The fields Bulwark stores, in a fixed order. */ +export function normalizeVacationAudience(audience: VacationAudience): VacationAudience { + return { only: audience.only, domains: audience.domains }; +} + +/** The domains of the given addresses, lower-cased, each once, in order. */ +export function ownDomains(addresses: string[]): string[] { + const domains: string[] = []; + for (const address of addresses) { + const at = address.lastIndexOf('@'); + const domain = at === -1 ? '' : address.slice(at + 1).trim().toLowerCase(); + if (DOMAIN.test(domain) && !domains.includes(domain)) domains.push(domain); + } + return domains; +} diff --git a/lib/sieve/vacation-forward.ts b/lib/sieve/vacation-forward.ts new file mode 100644 index 000000000..7a63198c3 --- /dev/null +++ b/lib/sieve/vacation-forward.ts @@ -0,0 +1,55 @@ +import type { VacationForward } from '@/lib/jmap/sieve-types'; +import { isValidEmail } from '@/lib/validation'; +import { isPeriodBoundary } from '@/lib/sieve/period'; + +/** + * Forwarding from the vacation card (see VacationForward). The generator + * writes it as its own block after the vacation include, marked by this + * comment; the parser knows the block by it and leaves it to the generator. + */ +export const VACATION_FORWARD_MARKER = '# Vacation forwarding'; + +/** + * The marker as the parser looks for it in a block: the whole line, so a + * comment of someone's own that only begins like it is not taken for it. + */ +export const VACATION_FORWARD_MARKER_RE = /^[ \t]*# Vacation forwarding[ \t]*$/m; + +/** Whether `value` is forwarding Bulwark can write back as it reads. */ +export function isValidVacationForward(value: unknown): value is VacationForward { + if (!value || typeof value !== 'object') return false; + const f = value as Record; + if (typeof f.enabled !== 'boolean' || typeof f.keepCopy !== 'boolean') return false; + if (typeof f.to !== 'string' || !isValidEmail(f.to)) return false; + if (f.activeFrom !== undefined && !isPeriodBoundary(f.activeFrom)) return false; + if (f.activeUntil !== undefined && !isPeriodBoundary(f.activeUntil)) return false; + return true; +} + +/** + * The forwarding with the vacation's period, whatever period it had: it runs + * while the vacation does (an empty date leaves that end open). + */ +export function withVacationPeriod( + forward: Pick, + period: { from: string | null; until: string | null }, +): VacationForward { + return { + enabled: forward.enabled, + to: forward.to, + keepCopy: forward.keepCopy, + ...(period.from ? { activeFrom: period.from } : {}), + ...(period.until ? { activeUntil: period.until } : {}), + }; +} + +/** The fields Bulwark stores, in a fixed order. */ +export function normalizeVacationForward(forward: VacationForward): VacationForward { + return { + enabled: forward.enabled, + to: forward.to, + keepCopy: forward.keepCopy, + ...(forward.activeFrom !== undefined ? { activeFrom: forward.activeFrom } : {}), + ...(forward.activeUntil !== undefined ? { activeUntil: forward.activeUntil } : {}), + }; +} diff --git a/lib/timezone.ts b/lib/timezone.ts index 220cb22c9..25fe7aebe 100644 --- a/lib/timezone.ts +++ b/lib/timezone.ts @@ -194,6 +194,20 @@ export function fromZonedDisplayDate(wall: Date, timeZone: string): Date { return new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), timeZone)); } +/** + * The instant at which the clock in `timeZone` reads `w`. Unlike + * fromZonedDisplayDate it never goes through a Date's local fields, so a + * time that the browser's own zone skips at a DST change is not shifted. + */ +export function fromWallClock(w: WallClock, timeZone: string): Date { + const d = new Date(0); + d.setUTCFullYear(w.year, w.month - 1, w.day); + d.setUTCHours(w.hour, w.minute, w.second, 0); + const asUtc = d.getTime(); + const guess = asUtc - getTimeZoneOffsetMs(new Date(asUtc), timeZone); + return new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), timeZone)); +} + /** * Instant -> display date for the calendar grid. Identity unless the user's * time zone override differs from the browser zone. diff --git a/locales/ar/common.json b/locales/ar/common.json index 1f39cc695..f12f9825d 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "فشل حذف الهوية: {error}", "identity_unauthorized": "غير مخوّل لك الإرسال من عنوان البريد الإلكتروني هذا", "identity_not_found": "الهوية غير موجودة", - "vacation_saved": "تم حفظ إعدادات الرد التلقائي أثناء الغياب", - "vacation_save_failed": "فشل حفظ إعدادات الرد التلقائي أثناء الغياب", + "vacation_saved": "تم حفظ إعدادات الغياب", + "vacation_save_failed": "فشل حفظ إعدادات الغياب", "filters_saved": "تم حفظ المرشحات بنجاح", "filters_save_failed": "فشل حفظ المرشحات", "filters_deleted": "تم حذف قاعدة المرشح", @@ -857,7 +857,8 @@ "rule_undone": "تم التراجع عن تغيير القاعدة", "rule_undo_failed": "تعذّر التراجع عن تغيير القاعدة", "rule_undo_conflict": "تغيّرت مرشحاتك منذ ذلك الحين. تراجع عن التغيير من إعدادات المرشحات.", - "rule_folder_failed": "تعذّر إنشاء المجلد" + "rule_folder_failed": "تعذّر إنشاء المجلد", + "vacation_filters_save_failed": "تم حفظ إعدادات الغياب، لكن تعذّر حفظ إعادة التوجيه ومستلمي الرد" }, "date": { "today": "اليوم", @@ -945,7 +946,7 @@ "privacy": "الخصوصية والأمان", "account": "الحساب", "identities": "الهويات", - "vacation": "الرد التلقائي أثناء الغياب", + "vacation": "الغياب", "advanced": "متقدم", "calendar": "التقويم", "filters": "المرشحات", @@ -1824,28 +1825,24 @@ } }, "vacation": { - "title": "الرد التلقائي أثناء الغياب", - "description": "الرد تلقائيًا على الرسائل الواردة أثناء غيابك", + "title": "الغياب", + "description": "التعامل تلقائيًا مع الرسائل الواردة أثناء غيابك", "loading": "جارٍ تحميل إعدادات الغياب...", "not_supported": "خادم بريدك لا يدعم ردود الغياب.", "fetch_error": "فشل تحميل إعدادات الغياب. يرجى المحاولة مرة أخرى.", "status": { - "label": "الرد التلقائي أثناء الغياب", + "label": "إرسال رد تلقائي", "description": "إرسال رد تلقائي إلى من يرسل إليك بريدًا", "active": "مفعّل", "inactive": "معطّل" }, "date_range": { - "title": "النطاق الزمني", - "description": "يمكنك اختياريًا تحديد الرد التلقائي بفترة معينة", "start": "تاريخ البدء", "start_description": "اتركه فارغًا لعدم وجود حد للبدء", "end": "تاريخ الانتهاء", "end_description": "اتركه فارغًا لعدم وجود حد للانتهاء" }, "message": { - "title": "رسالة الرد التلقائي", - "description": "الرسالة التي ستُرسل كرد", "subject_label": "الموضوع", "subject_description": "سطر موضوع الرد التلقائي", "subject_placeholder": "خارج المكتب", @@ -1865,10 +1862,32 @@ "saving": "جارٍ الحفظ...", "warnings": { "end_before_start": "يجب أن يكون تاريخ الانتهاء بعد تاريخ البدء", + "invalid_date": "أدخل تاريخ بدء وانتهاء صالحين مع الوقت، أو اترك الحقل فارغًا", "start_in_past": "تاريخ البدء في الماضي", "empty_body": "نص الرسالة فارغ - سيتلقى المستلمون ردًا فارغًا", "subject_too_long": "الموضوع طويل جدًا بالنسبة للخادم (بحد أقصى {max} بايت).", - "body_too_long": "الرسالة طويلة جدًا بالنسبة للخادم (بحد أقصى {max} بايت بما في ذلك التنسيق)." + "body_too_long": "الرسالة طويلة جدًا بالنسبة للخادم (بحد أقصى {max} بايت بما في ذلك التنسيق).", + "forward_address": "أدخل عنوانًا صالحًا لإعادة التوجيه إليه", + "not_running": "إعادة التوجيه أو مستلمو الرد محفوظون لكنهم غير مفعّلين الآن. احفظ لإعادة تفعيلهم." + }, + "forward": { + "title": "إعادة التوجيه", + "description": "تُعيد توجيه الرسائل الواردة إلى عنوان آخر، مع الرد التلقائي أو بدونه، خلال الفترة أعلاه (بدون تاريخ انتهاء، حتى يتم إيقافها). لا تتم إعادة توجيه الرسائل غير المرغوب فيها.", + "enabled_label": "إعادة توجيه الرسائل", + "to_label": "إعادة التوجيه إلى", + "to_placeholder": "email@example.com", + "keep_label": "الإبقاء في الوارد", + "keep_description": "وإلا فستتم إعادة توجيه الرسالة فقط: لن تبقى في صندوق البريد هذا، ولن تُطبَّق عليها أي قاعدة تصفية." + }, + "audience": { + "label": "الرد على", + "description": "المرسلون الداخليون: {domains}", + "all": "جميع المرسلين", + "internal": "المرسلون الداخليون فقط", + "external": "المرسلون الخارجيون فقط" + }, + "auto_reply": { + "title": "الرد التلقائي" } }, "folders": { @@ -2236,7 +2255,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "طبّقها أيضًا على الرسائل الموجودة في هذا المجلد", - "apply_existing_unsupported": "لا يمكن تطبيق القواعد على الرسائل الموجودة إلا إذا كانت تفحص المرسل أو المستلمين أو الموضوع أو الترويسات، وتنقل أو تنسخ أو تميّز كمقروءة أو بنجمة أو بوسم." + "apply_existing_unsupported": "لا يمكن تطبيق القواعد على الرسائل الموجودة إلا إذا كانت تفحص المرسل أو المستلمين أو الموضوع أو الترويسات، وتنقل أو تنسخ أو تميّز كمقروءة أو بنجمة أو بوسم.", + "period_title": "النطاق الزمني", + "period_start": "تاريخ البدء", + "period_end": "تاريخ الانتهاء", + "validation_period_order": "يجب أن يكون تاريخ الانتهاء بعد تاريخ البدء", + "period_toggle": "نشطة فقط خلال فترة زمنية", + "period_hint": "تنطبق على الرسائل التي تصل خلال هذه الفترة. اترك حقلًا فارغًا لإبقاء ذلك الطرف مفتوحًا.", + "validation_period_empty": "أدخل تاريخ بدء أو انتهاء للفترة", + "validation_period_invalid": "أدخل تاريخًا صالحًا مع الوقت للفترة، أو اترك الحقل فارغًا", + "apply_existing_period": "القاعدة ذات الفترة الزمنية تعمل فقط على الرسائل التي تصل خلالها، لذا لا يمكن تطبيقها على الرسائل الموجودة.", + "period_status_scheduled": "مجدولة", + "period_status_active": "نشطة", + "period_status_expired": "منتهية" }, "templates": { "title": "قوالب البريد", diff --git a/locales/ca/common.json b/locales/ca/common.json index 24b353f79..c065ff784 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "No s'ha pogut suprimir la identitat: {error}", "identity_unauthorized": "No teniu autorització per enviar des d'aquesta adreça electrònica", "identity_not_found": "No s'ha trobat la identitat", - "vacation_saved": "S'ha desat la configuració de la resposta automàtica", - "vacation_save_failed": "No s'ha pogut desar la configuració de la resposta automàtica", + "vacation_saved": "S'ha desat la configuració d'absència", + "vacation_save_failed": "No s'ha pogut desar la configuració d'absència", "filters_saved": "Filtres desats correctament", "filters_save_failed": "No s'han pogut desar els filtres", "filters_deleted": "Regla de filtre suprimida", @@ -857,7 +857,8 @@ "rule_undone": "S'ha desfet el canvi de la regla", "rule_undo_failed": "No s'ha pogut desfer el canvi de la regla", "rule_undo_conflict": "Els teus filtres han canviat des d'aleshores. Desfés el canvi a la configuració dels filtres.", - "rule_folder_failed": "No s'ha pogut crear la carpeta" + "rule_folder_failed": "No s'ha pogut crear la carpeta", + "vacation_filters_save_failed": "S'ha desat la configuració d'absència, però no s'han pogut desar el reenviament ni els destinataris de la resposta" }, "date": { "today": "Avui", @@ -945,7 +946,7 @@ "privacy": "Privadesa i seguretat", "account": "Compte", "identities": "Identitats", - "vacation": "Resposta automàtica", + "vacation": "Absència", "advanced": "Avançat", "calendar": "Calendari", "filters": "Filtres", @@ -1824,28 +1825,24 @@ } }, "vacation": { - "title": "Resposta automàtica", - "description": "Respon automàticament als correus entrants mentre esteu fora", + "title": "Absència", + "description": "Gestiona automàticament els correus entrants mentre esteu fora", "loading": "Carregant la configuració de la resposta automàtica...", "not_supported": "El servidor de correu no admet respostes automàtiques.", "fetch_error": "No s'ha pogut carregar la configuració de la resposta automàtica. Torneu-ho a provar.", "status": { - "label": "Resposta automàtica", + "label": "Respon automàticament", "description": "Envia una resposta automàtica a les persones que us envien correu", "active": "Activa", "inactive": "Inactiva" }, "date_range": { - "title": "Interval de dates", - "description": "Opcionalment, limiteu la resposta automàtica a un període específic", "start": "Data d'inici", "start_description": "Deixeu-ho buit per no establir cap límit inicial", "end": "Data de finalització", "end_description": "Deixeu-ho buit per no establir cap límit final" }, "message": { - "title": "Missatge de resposta automàtica", - "description": "El missatge que s'enviarà com a resposta", "subject_label": "Assumpte", "subject_description": "Línia d'assumpte de la resposta automàtica", "subject_placeholder": "Fora de l'oficina", @@ -1865,10 +1862,32 @@ "saving": "Desant...", "warnings": { "end_before_start": "La data de finalització ha de ser posterior a la data d'inici", + "invalid_date": "Introdueix una data d'inici i de finalització vàlides amb l'hora, o deixa el camp buit", "start_in_past": "La data d'inici és al passat", "empty_body": "El cos del missatge està buit - els destinataris rebran una resposta en blanc", "subject_too_long": "L'assumpte és massa llarg per al servidor (com a màxim {max} bytes).", - "body_too_long": "El missatge és massa llarg per al servidor (com a màxim {max} bytes, amb el format inclòs)." + "body_too_long": "El missatge és massa llarg per al servidor (com a màxim {max} bytes, amb el format inclòs).", + "forward_address": "Introdueix una adreça vàlida per al reenviament", + "not_running": "El reenviament o els destinataris de la resposta estan desats però ara no estan actius. Desa per tornar-los a activar." + }, + "forward": { + "title": "Reenviament", + "description": "Reenvia els missatges entrants a una altra adreça, amb resposta automàtica o sense, dins del període indicat a dalt (sense data de finalització, fins que es desactivi). El correu brossa no es reenvia.", + "enabled_label": "Reenvia els missatges", + "to_label": "Reenvia a", + "to_placeholder": "correu@exemple.com", + "keep_label": "Mantén a la safata d'entrada", + "keep_description": "Si no, el missatge només es reenvia: no es conserva en aquesta bústia i no s'hi aplica cap regla de filtre." + }, + "audience": { + "label": "Respon a", + "description": "Remitents interns: {domains}", + "all": "Tots els remitents", + "internal": "Només remitents interns", + "external": "Només remitents externs" + }, + "auto_reply": { + "title": "Resposta automàtica" } }, "folders": { @@ -2236,7 +2255,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Aplica-ho també als missatges que ja hi ha en aquesta carpeta", - "apply_existing_unsupported": "Només les regles que comproven el remitent, els destinataris, l'assumpte o les capçaleres, i que mouen, copien, marquen com a llegit, destaquen o etiqueten, es poden aplicar a missatges existents." + "apply_existing_unsupported": "Només les regles que comproven el remitent, els destinataris, l'assumpte o les capçaleres, i que mouen, copien, marquen com a llegit, destaquen o etiqueten, es poden aplicar a missatges existents.", + "period_title": "Interval de dates", + "period_start": "Data d'inici", + "period_end": "Data de finalització", + "validation_period_order": "La data de finalització ha de ser posterior a la data d'inici", + "period_toggle": "Activa només durant un període", + "period_hint": "S'aplica als missatges que arriben durant aquest període. Deixa un camp buit per no limitar aquell extrem.", + "validation_period_empty": "Introdueix una data d'inici o de finalització per al període", + "validation_period_invalid": "Introdueix una data vàlida amb l'hora per al període, o deixa el camp buit", + "apply_existing_period": "Una regla amb període només actua sobre els missatges que arriben dins d'aquest període, de manera que no es pot aplicar als missatges existents.", + "period_status_scheduled": "programada", + "period_status_active": "activa", + "period_status_expired": "caducada" }, "templates": { "title": "Plantilles de correu", diff --git a/locales/cs/common.json b/locales/cs/common.json index 8057c0884..e32a3570d 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Odstranění identity selhalo: {error}", "identity_unauthorized": "Nemáte oprávnění odesílat z této e-mailové adresy", "identity_not_found": "Identita nenalezena", - "vacation_saved": "Nastavení automatické odpovědi bylo uloženo", - "vacation_save_failed": "Uložení nastavení automatické odpovědi selhalo", + "vacation_saved": "Nastavení nepřítomnosti bylo uloženo", + "vacation_save_failed": "Uložení nastavení nepřítomnosti selhalo", "filters_saved": "Filtry úspěšně uloženy", "filters_save_failed": "Uložení filtrů selhalo", "filters_deleted": "Pravidlo filtru bylo odstraněno", @@ -857,7 +857,8 @@ "rule_undone": "Změna pravidla vrácena", "rule_undo_failed": "Změnu pravidla se nepodařilo vrátit", "rule_undo_conflict": "Vaše filtry se mezitím změnily. Vraťte změnu v nastavení filtrů.", - "rule_folder_failed": "Složku se nepodařilo vytvořit" + "rule_folder_failed": "Složku se nepodařilo vytvořit", + "vacation_filters_save_failed": "Nastavení nepřítomnosti bylo uloženo, ale přeposílání a příjemce odpovědi se uložit nepodařilo" }, "date": { "today": "Dnes", @@ -942,7 +943,7 @@ "privacy": "Soukromí a bezpečnost", "account": "Účet", "identities": "Identity", - "vacation": "Automatická odpověď", + "vacation": "Nepřítomnost", "advanced": "Pokročilé", "calendar": "Kalendář", "filters": "Filtry", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "Automatická odpověď", - "description": "Během vaší nepřítomnosti bude automaticky odpovídáno na příchozí e-maily", + "title": "Nepřítomnost", + "description": "Během vaší nepřítomnosti se na příchozí e-maily bude reagovat automaticky", "loading": "Načítání nastavení automatické odpovědi...", "not_supported": "Váš poštovní server nepodporuje automatické odpovědi v nepřítomnosti.", "fetch_error": "Nepodařilo se načíst nastavení automatické odpovědi. Zkuste to znovu.", "status": { - "label": "Automatická odpověď", + "label": "Odpovídat automaticky", "description": "Odesílat automatickou odpověď lidem, kteří vám napíší", "active": "Aktivní", "inactive": "Neaktivní" }, "date_range": { - "title": "Časové období", - "description": "Volitelně omezte automatickou odpověď na určité období", "start": "Datum zahájení", "start_description": "Nechte prázdné pro spuštění ihned", "end": "Datum ukončení", "end_description": "Nechte prázdné pro běh bez ukončení" }, "message": { - "title": "Zpráva automatické odpovědi", - "description": "Zpráva, která bude odeslána jako odpověď", "subject_label": "Předmět", "subject_description": "Předmět automatické odpovědi", "subject_placeholder": "Mimo kancelář", @@ -1858,10 +1855,32 @@ "saving": "Ukládání...", "warnings": { "end_before_start": "Datum ukončení musí být po datu zahájení", + "invalid_date": "Zadejte platné datum zahájení a ukončení včetně času, nebo pole nechte prázdné", "start_in_past": "Datum zahájení je v minulosti", "empty_body": "Tělo zprávy je prázdné - příjemci obdrží prázdnou odpověď", "subject_too_long": "Předmět je pro server příliš dlouhý (nejvýše {max} bajtů).", - "body_too_long": "Zpráva je pro server příliš dlouhá (nejvýše {max} bajtů včetně formátování)." + "body_too_long": "Zpráva je pro server příliš dlouhá (nejvýše {max} bajtů včetně formátování).", + "forward_address": "Zadejte platnou adresu pro přeposílání", + "not_running": "Přeposílání nebo příjemci odpovědi jsou uloženi, ale právě nejsou aktivní. Uložením je znovu zapnete." + }, + "forward": { + "title": "Přeposílání", + "description": "Přeposílá příchozí zprávy na jinou adresu, s automatickou odpovědí i bez ní, v období uvedeném výše (bez data ukončení, dokud ho nevypnete). Spam se nepřeposílá.", + "enabled_label": "Přeposílat zprávy", + "to_label": "Přeposlat na", + "to_placeholder": "email@example.com", + "keep_label": "Ponechat v Doručených", + "keep_description": "Jinak se zpráva pouze přepošle: nezůstane v této schránce a nepoužije se na ni žádné pravidlo filtru." + }, + "audience": { + "label": "Odpovídat", + "description": "Interní odesílatelé: {domains}", + "all": "Všem odesílatelům", + "internal": "Jen interním odesílatelům", + "external": "Jen externím odesílatelům" + }, + "auto_reply": { + "title": "Automatická odpověď" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Použít i na zprávy, které už v této složce jsou", - "apply_existing_unsupported": "Na existující zprávy lze použít jen pravidla, která kontrolují odesílatele, příjemce, předmět nebo hlavičky a která přesouvají, kopírují, označují jako přečtené, hvězdičkou nebo štítkem." + "apply_existing_unsupported": "Na existující zprávy lze použít jen pravidla, která kontrolují odesílatele, příjemce, předmět nebo hlavičky a která přesouvají, kopírují, označují jako přečtené, hvězdičkou nebo štítkem.", + "period_title": "Časové období", + "period_start": "Datum zahájení", + "period_end": "Datum ukončení", + "validation_period_order": "Datum ukončení musí být po datu zahájení", + "period_toggle": "Aktivní jen v určitém období", + "period_hint": "Platí pro zprávy, které přijdou v tomto období. Prázdné pole ponechá daný konec otevřený.", + "validation_period_empty": "Zadejte datum zahájení nebo ukončení období", + "validation_period_invalid": "Zadejte pro období platné datum včetně času, nebo pole nechte prázdné", + "apply_existing_period": "Pravidlo s obdobím působí jen na zprávy, které v něm přijdou, a proto ho nelze použít na existující zprávy.", + "period_status_scheduled": "naplánováno", + "period_status_active": "aktivní", + "period_status_expired": "vypršelo" }, "templates": { "title": "E-mailové šablony", diff --git a/locales/da/common.json b/locales/da/common.json index f6f65a0e8..c6d72bbd3 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Kunne ikke slette identitet: {error}", "identity_unauthorized": "Du er ikke autoriseret til at sende fra denne e-mailadresse", "identity_not_found": "Identitet ikke fundet", - "vacation_saved": "Fraværsbesked-indstillinger gemt", - "vacation_save_failed": "Kunne ikke gemme fraværsbesked-indstillinger", + "vacation_saved": "Fraværsindstillinger gemt", + "vacation_save_failed": "Kunne ikke gemme fraværsindstillinger", "filters_saved": "Filtre gemt succesfuldt", "filters_save_failed": "Kunne ikke gemme filtre", "filters_deleted": "Filterregel slettet", @@ -857,7 +857,8 @@ "rule_undone": "Regelændring fortrudt", "rule_undo_failed": "Regelændringen kunne ikke fortrydes", "rule_undo_conflict": "Dine filtre er ændret siden. Fortryd ændringen i filterindstillingerne.", - "rule_folder_failed": "Mappen kunne ikke oprettes" + "rule_folder_failed": "Mappen kunne ikke oprettes", + "vacation_filters_save_failed": "Fraværsindstillinger gemt, men videresendelse og svarmodtagere kunne ikke gemmes" }, "date": { "today": "I dag", @@ -945,7 +946,7 @@ "privacy": "Privatliv & sikkerhed", "account": "Konto", "identities": "Identiteter", - "vacation": "Fraværsbeskeder", + "vacation": "Fravær", "advanced": "Avanceret", "calendar": "Kalender", "filters": "Filtre", @@ -1820,28 +1821,24 @@ } }, "vacation": { - "title": "Fraværsbeskeder", - "description": "Svar automatisk på indkommende e-mails, mens du er væk", + "title": "Fravær", + "description": "Håndter indkommende e-mails automatisk, mens du er væk", "loading": "Indlæser ferieindstillinger...", "not_supported": "Din mailserver understøtter ikke fraværsbeskeder.", "fetch_error": "Kunne ikke indlæse ferieindstillinger. Prøv igen.", "status": { - "label": "Fraværsbeskeder", + "label": "Svar automatisk", "description": "Send et automatisk svar til personer, der skriver til dig", "active": "Aktiv", "inactive": "Inaktiv" }, "date_range": { - "title": "Datoperiode", - "description": "Begræns eventuelt auto-svaret til en bestemt periode", "start": "Startdato", "start_description": "Lad være tom for ingen startbegrænsning", "end": "Slutdato", "end_description": "Lad være tom for ingen slutbegrænsning" }, "message": { - "title": "Auto-svar-besked", - "description": "Beskeden der sendes som svar", "subject_label": "Emne", "subject_description": "Emnelinje for auto-svaret", "subject_placeholder": "Fraværende", @@ -1861,10 +1858,32 @@ "saving": "Gemmer...", "warnings": { "end_before_start": "Slutdato skal være efter startdato", + "invalid_date": "Angiv en gyldig start- og slutdato med klokkeslæt, eller lad feltet stå tomt", "start_in_past": "Startdato er tidligere end i dag", "empty_body": "Beskedteksten er tom - modtagere vil modtage et tomt svar", "subject_too_long": "Emnet er for langt til serveren (højst {max} byte).", - "body_too_long": "Beskeden er for lang til serveren (højst {max} byte inkl. formatering)." + "body_too_long": "Beskeden er for lang til serveren (højst {max} byte inkl. formatering).", + "forward_address": "Angiv en gyldig adresse at videresende til", + "not_running": "Videresendelse eller svarmodtagere er gemt, men er ikke aktive lige nu. Gem for at slå dem til igen." + }, + "forward": { + "title": "Videresendelse", + "description": "Videresender indgående beskeder til en anden adresse, med eller uden fraværsbesked, i perioden ovenfor (uden slutdato, indtil den slås fra). Spam videresendes ikke.", + "enabled_label": "Videresend beskeder", + "to_label": "Videresend til", + "to_placeholder": "email@eksempel.dk", + "keep_label": "Behold i indbakke", + "keep_description": "Ellers bliver beskeden kun videresendt: Den bliver ikke i denne postkasse, og ingen filterregel køres på den." + }, + "audience": { + "label": "Svar til", + "description": "Interne afsendere: {domains}", + "all": "Alle afsendere", + "internal": "Kun interne afsendere", + "external": "Kun eksterne afsendere" + }, + "auto_reply": { + "title": "Automatisk svar" } }, "folders": { @@ -2232,7 +2251,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Anvend også på beskeder, der allerede ligger i denne mappe", - "apply_existing_unsupported": "Kun regler, der tjekker afsender, modtagere, emne eller headere, og som flytter, kopierer, markerer som læst, stjernemarkerer eller tagger, kan anvendes på eksisterende beskeder." + "apply_existing_unsupported": "Kun regler, der tjekker afsender, modtagere, emne eller headere, og som flytter, kopierer, markerer som læst, stjernemarkerer eller tagger, kan anvendes på eksisterende beskeder.", + "period_title": "Datoperiode", + "period_start": "Startdato", + "period_end": "Slutdato", + "validation_period_order": "Slutdato skal være efter startdato", + "period_toggle": "Kun aktiv i en periode", + "period_hint": "Gælder for beskeder, der ankommer i denne periode. Lad et felt stå tomt for at holde den ende åben.", + "validation_period_empty": "Angiv en start- eller slutdato for perioden", + "validation_period_invalid": "Angiv en gyldig dato med klokkeslæt for perioden, eller lad feltet stå tomt", + "apply_existing_period": "En regel med en periode virker kun på beskeder, der ankommer i perioden, og kan derfor ikke køres på eksisterende beskeder.", + "period_status_scheduled": "planlagt", + "period_status_active": "aktiv", + "period_status_expired": "udløbet" }, "templates": { "title": "E-mail-skabeloner", diff --git a/locales/de/common.json b/locales/de/common.json index 8b515b333..3f0168047 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Identität löschen fehlgeschlagen: {error}", "identity_unauthorized": "Sie sind nicht autorisiert, von dieser E-Mail-Adresse zu senden", "identity_not_found": "Identität nicht gefunden", - "vacation_saved": "Abwesenheitsnotiz-Einstellungen gespeichert", - "vacation_save_failed": "Fehler beim Speichern der Abwesenheitsnotiz-Einstellungen", + "vacation_saved": "Abwesenheits-Einstellungen gespeichert", + "vacation_save_failed": "Fehler beim Speichern der Abwesenheits-Einstellungen", "filters_saved": "Filter erfolgreich gespeichert", "filters_save_failed": "Fehler beim Speichern der Filter", "filters_deleted": "Filterregel gelöscht", @@ -857,7 +857,8 @@ "rule_undone": "Regeländerung rückgängig gemacht", "rule_undo_failed": "Die Regeländerung konnte nicht rückgängig gemacht werden", "rule_undo_conflict": "Ihre Filter wurden inzwischen geändert. Machen Sie die Änderung in den Filter-Einstellungen rückgängig.", - "rule_folder_failed": "Der Ordner konnte nicht erstellt werden" + "rule_folder_failed": "Der Ordner konnte nicht erstellt werden", + "vacation_filters_save_failed": "Abwesenheits-Einstellungen gespeichert, Weiterleitung und Antwortempfänger konnten aber nicht gespeichert werden" }, "date": { "today": "Heute", @@ -942,7 +943,7 @@ "privacy": "Datenschutz & Sicherheit", "account": "Konto", "identities": "Identitäten", - "vacation": "Abwesenheitsnotiz", + "vacation": "Abwesenheit", "advanced": "Erweitert", "calendar": "Kalender", "filters": "Filter", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "Abwesenheitsnotiz", - "description": "Automatisch auf eingehende E-Mails antworten, während Sie abwesend sind", + "title": "Abwesenheit", + "description": "Automatisch auf eingehende E-Mails reagieren, während Sie abwesend sind", "loading": "Abwesenheitseinstellungen werden geladen...", "not_supported": "Ihr Mailserver unterstützt keine Abwesenheitsantworten.", "fetch_error": "Fehler beim Laden der Abwesenheitseinstellungen. Bitte versuchen Sie es erneut.", "status": { - "label": "Abwesenheitsnotiz", + "label": "Automatisch antworten", "description": "Senden Sie eine automatische Antwort an Personen, die Ihnen eine E-Mail senden", "active": "Aktiv", "inactive": "Inaktiv" }, "date_range": { - "title": "Zeitraum", - "description": "Optional die automatische Antwort auf einen bestimmten Zeitraum beschränken", "start": "Startdatum", "start_description": "Leer lassen für kein Startlimit", "end": "Enddatum", "end_description": "Leer lassen für kein Endlimit" }, "message": { - "title": "Automatische Antwortnachricht", - "description": "Die Nachricht, die als Antwort gesendet wird", "subject_label": "Betreff", "subject_description": "Betreffzeile der automatischen Antwort", "subject_placeholder": "Abwesenheitsnotiz", @@ -1858,10 +1855,32 @@ "saving": "Speichern...", "warnings": { "end_before_start": "Das Enddatum muss nach dem Startdatum liegen", + "invalid_date": "Bitte ein gültiges Start- und Enddatum mit Uhrzeit angeben oder das Feld leer lassen", "start_in_past": "Das Startdatum liegt in der Vergangenheit", "empty_body": "Der Nachrichtentext ist leer - Empfänger erhalten eine leere Antwort", "subject_too_long": "Der Betreff ist für den Server zu lang (höchstens {max} Byte).", - "body_too_long": "Die Nachricht ist für den Server zu lang (höchstens {max} Byte, inklusive Formatierung)." + "body_too_long": "Die Nachricht ist für den Server zu lang (höchstens {max} Byte, inklusive Formatierung).", + "forward_address": "Bitte eine gültige Adresse für die Weiterleitung angeben", + "not_running": "Weiterleitung oder Antwortempfänger sind gespeichert, aber gerade nicht aktiv. Speichern aktiviert sie wieder." + }, + "forward": { + "title": "Weiterleitung", + "description": "Leitet eingehende Nachrichten an eine andere Adresse weiter, mit oder ohne automatische Antwort, im Zeitraum oben (ohne Enddatum, bis sie ausgeschaltet wird). Spam wird nicht weitergeleitet.", + "enabled_label": "Nachrichten weiterleiten", + "to_label": "Weiterleiten an", + "to_placeholder": "email@beispiel.de", + "keep_label": "Im Posteingang behalten", + "keep_description": "Sonst wird die Nachricht nur weitergeleitet: Sie bleibt nicht in diesem Postfach, und keine Filterregel läuft für sie." + }, + "audience": { + "label": "Antworten an", + "description": "Interne Absender: {domains}", + "all": "Alle Absender", + "internal": "Nur interne Absender", + "external": "Nur externe Absender" + }, + "auto_reply": { + "title": "Automatische Antwort" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Auch auf vorhandene Nachrichten in diesem Ordner anwenden", - "apply_existing_unsupported": "Nur Regeln, die Absender, Empfänger, Betreff oder Kopfzeilen prüfen und verschieben, kopieren, als gelesen markieren, mit Stern markieren oder mit Label versehen, lassen sich auf vorhandene Nachrichten anwenden." + "apply_existing_unsupported": "Nur Regeln, die Absender, Empfänger, Betreff oder Kopfzeilen prüfen und verschieben, kopieren, als gelesen markieren, mit Stern markieren oder mit Label versehen, lassen sich auf vorhandene Nachrichten anwenden.", + "period_title": "Zeitraum", + "period_start": "Startdatum", + "period_end": "Enddatum", + "validation_period_order": "Das Enddatum muss nach dem Startdatum liegen", + "period_toggle": "Nur in einem Zeitraum aktiv", + "period_hint": "Gilt für Nachrichten, die in diesem Zeitraum eintreffen. Ein leeres Feld lässt den Zeitraum an dieser Seite offen.", + "validation_period_empty": "Bitte ein Start- oder Enddatum für den Zeitraum angeben", + "validation_period_invalid": "Bitte für den Zeitraum ein gültiges Datum mit Uhrzeit angeben oder das Feld leer lassen", + "apply_existing_period": "Eine Regel mit Zeitraum wirkt nur auf Nachrichten, die in diesem Zeitraum eintreffen, und lässt sich daher nicht auf vorhandene Nachrichten anwenden.", + "period_status_scheduled": "geplant", + "period_status_active": "aktiv", + "period_status_expired": "abgelaufen" }, "templates": { "title": "E-Mail-Vorlagen", diff --git a/locales/en/common.json b/locales/en/common.json index 0a18fd029..aa167637a 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Failed to delete identity: {error}", "identity_unauthorized": "You are not authorized to send from this email address", "identity_not_found": "Identity not found", - "vacation_saved": "Vacation responder settings saved", - "vacation_save_failed": "Failed to save vacation responder settings", + "vacation_saved": "Out of office settings saved", + "vacation_save_failed": "Failed to save out of office settings", "filters_saved": "Filters saved successfully", "filters_save_failed": "Failed to save filters", "filters_deleted": "Filter rule deleted", @@ -857,7 +857,8 @@ "rule_undone": "Rule change undone", "rule_undo_failed": "Could not undo the rule change", "rule_undo_conflict": "Your filters have changed since. Undo the change in Filters settings.", - "rule_folder_failed": "Could not create the folder" + "rule_folder_failed": "Could not create the folder", + "vacation_filters_save_failed": "Out of office settings saved, but the forwarding and reply recipients could not be saved" }, "date": { "today": "Today", @@ -945,7 +946,7 @@ "privacy": "Privacy & Security", "account": "Account", "identities": "Identities", - "vacation": "Vacation Responder", + "vacation": "Out of Office", "advanced": "Advanced", "calendar": "Calendar", "filters": "Filters", @@ -1824,28 +1825,24 @@ } }, "vacation": { - "title": "Vacation Responder", - "description": "Automatically reply to incoming emails while you're away", + "title": "Out of Office", + "description": "Automatically handle incoming emails while you're away", "loading": "Loading vacation settings...", "not_supported": "Your mail server does not support vacation responses.", "fetch_error": "Failed to load vacation settings. Please try again.", "status": { - "label": "Vacation Responder", + "label": "Reply automatically", "description": "Send an automatic reply to people who email you", "active": "Active", "inactive": "Inactive" }, "date_range": { - "title": "Date Range", - "description": "Optionally limit the auto-reply to a specific period", "start": "Start Date", "start_description": "Leave empty for no start limit", "end": "End Date", "end_description": "Leave empty for no end limit" }, "message": { - "title": "Auto-Reply Message", - "description": "The message that will be sent as a reply", "subject_label": "Subject", "subject_description": "Subject line of the auto-reply", "subject_placeholder": "Out of Office", @@ -1865,10 +1862,32 @@ "saving": "Saving...", "warnings": { "end_before_start": "End date must be after start date", + "invalid_date": "Enter a valid start and end date with a time, or leave the field empty", "start_in_past": "Start date is in the past", "empty_body": "Message body is empty - recipients will receive a blank reply", "subject_too_long": "The subject is too long for the server (at most {max} bytes).", - "body_too_long": "The message is too long for the server (at most {max} bytes, formatting included)." + "body_too_long": "The message is too long for the server (at most {max} bytes, formatting included).", + "forward_address": "Enter a valid address to forward to", + "not_running": "Forwarding or the reply recipients are saved but not active right now. Save to turn them back on." + }, + "forward": { + "title": "Forwarding", + "description": "Passes incoming messages on to another address, with or without the auto-reply, within the period above (without an end date, until switched off). Spam is not forwarded.", + "enabled_label": "Forward messages", + "to_label": "Forward to", + "to_placeholder": "email@example.com", + "keep_label": "Keep in inbox", + "keep_description": "Otherwise the message is only forwarded: it is not kept in this mailbox, and no filter rule runs on it." + }, + "audience": { + "label": "Reply to", + "description": "Internal senders: {domains}", + "all": "All senders", + "internal": "Internal senders only", + "external": "External senders only" + }, + "auto_reply": { + "title": "Automatic Reply" } }, "folders": { @@ -2236,7 +2255,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Also apply to existing messages in this folder", - "apply_existing_unsupported": "Only rules that check the sender, recipients, subject or headers, and that move, copy, mark as read, star or tag, can run on existing messages." + "apply_existing_unsupported": "Only rules that check the sender, recipients, subject or headers, and that move, copy, mark as read, star or tag, can run on existing messages.", + "period_title": "Date Range", + "period_start": "Start Date", + "period_end": "End Date", + "validation_period_order": "End date must be after start date", + "period_toggle": "Only active during a period", + "period_hint": "Applies to messages that arrive during this period. Leave a field empty to keep that end open.", + "validation_period_empty": "Enter a start or end date for the period", + "validation_period_invalid": "Enter a valid date with a time for the period, or leave the field empty", + "apply_existing_period": "A rule with a period acts only on messages that arrive within it, so it cannot run on existing messages.", + "period_status_scheduled": "scheduled", + "period_status_active": "active", + "period_status_expired": "expired" }, "templates": { "title": "Email Templates", diff --git a/locales/es/common.json b/locales/es/common.json index f9a5631e8..4da29ad5f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Error al eliminar identidad: {error}", "identity_unauthorized": "No está autorizado para enviar desde esta dirección de correo", "identity_not_found": "Identidad no encontrada", - "vacation_saved": "Configuración de respuesta automática guardada", - "vacation_save_failed": "Error al guardar la configuración de respuesta automática", + "vacation_saved": "Configuración de ausencia guardada", + "vacation_save_failed": "Error al guardar la configuración de ausencia", "filters_saved": "Filtros guardados correctamente", "filters_save_failed": "Error al guardar los filtros", "filters_deleted": "Regla de filtrado eliminada", @@ -857,7 +857,8 @@ "rule_undone": "Cambio de regla deshecho", "rule_undo_failed": "No se pudo deshacer el cambio de la regla", "rule_undo_conflict": "Tus filtros han cambiado desde entonces. Deshaz el cambio en la configuración de filtros.", - "rule_folder_failed": "No se pudo crear la carpeta" + "rule_folder_failed": "No se pudo crear la carpeta", + "vacation_filters_save_failed": "Configuración de ausencia guardada, pero no se pudieron guardar el reenvío ni los destinatarios de la respuesta" }, "date": { "today": "Hoy", @@ -942,7 +943,7 @@ "privacy": "Privacidad y Seguridad", "account": "Cuenta", "identities": "Identidades", - "vacation": "Respuesta automática", + "vacation": "Ausencia", "advanced": "Avanzado", "calendar": "Calendario", "filters": "Filtros", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "Respuesta automática", - "description": "Responder automáticamente a los correos entrantes mientras está ausente", + "title": "Ausencia", + "description": "Gestionar automáticamente los correos entrantes mientras está ausente", "loading": "Cargando configuración de respuesta automática...", "not_supported": "Su servidor de correo no admite respuestas automáticas de ausencia.", "fetch_error": "Error al cargar la configuración de respuesta automática. Inténtelo de nuevo.", "status": { - "label": "Respuesta automática", + "label": "Responder automáticamente", "description": "Enviar una respuesta automática a quienes le envíen un correo", "active": "Activo", "inactive": "Inactivo" }, "date_range": { - "title": "Período", - "description": "Opcionalmente limitar la respuesta automática a un período específico", "start": "Fecha de inicio", "start_description": "Dejar vacío para sin límite de inicio", "end": "Fecha de fin", "end_description": "Dejar vacío para sin límite de fin" }, "message": { - "title": "Mensaje de respuesta automática", - "description": "El mensaje que se enviará como respuesta", "subject_label": "Asunto", "subject_description": "Línea de asunto de la respuesta automática", "subject_placeholder": "Fuera de la oficina", @@ -1858,10 +1855,32 @@ "saving": "Guardando...", "warnings": { "end_before_start": "La fecha de fin debe ser posterior a la fecha de inicio", + "invalid_date": "Introduce una fecha de inicio y de fin válidas con hora o deja el campo vacío", "start_in_past": "La fecha de inicio está en el pasado", "empty_body": "El cuerpo del mensaje está vacío - los destinatarios recibirán una respuesta en blanco", "subject_too_long": "El asunto es demasiado largo para el servidor (máximo {max} bytes).", - "body_too_long": "El mensaje es demasiado largo para el servidor (máximo {max} bytes, formato incluido)." + "body_too_long": "El mensaje es demasiado largo para el servidor (máximo {max} bytes, formato incluido).", + "forward_address": "Introduce una dirección válida para el reenvío", + "not_running": "El reenvío o los destinatarios de la respuesta están guardados, pero ahora no están activos. Guarda para volver a activarlos." + }, + "forward": { + "title": "Reenvío", + "description": "Reenvía los mensajes entrantes a otra dirección, con o sin respuesta automática, dentro del período indicado arriba (sin fecha de fin, hasta que se desactive). El correo no deseado no se reenvía.", + "enabled_label": "Reenviar mensajes", + "to_label": "Reenviar a", + "to_placeholder": "correo@ejemplo.com", + "keep_label": "Mantener en la bandeja de entrada", + "keep_description": "Si no, el mensaje solo se reenvía: no se conserva en este buzón y no se le aplica ninguna regla de filtro." + }, + "audience": { + "label": "Responder a", + "description": "Remitentes internos: {domains}", + "all": "Todos los remitentes", + "internal": "Solo remitentes internos", + "external": "Solo remitentes externos" + }, + "auto_reply": { + "title": "Respuesta automática" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Aplicar también a los mensajes que ya hay en esta carpeta", - "apply_existing_unsupported": "Solo las reglas que comprueban el remitente, los destinatarios, el asunto o los encabezados, y que mueven, copian, marcan como leído, destacan o etiquetan, se pueden aplicar a mensajes existentes." + "apply_existing_unsupported": "Solo las reglas que comprueban el remitente, los destinatarios, el asunto o los encabezados, y que mueven, copian, marcan como leído, destacan o etiquetan, se pueden aplicar a mensajes existentes.", + "period_title": "Período", + "period_start": "Fecha de inicio", + "period_end": "Fecha de fin", + "validation_period_order": "La fecha de fin debe ser posterior a la fecha de inicio", + "period_toggle": "Activa solo durante un período", + "period_hint": "Se aplica a los mensajes que llegan durante este período. Deja un campo vacío para no limitar ese extremo.", + "validation_period_empty": "Introduce una fecha de inicio o de fin para el período", + "validation_period_invalid": "Introduce una fecha válida con hora para el período o deja el campo vacío", + "apply_existing_period": "Una regla con período solo actúa sobre los mensajes que llegan dentro de él, por lo que no se puede aplicar a los mensajes existentes.", + "period_status_scheduled": "programada", + "period_status_active": "activa", + "period_status_expired": "caducada" }, "templates": { "title": "Plantillas de correo", diff --git a/locales/fa/common.json b/locales/fa/common.json index b36514e90..779398bf6 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "حذف هویت ناموفق بود: {error}", "identity_unauthorized": "شما مجاز به ارسال از این آدرس ایمیل نیستید", "identity_not_found": "هویت یافت نشد", - "vacation_saved": "تنظیمات پاسخ‌گوی خودکار ذخیره شد", - "vacation_save_failed": "ذخیره تنظیمات پاسخ‌گوی خودکار ناموفق بود", + "vacation_saved": "تنظیمات غیبت ذخیره شد", + "vacation_save_failed": "ذخیره تنظیمات غیبت ناموفق بود", "filters_saved": "فیلترها با موفقیت ذخیره شدند", "filters_save_failed": "ذخیره فیلترها ناموفق بود", "filters_deleted": "قانون فیلتر حذف شد", @@ -857,7 +857,8 @@ "rule_undone": "تغییر قانون بازگردانده شد", "rule_undo_failed": "بازگردانی تغییر قانون ممکن نشد", "rule_undo_conflict": "فیلترهای شما از آن زمان تغییر کرده‌اند. تغییر را در تنظیمات فیلترها بازگردانید.", - "rule_folder_failed": "ایجاد پوشه ممکن نشد" + "rule_folder_failed": "ایجاد پوشه ممکن نشد", + "vacation_filters_save_failed": "تنظیمات غیبت ذخیره شد، اما ذخیره بازارسال و گیرندگان پاسخ ناموفق بود" }, "date": { "today": "امروز", @@ -945,7 +946,7 @@ "privacy": "حریم خصوصی و امنیت", "account": "حساب", "identities": "هویت‌ها", - "vacation": "پاسخ‌گوی خودکار", + "vacation": "غیبت", "advanced": "پیشرفته", "calendar": "تقویم", "filters": "فیلترها", @@ -1824,28 +1825,24 @@ } }, "vacation": { - "title": "پاسخ‌گوی خودکار", - "description": "پاسخ خودکار به ایمیل‌های ورودی در زمان غیبت", + "title": "غیبت", + "description": "رسیدگی خودکار به ایمیل‌های ورودی در زمان غیبت", "loading": "در حال بارگذاری تنظیمات پاسخ‌گوی خودکار...", "not_supported": "سرور ایمیل شما از پاسخ خودکار پشتیبانی نمی‌کند.", "fetch_error": "بارگذاری تنظیمات پاسخ‌گوی خودکار ناموفق بود. لطفاً دوباره تلاش کنید.", "status": { - "label": "پاسخ‌گوی خودکار", + "label": "ارسال پاسخ خودکار", "description": "ارسال پاسخ خودکار به افرادی که به شما ایمیل می‌زنند", "active": "فعال", "inactive": "غیرفعال" }, "date_range": { - "title": "بازه زمانی", - "description": "محدود کردن پاسخ خودکار به یک بازه زمانی خاص", "start": "تاریخ شروع", "start_description": "برای بدون محدودیت شروع خالی بگذارید", "end": "تاریخ پایان", "end_description": "برای بدون محدودیت پایان خالی بگذارید" }, "message": { - "title": "پیام پاسخ خودکار", - "description": "پیامی که به عنوان پاسخ ارسال خواهد شد", "subject_label": "موضوع", "subject_description": "خط موضوع پاسخ خودکار", "subject_placeholder": "خارج از دفتر", @@ -1865,10 +1862,32 @@ "saving": "در حال ذخیره...", "warnings": { "end_before_start": "تاریخ پایان باید بعد از تاریخ شروع باشد", + "invalid_date": "تاریخ شروع و پایان معتبر را همراه با ساعت وارد کنید یا فیلد را خالی بگذارید", "start_in_past": "تاریخ شروع در گذشته است", "empty_body": "بدنه پیام خالی است - گیرندگان پاسخ خالی دریافت می‌کنند", "subject_too_long": "موضوع برای سرور بیش از حد طولانی است (حداکثر {max} بایت).", - "body_too_long": "پیام برای سرور بیش از حد طولانی است (حداکثر {max} بایت، همراه با قالب‌بندی)." + "body_too_long": "پیام برای سرور بیش از حد طولانی است (حداکثر {max} بایت، همراه با قالب‌بندی).", + "forward_address": "یک نشانی معتبر برای بازارسال وارد کنید", + "not_running": "بازارسال یا گیرندگان پاسخ ذخیره شده‌اند اما اکنون فعال نیستند. برای فعال‌سازی دوباره ذخیره کنید." + }, + "forward": { + "title": "بازارسال", + "description": "پیام‌های ورودی را با یا بدون پاسخ خودکار، در بازه زمانی بالا به نشانی دیگری بازارسال می‌کند (بدون تاریخ پایان، تا زمانی که خاموش شود). هرزنامه بازارسال نمی‌شود.", + "enabled_label": "بازارسال پیام‌ها", + "to_label": "ارسال به", + "to_placeholder": "email@example.com", + "keep_label": "نگه داشتن در صندوق ورودی", + "keep_description": "در غیر این صورت پیام فقط بازارسال می‌شود: در این صندوق پستی نمی‌ماند و هیچ قانون فیلتری روی آن اجرا نمی‌شود." + }, + "audience": { + "label": "پاسخ به", + "description": "فرستندگان داخلی: {domains}", + "all": "همه فرستندگان", + "internal": "فقط فرستندگان داخلی", + "external": "فقط فرستندگان خارجی" + }, + "auto_reply": { + "title": "پاسخ خودکار" } }, "folders": { @@ -2236,7 +2255,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "روی پیام‌هایی که از قبل در این پوشه هستند هم اعمال شود", - "apply_existing_unsupported": "فقط قانون‌هایی که فرستنده، گیرندگان، موضوع یا سرایندها را بررسی می‌کنند و پیام را منتقل، کپی، خوانده‌شده، ستاره‌دار یا برچسب‌دار می‌کنند، روی پیام‌های موجود اعمال می‌شوند." + "apply_existing_unsupported": "فقط قانون‌هایی که فرستنده، گیرندگان، موضوع یا سرایندها را بررسی می‌کنند و پیام را منتقل، کپی، خوانده‌شده، ستاره‌دار یا برچسب‌دار می‌کنند، روی پیام‌های موجود اعمال می‌شوند.", + "period_title": "بازه زمانی", + "period_start": "تاریخ شروع", + "period_end": "تاریخ پایان", + "validation_period_order": "تاریخ پایان باید بعد از تاریخ شروع باشد", + "period_toggle": "فقط در یک بازه زمانی فعال باشد", + "period_hint": "برای پیام‌هایی اعمال می‌شود که در این بازه می‌رسند. برای باز گذاشتن هر طرف، فیلد آن را خالی بگذارید.", + "validation_period_empty": "تاریخ شروع یا پایان بازه را وارد کنید", + "validation_period_invalid": "برای بازه یک تاریخ معتبر همراه با ساعت وارد کنید یا فیلد را خالی بگذارید", + "apply_existing_period": "قانونی که بازه زمانی دارد فقط روی پیام‌هایی عمل می‌کند که در آن بازه می‌رسند، بنابراین روی پیام‌های موجود اجرا نمی‌شود.", + "period_status_scheduled": "زمان‌بندی‌شده", + "period_status_active": "فعال", + "period_status_expired": "منقضی‌شده" }, "templates": { "title": "قالب‌های ایمیل", diff --git a/locales/fr/common.json b/locales/fr/common.json index 8a3c36efa..d5fdae7d8 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Échec de la suppression de l'identité: {error}", "identity_unauthorized": "Vous n'êtes pas autorisé à envoyer depuis cette adresse email", "identity_not_found": "Identité introuvable", - "vacation_saved": "Paramètres du répondeur d'absence enregistrés", - "vacation_save_failed": "Échec de l'enregistrement des paramètres du répondeur d'absence", + "vacation_saved": "Paramètres d'absence enregistrés", + "vacation_save_failed": "Échec de l'enregistrement des paramètres d'absence", "filters_saved": "Filtres enregistrés avec succès", "filters_save_failed": "Échec de l'enregistrement des filtres", "filters_deleted": "Règle de filtrage supprimée", @@ -857,7 +857,8 @@ "rule_undone": "Modification de la règle annulée", "rule_undo_failed": "Impossible d'annuler la modification de la règle", "rule_undo_conflict": "Vos filtres ont changé entre-temps. Annulez la modification dans les réglages des filtres.", - "rule_folder_failed": "Impossible de créer le dossier" + "rule_folder_failed": "Impossible de créer le dossier", + "vacation_filters_save_failed": "Paramètres d'absence enregistrés, mais le transfert et les destinataires de la réponse n'ont pas pu être enregistrés" }, "date": { "today": "Aujourd'hui", @@ -942,7 +943,7 @@ "privacy": "Confidentialité et sécurité", "account": "Compte", "identities": "Identités", - "vacation": "Répondeur d'absence", + "vacation": "Absence", "advanced": "Avancé", "calendar": "Calendrier", "filters": "Filtres", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "Répondeur d'absence", - "description": "Répondre automatiquement aux emails entrants pendant votre absence", + "title": "Absence", + "description": "Traiter automatiquement les emails entrants pendant votre absence", "loading": "Chargement des paramètres d'absence...", "not_supported": "Votre serveur de messagerie ne prend pas en charge les réponses d'absence.", "fetch_error": "Échec du chargement des paramètres d'absence. Veuillez réessayer.", "status": { - "label": "Répondeur d'absence", + "label": "Répondre automatiquement", "description": "Envoyer une réponse automatique aux personnes qui vous envoient un email", "active": "Actif", "inactive": "Inactif" }, "date_range": { - "title": "Période", - "description": "Limiter optionnellement la réponse automatique à une période spécifique", "start": "Date de début", "start_description": "Laisser vide pour aucune limite de début", "end": "Date de fin", "end_description": "Laisser vide pour aucune limite de fin" }, "message": { - "title": "Message de réponse automatique", - "description": "Le message qui sera envoyé en réponse", "subject_label": "Objet", "subject_description": "Ligne d'objet de la réponse automatique", "subject_placeholder": "Absence du bureau", @@ -1858,10 +1855,32 @@ "saving": "Enregistrement...", "warnings": { "end_before_start": "La date de fin doit être postérieure à la date de début", + "invalid_date": "Indiquez une date de début et de fin valides avec l'heure, ou laissez le champ vide", "start_in_past": "La date de début est dans le passé", "empty_body": "Le corps du message est vide - les destinataires recevront une réponse vide", "subject_too_long": "L'objet est trop long pour le serveur ({max} octets au maximum).", - "body_too_long": "Le message est trop long pour le serveur ({max} octets au maximum, mise en forme comprise)." + "body_too_long": "Le message est trop long pour le serveur ({max} octets au maximum, mise en forme comprise).", + "forward_address": "Indiquez une adresse valide pour le transfert", + "not_running": "Le transfert ou les destinataires de la réponse sont enregistrés mais pas actifs pour le moment. Enregistrez pour les réactiver." + }, + "forward": { + "title": "Transfert", + "description": "Transfère les messages entrants à une autre adresse, avec ou sans réponse automatique, pendant la période ci-dessus (sans date de fin, jusqu'à sa désactivation). Les spams ne sont pas transférés.", + "enabled_label": "Transférer les messages", + "to_label": "Transférer à", + "to_placeholder": "email@exemple.com", + "keep_label": "Conserver dans la boîte de réception", + "keep_description": "Sinon, le message est seulement transféré : il n'est pas conservé dans cette boîte aux lettres et aucune règle de filtre ne s'y applique." + }, + "audience": { + "label": "Répondre à", + "description": "Expéditeurs internes : {domains}", + "all": "Tous les expéditeurs", + "internal": "Expéditeurs internes uniquement", + "external": "Expéditeurs externes uniquement" + }, + "auto_reply": { + "title": "Réponse automatique" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Appliquer aussi aux messages déjà présents dans ce dossier", - "apply_existing_unsupported": "Seules les règles qui vérifient l'expéditeur, les destinataires, l'objet ou les en-têtes, et qui déplacent, copient, marquent comme lu, suivent ou étiquettent, peuvent s'appliquer aux messages existants." + "apply_existing_unsupported": "Seules les règles qui vérifient l'expéditeur, les destinataires, l'objet ou les en-têtes, et qui déplacent, copient, marquent comme lu, suivent ou étiquettent, peuvent s'appliquer aux messages existants.", + "period_title": "Période", + "period_start": "Date de début", + "period_end": "Date de fin", + "validation_period_order": "La date de fin doit être postérieure à la date de début", + "period_toggle": "Active uniquement pendant une période", + "period_hint": "S'applique aux messages qui arrivent pendant cette période. Laissez un champ vide pour ne pas limiter ce côté.", + "validation_period_empty": "Indiquez une date de début ou de fin pour la période", + "validation_period_invalid": "Indiquez une date valide avec l'heure pour la période, ou laissez le champ vide", + "apply_existing_period": "Une règle avec une période n'agit que sur les messages qui arrivent pendant celle-ci ; elle ne peut donc pas s'appliquer aux messages existants.", + "period_status_scheduled": "planifiée", + "period_status_active": "active", + "period_status_expired": "expirée" }, "templates": { "title": "Modèles d'e-mails", diff --git a/locales/he/common.json b/locales/he/common.json index 13f034d84..9a4467d2d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -799,8 +799,8 @@ "identity_delete_failed": "מחיקת הזהות נכשלה:{error}", "identity_unauthorized": "אינך מורשה לשלוח מכתובת דוא\"ל זו", "identity_not_found": "זהות לא נמצאה", - "vacation_saved": "הגדרות משיב חופשה נשמרו", - "vacation_save_failed": "שמירת הגדרות משיב חופשה נכשלה", + "vacation_saved": "הגדרות ההיעדרות נשמרו", + "vacation_save_failed": "שמירת הגדרות ההיעדרות נכשלה", "filters_saved": "המסננים נשמרו בהצלחה", "filters_save_failed": "שמירת המסננים נכשלה", "filters_deleted": "כלל הסינון נמחק", @@ -822,7 +822,8 @@ "rule_undone": "שינוי הכלל בוטל", "rule_undo_failed": "לא ניתן היה לבטל את שינוי הכלל", "rule_undo_conflict": "המסננים שלך השתנו מאז. בטל את השינוי בהגדרות המסננים.", - "rule_folder_failed": "לא ניתן היה ליצור את התיקייה" + "rule_folder_failed": "לא ניתן היה ליצור את התיקייה", + "vacation_filters_save_failed": "הגדרות ההיעדרות נשמרו, אך שמירת ההעברה ונמעני המענה נכשלה" }, "date": { "today": "היום", @@ -907,7 +908,7 @@ "privacy": "פרטיות ואבטחה", "account": "חשבון", "identities": "זהויות", - "vacation": "משיב נופש", + "vacation": "היעדרות", "advanced": "מתקדם", "calendar": "לוּחַ שָׁנָה", "filters": "מסננים", @@ -1782,28 +1783,24 @@ } }, "vacation": { - "title": "משיב נופש", - "description": "הגב באופן אוטומטי להודעות דוא״ל נכנסות בזמן שאתה רחוק", + "title": "היעדרות", + "description": "טפל באופן אוטומטי בהודעות דוא״ל נכנסות בזמן שאתה רחוק", "loading": "טוען הגדרות חופשה...", "not_supported": "שרת הדואר שלך אינו תומך בתגובות חופשה.", "fetch_error": "טעינת הגדרות החופשה נכשלה. אנא נסה שוב.", "status": { - "label": "משיב נופש", + "label": "שליחת מענה אוטומטי", "description": "שלח תשובה אוטומטית לאנשים ששלחו לך דוא\"ל", "active": "פעיל", "inactive": "לא פעיל" }, "date_range": { - "title": "טווח תאריכים", - "description": "אפשר להגביל את המענה האוטומטי לתקופה מסוימת", "start": "תאריך התחלה", "start_description": "השאר ריק ללא מגבלת התחלה", "end": "תאריך סיום", "end_description": "השאר ריק ללא הגבלה" }, "message": { - "title": "הודעת תשובה אוטומטית", - "description": "ההודעה שתישלח כתשובה", "subject_label": "נושא", "subject_description": "שורת הנושא של התשובה האוטומטית", "subject_placeholder": "מחוץ למשרד", @@ -1823,10 +1820,32 @@ "saving": "שומר...", "warnings": { "end_before_start": "תאריך הסיום חייב להיות אחרי תאריך ההתחלה", + "invalid_date": "יש להזין תאריך התחלה וסיום תקינים עם שעה, או להשאיר את השדה ריק", "start_in_past": "תאריך ההתחלה הוא בעבר", "empty_body": "גוף ההודעה ריק - הנמענים יקבלו תשובה ריקה", "subject_too_long": "הנושא ארוך מדי עבור השרת (לכל היותר {max} בתים).", - "body_too_long": "ההודעה ארוכה מדי עבור השרת (לכל היותר {max} בתים, כולל עיצוב)." + "body_too_long": "ההודעה ארוכה מדי עבור השרת (לכל היותר {max} בתים, כולל עיצוב).", + "forward_address": "יש להזין כתובת תקינה להעברה", + "not_running": "ההעברה או נמעני המענה שמורים אך אינם פעילים כרגע. יש לשמור כדי להפעיל אותם מחדש." + }, + "forward": { + "title": "העברה", + "description": "מעביר הודעות נכנסות לכתובת אחרת, עם מענה אוטומטי או בלעדיו, בטווח הזמן שלמעלה (ללא תאריך סיום, עד שיכובה). דואר זבל אינו מועבר.", + "enabled_label": "העברת הודעות", + "to_label": "העבר אל", + "to_placeholder": "email@example.com", + "keep_label": "שמור בתיבת הדואר הנכנס", + "keep_description": "אחרת ההודעה רק מועברת: היא לא נשמרת בתיבת דואר זו, ואף כלל סינון לא חל עליה." + }, + "audience": { + "label": "להשיב אל", + "description": "שולחים פנימיים: {domains}", + "all": "כל השולחים", + "internal": "שולחים פנימיים בלבד", + "external": "שולחים חיצוניים בלבד" + }, + "auto_reply": { + "title": "מענה אוטומטי" } }, "folders": { @@ -2151,7 +2170,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "החל גם על הודעות שכבר נמצאות בתיקייה זו", - "apply_existing_unsupported": "אפשר להחיל על הודעות קיימות רק כללים שבודקים שולח, נמענים, נושא או כותרות, ושמעבירים, מעתיקים, מסמנים כנקרא, בכוכב או בתווית." + "apply_existing_unsupported": "אפשר להחיל על הודעות קיימות רק כללים שבודקים שולח, נמענים, נושא או כותרות, ושמעבירים, מעתיקים, מסמנים כנקרא, בכוכב או בתווית.", + "period_title": "טווח תאריכים", + "period_start": "תאריך התחלה", + "period_end": "תאריך סיום", + "validation_period_order": "תאריך הסיום חייב להיות אחרי תאריך ההתחלה", + "period_toggle": "פעיל רק בטווח זמן מסוים", + "period_hint": "חל על הודעות שמגיעות בטווח זה. השאירו שדה ריק כדי להשאיר את הקצה הזה פתוח.", + "validation_period_empty": "יש להזין תאריך התחלה או סיום לטווח", + "validation_period_invalid": "יש להזין תאריך תקין עם שעה לטווח, או להשאיר את השדה ריק", + "apply_existing_period": "כלל עם טווח זמן פועל רק על הודעות שמגיעות בתוכו, ולכן לא ניתן להחיל אותו על הודעות קיימות.", + "period_status_scheduled": "מתוזמן", + "period_status_active": "פעיל", + "period_status_expired": "פג תוקף" }, "templates": { "title": "תבניות דואר אלקטרוני", diff --git a/locales/hu/common.json b/locales/hu/common.json index 6fc1998a9..7cefaabf4 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Nem sikerült törölni az identitást: {error}", "identity_unauthorized": "Nem vagy jogosult erről az e-mail címről küldeni", "identity_not_found": "Identitás nem található", - "vacation_saved": "Automatikus válasz beállítások elmentve", - "vacation_save_failed": "Nem sikerült menteni az automatikus válasz beállításokat", + "vacation_saved": "Távolléti beállítások elmentve", + "vacation_save_failed": "Nem sikerült menteni a távolléti beállításokat", "filters_saved": "Szűrők sikeresen elmentve", "filters_save_failed": "Nem sikerült menteni a szűrőket", "filters_deleted": "Szűrő szabály törölve", @@ -857,7 +857,8 @@ "rule_undone": "A szabálymódosítás visszavonva", "rule_undo_failed": "Nem sikerült visszavonni a szabálymódosítást", "rule_undo_conflict": "A szűrőid azóta megváltoztak. A módosítást a szűrőbeállításokban vond vissza.", - "rule_folder_failed": "Nem sikerült létrehozni a mappát" + "rule_folder_failed": "Nem sikerült létrehozni a mappát", + "vacation_filters_save_failed": "Távolléti beállítások elmentve, de a továbbítást és a válasz címzettjeit nem sikerült menteni" }, "date": { "today": "Ma", @@ -945,7 +946,7 @@ "privacy": "Adatvédelem és biztonság", "account": "Fiók", "identities": "Azonosságok", - "vacation": "Automatikus válasz", + "vacation": "Távollét", "advanced": "Speciális", "calendar": "Naptár", "filters": "Szűrők", @@ -1820,28 +1821,24 @@ } }, "vacation": { - "title": "Automatikus válasz", - "description": "Automatikus válasz a beérkező e-mailekre távolléted alatt", + "title": "Távollét", + "description": "A beérkező e-mailek automatikus kezelése távolléted alatt", "loading": "Automatikus válasz beállítások betöltése...", "not_supported": "A levelezőszervered nem támogatja az automatikus válaszokat.", "fetch_error": "Nem sikerült betölteni az automatikus válasz beállításokat. Kérjük, próbáld újra.", "status": { - "label": "Automatikus válasz", + "label": "Automatikus válasz küldése", "description": "Automatikus válasz küldése azoknak, akik e-mailt írnak neked", "active": "Aktív", "inactive": "Inaktív" }, "date_range": { - "title": "Dátumtartomány", - "description": "Opcionálisan korlátozd az automatikus választ egy adott időszakra", "start": "Kezdő dátum", "start_description": "Hagyd üresen, ha nincs kezdő korlát", "end": "Befejező dátum", "end_description": "Hagyd üresen, ha nincs befejező korlát" }, "message": { - "title": "Automatikus válasz üzenet", - "description": "Az üzenet, amely válaszként lesz elküldve", "subject_label": "Tárgy", "subject_description": "Az automatikus válasz tárgysora", "subject_placeholder": "Távol vagyok", @@ -1861,10 +1858,32 @@ "saving": "Mentés...", "warnings": { "end_before_start": "A befejező dátumnak a kezdő dátum után kell lennie", + "invalid_date": "Adjon meg érvényes kezdő és befejező dátumot időponttal, vagy hagyja üresen a mezőt", "start_in_past": "A kezdő dátum a múltban van", "empty_body": "Az üzenet szövege üres - a címzettek üres választ kapnak", "subject_too_long": "A tárgy túl hosszú a kiszolgálónak (legfeljebb {max} bájt).", - "body_too_long": "Az üzenet túl hosszú a kiszolgálónak (legfeljebb {max} bájt a formázással együtt)." + "body_too_long": "Az üzenet túl hosszú a kiszolgálónak (legfeljebb {max} bájt a formázással együtt).", + "forward_address": "Adjon meg egy érvényes címet a továbbításhoz", + "not_running": "A továbbítás vagy a válasz címzettjei el vannak mentve, de most nem aktívak. Mentéssel újra bekapcsolja őket." + }, + "forward": { + "title": "Továbbítás", + "description": "A beérkező üzeneteket egy másik címre továbbítja, automatikus válasszal vagy anélkül, a fenti időszakban (befejező dátum nélkül a kikapcsolásig). A levélszemét nem lesz továbbítva.", + "enabled_label": "Üzenetek továbbítása", + "to_label": "Továbbítás", + "to_placeholder": "email@example.com", + "keep_label": "Megtartás a beérkezőben", + "keep_description": "Ellenkező esetben az üzenet csak továbbítva lesz: nem marad ebben a postafiókban, és egyetlen szűrőszabály sem fut rá." + }, + "audience": { + "label": "Válasz küldése", + "description": "Belső feladók: {domains}", + "all": "Minden feladónak", + "internal": "Csak belső feladóknak", + "external": "Csak külső feladóknak" + }, + "auto_reply": { + "title": "Automatikus válasz" } }, "folders": { @@ -2232,7 +2251,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Alkalmazás a mappában már meglévő üzenetekre is", - "apply_existing_unsupported": "Meglévő üzenetekre csak olyan szabály alkalmazható, amely a feladót, a címzetteket, a tárgyat vagy a fejléceket vizsgálja, és áthelyez, másol, olvasottnak jelöl, csillagoz vagy címkéz." + "apply_existing_unsupported": "Meglévő üzenetekre csak olyan szabály alkalmazható, amely a feladót, a címzetteket, a tárgyat vagy a fejléceket vizsgálja, és áthelyez, másol, olvasottnak jelöl, csillagoz vagy címkéz.", + "period_title": "Dátumtartomány", + "period_start": "Kezdő dátum", + "period_end": "Befejező dátum", + "validation_period_order": "A befejező dátumnak a kezdő dátum után kell lennie", + "period_toggle": "Csak egy időszakban aktív", + "period_hint": "Az ebben az időszakban érkező üzenetekre vonatkozik. Hagyjon egy mezőt üresen, ha azt a végét nem szeretné korlátozni.", + "validation_period_empty": "Adjon meg kezdő vagy befejező dátumot az időszakhoz", + "validation_period_invalid": "Adjon meg érvényes dátumot időponttal az időszakhoz, vagy hagyja üresen a mezőt", + "apply_existing_period": "Az időszakhoz kötött szabály csak az abban érkező üzenetekre hat, ezért meglévő üzenetekre nem alkalmazható.", + "period_status_scheduled": "ütemezett", + "period_status_active": "aktív", + "period_status_expired": "lejárt" }, "templates": { "title": "E-mail sablonok", diff --git a/locales/it/common.json b/locales/it/common.json index 452932d8e..c215fcc18 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Impossibile eliminare l'identità: {error}", "identity_unauthorized": "Non sei autorizzato a inviare da questo indirizzo email", "identity_not_found": "Identità non trovata", - "vacation_saved": "Impostazioni del risponditore automatico salvate", - "vacation_save_failed": "Impossibile salvare le impostazioni del risponditore automatico", + "vacation_saved": "Impostazioni di assenza salvate", + "vacation_save_failed": "Impossibile salvare le impostazioni di assenza", "filters_saved": "Filtri salvati con successo", "filters_save_failed": "Impossibile salvare i filtri", "filters_deleted": "Regola di filtraggio eliminata", @@ -857,7 +857,8 @@ "rule_undone": "Modifica della regola annullata", "rule_undo_failed": "Impossibile annullare la modifica della regola", "rule_undo_conflict": "I tuoi filtri sono cambiati nel frattempo. Annulla la modifica nelle impostazioni dei filtri.", - "rule_folder_failed": "Impossibile creare la cartella" + "rule_folder_failed": "Impossibile creare la cartella", + "vacation_filters_save_failed": "Impostazioni di assenza salvate, ma non è stato possibile salvare l'inoltro e i destinatari della risposta" }, "date": { "today": "Oggi", @@ -942,7 +943,7 @@ "privacy": "Privacy e sicurezza", "account": "Account", "identities": "Identità", - "vacation": "Risponditore automatico", + "vacation": "Assenza", "advanced": "Avanzate", "calendar": "Calendario", "filters": "Filtri", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "Risponditore automatico", - "description": "Rispondi automaticamente alle email in arrivo durante la tua assenza", + "title": "Assenza", + "description": "Gestisci automaticamente le email in arrivo durante la tua assenza", "loading": "Caricamento impostazioni di risposta automatica...", "not_supported": "Il server di posta non supporta le risposte automatiche di assenza.", "fetch_error": "Impossibile caricare le impostazioni di risposta automatica. Riprova.", "status": { - "label": "Risponditore automatico", + "label": "Rispondi automaticamente", "description": "Invia una risposta automatica a chi ti scrive", "active": "Attivo", "inactive": "Inattivo" }, "date_range": { - "title": "Periodo", - "description": "Limita opzionalmente la risposta automatica a un periodo specifico", "start": "Data di inizio", "start_description": "Lascia vuoto per nessun limite di inizio", "end": "Data di fine", "end_description": "Lascia vuoto per nessun limite di fine" }, "message": { - "title": "Messaggio di risposta automatica", - "description": "Il messaggio che verrà inviato come risposta", "subject_label": "Oggetto", "subject_description": "Oggetto della risposta automatica", "subject_placeholder": "Fuori ufficio", @@ -1858,10 +1855,32 @@ "saving": "Salvataggio...", "warnings": { "end_before_start": "La data di fine deve essere successiva alla data di inizio", + "invalid_date": "Inserisci una data di inizio e di fine valide con l'ora oppure lascia vuoto il campo", "start_in_past": "La data di inizio è nel passato", "empty_body": "Il corpo del messaggio è vuoto - i destinatari riceveranno una risposta vuota", "subject_too_long": "L'oggetto è troppo lungo per il server (al massimo {max} byte).", - "body_too_long": "Il messaggio è troppo lungo per il server (al massimo {max} byte, formattazione inclusa)." + "body_too_long": "Il messaggio è troppo lungo per il server (al massimo {max} byte, formattazione inclusa).", + "forward_address": "Inserisci un indirizzo valido per l'inoltro", + "not_running": "L'inoltro o i destinatari della risposta sono salvati ma al momento non sono attivi. Salva per riattivarli." + }, + "forward": { + "title": "Inoltro", + "description": "Inoltra i messaggi in arrivo a un altro indirizzo, con o senza risposta automatica, nel periodo indicato sopra (senza data di fine, finché non viene disattivato). Lo spam non viene inoltrato.", + "enabled_label": "Inoltra i messaggi", + "to_label": "Inoltra a", + "to_placeholder": "email@esempio.com", + "keep_label": "Mantieni nella posta in arrivo", + "keep_description": "Altrimenti il messaggio viene solo inoltrato: non resta in questa casella e non gli si applica nessuna regola di filtro." + }, + "audience": { + "label": "Rispondi a", + "description": "Mittenti interni: {domains}", + "all": "Tutti i mittenti", + "internal": "Solo mittenti interni", + "external": "Solo mittenti esterni" + }, + "auto_reply": { + "title": "Risposta automatica" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Applica anche ai messaggi già presenti in questa cartella", - "apply_existing_unsupported": "Solo le regole che controllano mittente, destinatari, oggetto o intestazioni, e che spostano, copiano, segnano come letto, contrassegnano con stella o etichettano, si possono applicare ai messaggi esistenti." + "apply_existing_unsupported": "Solo le regole che controllano mittente, destinatari, oggetto o intestazioni, e che spostano, copiano, segnano come letto, contrassegnano con stella o etichettano, si possono applicare ai messaggi esistenti.", + "period_title": "Periodo", + "period_start": "Data di inizio", + "period_end": "Data di fine", + "validation_period_order": "La data di fine deve essere successiva alla data di inizio", + "period_toggle": "Attiva solo in un periodo", + "period_hint": "Si applica ai messaggi che arrivano in questo periodo. Lascia un campo vuoto per non limitare quell'estremo.", + "validation_period_empty": "Inserisci una data di inizio o di fine per il periodo", + "validation_period_invalid": "Inserisci una data valida con l'ora per il periodo oppure lascia vuoto il campo", + "apply_existing_period": "Una regola con periodo agisce solo sui messaggi che arrivano al suo interno, quindi non si può applicare ai messaggi esistenti.", + "period_status_scheduled": "programmata", + "period_status_active": "attiva", + "period_status_expired": "scaduta" }, "templates": { "title": "Modelli email", diff --git a/locales/ja/common.json b/locales/ja/common.json index 200a70360..f5f8b1c6b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "送信者情報の削除に失敗しました: {error}", "identity_unauthorized": "このメールアドレスからの送信は許可されていません", "identity_not_found": "送信者情報が見つかりません", - "vacation_saved": "不在応答の設定を保存しました", - "vacation_save_failed": "不在応答の設定の保存に失敗しました", + "vacation_saved": "不在の設定を保存しました", + "vacation_save_failed": "不在の設定の保存に失敗しました", "filters_saved": "フィルターを保存しました", "filters_save_failed": "フィルターの保存に失敗しました", "filters_deleted": "フィルタールールが削除されました", @@ -857,7 +857,8 @@ "rule_undone": "ルールの変更を元に戻しました", "rule_undo_failed": "ルールの変更を元に戻せませんでした", "rule_undo_conflict": "その後フィルターが変更されています。フィルター設定で変更を元に戻してください。", - "rule_folder_failed": "フォルダを作成できませんでした" + "rule_folder_failed": "フォルダを作成できませんでした", + "vacation_filters_save_failed": "不在の設定を保存しましたが、転送と返信する相手の保存に失敗しました" }, "date": { "today": "今日", @@ -942,7 +943,7 @@ "privacy": "プライバシーとセキュリティ", "account": "アカウント", "identities": "送信者情報", - "vacation": "不在応答", + "vacation": "不在", "advanced": "詳細設定", "calendar": "カレンダー", "filters": "フィルター", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "不在応答", - "description": "不在時に受信メールへ自動返信します", + "title": "不在", + "description": "不在時に受信メールを自動で処理します", "loading": "不在応答設定を読み込み中...", "not_supported": "メールサーバーは不在応答をサポートしていません。", "fetch_error": "不在応答設定の読み込みに失敗しました。もう一度お試しください。", "status": { - "label": "不在応答", + "label": "自動で返信する", "description": "メールを送信してきた方に自動返信を送信します", "active": "有効", "inactive": "無効" }, "date_range": { - "title": "期間", - "description": "オプションで自動返信を特定の期間に限定します", "start": "開始日", "start_description": "空白のままにすると開始制限なし", "end": "終了日", "end_description": "空白のままにすると終了制限なし" }, "message": { - "title": "自動返信メッセージ", - "description": "返信として送信されるメッセージ", "subject_label": "件名", "subject_description": "自動返信の件名", "subject_placeholder": "不在通知", @@ -1858,10 +1855,32 @@ "saving": "保存中...", "warnings": { "end_before_start": "終了日は開始日より後に設定してください", + "invalid_date": "開始日と終了日は時刻付きの有効な日付を入力するか、欄を空のままにしてください", "start_in_past": "開始日が過去の日付です", "empty_body": "メッセージ本文が空です - 受信者には空の返信が届きます", "subject_too_long": "件名がサーバーの上限を超えています(最大 {max} バイト)。", - "body_too_long": "メッセージがサーバーの上限を超えています(書式を含めて最大 {max} バイト)。" + "body_too_long": "メッセージがサーバーの上限を超えています(書式を含めて最大 {max} バイト)。", + "forward_address": "転送先に有効なアドレスを入力してください", + "not_running": "転送または返信する相手は保存されていますが、現在は有効になっていません。保存すると再び有効になります。" + }, + "forward": { + "title": "転送", + "description": "受信したメッセージを、不在応答の有無にかかわらず、上の期間内に別のアドレスへ転送します(終了日がない場合はオフにするまで)。迷惑メールは転送されません。", + "enabled_label": "メッセージを転送する", + "to_label": "転送先", + "to_placeholder": "email@example.com", + "keep_label": "受信トレイに保持", + "keep_description": "オフの場合、メッセージは転送されるだけで、このメールボックスには残らず、フィルタールールも適用されません。" + }, + "audience": { + "label": "返信する相手", + "description": "内部の送信者: {domains}", + "all": "すべての送信者", + "internal": "内部の送信者のみ", + "external": "外部の送信者のみ" + }, + "auto_reply": { + "title": "自動応答" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "このフォルダにある既存のメールにも適用", - "apply_existing_unsupported": "既存のメールに適用できるのは、差出人・宛先・件名・ヘッダーを条件とし、移動・コピー・既読・スター・ラベルを実行するルールだけです。" + "apply_existing_unsupported": "既存のメールに適用できるのは、差出人・宛先・件名・ヘッダーを条件とし、移動・コピー・既読・スター・ラベルを実行するルールだけです。", + "period_title": "期間", + "period_start": "開始日", + "period_end": "終了日", + "validation_period_order": "終了日は開始日より後に設定してください", + "period_toggle": "期間を限定して有効にする", + "period_hint": "この期間に届いたメッセージに適用されます。空欄にすると、その側は制限なしになります。", + "validation_period_empty": "期間の開始日または終了日を入力してください", + "validation_period_invalid": "期間には時刻付きの有効な日付を入力するか、欄を空のままにしてください", + "apply_existing_period": "期間付きのルールは期間内に届いたメッセージにのみ適用されるため、既存のメッセージには適用できません。", + "period_status_scheduled": "予定", + "period_status_active": "有効", + "period_status_expired": "期限切れ" }, "templates": { "title": "メールテンプレート", diff --git a/locales/ko/common.json b/locales/ko/common.json index 5cb17ed48..cc1334618 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "보내는 사람 삭제 실패: {error}", "identity_unauthorized": "이 이메일 주소로 메일을 보낼 권한이 없어요", "identity_not_found": "해당 계정을 찾을 수 없어요", - "vacation_saved": "부재중 자동 응답 설정이 저장되었어요", - "vacation_save_failed": "부재중 자동 응답 설정을 저장하지 못했어요", + "vacation_saved": "부재중 설정이 저장되었어요", + "vacation_save_failed": "부재중 설정을 저장하지 못했어요", "filters_saved": "필터가 저장되었어요", "filters_save_failed": "필터 저장에 실패했어요", "filters_deleted": "필터 규칙이 삭제되었어요", @@ -857,7 +857,8 @@ "rule_undone": "규칙 변경을 취소했어요", "rule_undo_failed": "규칙 변경을 취소하지 못했어요", "rule_undo_conflict": "그 사이에 필터가 바뀌었어요. 필터 설정에서 변경을 취소하세요.", - "rule_folder_failed": "폴더를 만들지 못했어요" + "rule_folder_failed": "폴더를 만들지 못했어요", + "vacation_filters_save_failed": "부재중 설정은 저장되었지만 전달 및 응답 대상 설정을 저장하지 못했어요" }, "date": { "today": "오늘", @@ -942,7 +943,7 @@ "privacy": "개인정보 및 보안", "account": "계정", "identities": "보내는 사람", - "vacation": "부재중 자동 응답", + "vacation": "부재중", "advanced": "고급 설정", "calendar": "캘린더", "filters": "필터", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "부재중 자동 응답", - "description": "자리를 비웠을 때 도착한 메일에 자동으로 답장을 보내요", + "title": "부재중", + "description": "자리를 비웠을 때 도착한 메일을 자동으로 처리해요", "loading": "자동 응답 설정을 불러오는 중...", "not_supported": "사용 중인 메일 서버는 자동 응답 기능을 지원하지 않아요.", "fetch_error": "자동 응답 설정을 불러오지 못했어요. 다시 시도해 주세요.", "status": { - "label": "자동 응답", + "label": "자동으로 응답하기", "description": "나에게 메일을 보낸 사람에게 자동으로 답장을 보내요", "active": "사용 중", "inactive": "사용 안 함" }, "date_range": { - "title": "기간 설정", - "description": "자동 응답을 보낼 특정 기간을 설정할 수 있어요", "start": "시작일", "start_description": "비워두면 지금부터 바로 시작해요", "end": "종료일", "end_description": "비워두면 계속해서 켜져 있어요" }, "message": { - "title": "자동 응답 메시지", - "description": "답장으로 보낼 메시지 내용을 작성해 주세요", "subject_label": "제목", "subject_description": "자동 응답 메일의 제목이에요", "subject_placeholder": "부재중 알림", @@ -1858,10 +1855,32 @@ "saving": "저장 중...", "warnings": { "end_before_start": "종료일은 시작일보다 나중이어야 해요", + "invalid_date": "시작일과 종료일에 시간을 포함한 올바른 날짜를 입력하거나 칸을 비워 두세요", "start_in_past": "시작일이 이미 지났어요", "empty_body": "메시지 내용이 비어있어요. 상대방에게 빈 메일이 가게 돼요", "subject_too_long": "제목이 서버에 비해 너무 깁니다(최대 {max}바이트).", - "body_too_long": "메시지가 서버에 비해 너무 깁니다(서식 포함 최대 {max}바이트)." + "body_too_long": "메시지가 서버에 비해 너무 깁니다(서식 포함 최대 {max}바이트).", + "forward_address": "전달받을 올바른 주소를 입력해 주세요", + "not_running": "전달 또는 응답 대상이 저장되어 있지만 지금은 작동하지 않아요. 저장하면 다시 켜져요." + }, + "forward": { + "title": "전달", + "description": "받은 메시지를 자동 응답 여부와 관계없이 위 기간 동안 다른 주소로 전달해요(종료일이 없으면 끌 때까지). 스팸은 전달하지 않아요.", + "enabled_label": "메시지 전달", + "to_label": "다음으로 전달", + "to_placeholder": "email@example.com", + "keep_label": "받은편지함에 유지", + "keep_description": "끄면 메시지는 전달만 되고, 이 메일함에 남지 않으며 필터 규칙도 적용되지 않아요." + }, + "audience": { + "label": "응답 대상", + "description": "내부 보낸 사람: {domains}", + "all": "모든 보낸 사람", + "internal": "내부 보낸 사람만", + "external": "외부 보낸 사람만" + }, + "auto_reply": { + "title": "자동 응답" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "이 폴더에 이미 있는 메일에도 적용", - "apply_existing_unsupported": "보낸 사람, 받는 사람, 제목 또는 헤더를 확인하고 이동, 복사, 읽음 표시, 별표 또는 태그를 하는 규칙만 기존 메일에 적용할 수 있어요." + "apply_existing_unsupported": "보낸 사람, 받는 사람, 제목 또는 헤더를 확인하고 이동, 복사, 읽음 표시, 별표 또는 태그를 하는 규칙만 기존 메일에 적용할 수 있어요.", + "period_title": "기간 설정", + "period_start": "시작일", + "period_end": "종료일", + "validation_period_order": "종료일은 시작일보다 나중이어야 해요", + "period_toggle": "특정 기간에만 활성화", + "period_hint": "이 기간에 도착하는 메시지에 적용돼요. 한쪽을 비워 두면 그쪽은 제한이 없어요.", + "validation_period_empty": "기간의 시작일 또는 종료일을 입력해 주세요", + "validation_period_invalid": "기간에 시간을 포함한 올바른 날짜를 입력하거나 칸을 비워 두세요", + "apply_existing_period": "기간이 설정된 규칙은 그 기간에 도착한 메시지에만 적용되므로 기존 메시지에는 적용할 수 없어요.", + "period_status_scheduled": "예정", + "period_status_active": "활성", + "period_status_expired": "만료" }, "templates": { "title": "이메일 템플릿", diff --git a/locales/lv/common.json b/locales/lv/common.json index 4ea8ef650..31fbf607d 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Neizdevās izdzēst identitāti: {error}", "identity_unauthorized": "Jums nav tiesību sūtīt no šīs e-pasta adreses", "identity_not_found": "Identitāte netika atrasta", - "vacation_saved": "Atvaļinājuma atbildes iestatījumi saglabāti", - "vacation_save_failed": "Neizdevās saglabāt atvaļinājuma atbildes iestatījumus", + "vacation_saved": "Prombūtnes iestatījumi saglabāti", + "vacation_save_failed": "Neizdevās saglabāt prombūtnes iestatījumus", "filters_saved": "Filtri veiksmīgi saglabāti", "filters_save_failed": "Neizdevās saglabāt filtrus", "filters_deleted": "Filtra nosacījums izdzēsts", @@ -857,7 +857,8 @@ "rule_undone": "Noteikuma izmaiņa atsaukta", "rule_undo_failed": "Neizdevās atsaukt noteikuma izmaiņu", "rule_undo_conflict": "Jūsu filtri kopš tā laika ir mainījušies. Atsauciet izmaiņu filtru iestatījumos.", - "rule_folder_failed": "Neizdevās izveidot mapi" + "rule_folder_failed": "Neizdevās izveidot mapi", + "vacation_filters_save_failed": "Prombūtnes iestatījumi saglabāti, taču pārsūtīšanu un atbildes saņēmējus neizdevās saglabāt" }, "date": { "today": "Šodien", @@ -942,7 +943,7 @@ "privacy": "Privātums un drošība", "account": "Konts", "identities": "Identitātes", - "vacation": "Atvaļinājuma atbilde", + "vacation": "Prombūtne", "advanced": "Papildu", "calendar": "Kalendārs", "filters": "Filtri", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "Atvaļinājuma atbilde", - "description": "Automātiski atbildēt uz ienākošajām vēstulēm prombūtnes laikā", + "title": "Prombūtne", + "description": "Automātiski apstrādāt ienākošās vēstules prombūtnes laikā", "loading": "Ielādē atvaļinājuma iestatījumus...", "not_supported": "Jūsu pasta serveris neatbalsta atvaļinājuma atbildes.", "fetch_error": "Neizdevās ielādēt atvaļinājuma iestatījumus.", "status": { - "label": "Atvaļinājuma atbilde", + "label": "Atbildēt automātiski", "description": "Sūtīt automātisku atbildi uz ienākošajām vēstulēm", "active": "Aktīva", "inactive": "Neaktīva" }, "date_range": { - "title": "Datumu diapazons", - "description": "Ierobežojiet atbildi noteiktā periodā (pēc izvēles)", "start": "Sākuma datums", "start_description": "Atstājiet tukšu, lai nebūtu ierobežojuma", "end": "Beigu datums", "end_description": "Atstājiet tukšu, lai nebūtu ierobežojuma" }, "message": { - "title": "Atbildes ziņojums", - "description": "Ziņojums, kas tiks nosūtīts kā atbilde", "subject_label": "Temats", "subject_description": "Atvaļinājuma atbildes temats", "subject_placeholder": "Atvaļinājumā / Esmu prom", @@ -1858,10 +1855,32 @@ "saving": "Saglabā...", "warnings": { "end_before_start": "Beigu datumam jābūt vēlāk par sākuma datumu", + "invalid_date": "Norādiet derīgu sākuma un beigu datumu ar laiku vai atstājiet lauku tukšu", "start_in_past": "Sākuma datums ir pagātnē", "empty_body": "Ziņojuma teksts ir tukšs - saņēmēji saņems tukšu atbildi", "subject_too_long": "Temats serverim ir pārāk garš (ne vairāk kā {max} baiti).", - "body_too_long": "Ziņa serverim ir pārāk gara (ne vairāk kā {max} baiti, ieskaitot formatējumu)." + "body_too_long": "Ziņa serverim ir pārāk gara (ne vairāk kā {max} baiti, ieskaitot formatējumu).", + "forward_address": "Norādiet derīgu adresi pārsūtīšanai", + "not_running": "Pārsūtīšana vai atbildes saņēmēji ir saglabāti, taču pašlaik nav aktīvi. Saglabājiet, lai tos atkal ieslēgtu." + }, + "forward": { + "title": "Pārsūtīšana", + "description": "Pārsūta ienākošos ziņojumus uz citu adresi, ar automātisko atbildi vai bez tās, iepriekš norādītajā laika posmā (bez beigu datuma, līdz to izslēdz). Mēstules netiek pārsūtītas.", + "enabled_label": "Pārsūtīt ziņojumus", + "to_label": "Pārsūtīt uz", + "to_placeholder": "lietotajs@piemers.lv", + "keep_label": "Atstāt iesūtnē", + "keep_description": "Pretējā gadījumā ziņojums tiek tikai pārsūtīts: tas nepaliek šajā pastkastē, un tam netiek piemērots neviens filtra noteikums." + }, + "audience": { + "label": "Atbildēt", + "description": "Iekšējie sūtītāji: {domains}", + "all": "Visiem sūtītājiem", + "internal": "Tikai iekšējiem sūtītājiem", + "external": "Tikai ārējiem sūtītājiem" + }, + "auto_reply": { + "title": "Automātiskā atbilde" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Piemērot arī vēstulēm, kas jau ir šajā mapē", - "apply_existing_unsupported": "Esošajām vēstulēm var piemērot tikai noteikumus, kas pārbauda sūtītāju, adresātus, tematu vai galvenes un kas pārvieto, kopē, atzīmē kā izlasītu, ar zvaigznīti vai tagu." + "apply_existing_unsupported": "Esošajām vēstulēm var piemērot tikai noteikumus, kas pārbauda sūtītāju, adresātus, tematu vai galvenes un kas pārvieto, kopē, atzīmē kā izlasītu, ar zvaigznīti vai tagu.", + "period_title": "Datumu diapazons", + "period_start": "Sākuma datums", + "period_end": "Beigu datums", + "validation_period_order": "Beigu datumam jābūt vēlāk par sākuma datumu", + "period_toggle": "Aktīvs tikai noteiktā laika posmā", + "period_hint": "Attiecas uz ziņojumiem, kas pienāk šajā laika posmā. Atstājiet lauku tukšu, lai šo galu neierobežotu.", + "validation_period_empty": "Norādiet laika posma sākuma vai beigu datumu", + "validation_period_invalid": "Norādiet laika posmam derīgu datumu ar laiku vai atstājiet lauku tukšu", + "apply_existing_period": "Noteikums ar laika posmu darbojas tikai ar ziņojumiem, kas pienāk šajā posmā, tāpēc to nevar lietot esošajiem ziņojumiem.", + "period_status_scheduled": "ieplānots", + "period_status_active": "aktīvs", + "period_status_expired": "beidzies" }, "templates": { "title": "Vēstuļu veidnes", diff --git a/locales/mn/common.json b/locales/mn/common.json index 64d84078f..c5c6620bb 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Таниулбарыг устгаж чадсангүй: {error}", "identity_unauthorized": "Та энэ имэйл хаягаас илгээх эрхгүй", "identity_not_found": "Таниулбар олдсонгүй", - "vacation_saved": "Амралтын хариулагчийн тохиргоог хадгалсан", - "vacation_save_failed": "Амралтын хариулагчийн тохиргоог хадгалж чадсангүй", + "vacation_saved": "Эзгүй байх тохиргоог хадгалсан", + "vacation_save_failed": "Эзгүй байх тохиргоог хадгалж чадсангүй", "filters_saved": "Шүүлтүүрийг амжилттай хадгаллаа", "filters_save_failed": "Шүүлтүүрийг хадгалж чадсангүй", "filters_deleted": "Шүүлтүүрийн дүрмийг устгасан", @@ -857,7 +857,8 @@ "rule_undone": "Дүрмийн өөрчлөлтийг буцаалаа", "rule_undo_failed": "Дүрмийн өөрчлөлтийг буцааж чадсангүй", "rule_undo_conflict": "Үүнээс хойш таны шүүлтүүр өөрчлөгдсөн. Өөрчлөлтийг шүүлтүүрийн тохиргооноос буцаана уу.", - "rule_folder_failed": "Хавтас үүсгэж чадсангүй" + "rule_folder_failed": "Хавтас үүсгэж чадсангүй", + "vacation_filters_save_failed": "Эзгүй байх тохиргоог хадгалсан боловч дамжуулалт болон хариу хүлээн авагчдыг хадгалж чадсангүй" }, "date": { "today": "Өнөөдөр", @@ -945,7 +946,7 @@ "privacy": "Нууцлал ба аюулгүй байдал", "account": " Данс", "identities": "Тэмдэгтүүд", - "vacation": "Амралтын хариулагч", + "vacation": "Эзгүй байх", "advanced": "Дэвшилтэт", "calendar": "Хуанли", "filters": "Шүүлтүүр", @@ -1824,28 +1825,24 @@ } }, "vacation": { - "title": "Амралтын хариулагч", - "description": "Таныг эзгүй байхад ирж буй имэйлд автоматаар хариу бичих", + "title": "Эзгүй байх", + "description": "Таныг эзгүй байхад ирж буй имэйлийг автоматаар боловсруулах", "loading": "Амралтын тохиргоог ачаалж байна...", "not_supported": "Таны мэйл сервер амралтын хариуг дэмждэггүй.", "fetch_error": "Амралтын тохиргоог ачаалж чадсангүй. Дахин оролдоно уу.", "status": { - "label": "Амралтын хариулагч", + "label": "Автоматаар хариулах", "description": "Тан руу имэйл илгээсэн хүмүүст автоматаар хариу илгээх", "active": "Идэвхтэй", "inactive": "Идэвхгүй" }, "date_range": { - "title": "Огнооны муж", - "description": "Заавал автомат хариуг тодорхой хугацаанд хязгаарлах", "start": "Эхлэх огноо", "start_description": "Эхлэх хязгааргүй хоосон орхино", "end": "Төгсгөлийн огноо", "end_description": "Төгсгөлгүй хоосон орхино" }, "message": { - "title": "Автоматаар хариулах мессеж", - "description": "Хариулт хэлбэрээр илгээх мессеж", "subject_label": "Сэдэв", "subject_description": "Автомат хариултын сэдвийн мөр", "subject_placeholder": "Оффисоос гадуур", @@ -1865,10 +1862,32 @@ "saving": "Хадгалж байна...", "warnings": { "end_before_start": "Төгсгөлийн огноо нь эхлэх огнооноос хойш байх ёстой", + "invalid_date": "Цагтай хүчинтэй эхлэх болон дуусах огноо оруулах эсвэл талбарыг хоосон үлдээнэ үү", "start_in_past": "Эхлэх огноо өнгөрсөн байна", "empty_body": "Зурвасны хэсэг хоосон байна - хүлээн авагчид хоосон хариу ирэх болно", "subject_too_long": "Гарчиг сервертэй харьцуулахад хэт урт байна (дээд тал нь {max} байт).", - "body_too_long": "Захидал сервертэй харьцуулахад хэт урт байна (форматыг оруулаад дээд тал нь {max} байт)." + "body_too_long": "Захидал сервертэй харьцуулахад хэт урт байна (форматыг оруулаад дээд тал нь {max} байт).", + "forward_address": "Дамжуулах хүчинтэй хаяг оруулна уу", + "not_running": "Дамжуулалт эсвэл хариу хүлээн авагчид хадгалагдсан ч одоогоор идэвхгүй байна. Дахин идэвхжүүлэхийн тулд хадгална уу." + }, + "forward": { + "title": "Дамжуулалт", + "description": "Ирсэн мессежийг автомат хариутай эсвэл хариугүйгээр дээрх хугацаанд өөр хаяг руу дамжуулна (дуусах огноогүй бол унтраах хүртэл). Спамыг дамжуулахгүй.", + "enabled_label": "Мессеж дамжуулах", + "to_label": "Дамжуулах хаяг", + "to_placeholder": "email@example.com", + "keep_label": "Ирсэн имэйлд хадгалах", + "keep_description": "Эс бөгөөс мессежийг зөвхөн дамжуулна: энэ шуудангийн хайрцагт үлдэхгүй бөгөөд ямар ч шүүлтүүрийн дүрэм ажиллахгүй." + }, + "audience": { + "label": "Хариу илгээх", + "description": "Дотоод илгээгчид: {domains}", + "all": "Бүх илгээгчид", + "internal": "Зөвхөн дотоод илгээгчид", + "external": "Зөвхөн гадаад илгээгчид" + }, + "auto_reply": { + "title": "Автомат хариу" } }, "folders": { @@ -2236,7 +2255,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Энэ хавтсанд байгаа захиануудад ч мөн хэрэглэх", - "apply_existing_unsupported": "Илгээгч, хүлээн авагч, гарчиг эсвэл толгойг шалгаж, зөөх, хуулах, уншсан болгох, од тавих эсвэл таг нэмэх дүрмүүдийг л байгаа захиануудад хэрэглэж болно." + "apply_existing_unsupported": "Илгээгч, хүлээн авагч, гарчиг эсвэл толгойг шалгаж, зөөх, хуулах, уншсан болгох, од тавих эсвэл таг нэмэх дүрмүүдийг л байгаа захиануудад хэрэглэж болно.", + "period_title": "Огнооны муж", + "period_start": "Эхлэх огноо", + "period_end": "Төгсгөлийн огноо", + "validation_period_order": "Төгсгөлийн огноо нь эхлэх огнооноос хойш байх ёстой", + "period_toggle": "Зөвхөн тодорхой хугацаанд идэвхтэй", + "period_hint": "Энэ хугацаанд ирсэн мессежид хамаарна. Аль нэг талыг хязгаарлахгүй бол талбарыг хоосон үлдээнэ үү.", + "validation_period_empty": "Хугацааны эхлэх эсвэл төгсгөлийн огноог оруулна уу", + "validation_period_invalid": "Хугацаанд цагтай хүчинтэй огноо оруулах эсвэл талбарыг хоосон үлдээнэ үү", + "apply_existing_period": "Хугацаатай дүрэм зөвхөн тэр хугацаанд ирсэн мессежид үйлчилдэг тул одоо байгаа мессежид хэрэглэх боломжгүй.", + "period_status_scheduled": "төлөвлөсөн", + "period_status_active": "идэвхтэй", + "period_status_expired": "хугацаа дууссан" }, "templates": { "title": "И-мэйл загварууд", diff --git a/locales/nb/common.json b/locales/nb/common.json index efe424d03..545296942 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Kunne ikke slette identitet: {error}", "identity_unauthorized": "Du er ikke autorisert til å sende fra denne e-postadressen", "identity_not_found": "Finner ikke identiteten", - "vacation_saved": "Innstillinger for feriesvar er lagret", - "vacation_save_failed": "Kunne ikke lagre innstillingene for feriesvar", + "vacation_saved": "Fraværsinnstillinger er lagret", + "vacation_save_failed": "Kunne ikke lagre fraværsinnstillingene", "filters_saved": "Filtrene er lagret", "filters_save_failed": "Kunne ikke lagre filtrene", "filters_deleted": "Filterregelen er slettet", @@ -857,7 +857,8 @@ "rule_undone": "Regelendringen er angret", "rule_undo_failed": "Kunne ikke angre regelendringen", "rule_undo_conflict": "Filtrene dine er endret siden. Angre endringen i filterinnstillingene.", - "rule_folder_failed": "Kunne ikke opprette mappen" + "rule_folder_failed": "Kunne ikke opprette mappen", + "vacation_filters_save_failed": "Fraværsinnstillinger er lagret, men videresending og svarmottakere kunne ikke lagres" }, "date": { "today": "I dag", @@ -945,7 +946,7 @@ "privacy": "Personvern og sikkerhet", "account": "Konto", "identities": "Identiteter", - "vacation": "Feriesvar", + "vacation": "Fravær", "advanced": "Avansert", "calendar": "Kalender", "filters": "Filtre", @@ -1824,28 +1825,24 @@ } }, "vacation": { - "title": "Feriesvar", - "description": "Svar automatisk på innkommende e-poster mens du er borte", + "title": "Fravær", + "description": "Håndter innkommende e-poster automatisk mens du er borte", "loading": "Laster inn innstillinger for feriesvar...", "not_supported": "E-postserveren din støtter ikke feriesvar.", "fetch_error": "Kunne ikke laste inn innstillingene for feriesvar. Prøv igjen.", "status": { - "label": "Feriesvar", + "label": "Svar automatisk", "description": "Send et automatisk svar til folk som sender deg e-post", "active": "Aktiv", "inactive": "Inaktiv" }, "date_range": { - "title": "Datoperiode", - "description": "Begrens om ønskelig autosvaret til en bestemt periode", "start": "Startdato", "start_description": "La feltet stå tomt hvis det ikke skal være noen startdato", "end": "Sluttdato", "end_description": "La feltet stå tomt hvis det ikke skal være noen sluttdato" }, "message": { - "title": "Melding for automatisk svar", - "description": "Meldingen som sendes som svar", "subject_label": "Emne", "subject_description": "Emnelinje for autosvaret", "subject_placeholder": "Ikke på kontoret", @@ -1865,10 +1862,32 @@ "saving": "Lagrer...", "warnings": { "end_before_start": "Sluttdatoen må være etter startdatoen", + "invalid_date": "Angi en gyldig start- og sluttdato med klokkeslett, eller la feltet stå tomt", "start_in_past": "Startdatoen er i fortiden", "empty_body": "Meldingsteksten er tom – mottakerne får et tomt svar", "subject_too_long": "Emnet er for langt for serveren (maks. {max} byte).", - "body_too_long": "Meldingen er for lang for serveren (maks. {max} byte inkludert formatering)." + "body_too_long": "Meldingen er for lang for serveren (maks. {max} byte inkludert formatering).", + "forward_address": "Angi en gyldig adresse å videresende til", + "not_running": "Videresending eller svarmottakere er lagret, men er ikke aktive akkurat nå. Lagre for å slå dem på igjen." + }, + "forward": { + "title": "Videresending", + "description": "Videresender innkommende meldinger til en annen adresse, med eller uten feriesvar, i perioden ovenfor (uten sluttdato, til den slås av). Søppelpost videresendes ikke.", + "enabled_label": "Videresend meldinger", + "to_label": "Videresend til", + "to_placeholder": "email@example.com", + "keep_label": "Behold i innboksen", + "keep_description": "Ellers blir meldingen bare videresendt: Den blir ikke liggende i denne postkassen, og ingen filterregel kjøres på den." + }, + "audience": { + "label": "Svar til", + "description": "Interne avsendere: {domains}", + "all": "Alle avsendere", + "internal": "Bare interne avsendere", + "external": "Bare eksterne avsendere" + }, + "auto_reply": { + "title": "Automatisk svar" } }, "folders": { @@ -2236,7 +2255,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Bruk også på meldinger som allerede ligger i denne mappen", - "apply_existing_unsupported": "Bare regler som sjekker avsender, mottakere, emne eller hoder, og som flytter, kopierer, merker som lest, stjernemerker eller setter etikett, kan brukes på eksisterende meldinger." + "apply_existing_unsupported": "Bare regler som sjekker avsender, mottakere, emne eller hoder, og som flytter, kopierer, merker som lest, stjernemerker eller setter etikett, kan brukes på eksisterende meldinger.", + "period_title": "Datoperiode", + "period_start": "Startdato", + "period_end": "Sluttdato", + "validation_period_order": "Sluttdatoen må være etter startdatoen", + "period_toggle": "Bare aktiv i en periode", + "period_hint": "Gjelder meldinger som kommer i denne perioden. La et felt stå tomt for å holde den enden åpen.", + "validation_period_empty": "Angi en start- eller sluttdato for perioden", + "validation_period_invalid": "Angi en gyldig dato med klokkeslett for perioden, eller la feltet stå tomt", + "apply_existing_period": "En regel med periode virker bare på meldinger som kommer i perioden, og kan derfor ikke brukes på eksisterende meldinger.", + "period_status_scheduled": "planlagt", + "period_status_active": "aktiv", + "period_status_expired": "utløpt" }, "templates": { "title": "E-postmaler", diff --git a/locales/nl/common.json b/locales/nl/common.json index 1591de8e2..0cf12de9a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -857,7 +857,8 @@ "rule_undone": "Regelwijziging ongedaan gemaakt", "rule_undo_failed": "De regelwijziging kon niet ongedaan worden gemaakt", "rule_undo_conflict": "Je filters zijn intussen gewijzigd. Maak de wijziging ongedaan in de filterinstellingen.", - "rule_folder_failed": "De map kon niet worden gemaakt" + "rule_folder_failed": "De map kon niet worden gemaakt", + "vacation_filters_save_failed": "Afwezigheidsinstellingen opgeslagen, maar het doorsturen en de ontvangers van het antwoord konden niet worden opgeslagen" }, "date": { "today": "Vandaag", @@ -942,7 +943,7 @@ "privacy": "Privacy & Beveiliging", "account": "Account", "identities": "Identiteiten", - "vacation": "Afwezigheidsmelder", + "vacation": "Afwezigheid", "advanced": "Geavanceerd", "calendar": "Agenda", "filters": "Filters", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "Afwezigheidsmelder", - "description": "Automatisch antwoorden op inkomende e-mails terwijl je afwezig bent", + "title": "Afwezigheid", + "description": "Inkomende e-mails automatisch afhandelen terwijl je afwezig bent", "loading": "Afwezigheidsinstellingen laden...", "not_supported": "Je mailserver ondersteunt geen afwezigheidsantwoorden.", "fetch_error": "Kan afwezigheidsinstellingen niet laden. Probeer het opnieuw.", "status": { - "label": "Afwezigheidsmelder", + "label": "Automatisch antwoorden", "description": "Stuur een automatisch antwoord naar mensen die je mailen", "active": "Actief", "inactive": "Inactief" }, "date_range": { - "title": "Periode", - "description": "Beperk optioneel het automatische antwoord tot een specifieke periode", "start": "Startdatum", "start_description": "Leeg laten voor geen startlimiet", "end": "Einddatum", "end_description": "Leeg laten voor geen eindlimiet" }, "message": { - "title": "Automatisch antwoordbericht", - "description": "Het bericht dat als antwoord wordt verzonden", "subject_label": "Onderwerp", "subject_description": "Onderwerpregel van het automatische antwoord", "subject_placeholder": "Afwezigheidsbericht", @@ -1858,10 +1855,32 @@ "saving": "Opslaan...", "warnings": { "end_before_start": "De einddatum moet na de startdatum liggen", + "invalid_date": "Vul een geldige start- en einddatum met tijd in, of laat het veld leeg", "start_in_past": "De startdatum ligt in het verleden", "empty_body": "De berichttekst is leeg - ontvangers krijgen een leeg antwoord", "subject_too_long": "Het onderwerp is te lang voor de server (maximaal {max} bytes).", - "body_too_long": "Het bericht is te lang voor de server (maximaal {max} bytes, inclusief opmaak)." + "body_too_long": "Het bericht is te lang voor de server (maximaal {max} bytes, inclusief opmaak).", + "forward_address": "Vul een geldig adres in om naar door te sturen", + "not_running": "Doorsturen of de ontvangers van het antwoord zijn opgeslagen, maar nu niet actief. Sla op om ze weer in te schakelen." + }, + "forward": { + "title": "Doorsturen", + "description": "Stuurt binnenkomende berichten door naar een ander adres, met of zonder automatisch antwoord, binnen de periode hierboven (zonder einddatum, tot het wordt uitgeschakeld). Spam wordt niet doorgestuurd.", + "enabled_label": "Berichten doorsturen", + "to_label": "Doorsturen naar", + "to_placeholder": "email@voorbeeld.nl", + "keep_label": "In postvak IN houden", + "keep_description": "Anders wordt het bericht alleen doorgestuurd: het blijft niet in deze mailbox en er wordt geen filterregel op toegepast." + }, + "audience": { + "label": "Antwoorden aan", + "description": "Interne afzenders: {domains}", + "all": "Alle afzenders", + "internal": "Alleen interne afzenders", + "external": "Alleen externe afzenders" + }, + "auto_reply": { + "title": "Automatisch antwoord" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Ook toepassen op berichten die al in deze map staan", - "apply_existing_unsupported": "Alleen regels die de afzender, ontvangers, het onderwerp of headers controleren, en die verplaatsen, kopiëren, als gelezen markeren, een ster geven of labelen, kunnen op bestaande berichten worden toegepast." + "apply_existing_unsupported": "Alleen regels die de afzender, ontvangers, het onderwerp of headers controleren, en die verplaatsen, kopiëren, als gelezen markeren, een ster geven of labelen, kunnen op bestaande berichten worden toegepast.", + "period_title": "Periode", + "period_start": "Startdatum", + "period_end": "Einddatum", + "validation_period_order": "De einddatum moet na de startdatum liggen", + "period_toggle": "Alleen actief in een periode", + "period_hint": "Geldt voor berichten die in deze periode binnenkomen. Laat een veld leeg om die kant open te laten.", + "validation_period_empty": "Vul een start- of einddatum voor de periode in", + "validation_period_invalid": "Vul voor de periode een geldige datum met tijd in, of laat het veld leeg", + "apply_existing_period": "Een regel met een periode werkt alleen op berichten die binnen die periode binnenkomen en kan dus niet op bestaande berichten worden toegepast.", + "period_status_scheduled": "gepland", + "period_status_active": "actief", + "period_status_expired": "verlopen" }, "templates": { "title": "E-mailsjablonen", diff --git a/locales/pl/common.json b/locales/pl/common.json index c4e62bdff..8fd96cdb1 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Nie udało się usunąć tożsamości: {error}", "identity_unauthorized": "Nie masz uprawnień do wysyłania z tego adresu e-mail", "identity_not_found": "Nie znaleziono tożsamości", - "vacation_saved": "Ustawienia autorespondera wakacyjnego zapisane", - "vacation_save_failed": "Nie udało się zapisać ustawień autorespondera wakacyjnego", + "vacation_saved": "Ustawienia nieobecności zapisane", + "vacation_save_failed": "Nie udało się zapisać ustawień nieobecności", "filters_saved": "Filtry zapisane pomyślnie", "filters_save_failed": "Nie udało się zapisać filtrów", "filters_deleted": "Reguła filtra usunięta", @@ -857,7 +857,8 @@ "rule_undone": "Cofnięto zmianę reguły", "rule_undo_failed": "Nie udało się cofnąć zmiany reguły", "rule_undo_conflict": "Twoje filtry zmieniły się w międzyczasie. Cofnij zmianę w ustawieniach filtrów.", - "rule_folder_failed": "Nie udało się utworzyć folderu" + "rule_folder_failed": "Nie udało się utworzyć folderu", + "vacation_filters_save_failed": "Ustawienia nieobecności zapisane, ale nie udało się zapisać przekazywania ani odbiorców odpowiedzi" }, "date": { "today": "Dzisiaj", @@ -942,7 +943,7 @@ "privacy": "Prywatność i bezpieczeństwo", "account": "Konto", "identities": "Tożsamości", - "vacation": "Autoresponder urlopowy", + "vacation": "Nieobecność", "advanced": "Zaawansowane", "calendar": "Kalendarz", "filters": "Filtry", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "Autoresponder urlopowy", - "description": "Automatycznie odpowiadaj na przychodzące wiadomości e-mail podczas nieobecności", + "title": "Nieobecność", + "description": "Automatycznie obsługuj przychodzące wiadomości e-mail podczas nieobecności", "loading": "Ładowanie ustawień autorespondera urlopowego...", "not_supported": "Twój serwer pocztowy nie obsługuje odpowiedzi urlopowych.", "fetch_error": "Nie udało się załadować ustawień urlopowych. Spróbuj ponownie.", "status": { - "label": "Autoresponder urlopowy", + "label": "Odpowiadaj automatycznie", "description": "Wysyłaj automatyczną odpowiedź do osób, które do Ciebie piszą", "active": "Aktywny", "inactive": "Nieaktywny" }, "date_range": { - "title": "Zakres dat", - "description": "Opcjonalnie ogranicz automatyczną odpowiedź do określonego okresu", "start": "Data rozpoczęcia", "start_description": "Pozostaw puste, aby nie ustawiać daty początkowej", "end": "Data zakończenia", "end_description": "Pozostaw puste, aby nie ustawiać daty końcowej" }, "message": { - "title": "Wiadomość automatycznej odpowiedzi", - "description": "Wiadomość, która zostanie wysłana jako odpowiedź", "subject_label": "Temat", "subject_description": "Wiersz tematu automatycznej odpowiedzi", "subject_placeholder": "Poza biurem", @@ -1858,10 +1855,32 @@ "saving": "Zapisywanie...", "warnings": { "end_before_start": "Data zakończenia musi być późniejsza niż data rozpoczęcia", + "invalid_date": "Podaj prawidłową datę rozpoczęcia i zakończenia z godziną albo pozostaw pole puste", "start_in_past": "Data rozpoczęcia jest w przeszłości", "empty_body": "Treść wiadomości jest pusta - odbiorcy otrzymają pustą odpowiedź", "subject_too_long": "Temat jest za długi dla serwera (maksymalnie {max} bajtów).", - "body_too_long": "Wiadomość jest za długa dla serwera (maksymalnie {max} bajtów wraz z formatowaniem)." + "body_too_long": "Wiadomość jest za długa dla serwera (maksymalnie {max} bajtów wraz z formatowaniem).", + "forward_address": "Podaj prawidłowy adres do przekazywania", + "not_running": "Przekazywanie lub odbiorcy odpowiedzi są zapisani, ale obecnie nieaktywni. Zapisz, aby ponownie je włączyć." + }, + "forward": { + "title": "Przekazywanie", + "description": "Przekazuje wiadomości przychodzące na inny adres, z automatyczną odpowiedzią lub bez niej, w okresie podanym powyżej (bez daty zakończenia, dopóki nie zostanie wyłączone). Spam nie jest przekazywany.", + "enabled_label": "Przekazuj wiadomości", + "to_label": "Prześlij dalej do", + "to_placeholder": "email@example.com", + "keep_label": "Pozostaw w Odebranych", + "keep_description": "W przeciwnym razie wiadomość jest tylko przekazywana: nie zostaje w tej skrzynce i nie działa na nią żadna reguła filtrowania." + }, + "audience": { + "label": "Odpowiadaj", + "description": "Nadawcy wewnętrzni: {domains}", + "all": "Wszystkim nadawcom", + "internal": "Tylko nadawcom wewnętrznym", + "external": "Tylko nadawcom zewnętrznym" + }, + "auto_reply": { + "title": "Automatyczna odpowiedź" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Zastosuj też do wiadomości, które już są w tym folderze", - "apply_existing_unsupported": "Do istniejących wiadomości można zastosować tylko reguły, które sprawdzają nadawcę, odbiorców, temat lub nagłówki i które przenoszą, kopiują, oznaczają jako przeczytane, oznaczają gwiazdką lub etykietą." + "apply_existing_unsupported": "Do istniejących wiadomości można zastosować tylko reguły, które sprawdzają nadawcę, odbiorców, temat lub nagłówki i które przenoszą, kopiują, oznaczają jako przeczytane, oznaczają gwiazdką lub etykietą.", + "period_title": "Zakres dat", + "period_start": "Data rozpoczęcia", + "period_end": "Data zakończenia", + "validation_period_order": "Data zakończenia musi być późniejsza niż data rozpoczęcia", + "period_toggle": "Aktywna tylko w wybranym okresie", + "period_hint": "Dotyczy wiadomości, które przychodzą w tym okresie. Pozostaw pole puste, aby nie ograniczać tej strony.", + "validation_period_empty": "Podaj datę rozpoczęcia lub zakończenia okresu", + "validation_period_invalid": "Podaj prawidłową datę z godziną dla okresu albo pozostaw pole puste", + "apply_existing_period": "Reguła z okresem działa tylko na wiadomości, które przychodzą w tym okresie, dlatego nie można jej zastosować do istniejących wiadomości.", + "period_status_scheduled": "zaplanowana", + "period_status_active": "aktywna", + "period_status_expired": "wygasła" }, "templates": { "title": "Szablony wiadomości e-mail", diff --git a/locales/pt/common.json b/locales/pt/common.json index 714bedf57..fb51072ae 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Falha ao excluir identidade: {error}", "identity_unauthorized": "Você não está autorizado a enviar deste endereço de e-mail", "identity_not_found": "Identidade não encontrada", - "vacation_saved": "Configurações de resposta automática salvas", - "vacation_save_failed": "Falha ao salvar configurações de resposta automática", + "vacation_saved": "Configurações de ausência salvas", + "vacation_save_failed": "Falha ao salvar configurações de ausência", "filters_saved": "Filtros salvos com sucesso", "filters_save_failed": "Falha ao salvar os filtros", "filters_deleted": "Regra de filtragem eliminada", @@ -857,7 +857,8 @@ "rule_undone": "Alteração da regra desfeita", "rule_undo_failed": "Não foi possível desfazer a alteração da regra", "rule_undo_conflict": "Seus filtros mudaram desde então. Desfaça a alteração nas configurações de filtros.", - "rule_folder_failed": "Não foi possível criar a pasta" + "rule_folder_failed": "Não foi possível criar a pasta", + "vacation_filters_save_failed": "Configurações de ausência salvas, mas não foi possível salvar o encaminhamento e os destinatários da resposta" }, "date": { "today": "Hoje", @@ -942,7 +943,7 @@ "privacy": "Privacidade e Segurança", "account": "Conta", "identities": "Identidades", - "vacation": "Resposta automática", + "vacation": "Ausência", "advanced": "Avançado", "calendar": "Calendário", "filters": "Filtros", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "Resposta automática", - "description": "Responder automaticamente a e-mails recebidos enquanto estiver ausente", + "title": "Ausência", + "description": "Tratar automaticamente os e-mails recebidos enquanto estiver ausente", "loading": "Carregando configurações de resposta automática...", "not_supported": "Seu servidor de e-mail não suporta respostas automáticas de ausência.", "fetch_error": "Falha ao carregar as configurações de resposta automática. Tente novamente.", "status": { - "label": "Resposta automática", + "label": "Responder automaticamente", "description": "Enviar uma resposta automática para quem lhe enviar um e-mail", "active": "Ativo", "inactive": "Inativo" }, "date_range": { - "title": "Período", - "description": "Opcionalmente limitar a resposta automática a um período específico", "start": "Data de início", "start_description": "Deixe vazio para sem limite de início", "end": "Data de fim", "end_description": "Deixe vazio para sem limite de fim" }, "message": { - "title": "Mensagem de resposta automática", - "description": "A mensagem que será enviada como resposta", "subject_label": "Assunto", "subject_description": "Linha de assunto da resposta automática", "subject_placeholder": "Fora do escritório", @@ -1858,10 +1855,32 @@ "saving": "Salvando...", "warnings": { "end_before_start": "A data de fim deve ser posterior à data de início", + "invalid_date": "Indique uma data de início e de fim válidas com hora ou deixe o campo vazio", "start_in_past": "A data de início está no passado", "empty_body": "O corpo da mensagem está vazio - os destinatários receberão uma resposta em branco", "subject_too_long": "O assunto é demasiado longo para o servidor (no máximo {max} bytes).", - "body_too_long": "A mensagem é demasiado longa para o servidor (no máximo {max} bytes, incluindo a formatação)." + "body_too_long": "A mensagem é demasiado longa para o servidor (no máximo {max} bytes, incluindo a formatação).", + "forward_address": "Informe um endereço válido para o encaminhamento", + "not_running": "O encaminhamento ou os destinatários da resposta estão salvos, mas não estão ativos agora. Salve para reativá-los." + }, + "forward": { + "title": "Encaminhamento", + "description": "Encaminha as mensagens recebidas para outro endereço, com ou sem resposta automática, dentro do período acima (sem data de término, até ser desativado). Spam não é encaminhado.", + "enabled_label": "Encaminhar mensagens", + "to_label": "Encaminhar para", + "to_placeholder": "email@exemplo.com", + "keep_label": "Manter na caixa de entrada", + "keep_description": "Caso contrário, a mensagem é apenas encaminhada: ela não fica nesta caixa de correio e nenhuma regra de filtro é aplicada a ela." + }, + "audience": { + "label": "Responder a", + "description": "Remetentes internos: {domains}", + "all": "Todos os remetentes", + "internal": "Somente remetentes internos", + "external": "Somente remetentes externos" + }, + "auto_reply": { + "title": "Resposta automática" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Aplicar também às mensagens que já estão nesta pasta", - "apply_existing_unsupported": "Só regras que verificam o remetente, os destinatários, o assunto ou os cabeçalhos, e que movem, copiam, marcam como lida, marcam com estrela ou etiquetam, podem ser aplicadas a mensagens existentes." + "apply_existing_unsupported": "Só regras que verificam o remetente, os destinatários, o assunto ou os cabeçalhos, e que movem, copiam, marcam como lida, marcam com estrela ou etiquetam, podem ser aplicadas a mensagens existentes.", + "period_title": "Período", + "period_start": "Data de início", + "period_end": "Data de fim", + "validation_period_order": "A data de fim deve ser posterior à data de início", + "period_toggle": "Ativa apenas durante um período", + "period_hint": "Aplica-se às mensagens que chegam durante este período. Deixe um campo vazio para não limitar esse extremo.", + "validation_period_empty": "Indique uma data de início ou de fim para o período", + "validation_period_invalid": "Indique uma data válida com hora para o período ou deixe o campo vazio", + "apply_existing_period": "Uma regra com período só atua sobre as mensagens que chegam dentro dele, por isso não pode ser aplicada às mensagens existentes.", + "period_status_scheduled": "agendada", + "period_status_active": "ativa", + "period_status_expired": "expirada" }, "templates": { "title": "Modelos de e-mail", diff --git a/locales/ro/common.json b/locales/ro/common.json index 3524683e6..e714bdb03 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Nu s-a putut șterge identitatea: {error}", "identity_unauthorized": "Nu aveți autorizație să trimiteți mesaje de la această adresă de e-mail", "identity_not_found": "Identitate neidentificată", - "vacation_saved": "Setările răspunsului automat de vacanță au fost salvate", - "vacation_save_failed": "Nu s-au putut salva setările răspunsului automat de concediu", + "vacation_saved": "Setările de absență au fost salvate", + "vacation_save_failed": "Nu s-au putut salva setările de absență", "filters_saved": "Filtrele au fost salvate cu succes", "filters_save_failed": "Nu s-au putut salva filtrele", "filters_deleted": "Regula de filtrare a fost ștersă", @@ -857,7 +857,8 @@ "rule_undone": "Modificarea regulii a fost anulată", "rule_undo_failed": "Modificarea regulii nu a putut fi anulată", "rule_undo_conflict": "Filtrele dvs. s-au schimbat între timp. Anulați modificarea din setările filtrelor.", - "rule_folder_failed": "Dosarul nu a putut fi creat" + "rule_folder_failed": "Dosarul nu a putut fi creat", + "vacation_filters_save_failed": "Setările de absență au fost salvate, dar redirecționarea și destinatarii răspunsului nu au putut fi salvați" }, "date": { "today": "Astăzi", @@ -945,7 +946,7 @@ "privacy": "Confidențialitate și securitate", "account": "Cont", "identities": "Identități", - "vacation": "Răspuns automat de vacanță", + "vacation": "Absență", "advanced": "Nivel avansat", "calendar": "Calendar", "filters": "Filtre", @@ -1824,28 +1825,24 @@ } }, "vacation": { - "title": "Răspuns automat de vacanță", - "description": "Răspundeți automat la e-mailurile primite în timp ce sunteți plecat", + "title": "Absență", + "description": "Gestionați automat e-mailurile primite în timp ce sunteți plecat", "loading": "Se încarcă setările de concediu...", "not_supported": "Serverul dvs. de e-mail nu acceptă răspunsurile de vacanță.", "fetch_error": "Nu s-au putut încărca setările de concediu. Vă rugăm să încercați din nou.", "status": { - "label": "Răspuns automat de vacanță", + "label": "Răspunde automat", "description": "Trimiteți un răspuns automat persoanelor care vă trimit e-mailuri", "active": "Activ", "inactive": "Inactiv" }, "date_range": { - "title": "Interval de date", - "description": "Opțional, limitați răspunsul automat la o anumită perioadă", "start": "Data de începere", "start_description": "Lăsați necompletat dacă nu există o limită inițială", "end": "Data de încheiere", "end_description": "Lăsați necompletat dacă nu există o limită de sfârșit" }, "message": { - "title": "Mesaj de răspuns automat", - "description": "Mesajul care va fi trimis ca răspuns", "subject_label": "Subiect", "subject_description": "Subiectul răspunsului automat", "subject_placeholder": "Absent de la birou", @@ -1865,10 +1862,32 @@ "saving": "Se salvează...", "warnings": { "end_before_start": "Data de încheiere trebuie să fie ulterioară datei de începere", + "invalid_date": "Introduceți o dată de început și de sfârșit validă, cu oră, sau lăsați câmpul gol", "start_in_past": "Data de începere este în trecut", "empty_body": "Corpul mesajului este gol – destinatarii vor primi un răspuns gol", "subject_too_long": "Subiectul este prea lung pentru server (cel mult {max} octeți).", - "body_too_long": "Mesajul este prea lung pentru server (cel mult {max} octeți, inclusiv formatarea)." + "body_too_long": "Mesajul este prea lung pentru server (cel mult {max} octeți, inclusiv formatarea).", + "forward_address": "Introduceți o adresă validă pentru redirecționare", + "not_running": "Redirecționarea sau destinatarii răspunsului sunt salvați, dar momentan nu sunt activi. Salvați pentru a-i reactiva." + }, + "forward": { + "title": "Redirecționare", + "description": "Redirecționează mesajele primite către o altă adresă, cu sau fără răspuns automat, în perioada de mai sus (fără dată de încheiere, până când este dezactivată). Spamul nu este redirecționat.", + "enabled_label": "Redirecționează mesajele", + "to_label": "Redirecționează către", + "to_placeholder": "email@example.com", + "keep_label": "Păstrează în căsuța de primire", + "keep_description": "Altfel, mesajul este doar redirecționat: nu rămâne în această căsuță poștală și nu i se aplică nicio regulă de filtrare." + }, + "audience": { + "label": "Răspunde la", + "description": "Expeditori interni: {domains}", + "all": "Toți expeditorii", + "internal": "Doar expeditorii interni", + "external": "Doar expeditorii externi" + }, + "auto_reply": { + "title": "Răspuns automat" } }, "folders": { @@ -2236,7 +2255,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Aplicați și mesajelor care sunt deja în acest dosar", - "apply_existing_unsupported": "Doar regulile care verifică expeditorul, destinatarii, subiectul sau antetele și care mută, copiază, marchează ca citit, marchează cu stea sau etichetează pot fi aplicate mesajelor existente." + "apply_existing_unsupported": "Doar regulile care verifică expeditorul, destinatarii, subiectul sau antetele și care mută, copiază, marchează ca citit, marchează cu stea sau etichetează pot fi aplicate mesajelor existente.", + "period_title": "Interval de date", + "period_start": "Data de începere", + "period_end": "Data de încheiere", + "validation_period_order": "Data de încheiere trebuie să fie ulterioară datei de începere", + "period_toggle": "Activă doar într-o anumită perioadă", + "period_hint": "Se aplică mesajelor care sosesc în această perioadă. Lăsați un câmp gol pentru a nu limita acel capăt.", + "validation_period_empty": "Introduceți o dată de începere sau de încheiere pentru perioadă", + "validation_period_invalid": "Introduceți o dată validă cu oră pentru perioadă sau lăsați câmpul gol", + "apply_existing_period": "O regulă cu perioadă acționează doar asupra mesajelor care sosesc în acea perioadă, deci nu poate fi aplicată mesajelor existente.", + "period_status_scheduled": "programată", + "period_status_active": "activă", + "period_status_expired": "expirată" }, "templates": { "title": "Șabloane de e-mail", diff --git a/locales/ru/common.json b/locales/ru/common.json index 33e259245..9268e5601 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Не удалось удалить удостоверение: {error}", "identity_unauthorized": "У вас нет прав для отправки с этого email-адреса", "identity_not_found": "Удостоверение не найдено", - "vacation_saved": "Настройки автоответа на отпуск сохранены", - "vacation_save_failed": "Не удалось сохранить настройки автоответа на отпуск", + "vacation_saved": "Настройки отсутствия сохранены", + "vacation_save_failed": "Не удалось сохранить настройки отсутствия", "filters_saved": "Фильтры успешно сохранены", "filters_save_failed": "Не удалось сохранить фильтры", "filters_deleted": "Правило фильтра удалено", @@ -857,7 +857,8 @@ "rule_undone": "Изменение правила отменено", "rule_undo_failed": "Не удалось отменить изменение правила", "rule_undo_conflict": "Ваши фильтры с тех пор изменились. Отмените изменение в настройках фильтров.", - "rule_folder_failed": "Не удалось создать папку" + "rule_folder_failed": "Не удалось создать папку", + "vacation_filters_save_failed": "Настройки отсутствия сохранены, но пересылку и получателей ответа сохранить не удалось" }, "date": { "today": "Сегодня", @@ -942,7 +943,7 @@ "privacy": "Конфиденциальность и безопасность", "account": "Аккаунт", "identities": "Удостоверения", - "vacation": "Автоответ на отпуск", + "vacation": "Отсутствие", "advanced": "Дополнительно", "calendar": "Календарь", "filters": "Фильтры", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "Автоответ на отпуск", - "description": "Автоматически отвечать на входящие письма в ваше отсутствие", + "title": "Отсутствие", + "description": "Автоматически обрабатывать входящие письма в ваше отсутствие", "loading": "Загрузка настроек автоответа...", "not_supported": "Ваш почтовый сервер не поддерживает автоответы на отпуск.", "fetch_error": "Не удалось загрузить настройки автоответа. Повторите попытку.", "status": { - "label": "Автоответ на отпуск", + "label": "Отвечать автоматически", "description": "Отправлять автоматический ответ на входящие письма", "active": "Активен", "inactive": "Неактивен" }, "date_range": { - "title": "Диапазон дат", - "description": "Ограничьте автоответ определённым периодом (необязательно)", "start": "Дата начала", "start_description": "Оставьте пустым для без ограничения", "end": "Дата окончания", "end_description": "Оставьте пустым для без ограничения" }, "message": { - "title": "Сообщение автоответа", - "description": "Сообщение, которое будет отправляться в качестве ответа", "subject_label": "Тема", "subject_description": "Тема автоответа", "subject_placeholder": "Вне офиса", @@ -1858,10 +1855,32 @@ "saving": "Сохранение...", "warnings": { "end_before_start": "Дата окончания должна быть позже даты начала", + "invalid_date": "Укажите корректные даты начала и окончания со временем или оставьте поле пустым", "start_in_past": "Дата начала в прошлом", "empty_body": "Текст сообщения пуст - получатели получат пустой ответ", "subject_too_long": "Тема слишком длинная для сервера (не более {max} байт).", - "body_too_long": "Сообщение слишком длинное для сервера (не более {max} байт с учётом форматирования)." + "body_too_long": "Сообщение слишком длинное для сервера (не более {max} байт с учётом форматирования).", + "forward_address": "Укажите действительный адрес для пересылки", + "not_running": "Пересылка или получатели ответа сохранены, но сейчас не действуют. Сохраните, чтобы снова их включить." + }, + "forward": { + "title": "Пересылка", + "description": "Пересылает входящие письма на другой адрес, с автоответом или без него, в указанный выше период (без даты окончания, пока пересылку не выключат). Спам не пересылается.", + "enabled_label": "Пересылать письма", + "to_label": "Переслать на", + "to_placeholder": "user@пример.рф", + "keep_label": "Оставить во входящих", + "keep_description": "Иначе письмо только пересылается: оно не остаётся в этом почтовом ящике, и к нему не применяется ни одно правило фильтрации." + }, + "audience": { + "label": "Отвечать", + "description": "Внутренние отправители: {domains}", + "all": "Всем отправителям", + "internal": "Только внутренним отправителям", + "external": "Только внешним отправителям" + }, + "auto_reply": { + "title": "Автоответ" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Применить и к письмам, которые уже есть в этой папке", - "apply_existing_unsupported": "К имеющимся письмам можно применить только правила, которые проверяют отправителя, получателей, тему или заголовки и которые перемещают, копируют, отмечают прочитанными, помечают звёздочкой или тегом." + "apply_existing_unsupported": "К имеющимся письмам можно применить только правила, которые проверяют отправителя, получателей, тему или заголовки и которые перемещают, копируют, отмечают прочитанными, помечают звёздочкой или тегом.", + "period_title": "Диапазон дат", + "period_start": "Дата начала", + "period_end": "Дата окончания", + "validation_period_order": "Дата окончания должна быть позже даты начала", + "period_toggle": "Активно только в указанный период", + "period_hint": "Действует для писем, которые приходят в этот период. Оставьте поле пустым, чтобы не ограничивать эту границу.", + "validation_period_empty": "Укажите дату начала или окончания периода", + "validation_period_invalid": "Укажите для периода корректную дату со временем или оставьте поле пустым", + "apply_existing_period": "Правило с периодом действует только на письма, пришедшие в этот период, поэтому его нельзя применить к уже имеющимся письмам.", + "period_status_scheduled": "запланировано", + "period_status_active": "активно", + "period_status_expired": "истекло" }, "templates": { "title": "Шаблоны писем", diff --git a/locales/sk/common.json b/locales/sk/common.json index 433a233df..885eb9ec2 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Odstránenie identity zlyhalo: {error}", "identity_unauthorized": "Nemáte oprávnenie odosielať z tejto e-mailovej adresy", "identity_not_found": "Identita sa nenašla", - "vacation_saved": "Nastavenia automatickej odpovede boli uložené", - "vacation_save_failed": "Uloženie nastavenia automatickej odpovede zlyhalo", + "vacation_saved": "Nastavenia neprítomnosti boli uložené", + "vacation_save_failed": "Uloženie nastavení neprítomnosti zlyhalo", "filters_saved": "Filtre boli úspešne uložené", "filters_save_failed": "Uloženie filtrov zlyhalo", "filters_deleted": "Pravidlo filtra bolo odstránené", @@ -857,7 +857,8 @@ "rule_undone": "Zmena pravidla bola vrátená", "rule_undo_failed": "Zmenu pravidla sa nepodarilo vrátiť", "rule_undo_conflict": "Vaše filtre sa medzitým zmenili. Vráťte zmenu v nastaveniach filtrov.", - "rule_folder_failed": "Priečinok sa nepodarilo vytvoriť" + "rule_folder_failed": "Priečinok sa nepodarilo vytvoriť", + "vacation_filters_save_failed": "Nastavenia neprítomnosti boli uložené, ale preposielanie a príjemcov odpovede sa nepodarilo uložiť" }, "date": { "today": "Dnes", @@ -945,7 +946,7 @@ "privacy": "Súkromie a zabezpečenie", "account": "Účet", "identities": "Identity", - "vacation": "Automatická odpoveď", + "vacation": "Neprítomnosť", "advanced": "Pokročilé", "calendar": "Kalendár", "filters": "Filtre", @@ -1824,28 +1825,24 @@ } }, "vacation": { - "title": "Automatická odpoveď", - "description": "Automaticky odpovedať na prichádzajúce e-maily počas vašej neprítomnosti", + "title": "Neprítomnosť", + "description": "Automaticky spracovať prichádzajúce e-maily počas vašej neprítomnosti", "loading": "Načítavanie nastavení automatickej odpovede...", "not_supported": "Váš poštový server nepodporuje automatické odpovede v neprítomnosti.", "fetch_error": "Nepodarilo sa načítať nastavenia automatickej odpovede. Skúste to znova.", "status": { - "label": "Automatická odpoveď", + "label": "Odpovedať automaticky", "description": "Odosielať automatickú odpoveď ľuďom, ktorí vám napíšu", "active": "Aktívna", "inactive": "Neaktívna" }, "date_range": { - "title": "Časové obdobie", - "description": "Voliteľne obmedzte automatickú odpoveď na určité obdobie", "start": "Dátum začiatku", "start_description": "Nechajte prázdne pre okamžité spustenie", "end": "Dátum ukončenia", "end_description": "Nechajte prázdne pre beh bez ukončenia" }, "message": { - "title": "Správa automatickej odpovede", - "description": "Správa, ktorá bude odoslaná ako odpoveď", "subject_label": "Predmet", "subject_description": "Predmet automatickej odpovede", "subject_placeholder": "Mimo kancelárie", @@ -1865,10 +1862,32 @@ "saving": "Ukladanie...", "warnings": { "end_before_start": "Dátum ukončenia musí byť po dátume začiatku", + "invalid_date": "Zadajte platný dátum začiatku a konca aj s časom alebo pole nechajte prázdne", "start_in_past": "Dátum začiatku je v minulosti", "empty_body": "Telo správy je prázdne – príjemcovia dostanú prázdnu odpoveď", "subject_too_long": "Predmet je pre server príliš dlhý (najviac {max} bajtov).", - "body_too_long": "Správa je pre server príliš dlhá (najviac {max} bajtov vrátane formátovania)." + "body_too_long": "Správa je pre server príliš dlhá (najviac {max} bajtov vrátane formátovania).", + "forward_address": "Zadajte platnú adresu na preposielanie", + "not_running": "Preposielanie alebo príjemcovia odpovede sú uložení, ale momentálne nie sú aktívni. Uložením ich znova zapnete." + }, + "forward": { + "title": "Preposielanie", + "description": "Preposiela prichádzajúce správy na inú adresu, s automatickou odpoveďou alebo bez nej, v období uvedenom vyššie (bez dátumu ukončenia, kým ho nevypnete). Spam sa nepreposiela.", + "enabled_label": "Preposielať správy", + "to_label": "Preposlať na", + "to_placeholder": "email@example.com", + "keep_label": "Ponechať v Doručených", + "keep_description": "Inak sa správa iba prepošle: nezostane v tejto schránke a nepoužije sa na ňu žiadne pravidlo filtra." + }, + "audience": { + "label": "Odpovedať", + "description": "Interní odosielatelia: {domains}", + "all": "Všetkým odosielateľom", + "internal": "Iba interným odosielateľom", + "external": "Iba externým odosielateľom" + }, + "auto_reply": { + "title": "Automatická odpoveď" } }, "folders": { @@ -2236,7 +2255,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Použiť aj na správy, ktoré už sú v tomto priečinku", - "apply_existing_unsupported": "Na existujúce správy možno použiť len pravidlá, ktoré kontrolujú odosielateľa, príjemcov, predmet alebo hlavičky a ktoré presúvajú, kopírujú, označujú ako prečítané, hviezdičkou alebo štítkom." + "apply_existing_unsupported": "Na existujúce správy možno použiť len pravidlá, ktoré kontrolujú odosielateľa, príjemcov, predmet alebo hlavičky a ktoré presúvajú, kopírujú, označujú ako prečítané, hviezdičkou alebo štítkom.", + "period_title": "Časové obdobie", + "period_start": "Dátum začiatku", + "period_end": "Dátum ukončenia", + "validation_period_order": "Dátum ukončenia musí byť po dátume začiatku", + "period_toggle": "Aktívne iba v určitom období", + "period_hint": "Platí pre správy, ktoré prídu v tomto období. Prázdne pole ponechá daný koniec otvorený.", + "validation_period_empty": "Zadajte dátum začiatku alebo ukončenia obdobia", + "validation_period_invalid": "Zadajte pre obdobie platný dátum aj s časom alebo pole nechajte prázdne", + "apply_existing_period": "Pravidlo s obdobím pôsobí len na správy, ktoré v ňom prídu, preto ho nemožno použiť na existujúce správy.", + "period_status_scheduled": "naplánované", + "period_status_active": "aktívne", + "period_status_expired": "uplynulo" }, "templates": { "title": "E-mailové šablóny", diff --git a/locales/tr/common.json b/locales/tr/common.json index 1048d6fb8..4745d9f41 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Kimlik silinemedi: {error}", "identity_unauthorized": "Bu e-posta adresinden gönderme yetkiniz yok", "identity_not_found": "Kimlik bulunamadı", - "vacation_saved": "Tatil otomatik yanıtı ayarları kaydedildi", - "vacation_save_failed": "Tatil otomatik yanıtı ayarları kaydedilemedi", + "vacation_saved": "Ofis dışında ayarları kaydedildi", + "vacation_save_failed": "Ofis dışında ayarları kaydedilemedi", "filters_saved": "Filtreler başarıyla kaydedildi", "filters_save_failed": "Filtreler kaydedilemedi", "filters_deleted": "Filtre kuralı silindi", @@ -857,7 +857,8 @@ "rule_undone": "Kural değişikliği geri alındı", "rule_undo_failed": "Kural değişikliği geri alınamadı", "rule_undo_conflict": "Filtreleriniz o zamandan beri değişti. Değişikliği filtre ayarlarından geri alın.", - "rule_folder_failed": "Klasör oluşturulamadı" + "rule_folder_failed": "Klasör oluşturulamadı", + "vacation_filters_save_failed": "Ofis dışında ayarları kaydedildi, ancak iletme ve yanıt alıcıları kaydedilemedi" }, "date": { "today": "Bugün", @@ -942,7 +943,7 @@ "privacy": "Gizlilik ve Güvenlik", "account": "Hesap", "identities": "Kimlikler", - "vacation": "Tatil Yanıtlayıcı", + "vacation": "Ofis Dışında", "advanced": "Gelişmiş", "calendar": "Takvim", "filters": "Filtreler", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "Tatil Yanıtlayıcı", - "description": "Yokken gelen e-postalara otomatik yanıt gönderin", + "title": "Ofis Dışında", + "description": "Yokken gelen e-postaları otomatik olarak yönetin", "loading": "Tatil ayarları yükleniyor...", "not_supported": "Posta sunucunuz tatil yanıtlarını desteklemiyor.", "fetch_error": "Tatil ayarları yüklenemedi. Lütfen tekrar deneyin.", "status": { - "label": "Tatil Yanıtlayıcı", + "label": "Otomatik yanıt gönder", "description": "Size e-posta gönderenlere otomatik yanıt gönderin", "active": "Etkin", "inactive": "Devre dışı" }, "date_range": { - "title": "Tarih Aralığı", - "description": "Otomatik yanıtı isteğe bağlı olarak belirli bir dönemle sınırlayın", "start": "Başlangıç Tarihi", "start_description": "Başlangıç sınırı olmaması için boş bırakın", "end": "Bitiş Tarihi", "end_description": "Bitiş sınırı olmaması için boş bırakın" }, "message": { - "title": "Otomatik Yanıt İletisi", - "description": "Yanıt olarak gönderilecek ileti", "subject_label": "Konu", "subject_description": "Otomatik yanıtın konu satırı", "subject_placeholder": "Ofis Dışındayım", @@ -1858,10 +1855,32 @@ "saving": "Kaydediliyor...", "warnings": { "end_before_start": "Bitiş tarihi başlangıç tarihinden sonra olmalıdır", + "invalid_date": "Saatiyle birlikte geçerli bir başlangıç ve bitiş tarihi girin veya alanı boş bırakın", "start_in_past": "Başlangıç tarihi geçmişte", "empty_body": "İleti gövdesi boş - alıcılar boş bir yanıt alacak", "subject_too_long": "Konu sunucu için çok uzun (en fazla {max} bayt).", - "body_too_long": "İleti sunucu için çok uzun (biçimlendirme dahil en fazla {max} bayt)." + "body_too_long": "İleti sunucu için çok uzun (biçimlendirme dahil en fazla {max} bayt).", + "forward_address": "İletilecek geçerli bir adres girin", + "not_running": "İletme veya yanıt alıcıları kaydedildi, ancak şu anda etkin değil. Yeniden etkinleştirmek için kaydedin." + }, + "forward": { + "title": "İletme", + "description": "Gelen iletileri, otomatik yanıtla ya da yanıtsız, yukarıdaki dönem içinde başka bir adrese iletir (bitiş tarihi yoksa kapatılana kadar). İstenmeyen e-postalar iletilmez.", + "enabled_label": "İletileri ilet", + "to_label": "İlet", + "to_placeholder": "email@example.com", + "keep_label": "Gelen kutusunda tut", + "keep_description": "Aksi halde ileti yalnızca iletilir: bu posta kutusunda kalmaz ve ona hiçbir filtre kuralı uygulanmaz." + }, + "audience": { + "label": "Yanıt gönderilecek kişiler", + "description": "Dahili gönderenler: {domains}", + "all": "Tüm gönderenler", + "internal": "Yalnızca dahili gönderenler", + "external": "Yalnızca harici gönderenler" + }, + "auto_reply": { + "title": "Otomatik Yanıt" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Bu klasörde zaten bulunan iletilere de uygula", - "apply_existing_unsupported": "Mevcut iletilere yalnızca göndereni, alıcıları, konuyu veya üstbilgileri denetleyen ve taşıyan, kopyalayan, okundu işaretleyen, yıldızlayan veya etiketleyen kurallar uygulanabilir." + "apply_existing_unsupported": "Mevcut iletilere yalnızca göndereni, alıcıları, konuyu veya üstbilgileri denetleyen ve taşıyan, kopyalayan, okundu işaretleyen, yıldızlayan veya etiketleyen kurallar uygulanabilir.", + "period_title": "Tarih Aralığı", + "period_start": "Başlangıç Tarihi", + "period_end": "Bitiş Tarihi", + "validation_period_order": "Bitiş tarihi başlangıç tarihinden sonra olmalıdır", + "period_toggle": "Yalnızca belirli bir dönemde etkin", + "period_hint": "Bu dönemde gelen iletilere uygulanır. O ucu sınırlamamak için alanı boş bırakın.", + "validation_period_empty": "Dönem için bir başlangıç veya bitiş tarihi girin", + "validation_period_invalid": "Dönem için saatiyle birlikte geçerli bir tarih girin veya alanı boş bırakın", + "apply_existing_period": "Dönemi olan bir kural yalnızca o dönemde gelen iletilere uygulanır, bu nedenle mevcut iletilere uygulanamaz.", + "period_status_scheduled": "planlandı", + "period_status_active": "etkin", + "period_status_expired": "süresi doldu" }, "templates": { "title": "E-posta Şablonları", diff --git a/locales/uk/common.json b/locales/uk/common.json index 828e5c95e..9802b0ead 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "Не вдалося видалити особу: {error}", "identity_unauthorized": "Ви не авторизовані надсилати з цієї електронної адреси", "identity_not_found": "Особа не знайдена", - "vacation_saved": "Налаштування автовідповідача збережено", - "vacation_save_failed": "Не вдалося зберегти налаштування автовідповідача", + "vacation_saved": "Налаштування відсутності збережено", + "vacation_save_failed": "Не вдалося зберегти налаштування відсутності", "filters_saved": "Фільтри успішно збережено", "filters_save_failed": "Не вдалося зберегти фільтри", "filters_deleted": "Правило фільтра видалено", @@ -857,7 +857,8 @@ "rule_undone": "Зміну правила скасовано", "rule_undo_failed": "Не вдалося скасувати зміну правила", "rule_undo_conflict": "Ваші фільтри відтоді змінилися. Скасуйте зміну в налаштуваннях фільтрів.", - "rule_folder_failed": "Не вдалося створити папку" + "rule_folder_failed": "Не вдалося створити папку", + "vacation_filters_save_failed": "Налаштування відсутності збережено, але пересилання та отримувачів відповіді зберегти не вдалося" }, "date": { "today": "Сьогодні", @@ -942,7 +943,7 @@ "privacy": "Конфіденційність і безпека", "account": "Обліковий запис", "identities": "Ідентичності", - "vacation": "Відповідач у відпустці", + "vacation": "Відсутність", "advanced": "Просунутий", "calendar": "Календар", "filters": "Фільтри", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "Відповідач у відпустці", - "description": "Автоматично відповідайте на вхідні листи, поки вас немає", + "title": "Відсутність", + "description": "Автоматично обробляйте вхідні листи, поки вас немає", "loading": "Завантаження налаштувань відпустки...", "not_supported": "Ваш поштовий сервер не підтримує відповіді про відпустку.", "fetch_error": "Не вдалося завантажити налаштування відпустки. Спробуйте ще раз.", "status": { - "label": "Відповідач у відпустці", + "label": "Відповідати автоматично", "description": "Надсилайте автоматичну відповідь людям, які надсилають вам електронні листи", "active": "Активний", "inactive": "Неактивний" }, "date_range": { - "title": "Діапазон дат", - "description": "За бажанням можна обмежити автоматичну відповідь певним періодом", "start": "Дата початку", "start_description": "Залиште пустим, щоб не було обмеження на початок", "end": "Дата закінчення", "end_description": "Залиште пустим, щоб не було кінцевого обмеження" }, "message": { - "title": "Повідомлення автоматичної відповіді", - "description": "Повідомлення, яке буде надіслано у відповідь", "subject_label": "Тема", "subject_description": "Тема автоматичної відповіді", "subject_placeholder": "Поза офісом", @@ -1858,10 +1855,32 @@ "saving": "Збереження...", "warnings": { "end_before_start": "Дата завершення має бути після дати початку", + "invalid_date": "Вкажіть дійсні дати початку й закінчення з часом або залиште поле порожнім", "start_in_past": "Дата початку в минулому", "empty_body": "Тіло повідомлення порожнє - одержувачі отримають порожню відповідь", "subject_too_long": "Тема задовга для сервера (не більше {max} байтів).", - "body_too_long": "Повідомлення задовге для сервера (не більше {max} байтів разом із форматуванням)." + "body_too_long": "Повідомлення задовге для сервера (не більше {max} байтів разом із форматуванням).", + "forward_address": "Вкажіть дійсну адресу для пересилання", + "not_running": "Пересилання або отримувачів відповіді збережено, але зараз вони не діють. Збережіть, щоб знову їх увімкнути." + }, + "forward": { + "title": "Пересилання", + "description": "Пересилає вхідні листи на іншу адресу, з автовідповіддю або без неї, у зазначений вище період (без дати завершення, доки пересилання не вимкнуть). Спам не пересилається.", + "enabled_label": "Пересилати листи", + "to_label": "Переслати до", + "to_placeholder": "email@example.com", + "keep_label": "Зберігати у вхідних", + "keep_description": "Інакше лист лише пересилається: він не залишається в цій поштовій скриньці, і до нього не застосовується жодне правило фільтра." + }, + "audience": { + "label": "Відповідати", + "description": "Внутрішні відправники: {domains}", + "all": "Усім відправникам", + "internal": "Лише внутрішнім відправникам", + "external": "Лише зовнішнім відправникам" + }, + "auto_reply": { + "title": "Автовідповідь" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "Застосувати й до листів, які вже є в цій папці", - "apply_existing_unsupported": "До наявних листів можна застосувати лише правила, що перевіряють відправника, одержувачів, тему або заголовки й переміщують, копіюють, позначають прочитаними, зірочкою чи міткою." + "apply_existing_unsupported": "До наявних листів можна застосувати лише правила, що перевіряють відправника, одержувачів, тему або заголовки й переміщують, копіюють, позначають прочитаними, зірочкою чи міткою.", + "period_title": "Діапазон дат", + "period_start": "Дата початку", + "period_end": "Дата закінчення", + "validation_period_order": "Дата завершення має бути після дати початку", + "period_toggle": "Активне лише в певний період", + "period_hint": "Діє для листів, що надходять у цей період. Залиште поле порожнім, щоб не обмежувати цю межу.", + "validation_period_empty": "Вкажіть дату початку або закінчення періоду", + "validation_period_invalid": "Вкажіть для періоду дійсну дату з часом або залиште поле порожнім", + "apply_existing_period": "Правило з періодом діє лише на листи, що надходять у цей період, тому його не можна застосувати до наявних листів.", + "period_status_scheduled": "заплановано", + "period_status_active": "активне", + "period_status_expired": "завершено" }, "templates": { "title": "Шаблони електронної пошти", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 2708d0616..1f839fb51 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "無法刪除寄件身分:{error}", "identity_unauthorized": "您沒有使用此電子郵件地址寄信的權限", "identity_not_found": "找不到寄件身分", - "vacation_saved": "休假自動回覆設定已儲存", - "vacation_save_failed": "無法儲存休假自動回覆設定", + "vacation_saved": "外出設定已儲存", + "vacation_save_failed": "無法儲存外出設定", "filters_saved": "篩選規則已成功儲存", "filters_save_failed": "無法儲存篩選規則", "filters_deleted": "篩選規則已刪除", @@ -857,7 +857,8 @@ "rule_undone": "已復原規則變更", "rule_undo_failed": "無法復原規則變更", "rule_undo_conflict": "篩選規則在那之後已變更。請在篩選規則設定中復原該變更。", - "rule_folder_failed": "無法建立資料夾" + "rule_folder_failed": "無法建立資料夾", + "vacation_filters_save_failed": "外出設定已儲存,但轉寄和回覆對象設定未能儲存" }, "date": { "today": "今天", @@ -945,7 +946,7 @@ "privacy": "隱私權與安全", "account": "帳號", "identities": "寄件身分", - "vacation": "休假自動回覆", + "vacation": "外出", "advanced": "進階", "calendar": "行事曆", "filters": "篩選規則", @@ -1824,28 +1825,24 @@ } }, "vacation": { - "title": "休假自動回覆", - "description": "您不在時,自動回覆收到的郵件", + "title": "外出", + "description": "您不在時,自動處理收到的郵件", "loading": "正在載入自動回覆設定…", "not_supported": "您的郵件伺服器不支援自動回覆功能。", "fetch_error": "無法載入自動回覆設定。請再試一次。", "status": { - "label": "休假自動回覆", + "label": "傳送自動回覆", "description": "自動回覆寄信給您的人", "active": "已啟用", "inactive": "已停用" }, "date_range": { - "title": "日期範圍", - "description": "可選擇將自動回覆限制在特定期間", "start": "開始日期", "start_description": "留空表示不限制開始時間", "end": "結束日期", "end_description": "留空表示不限制結束時間" }, "message": { - "title": "自動回覆郵件", - "description": "作為回覆傳送的郵件內容", "subject_label": "主題", "subject_description": "自動回覆郵件的主題", "subject_placeholder": "不在辦公室", @@ -1865,10 +1862,32 @@ "saving": "正在儲存…", "warnings": { "end_before_start": "結束日期必須晚於開始日期", + "invalid_date": "請輸入含時間的有效開始與結束日期,或將該欄位留空", "start_in_past": "開始日期已在過去", "empty_body": "郵件內文為空 — 收件者將收到空白回覆", "subject_too_long": "主旨對伺服器來說太長(最多 {max} 位元組)。", - "body_too_long": "訊息對伺服器來說太長(含格式最多 {max} 位元組)。" + "body_too_long": "訊息對伺服器來說太長(含格式最多 {max} 位元組)。", + "forward_address": "請輸入有效的轉寄地址", + "not_running": "轉寄或回覆對象已儲存,但目前未生效。儲存即可重新啟用。" + }, + "forward": { + "title": "轉寄", + "description": "在上方期間內將收到的郵件轉寄到另一個地址,無論是否啟用自動回覆(未設結束日期時,直到關閉為止)。垃圾郵件不會被轉寄。", + "enabled_label": "轉寄郵件", + "to_label": "轉寄至", + "to_placeholder": "email@example.com", + "keep_label": "保留在收件匣中", + "keep_description": "否則郵件只會被轉寄:不會保留在此信箱中,也不會對其執行任何篩選規則。" + }, + "audience": { + "label": "回覆對象", + "description": "內部寄件者:{domains}", + "all": "所有寄件者", + "internal": "僅內部寄件者", + "external": "僅外部寄件者" + }, + "auto_reply": { + "title": "自動回覆" } }, "folders": { @@ -2236,7 +2255,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "也套用到此資料夾中既有的郵件", - "apply_existing_unsupported": "只有檢查寄件者、收件者、主旨或標頭,並執行移動、複製、標示為已讀、加上星號或標籤的規則,才能套用到既有郵件。" + "apply_existing_unsupported": "只有檢查寄件者、收件者、主旨或標頭,並執行移動、複製、標示為已讀、加上星號或標籤的規則,才能套用到既有郵件。", + "period_title": "日期範圍", + "period_start": "開始日期", + "period_end": "結束日期", + "validation_period_order": "結束日期必須晚於開始日期", + "period_toggle": "僅在指定期間內生效", + "period_hint": "適用於在此期間內收到的郵件。留空某一欄即表示該端不設限制。", + "validation_period_empty": "請輸入期間的開始日期或結束日期", + "validation_period_invalid": "請為期間輸入含時間的有效日期,或將該欄位留空", + "apply_existing_period": "設定了期間的規則只作用於在該期間內收到的郵件,因此無法套用到既有郵件。", + "period_status_scheduled": "尚未開始", + "period_status_active": "生效中", + "period_status_expired": "已過期" }, "templates": { "title": "郵件範本", diff --git a/locales/zh/common.json b/locales/zh/common.json index 7a3f224cf..cb7c2ecfe 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -834,8 +834,8 @@ "identity_delete_failed": "无法删除身份:{error}", "identity_unauthorized": "您无权从此邮箱地址发送", "identity_not_found": "未找到身份", - "vacation_saved": "假期自动回复设置已保存", - "vacation_save_failed": "无法保存假期自动回复设置", + "vacation_saved": "外出设置已保存", + "vacation_save_failed": "无法保存外出设置", "filters_saved": "过滤规则保存成功", "filters_save_failed": "无法保存过滤器", "filters_deleted": "过滤规则已删除", @@ -857,7 +857,8 @@ "rule_undone": "已撤销规则更改", "rule_undo_failed": "无法撤销规则更改", "rule_undo_conflict": "过滤器在此之后已更改。请在过滤器设置中撤销该更改。", - "rule_folder_failed": "无法创建文件夹" + "rule_folder_failed": "无法创建文件夹", + "vacation_filters_save_failed": "外出设置已保存,但转发和回复对象设置未能保存" }, "date": { "today": "今天", @@ -942,7 +943,7 @@ "privacy": "隐私与安全", "account": "账户", "identities": "身份", - "vacation": "假期自动回复", + "vacation": "外出", "advanced": "高级", "calendar": "日历", "filters": "过滤器", @@ -1817,28 +1818,24 @@ } }, "vacation": { - "title": "假期自动回复", - "description": "在您不在时自动回复收到的邮件", + "title": "外出", + "description": "在您不在时自动处理收到的邮件", "loading": "正在加载自动回复设置…", "not_supported": "您的邮件服务器不支持自动回复功能。", "fetch_error": "加载自动回复设置失败,请重试。", "status": { - "label": "自动回复", + "label": "发送自动回复", "description": "向给您发送邮件的人自动发送回复", "active": "已开启", "inactive": "已关闭" }, "date_range": { - "title": "日期范围", - "description": "可选择仅在指定时间段内启用自动回复", "start": "开始日期", "start_description": "留空表示不限制开始时间", "end": "结束日期", "end_description": "留空表示不限制结束时间" }, "message": { - "title": "自动回复正文", - "description": "作为回复发送的邮件内容", "subject_label": "主题", "subject_description": "自动回复邮件的主题", "subject_placeholder": "外出办公", @@ -1858,10 +1855,32 @@ "saving": "正在保存…", "warnings": { "end_before_start": "结束日期必须晚于开始日期", + "invalid_date": "请输入带时间的有效开始和结束日期,或将该字段留空", "start_in_past": "开始日期已在过去", "empty_body": "邮件正文为空--收件人将收到空白回复", "subject_too_long": "主题对服务器来说太长(最多 {max} 字节)。", - "body_too_long": "消息对服务器来说太长(含格式最多 {max} 字节)。" + "body_too_long": "消息对服务器来说太长(含格式最多 {max} 字节)。", + "forward_address": "请输入有效的转发地址", + "not_running": "转发或回复对象已保存,但当前未生效。保存即可重新启用。" + }, + "forward": { + "title": "转发", + "description": "在上方时间段内将收到的邮件转发到另一个地址,无论是否开启自动回复(未设结束日期时,直到关闭为止)。垃圾邮件不会被转发。", + "enabled_label": "转发邮件", + "to_label": "转发至", + "to_placeholder": "email@example.com", + "keep_label": "保留在收件箱中", + "keep_description": "否则邮件只会被转发:不会保留在此邮箱中,也不会对其执行任何过滤规则。" + }, + "audience": { + "label": "回复对象", + "description": "内部发件人:{domains}", + "all": "所有发件人", + "internal": "仅内部发件人", + "external": "仅外部发件人" + }, + "auto_reply": { + "title": "自动回复" } }, "folders": { @@ -2229,7 +2248,19 @@ "suggest_list": "List-Id", "suggest_domain": "@{domain}", "apply_existing": "同时应用于此文件夹中已有的邮件", - "apply_existing_unsupported": "只有检查发件人、收件人、主题或邮件头,并执行移动、复制、标记为已读、加星标或添加标签的规则,才能应用于已有邮件。" + "apply_existing_unsupported": "只有检查发件人、收件人、主题或邮件头,并执行移动、复制、标记为已读、加星标或添加标签的规则,才能应用于已有邮件。", + "period_title": "日期范围", + "period_start": "开始日期", + "period_end": "结束日期", + "validation_period_order": "结束日期必须晚于开始日期", + "period_toggle": "仅在指定时间段内生效", + "period_hint": "适用于在此时间段内收到的邮件。留空某一项即表示该端不设限制。", + "validation_period_empty": "请输入时间段的开始日期或结束日期", + "validation_period_invalid": "请为时间段输入带时间的有效日期,或将该字段留空", + "apply_existing_period": "设置了时间段的规则只作用于在该时间段内收到的邮件,因此无法应用于已有邮件。", + "period_status_scheduled": "未开始", + "period_status_active": "生效中", + "period_status_expired": "已过期" }, "templates": { "title": "邮件模板", diff --git a/stores/__tests__/filter-store.test.ts b/stores/__tests__/filter-store.test.ts index de3a84e36..05f8658a0 100644 --- a/stores/__tests__/filter-store.test.ts +++ b/stores/__tests__/filter-store.test.ts @@ -531,9 +531,9 @@ describe('filter-store', () => { }); it('reports the auto-reply as on while the filters include it', async () => { - const { isVacationIncludedInFilters } = await import('../filter-store'); - expect(await isVacationIncludedInFilters((await makeClient(false, true, true)).client)).toBe(true); - expect(await isVacationIncludedInFilters((await makeClient(false, true)).client)).toBe(false); + const { readVacationFilters } = await import('../filter-store'); + expect((await readVacationFilters((await makeClient(false, true, true)).client)).includesVacation).toBe(true); + expect((await readVacationFilters((await makeClient(false, true)).client)).includesVacation).toBe(false); }); }); }); diff --git a/stores/__tests__/vacation-audience.test.ts b/stores/__tests__/vacation-audience.test.ts new file mode 100644 index 000000000..7f3129505 --- /dev/null +++ b/stores/__tests__/vacation-audience.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { FilterRule, VacationAudience } from '@/lib/jmap/sieve-types'; +import { generateScript } from '@/lib/sieve/generator'; +import { parseScript } from '@/lib/sieve/parser'; +import { updateAccountFilters } from '@/lib/filters/account-filters'; +import { insertRuleAtTop } from '@/lib/filters/quick-rules'; +import { STALWART_VACATION_SCRIPT, mockStalwartAccount } from '@/lib/filters/__tests__/sieve-mock'; +import { readVacationFilters, syncVacationWithFilters, useFilterStore } from '../filter-store'; +import { VacationFiltersError, useVacationStore } from '../vacation-store'; + +const EXTENSIONS = ['fileinto', 'imap4flags', 'include', 'envelope']; +const INCLUDE = 'include :personal :optional "vacation";'; +const external: VacationAudience = { only: 'external', domains: ['example.com'] }; +const NARROWED = 'if not envelope :domain :is "from" ["example.com"] {'; + +const rule = (id: string): FilterRule => ({ + id, + name: `Rule ${id}`, + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: `${id}@example.net` }], + actions: [{ type: 'mark_read' }], + stopProcessing: false, +}); + +const filters = (rules: FilterRule[], options: Parameters[2] = {}) => + generateScript(rules, undefined, { extensions: EXTENSIONS, ...options }); +const stalwart = (initial: Parameters[1], extensions = EXTENSIONS) => + mockStalwartAccount('b', initial, extensions); +const vacationScript = (isActive: boolean) => ({ name: 'vacation', content: STALWART_VACATION_SCRIPT, isActive }); + +const initialVacation = useVacationStore.getState(); +const initialFilters = useFilterStore.getState(); +afterEach(() => { + useVacationStore.setState(initialVacation, true); + useFilterStore.setState(initialFilters, true); +}); + +describe('syncVacationWithFilters with a narrowed auto-reply', () => { + it('runs it from the filters script even without rules: on its own it would answer everyone', async () => { + const server = stalwart([vacationScript(true)]); + await syncVacationWithFilters(server.client, true, 'b', undefined, external); + expect(server.active()).toBe('filters'); + expect(server.content('filters')).toContain(`${NARROWED}\n ${INCLUDE}\n}`); + }); + + it('takes over again when Stalwart switched its own script back on', async () => { + const server = stalwart([ + { name: 'filters', content: filters([], { includeVacation: true, vacationAudience: external }), isActive: false }, + vacationScript(true), + ]); + await syncVacationWithFilters(server.client, true, 'b'); + expect(server.active()).toBe('filters'); + expect(server.content('filters')).toContain(NARROWED); + }); + + it('answers everyone again once the choice is cleared', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationAudience: external }), isActive: true }, + vacationScript(false), + ]); + await syncVacationWithFilters(server.client, true, 'b', undefined, null); + const written = server.content('filters'); + expect(written).not.toContain('envelope :domain'); + expect(written).toContain(`# Vacation auto-reply\n${INCLUDE}`); + expect(parseScript(written).vacationAudience).toBeUndefined(); + }); + + it('keeps the choice while the auto-reply is off', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationAudience: external }), isActive: true }, + vacationScript(false), + ]); + await syncVacationWithFilters(server.client, false, 'b'); + const written = server.content('filters'); + expect(written).not.toContain('include'); + expect(parseScript(written).vacationAudience).toEqual(external); + }); + + it('refuses a choice it could not read back, rather than answer everyone', async () => { + const server = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }]); + await expect(syncVacationWithFilters(server.client, true, 'b', undefined, { only: 'internal', domains: [] })) + .rejects.toThrow(/Unusable/); + expect(server.writes()).toBe(0); + }); +}); + +describe('readVacationFilters and the other writes', () => { + it('reports the choice and that it can be made', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationAudience: external }), isActive: true }, + ]); + expect(await readVacationFilters(server.client, 'b')).toMatchObject({ audience: external, audienceAvailable: true }); + const handEdited = stalwart([{ name: 'filters', content: '/* @metadata:begin\n{\n@metadata:end */\n', isActive: true }]); + expect((await readVacationFilters(handEdited.client, 'b')).audienceAvailable).toBe(false); + const noInclude = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }], ['fileinto']); + expect((await readVacationFilters(noInclude.client, 'b')).audienceAvailable).toBe(false); + // The sender is judged by the envelope, which the reply goes to. + const noEnvelope = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }], ['fileinto', 'include']); + expect((await readVacationFilters(noEnvelope.client, 'b')).audienceAvailable).toBe(false); + }); + + it('says when Stalwart answers everyone although the auto-reply is for some senders only', async () => { + const read = async (filtersActive: boolean, vacationActive: boolean) => (await readVacationFilters(stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationAudience: external }), isActive: filtersActive }, + vacationScript(vacationActive), + ]).client, 'b')).notRunning; + expect(await read(false, true)).toBe(true); + expect(await read(true, false)).toBe(false); + // The auto-reply is off: whom it would answer does not matter. + expect(await read(false, false)).toBe(false); + }); + + it('keeps the choice through a rule made from a message and a save on the filter settings page', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationAudience: external }), isActive: true }, + vacationScript(false), + ]); + await updateAccountFilters(server.client, 'b', (rules) => insertRuleAtTop(rules, rule('quick'))); + expect(server.content('filters')).toContain(NARROWED); + + await useFilterStore.getState().fetchFilters(server.client, 'b'); + useFilterStore.getState().addRule(rule('settings')); + await useFilterStore.getState().saveFilters(server.client); + const written = server.content('filters'); + expect(written).toContain('# Rule: Rule settings'); + expect(written).toContain(NARROWED); + expect(written.split(INCLUDE).length).toBe(2); + }); +}); + +describe('vacation store with a narrowed auto-reply', () => { + it('loads the choice and saves a new one', async () => { + const server = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }]); + await useVacationStore.getState().fetchVacationResponse(server.client); + expect(useVacationStore.getState()).toMatchObject({ audience: null, audienceAvailable: true }); + + await useVacationStore.getState().updateVacationResponse(server.client, { isEnabled: true, audience: external }); + expect(server.active()).toBe('filters'); + expect(server.content('filters')).toContain(NARROWED); + expect(server.client.setVacationResponse.mock.calls[0][0]).not.toHaveProperty('audience'); + expect(useVacationStore.getState().audience).toEqual(external); + }); + + it('says when the auto-reply was saved but the choice of who gets it was not', async () => { + const server = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }]); + await useVacationStore.getState().fetchVacationResponse(server.client); + server.client.updateSieveScript.mockRejectedValueOnce(new Error('server said no')); + await expect( + useVacationStore.getState().updateVacationResponse(server.client, { isEnabled: true, audience: external }), + ).rejects.toBeInstanceOf(VacationFiltersError); + expect(useVacationStore.getState().audience).toBeNull(); + }); + + it('says when a stored choice could not be cleared', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationAudience: external }), isActive: true }, + vacationScript(false), + ]); + await useVacationStore.getState().fetchVacationResponse(server.client); + server.client.updateSieveScript.mockRejectedValueOnce(new Error('server said no')); + // Only the stored choice tells that the server may now answer everyone. + await expect( + useVacationStore.getState().updateVacationResponse(server.client, { isEnabled: true, audience: null }), + ).rejects.toBeInstanceOf(VacationFiltersError); + expect(useVacationStore.getState().audience).toEqual(external); + }); +}); diff --git a/stores/__tests__/vacation-forward.test.ts b/stores/__tests__/vacation-forward.test.ts new file mode 100644 index 000000000..367c87f05 --- /dev/null +++ b/stores/__tests__/vacation-forward.test.ts @@ -0,0 +1,465 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { FilterRule, VacationForward } from '@/lib/jmap/sieve-types'; +import { generateScript } from '@/lib/sieve/generator'; +import { parseScript } from '@/lib/sieve/parser'; +import { OpaqueFiltersError, updateAccountFilters } from '@/lib/filters/account-filters'; +import { insertRuleAtTop } from '@/lib/filters/quick-rules'; +import { STALWART_VACATION_SCRIPT, mockStalwartAccount } from '@/lib/filters/__tests__/sieve-mock'; +import { readVacationFilters, syncVacationWithFilters, useFilterStore } from '../filter-store'; +import { VacationFiltersError, useVacationStore } from '../vacation-store'; + +const EXTENSIONS = ['fileinto', 'mailbox', 'mailboxid', 'imap4flags', 'include', 'envelope', 'copy', 'date', 'relational', 'spamtestplus', 'comparator-i;ascii-numeric']; +const FORWARD_MARKER = '# Vacation forwarding'; +const INCLUDE = 'include :personal :optional "vacation";'; + +const forward = (extra: Partial = {}): VacationForward => ({ + enabled: true, + to: 'kollege@example.com', + keepCopy: false, + activeFrom: '2026-10-05T06:00:00.000Z', + activeUntil: '2026-10-16T16:00:00.000Z', + ...extra, +}); + +const rule = (id: string, extra: Partial = {}): FilterRule => ({ + id, + name: `Rule ${id}`, + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: `${id}@example.net` }], + actions: [{ type: 'mark_read' }], + stopProcessing: false, + ...extra, +}); + +const filters = (rules: FilterRule[], options: Parameters[2] = {}) => + generateScript(rules, undefined, { extensions: EXTENSIONS, ...options }); + +const VACATION_SCRIPT = STALWART_VACATION_SCRIPT; + +const stalwart = ( + initial: Parameters[1], + options: { extensions?: string[]; maxNumberRedirects?: number } = {}, +) => mockStalwartAccount('b', initial, options.extensions ?? EXTENSIONS, options); + +const initialVacation = useVacationStore.getState(); +const initialFilters = useFilterStore.getState(); +afterEach(() => { + useVacationStore.setState(initialVacation, true); + useFilterStore.setState(initialFilters, true); +}); + +describe('syncVacationWithFilters with forwarding', () => { + it('runs forwarding next to the auto-reply even when there is no filter rule', async () => { + const server = stalwart([ + { name: 'filters', content: filters([]), isActive: false }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: true }, + ]); + await syncVacationWithFilters(server.client, true, 'b', forward()); + expect(server.active()).toBe('filters'); + const written = server.content('filters'); + expect(written).toContain(INCLUDE); + expect(written).toContain(FORWARD_MARKER); + expect(parseScript(written).vacationForward).toEqual(forward()); + }); + + it('creates the filters script for forwarding when there is none', async () => { + const server = stalwart([{ name: 'vacation', content: VACATION_SCRIPT, isActive: true }]); + await syncVacationWithFilters(server.client, true, 'b', forward()); + expect(server.active()).toBe('filters'); + expect(server.content('filters')).toContain(INCLUDE); + expect(server.content('filters')).toContain(FORWARD_MARKER); + }); + + it('brings the stored forwarding back when the auto-reply took over again', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: false }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: true }, + ]); + await syncVacationWithFilters(server.client, true, 'b'); + expect(server.active()).toBe('filters'); + expect(server.content('filters')).toContain(FORWARD_MARKER); + }); + + it('brings the stored forwarding back without any rule when the auto-reply took over again', async () => { + const server = stalwart([ + { name: 'filters', content: filters([], { includeVacation: true, vacationForward: forward() }), isActive: false }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: true }, + ]); + await syncVacationWithFilters(server.client, true, 'b'); + expect(server.active()).toBe('filters'); + expect(server.content('filters')).toContain(FORWARD_MARKER); + }); + + it('moves the forwarding to new vacation dates', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: false }, + ]); + const later = forward({ activeFrom: '2026-11-02T07:00:00.000Z', activeUntil: '2026-11-13T17:00:00.000Z' }); + await syncVacationWithFilters(server.client, true, 'b', later); + const written = server.content('filters'); + expect(parseScript(written).vacationForward).toEqual(later); + expect(written).toContain('"date" "2026-11-02"'); + expect(written).not.toContain('"date" "2026-10-05"'); + }); + + it('keeps forwarding when the auto-reply is turned off', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: false }, + ]); + await syncVacationWithFilters(server.client, false, 'b', forward()); + const written = server.content('filters'); + expect(written).not.toContain(INCLUDE); + expect(written).toContain(FORWARD_MARKER); + expect(parseScript(written).vacationForward).toEqual(forward()); + expect(server.active()).toBe('filters'); + }); + + it('forwards without any auto-reply, creating the filters script for it', async () => { + const server = stalwart([]); + await syncVacationWithFilters(server.client, false, 'b', forward()); + expect(server.active()).toBe('filters'); + const written = server.content('filters'); + expect(written).not.toContain(INCLUDE); + expect(written).toContain(FORWARD_MARKER); + }); + + it('switches the filters back on for forwarding when the auto-reply was the only active script', async () => { + const server = stalwart([ + { name: 'filters', content: filters([], { vacationForward: forward() }), isActive: false }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: false }, + ]); + await syncVacationWithFilters(server.client, false, 'b'); + expect(server.active()).toBe('filters'); + expect(server.content('filters')).toContain(FORWARD_MARKER); + }); + + it('leaves switched-off forwarding where nothing else needs the filters script', async () => { + const server = stalwart([ + { name: 'filters', content: filters([], { vacationForward: forward({ enabled: false }) }), isActive: false }, + ]); + await syncVacationWithFilters(server.client, false, 'b'); + expect(server.writes()).toBe(0); + expect(server.active()).toBeNull(); + }); + + it('removes forwarding that was cleared', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: false }, + ]); + await syncVacationWithFilters(server.client, true, 'b', null); + const written = server.content('filters'); + expect(written).toContain(INCLUDE); + expect(written).not.toContain(FORWARD_MARKER); + expect(parseScript(written).vacationForward).toBeUndefined(); + }); + + it('leaves the auto-reply on its own without rules or forwarding, as before', async () => { + const server = stalwart([ + { name: 'filters', content: filters([]), isActive: false }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: true }, + ]); + await syncVacationWithFilters(server.client, true, 'b'); + await syncVacationWithFilters(server.client, true, 'b', null); + expect(server.writes()).toBe(0); + expect(server.active()).toBe('vacation'); + }); + + it('refuses forwarding on a hand-edited script, and writes nothing', async () => { + const handEdited = '/* @metadata:begin\n{not json\n@metadata:end */\nkeep;\n'; + const server = stalwart([ + { name: 'filters', content: handEdited, isActive: false }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: true }, + ]); + await expect(syncVacationWithFilters(server.client, true, 'b', forward())).rejects.toBeInstanceOf(OpaqueFiltersError); + expect(server.writes()).toBe(0); + }); + + it('refuses forwarding where the server cannot include the auto-reply', async () => { + const server = stalwart([{ name: 'vacation', content: VACATION_SCRIPT, isActive: true }], { extensions: ['fileinto'] }); + await expect(syncVacationWithFilters(server.client, true, 'b', forward())).rejects.toThrow(/"include"/); + expect(server.writes()).toBe(0); + }); + + it('moves the stored forwarding to the vacation dates without it being sent again', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: false }, + ]); + await syncVacationWithFilters(server.client, true, 'b', undefined, undefined, { + from: '2026-11-02T07:00:00.000Z', until: null, + }); + // Without an end date it forwards until switched off. + expect(parseScript(server.content('filters')).vacationForward).toEqual({ + enabled: true, to: 'kollege@example.com', keepCopy: false, activeFrom: '2026-11-02T07:00:00.000Z', + }); + }); + + it('takes the same moment, written another way, as no change', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: false }, + ]); + // The server hands the vacation's dates back without milliseconds. + await syncVacationWithFilters(server.client, true, 'b', undefined, undefined, { + from: '2026-10-05T06:00:00Z', until: '2026-10-16T18:00:00+02:00', + }); + expect(server.writes()).toBe(0); + }); + + it('refuses forwarding it could not read back, and writes nothing', async () => { + const server = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }]); + // The generator would leave it out, and the save would look done. + await expect(syncVacationWithFilters(server.client, false, 'b', forward(), undefined, { + from: null, until: '+010000-01-01T00:59:00.000Z', + })).rejects.toThrow(/Unusable/); + await expect(syncVacationWithFilters(server.client, false, 'b', forward({ to: 'kollege' }))).rejects.toThrow(/Unusable/); + expect(server.writes()).toBe(0); + }); +}); + +describe('readVacationFilters', () => { + it('reports the forwarding, that it can be set up, and the forwards in the rules', async () => { + const forwarding = rule('f', { actions: [{ type: 'forward', value: 'chef@example.com' }] }); + const server = stalwart([ + { name: 'filters', content: filters([forwarding, rule('off', { ...forwarding, enabled: false })], { includeVacation: true, vacationForward: forward() }), isActive: true }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: false }, + ]); + expect(await readVacationFilters(server.client, 'b')).toEqual({ + includesVacation: true, + forward: forward(), + forwardAvailable: true, + audience: null, + audienceAvailable: true, + notRunning: false, + otherForwards: 1, + }); + }); + + it('reports the most forwards one message can collect in the rules, not all of them', async () => { + const forwarding = (id: string, stopProcessing: boolean) => + rule(id, { actions: [{ type: 'forward', value: 'chef@example.com' }], stopProcessing }); + const read = async (rules: FilterRule[]) => + (await readVacationFilters(stalwart([{ name: 'filters', content: filters(rules), isActive: true }]).client, 'b')).otherForwards; + // A message the first rule matches stops there. + expect(await read([forwarding('a', true), forwarding('b', false)])).toBe(1); + expect(await read([forwarding('a', false), forwarding('b', false)])).toBe(2); + }); + + it('offers no forwarding without include, on a hand-edited script, or without redirects', async () => { + const plain = (options = {}, content = filters([])) => + stalwart([{ name: 'filters', content, isActive: true }], options); + expect((await readVacationFilters(plain({ extensions: ['fileinto'] }).client, 'b')).forwardAvailable).toBe(false); + expect((await readVacationFilters(plain({}, '/* @metadata:begin\n{\n@metadata:end */\n').client, 'b')).forwardAvailable).toBe(false); + expect((await readVacationFilters(plain({ maxNumberRedirects: 0 }).client, 'b')).forwardAvailable).toBe(false); + expect((await readVacationFilters(plain().client, 'b')).forwardAvailable).toBe(true); + }); + + it('offers no forwarding where the server lacks what the block uses', async () => { + // Its period, its spam check, and the copy kept here. + for (const missing of ['date', 'relational', 'spamtestplus', 'copy']) { + const server = stalwart([{ name: 'filters', content: filters([]), isActive: true }], { + extensions: EXTENSIONS.filter((e) => e !== missing), + }); + expect((await readVacationFilters(server.client, 'b')).forwardAvailable, missing).toBe(false); + } + }); + + it('still offers forwarding that is on, so that it can be switched off', async () => { + const server = stalwart( + [{ name: 'filters', content: filters([], { vacationForward: forward() }), isActive: true }], + { maxNumberRedirects: 0 }, + ); + expect((await readVacationFilters(server.client, 'b')).forwardAvailable).toBe(true); + }); + + it('says when forwarding that is on does not run, and when it does', async () => { + const read = async (filtersActive: boolean, vacationActive: boolean, f = forward()) => (await readVacationFilters(stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: f }), isActive: filtersActive }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: vacationActive }, + ]).client, 'b')).notRunning; + // Stalwart's own script took over: it answers, nothing forwards. + expect(await read(false, true)).toBe(true); + // No script runs at all. + expect(await read(false, false)).toBe(true); + expect(await read(true, false)).toBe(false); + // Switched off, there is nothing that should run. + expect(await read(false, true, forward({ enabled: false }))).toBe(false); + }); +}); + +describe('every other write keeps the forwarding', () => { + const withForwarding = () => stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: false }, + ]); + + it('a rule made from a message', async () => { + const server = withForwarding(); + await updateAccountFilters(server.client, 'b', (rules) => insertRuleAtTop(rules, rule('new'))); + const written = server.content('filters'); + expect(written).toContain('# Rule: Rule new'); + expect(written.split(FORWARD_MARKER).length).toBe(2); + expect(parseScript(written).vacationForward).toEqual(forward()); + }); + + it('a save on the filter settings page', async () => { + const server = withForwarding(); + await useFilterStore.getState().fetchFilters(server.client, 'b'); + expect(useFilterStore.getState().vacationForward).toEqual(forward()); + useFilterStore.getState().addRule(rule('new')); + await useFilterStore.getState().saveFilters(server.client); + const written = server.content('filters'); + expect(written).toContain('# Rule: Rule new'); + expect(written.split(FORWARD_MARKER).length).toBe(2); + expect(parseScript(written).vacationForward).toEqual(forward()); + }); +}); + +describe('vacation store', () => { + it('loads the forwarding along with the auto-reply', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: false }, + ]); + await useVacationStore.getState().fetchVacationResponse(server.client); + expect(useVacationStore.getState()).toMatchObject({ + isEnabled: true, + forward: forward(), + forwardAvailable: true, + otherForwards: 0, + }); + }); + + it('forwards in the vacation period it saves, with the auto-reply and after it', async () => { + const server = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }]); + await useVacationStore.getState().fetchVacationResponse(server.client); + await useVacationStore.getState().updateVacationResponse(server.client, { + isEnabled: true, + fromDate: '2026-10-05T06:00:00.000Z', + toDate: '2026-10-16T16:00:00.000Z', + forward: { enabled: true, to: 'kollege@example.com', keepCopy: false }, + }); + // The auto-reply runs from the filters script, which forwards in its period. + expect(server.active()).toBe('filters'); + const written = server.content('filters'); + expect(written).toContain(INCLUDE); + expect(parseScript(written).vacationForward).toEqual(forward()); + // VacationResponse itself never sees the forwarding. + expect(server.client.setVacationResponse.mock.calls[0][0]).not.toHaveProperty('forward'); + expect(useVacationStore.getState().forward).toEqual(forward()); + + // Turning the auto-reply off leaves the forwarding running on its own. + await useVacationStore.getState().updateVacationResponse(server.client, { isEnabled: false }); + expect(server.active()).toBe('filters'); + expect(server.content('filters')).not.toContain(INCLUDE); + expect(server.content('filters')).toContain(FORWARD_MARKER); + }); + + it('turns the auto-reply off and forwarding on in one save, even when the auto-reply ran alone', async () => { + const server = stalwart([{ name: 'vacation', content: VACATION_SCRIPT, isActive: true }]); + await useVacationStore.getState().fetchVacationResponse(server.client); + expect(useVacationStore.getState().isEnabled).toBe(true); + await useVacationStore.getState().updateVacationResponse(server.client, { + isEnabled: false, + fromDate: null, + toDate: null, + forward: { enabled: true, to: 'kollege@example.com', keepCopy: false }, + }); + // No auto-reply any more; the filters script forwards on its own. + expect(server.active()).toBe('filters'); + expect(server.content('filters')).not.toContain(INCLUDE); + expect(server.content('filters')).toContain('redirect "kollege@example.com";'); + }); + + it('says when the auto-reply was saved but its forwarding was not', async () => { + const server = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }]); + await useVacationStore.getState().fetchVacationResponse(server.client); + server.client.updateSieveScript.mockRejectedValueOnce(new Error('server said no')); + await expect(useVacationStore.getState().updateVacationResponse(server.client, { + isEnabled: true, + forward: { enabled: true, to: 'kollege@example.com', keepCopy: false }, + })).rejects.toBeInstanceOf(VacationFiltersError); + expect(server.client.setVacationResponse).toHaveBeenCalledTimes(1); + expect(useVacationStore.getState()).toMatchObject({ isEnabled: true, forward: null, isSaving: false, error: null }); + }); + + it('says when forwarding that is on could not be switched off', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { vacationForward: forward() }), isActive: true }, + ]); + await useVacationStore.getState().fetchVacationResponse(server.client); + server.client.updateSieveScript.mockRejectedValueOnce(new Error('server said no')); + // Only the stored forwarding tells that mail is still forwarded. + await expect(useVacationStore.getState().updateVacationResponse(server.client, { + isEnabled: false, + forward: { enabled: false, to: 'kollege@example.com', keepCopy: false }, + })).rejects.toBeInstanceOf(VacationFiltersError); + expect(server.content('filters')).toContain(FORWARD_MARKER); + expect(useVacationStore.getState().forward).toEqual(forward()); + }); + + it('says when a save cut short leaves the forwarding idle, and the next save sets it right', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: false }, + ]); + await useVacationStore.getState().fetchVacationResponse(server.client); + expect(useVacationStore.getState()).toMatchObject({ isEnabled: true, notRunning: false }); + + // Only the text changes. Stalwart's own script takes over with the + // response, and putting the filters back fails. + server.client.updateSieveScript.mockRejectedValueOnce(new Error('connection lost')); + await expect(useVacationStore.getState().updateVacationResponse(server.client, { + isEnabled: true, subject: 'Weg', + })).rejects.toBeInstanceOf(VacationFiltersError); + expect(server.active()).toBe('vacation'); + expect(useVacationStore.getState()).toMatchObject({ forward: forward(), notRunning: true }); + + await useVacationStore.getState().updateVacationResponse(server.client, { isEnabled: true }); + expect(server.active()).toBe('filters'); + expect(server.content('filters')).toContain(FORWARD_MARKER); + expect(useVacationStore.getState().notRunning).toBe(false); + }); + + it('leaves forwarding it was not asked to change as the server has it, in the vacation period', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward({ enabled: false }) }), isActive: true }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: false }, + ]); + await useVacationStore.getState().fetchVacationResponse(server.client); + // Another tab switched it on since this one loaded. + await syncVacationWithFilters(server.client, true, 'b', forward()); + await useVacationStore.getState().updateVacationResponse(server.client, { + isEnabled: true, fromDate: '2026-11-02T07:00:00.000Z', toDate: '2026-11-13T17:00:00.000Z', + }); + const stored = parseScript(server.content('filters')).vacationForward; + expect(stored).toEqual(forward({ activeFrom: '2026-11-02T07:00:00.000Z', activeUntil: '2026-11-13T17:00:00.000Z' })); + expect(useVacationStore.getState().forward).toEqual(stored); + }); + + it('drops what a load finds once a later load or an account switch outdated it', async () => { + const server = stalwart([ + { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true }, + { name: 'vacation', content: VACATION_SCRIPT, isActive: false }, + ]); + const other = { fromDate: null, toDate: null, subject: 'Other account', textBody: 'x', htmlBody: null, isEnabled: false }; + let answer: (value: typeof other) => void = () => {}; + server.client.getVacationResponse.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve; })); + const slow = useVacationStore.getState().fetchVacationResponse(server.client); + await useVacationStore.getState().fetchVacationResponse(server.client); + answer(other); + await slow; + expect(useVacationStore.getState()).toMatchObject({ subject: '', forward: forward(), isLoading: false }); + + server.client.getVacationResponse.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve; })); + const beforeSwitch = useVacationStore.getState().fetchVacationResponse(server.client); + useVacationStore.getState().clearState(); + answer(other); + await beforeSwitch; + expect(useVacationStore.getState()).toMatchObject({ subject: '', forward: null, isLoading: false }); + }); +}); diff --git a/stores/filter-store.ts b/stores/filter-store.ts index 4248fd274..35f113e88 100644 --- a/stores/filter-store.ts +++ b/stores/filter-store.ts @@ -1,9 +1,13 @@ import { create } from 'zustand'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; -import type { FilterRule, SieveCapabilities, VacationSieveConfig } from '@/lib/jmap/sieve-types'; +import type { FilterRule, SieveCapabilities, VacationAudience, VacationForward, VacationSieveConfig } from '@/lib/jmap/sieve-types'; import { parseScript } from '@/lib/sieve/parser'; -import { generateScript, VACATION_SCRIPT_NAME } from '@/lib/sieve/generator'; -import { applyScriptTransforms, supportsInclude, writeFiltersScript } from '@/lib/filters/account-filters'; +import { generateScript, supportsSpamGuard, VACATION_SCRIPT_NAME } from '@/lib/sieve/generator'; +import { OpaqueFiltersError, applyScriptTransforms, supportsInclude, writeFiltersScript } from '@/lib/filters/account-filters'; +import { worstCaseForwards } from '@/lib/filters/forward-limit'; +import { supportsPeriods } from '@/lib/sieve/period'; +import { isValidVacationForward, withVacationPeriod } from '@/lib/sieve/vacation-forward'; +import { isValidVacationAudience, normalizeVacationAudience } from '@/lib/sieve/vacation-audience'; import { debug } from '@/lib/debug'; interface SieveAccount { @@ -25,6 +29,10 @@ interface FilterStore { vacationSettings: VacationSieveConfig | null; externalRequires: string[]; includeVacation: boolean; + /** Forwarding from the vacation card; carried through every save. */ + vacationForward: VacationForward | null; + /** Who gets the auto-reply, when not everyone; carried through every save. */ + vacationAudience: VacationAudience | null; availableAccounts: SieveAccount[]; selectedAccountId: string | null; @@ -64,6 +72,8 @@ export const useFilterStore = create()((set, get) => ({ vacationSettings: null, externalRequires: [], includeVacation: false, + vacationForward: null, + vacationAudience: null, availableAccounts: [], selectedAccountId: null, @@ -105,6 +115,8 @@ export const useFilterStore = create()((set, get) => ({ rawScript: '', isOpaque: false, includeVacation: vacationActive, + vacationForward: null, + vacationAudience: null, }); return; } @@ -126,6 +138,8 @@ export const useFilterStore = create()((set, get) => ({ vacationSettings: result.vacation || null, externalRequires: result.externalRequires, includeVacation: false, + vacationForward: null, + vacationAudience: null, }); } else { debug.log('filters', 'Parsed', result.rules.length, 'filter rules'); @@ -136,6 +150,8 @@ export const useFilterStore = create()((set, get) => ({ vacationSettings: result.vacation || null, externalRequires: result.externalRequires, includeVacation: !!result.includeVacation || vacationActive, + vacationForward: result.vacationForward ?? null, + vacationAudience: result.vacationAudience ?? null, }); } } catch (error) { @@ -160,6 +176,8 @@ export const useFilterStore = create()((set, get) => ({ vacationSettings: null, externalRequires: [], includeVacation: false, + vacationForward: null, + vacationAudience: null, }); await get().fetchFilters(client, accountId); }, @@ -169,7 +187,7 @@ export const useFilterStore = create()((set, get) => ({ try { const { isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires, includeVacation, - selectedAccountId, sieveCapabilities, + vacationForward, vacationAudience, selectedAccountId, sieveCapabilities, } = get(); let content: string; @@ -179,6 +197,8 @@ export const useFilterStore = create()((set, get) => ({ content = generateScript(rules, vacationSettings || undefined, { externalRequires, includeVacation, + vacationForward, + vacationAudience, extensions: sieveCapabilities?.sieveExtensions, }); } @@ -272,6 +292,8 @@ export const useFilterStore = create()((set, get) => ({ vacationSettings: null, externalRequires: [], includeVacation: false, + vacationForward: null, + vacationAudience: null, availableAccounts: [], selectedAccountId: null, }); @@ -288,56 +310,187 @@ async function loadManagedScript(client: IJMAPClient, accountId: string) { return { vacationScript, target, parsed: parsed.isOpaque ? undefined : parsed }; } -/** - * Whether the account's filters script runs the server's vacation script. - * VacationResponse.isEnabled reads false in that case, because the vacation - * script itself is not the active one. - */ -export async function isVacationIncludedInFilters( - client: IJMAPClient, - accountId?: string, -): Promise { +/** What the vacation card needs from the account's filters script. */ +export interface VacationFilters { + /** + * The filters script runs the server's vacation script. VacationResponse + * .isEnabled reads false in that case, because the vacation script itself + * is not the active one. + */ + includesVacation: boolean; + /** The forwarding as stored, on or off; null when none is set up. */ + forward: VacationForward | null; + /** + * Forwarding can be set up: it runs from the filters script next to the + * included vacation script, so it needs `include`, a script Bulwark can + * read, a server that allows a redirect and what the block uses besides + * (its period, its spam check, the copy). Forwarding that is on is offered + * all the same, so that it can be switched off. + */ + forwardAvailable: boolean; + /** Who gets the auto-reply as stored; null when everyone does. */ + audience: VacationAudience | null; + /** + * The auto-reply can be narrowed to some senders: it then runs from the + * filters script, so this needs `include`, `envelope` and a script Bulwark + * can read. + */ + audienceAvailable: boolean; + /** + * Forwarding that is on, or an auto-reply for some senders only, is stored + * but does not run: both run only from the active filters script, and + * Stalwart's own vacation script took over (a save cut short, another + * client), or no script runs at all. Saving the card sets it right. + */ + notRunning: boolean; + /** + * The most forwards the filter rules let one message collect (see + * worstCaseForwards). Forwarding that keeps a copy runs ahead of them, so + * it shares the server's redirect limit with these. + */ + otherForwards: number; +} + +/** See VacationFilters.notRunning. */ +function storedButIdle( + filtersActive: boolean, + vacationActive: boolean, + forward: VacationForward | null, + audience: VacationAudience | null, +): boolean { + if (filtersActive) return false; + return !!forward?.enabled || (!!audience && vacationActive); +} + +/** The server runs the forwarding block: a redirect, its period, its spam check and the copy. */ +function canForward(capabilities: SieveCapabilities | null | undefined): boolean { + const extensions = capabilities?.sieveExtensions; + return capabilities?.maxNumberRedirects !== 0 && + supportsPeriods(extensions) && + supportsSpamGuard(extensions) && + !!extensions?.includes('copy'); +} + +export async function readVacationFilters(client: IJMAPClient, accountId?: string): Promise { const sieveAccountId = accountId || client.getSieveAccountId(); + const capabilities = client.getSieveCapabilities(sieveAccountId); const { vacationScript, target, parsed } = await loadManagedScript(client, sieveAccountId); - return !!(vacationScript && target?.isActive && parsed?.includeVacation); + const filtersUsable = supportsInclude(capabilities) && !(target && !parsed); + const forward = parsed?.vacationForward ?? null; + const audience = parsed?.vacationAudience ?? null; + return { + includesVacation: !!(vacationScript && target?.isActive && parsed?.includeVacation), + forward, + forwardAvailable: filtersUsable && (canForward(capabilities) || !!forward?.enabled), + audience, + // Told apart by the envelope sender, which the auto-reply goes to. + audienceAvailable: filtersUsable && !!capabilities?.sieveExtensions?.includes('envelope'), + notRunning: storedButIdle(!!target?.isActive, !!vacationScript?.isActive, forward, audience), + otherForwards: worstCaseForwards(parsed?.rules ?? []), + }; +} + +function same(a: T | null, b: T | null, normalize: (value: T) => T): boolean { + if (!a || !b) return a === b; + return JSON.stringify(normalize(a)) === JSON.stringify(normalize(b)); +} + +// The same moment, however it is written: the server hands the vacation's +// dates back in its own form. +function sameMoment(a: string | undefined, b: string | undefined): boolean { + if (a === undefined || b === undefined) return a === b; + return a === b || Date.parse(a) === Date.parse(b); +} + +function sameForward(a: VacationForward | null, b: VacationForward | null): boolean { + if (!a || !b) return a === b; + return a.enabled === b.enabled && a.to === b.to && a.keepCopy === b.keepCopy && + sameMoment(a.activeFrom, b.activeFrom) && sameMoment(a.activeUntil, b.activeUntil); } /** - * Keep the filters and the auto-reply both running after VacationResponse/set. + * Keep the filters, the auto-reply and its forwarding running after + * VacationResponse/set. * * Stalwart allows one active Sieve script and turns the auto-reply on by * activating its own "vacation" script, which switches every filter off. * When that happened, re-activate the filters script with an `include` of * the vacation script. When the auto-reply is turned off, drop the include. + * + * `forward` replaces the stored forwarding (null removes it, undefined keeps + * it). Forwarding runs with or without the auto-reply, but only from an + * active filters script, so for it the script is activated even without + * rules, and created when there is none. `audience` likewise replaces who + * gets the auto-reply (null: everyone). `period` is the vacation's, which + * whatever forwarding there is keeps to. */ export async function syncVacationWithFilters( client: IJMAPClient, enabled: boolean, accountId?: string, + forward?: VacationForward | null, + audience?: VacationAudience | null, + period?: { from: string | null; until: string | null }, ): Promise { const sieveAccountId = accountId || client.getSieveAccountId(); const capabilities = client.getSieveCapabilities(sieveAccountId); - if (enabled && !supportsInclude(capabilities)) return; + const canInclude = supportsInclude(capabilities); + if (enabled && !canInclude && forward === undefined && audience === undefined) return; const { vacationScript, target, parsed } = await loadManagedScript(client, sieveAccountId); - if (!target || !parsed) return; + const opaque = !!target && !parsed; + const storedForward = parsed?.vacationForward ?? null; + const storedAudience = parsed?.vacationAudience ?? null; + const requestedForward = forward === undefined ? storedForward : forward; + const nextForward = requestedForward && period ? withVacationPeriod(requestedForward, period) : requestedForward; + const nextAudience = audience === undefined ? storedAudience : audience; + const changed = + !sameForward(storedForward, nextForward) || + !same(storedAudience, nextAudience, normalizeVacationAudience); + if (changed) { + // The card only offers these where they can be stored and run. + if (opaque) throw new OpaqueFiltersError(); + if (!canInclude) throw new Error('Forwarding and reply recipients need the Sieve "include" extension'); + // The generator leaves out what it could not read back, and the save + // would look done while nothing forwards, or everyone gets the reply. + if (nextForward && !isValidVacationForward(nextForward)) throw new Error('Unusable vacation forwarding'); + if (nextAudience && !isValidVacationAudience(nextAudience)) throw new Error('Unusable vacation reply recipients'); + } + if ((enabled && !canInclude) || opaque) return; + + const rules = parsed?.rules ?? []; + const forwarding = !!nextForward?.enabled; if (enabled) { - // Only act when the vacation script took over from existing filters. - if (!vacationScript?.isActive || target.isActive || parsed.rules.length === 0) return; - } else if (!parsed.includeVacation) { - return; + // Act when the vacation script took over from filters that must keep + // running, or when something changed. An auto-reply for some senders + // only must run from the filters script: on its own it answers everyone. + const tookOver = !!vacationScript?.isActive && !target?.isActive; + const needsFilters = rules.length > 0 || forwarding || !!nextAudience; + if (!changed && !(tookOver && needsFilters)) return; + } else { + // Forwarding also runs without the auto-reply, from an active filters + // script; turning the auto-reply off can leave no script active. + const forwardingIdle = forwarding && !target?.isActive; + if (!changed && !parsed?.includeVacation && !forwardingIdle) return; } const content = await applyScriptTransforms( - generateScript(parsed.rules, parsed.vacation, { - externalRequires: parsed.externalRequires, + generateScript(rules, parsed?.vacation, { + externalRequires: parsed?.externalRequires, includeVacation: enabled, + vacationForward: nextForward, + vacationAudience: nextAudience, extensions: capabilities?.sieveExtensions, }), sieveAccountId, ); - await client.updateSieveScript(target.id, content, enabled || target.isActive, sieveAccountId); + const activate = enabled || forwarding || !!target?.isActive; + if (target) { + await client.updateSieveScript(target.id, content, activate, sieveAccountId); + } else { + await client.createSieveScript('filters', content, activate, sieveAccountId); + } debug.log('filters', enabled ? 'Filters now include the vacation script' : 'Removed the vacation include'); const store = useFilterStore.getState(); diff --git a/stores/vacation-store.ts b/stores/vacation-store.ts index 4057f93ab..cacebd65b 100644 --- a/stores/vacation-store.ts +++ b/stores/vacation-store.ts @@ -1,8 +1,26 @@ import { create } from 'zustand'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; -import { isVacationIncludedInFilters, syncVacationWithFilters } from '@/stores/filter-store'; +import type { VacationAudience, VacationForward } from '@/lib/jmap/sieve-types'; +import { readVacationFilters, syncVacationWithFilters, type VacationFilters } from '@/stores/filter-store'; +import { withVacationPeriod } from '@/lib/sieve/vacation-forward'; +import { currentStoreEpoch } from '@/lib/store-epoch'; import { debug } from '@/lib/debug'; +/** What the vacation card sets for forwarding; the period comes from the vacation. */ +export type VacationForwardSettings = Pick; + +/** + * The vacation response was saved, but what the filters script carries for + * it (forwarding, who gets the auto-reply) was not: forwarding may not run + * or still run, and the auto-reply may go to the wrong senders. + */ +export class VacationFiltersError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : 'filters_save_error'); + this.name = 'VacationFiltersError'; + } +} + interface VacationStore { isEnabled: boolean; fromDate: string | null; @@ -10,12 +28,25 @@ interface VacationStore { subject: string; textBody: string; htmlBody: string | null; + /** Forwarding as stored in the filters script; null when none is set up. */ + forward: VacationForward | null; + /** The account's filters script can carry forwarding (see readVacationFilters). */ + forwardAvailable: boolean; + /** Who gets the auto-reply as stored; null when everyone does. */ + audience: VacationAudience | null; + /** The auto-reply can be narrowed to some senders (see readVacationFilters). */ + audienceAvailable: boolean; + /** Stored forwarding or recipients do not run until the card is saved (see readVacationFilters). */ + notRunning: boolean; + /** The most forwards the filter rules let one message collect (see readVacationFilters). */ + otherForwards: number; isLoading: boolean; isSaving: boolean; error: string | null; isSupported: boolean; fetchVacationResponse: (client: IJMAPClient, accountId?: string) => Promise; + /** Throws VacationFiltersError when only the filters script part could not be saved. */ updateVacationResponse: (client: IJMAPClient, updates: { isEnabled?: boolean; fromDate?: string | null; @@ -23,33 +54,66 @@ interface VacationStore { subject?: string; textBody?: string; htmlBody?: string | null; + /** null removes the forwarding, leaving it out keeps it as it is. */ + forward?: VacationForwardSettings | null; + /** null lets everyone have the auto-reply, leaving it out keeps it as it is. */ + audience?: VacationAudience | null; }, accountId?: string) => Promise; setSupported: (supported: boolean) => void; clearState: () => void; } -export const useVacationStore = create()((set) => ({ +/** The card's part of what the filters script holds. */ +function fromFilters(filters: VacationFilters) { + return { + forward: filters.forward, + forwardAvailable: filters.forwardAvailable, + audience: filters.audience, + audienceAvailable: filters.audienceAvailable, + notRunning: filters.notRunning, + otherForwards: filters.otherForwards, + }; +} + +// Each load, and each account switch (the store epoch), outdates the answers +// of the loads before it: one for another account must not land in the card +// on screen, where saving would write it to this account. +let loadGeneration = 0; + +export const useVacationStore = create()((set, get) => ({ isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', htmlBody: null, + forward: null, + forwardAvailable: false, + audience: null, + audienceAvailable: false, + notRunning: false, + otherForwards: 0, isLoading: false, isSaving: false, error: null, isSupported: false, fetchVacationResponse: async (client, accountId) => { + const generation = ++loadGeneration; + const epoch = currentStoreEpoch(); + const stale = () => generation !== loadGeneration || currentStoreEpoch() !== epoch; set({ isLoading: true, error: null }); try { const vacation = await client.getVacationResponse(accountId); let isEnabled = vacation.isEnabled; - if (!isEnabled && client.supportsSieve()) { + let filters: VacationFilters | null = null; + if (client.supportsSieve()) { + filters = await readVacationFilters(client, accountId).catch(() => null); // Running from the filters script leaves the vacation script itself // inactive, so VacationResponse reports it as off. - isEnabled = await isVacationIncludedInFilters(client, accountId).catch(() => false); + if (filters) isEnabled = isEnabled || filters.includesVacation; } + if (stale()) return; set({ isEnabled, fromDate: vacation.fromDate, @@ -57,9 +121,17 @@ export const useVacationStore = create()((set) => ({ subject: vacation.subject || '', textBody: vacation.textBody || '', htmlBody: vacation.htmlBody, + forward: null, + forwardAvailable: false, + audience: null, + audienceAvailable: false, + notRunning: false, + otherForwards: 0, + ...(filters ? fromFilters(filters) : {}), isLoading: false, }); } catch (error) { + if (stale()) return; set({ isLoading: false, error: error instanceof Error ? error.message : 'fetch_error', @@ -68,42 +140,90 @@ export const useVacationStore = create()((set) => ({ }, updateVacationResponse: async (client, updates, accountId) => { + // The save itself goes to the account it was made for; only what it + // leaves in the store is dropped once another load or account took over. + const generation = loadGeneration; + const epoch = currentStoreEpoch(); + const superseded = () => generation !== loadGeneration || currentStoreEpoch() !== epoch; set({ isSaving: true, error: null }); + const { forward: forwardSettings, audience, ...vacationUpdates } = updates; try { - await client.setVacationResponse(updates, accountId); - if (updates.isEnabled !== undefined && client.supportsSieve()) { - try { - await syncVacationWithFilters(client, updates.isEnabled, accountId); - } catch (error) { - debug.error('Failed to keep filters active next to the vacation response:', error); - } - } - set((state) => ({ - ...state, - ...updates, - isSaving: false, - })); + await client.setVacationResponse(vacationUpdates, accountId); } catch (error) { - set({ - isSaving: false, - error: error instanceof Error ? error.message : 'save_error', - }); + set(superseded() + ? { isSaving: false } + : { isSaving: false, error: error instanceof Error ? error.message : 'save_error' }); throw error; } + + // Forwarding runs in the vacation's period, so it takes the dates along. + const state = get(); + const fromDate = vacationUpdates.fromDate !== undefined ? vacationUpdates.fromDate : state.fromDate; + const toDate = vacationUpdates.toDate !== undefined ? vacationUpdates.toDate : state.toDate; + const period = { from: fromDate, until: toDate }; + + let filtersError: VacationFiltersError | null = null; + let filtersState: Partial = {}; + const needsSync = vacationUpdates.isEnabled !== undefined || forwardSettings !== undefined || audience !== undefined; + if (needsSync && client.supportsSieve()) { + let synced = false; + try { + await syncVacationWithFilters( + client, vacationUpdates.isEnabled ?? state.isEnabled, accountId, forwardSettings, audience, period, + ); + synced = true; + } catch (error) { + debug.error('Failed to keep filters active next to the vacation response:', error); + // Forwarding that is or was on may now not run, or still run; an + // auto-reply meant for some senders may go to everyone, or the other + // way round. + if (forwardSettings?.enabled || state.forward?.enabled || audience || state.audience) { + filtersError = new VacationFiltersError(error); + } + } + // What the filters script holds now, and whether it runs. After a + // failed save the card keeps what the user set, to save it again. + const filters = await readVacationFilters(client, accountId).catch(() => null); + if (filters) { + filtersState = synced ? fromFilters(filters) : { notRunning: filters.notRunning }; + } else if (synced) { + filtersState = { + ...(forwardSettings !== undefined + ? { forward: forwardSettings && withVacationPeriod(forwardSettings, period) } + : {}), + ...(audience !== undefined ? { audience } : {}), + notRunning: false, + }; + } + } + + set((current) => (superseded() + ? { ...current, isSaving: false } + : { ...current, ...vacationUpdates, ...filtersState, isSaving: false })); + if (filtersError) throw filtersError; }, setSupported: (supported) => set({ isSupported: supported }), - clearState: () => set({ - isEnabled: false, - fromDate: null, - toDate: null, - subject: '', - textBody: '', - htmlBody: null, - isLoading: false, - isSaving: false, - error: null, - isSupported: false, - }), + clearState: () => { + loadGeneration++; + set({ + isEnabled: false, + fromDate: null, + toDate: null, + subject: '', + textBody: '', + htmlBody: null, + forward: null, + forwardAvailable: false, + audience: null, + audienceAvailable: false, + notRunning: false, + otherForwards: 0, + isLoading: false, + isSaving: false, + error: null, + isSupported: false, + }); + }, })); From 0c376dd0e45c8d19c82e5e93a3202244b38241cc Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 2 Oct 2026 12:42:40 +0200 Subject: [PATCH 02/35] fix: keep the focus in a dialog's field while the page behind renders again useFocusTrap started over whenever its onEscape changed, and callers pass a new function on every render. Each start moved the focus back to the opener and then to the dialog's first element, so a field lost the focus whenever the page behind rendered again: every 30 seconds through the connection check, or when new mail updated the folder counts. The trap now reads the latest onEscape from a ref and starts once per opening. A view change in a dialog (a list giving way to a form) can remove the element that had the focus. The restart used to pull it back by accident; the trap now does that on purpose, and only when the focused element was removed. --- .../__tests__/filter-settings-focus.test.tsx | 84 +++++++++++++++++ hooks/__tests__/use-focus-trap.test.tsx | 90 +++++++++++++++++++ hooks/use-focus-trap.ts | 35 +++++++- 3 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 components/settings/__tests__/filter-settings-focus.test.tsx create mode 100644 hooks/__tests__/use-focus-trap.test.tsx diff --git a/components/settings/__tests__/filter-settings-focus.test.tsx b/components/settings/__tests__/filter-settings-focus.test.tsx new file mode 100644 index 000000000..a5372fc9e --- /dev/null +++ b/components/settings/__tests__/filter-settings-focus.test.tsx @@ -0,0 +1,84 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { SieveCapabilities } from '@/lib/jmap/sieve-types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useFilterStore } from '@/stores/filter-store'; +import { FilterSettings } from '../filter-settings'; + +// The real settings page and its dialogs, against what happens in the +// background while the user types: the connection check every 30 seconds +// and a mailbox refresh. + +vi.mock('@/stores/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })); + +const CAPABILITIES: SieveCapabilities = { + implementation: 'Stalwart', + maxSizeScript: 102400, + sieveExtensions: ['fileinto', 'mailbox', 'imap4flags', 'copy'], + notificationMethods: [], + externalLists: [], + maxNumberRedirects: 1, +}; + +const initial = { + auth: useAuthStore.getState(), + email: useEmailStore.getState(), + filter: useFilterStore.getState(), +}; + +beforeEach(() => { + useAuthStore.setState({ client: { updateSieveScript: vi.fn(async () => {}), getSieveAccountId: () => 'primary' } as never }); + useEmailStore.setState({ fetchMailboxes: vi.fn(async () => {}) as never }); + useFilterStore.setState({ + rules: [], + isSupported: true, + isLoading: false, + error: null, + isOpaque: false, + activeScriptId: 'script-1', + selectedAccountId: 'primary', + sieveCapabilities: CAPABILITIES, + selectAccount: vi.fn(async () => {}), + }); +}); + +afterEach(() => { + useAuthStore.setState(initial.auth, true); + useEmailStore.setState(initial.email, true); + useFilterStore.setState(initial.filter, true); +}); + +function typeNewRule() { + render(); + fireEvent.click(screen.getByText('add_rule')); + const name = screen.getByPlaceholderText('rule_name_placeholder'); + name.focus(); + fireEvent.change(name, { target: { value: 'News' } }); + return name; +} + +describe('typing in a dialog on the filter page while things happen in the background', () => { + it('keeps the focus in the rule name through the connection check', () => { + const name = typeNewRule(); + // What the ping every 30 seconds does: it sets the connection state again. + act(() => useAuthStore.setState({ connectionLost: false })); + expect(document.activeElement).toBe(name); + }); + + it('keeps the focus in the rule name through a mailbox refresh', () => { + const name = typeNewRule(); + act(() => useEmailStore.setState({ mailboxes: [] })); + expect(document.activeElement).toBe(name); + }); + + it('keeps the focus in the Sieve editor through the connection check', () => { + render(); + fireEvent.click(screen.getByText('raw_editor')); + const script = screen.getByLabelText('script_content'); + script.focus(); + act(() => useAuthStore.setState({ connectionLost: false })); + expect(document.activeElement).toBe(script); + }); +}); diff --git a/hooks/__tests__/use-focus-trap.test.tsx b/hooks/__tests__/use-focus-trap.test.tsx new file mode 100644 index 000000000..4492caf62 --- /dev/null +++ b/hooks/__tests__/use-focus-trap.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import React, { useState } from 'react'; +import { useFocusTrap } from '../use-focus-trap'; + +function Dialog({ onEscape, note }: { onEscape: () => void; note?: string }) { + const ref = useFocusTrap({ isActive: true, onEscape }); + return ( +
+ + + {note &&

{note}

} +
+ ); +} + +function Page({ open, onEscape, note }: { open: boolean; onEscape: () => void; note?: string }) { + return ( + <> + + {open && } + + ); +} + +// Like the identity and template managers: a list that gives way to a form, +// taking the button that had the focus with it. +function Manager() { + const [editing, setEditing] = useState(false); + const ref = useFocusTrap({ isActive: true, onEscape: () => setEditing(false) }); + return ( +
+ + {editing + ? + : } +
+ ); +} + +describe('useFocusTrap', () => { + it('moves the focus in when it opens and back to the opener when it closes', () => { + const { rerender } = render( {}} />); + screen.getByText('opener').focus(); + rerender( {}} />); + expect(document.activeElement).toBe(screen.getByText('close')); + rerender( {}} />); + expect(document.activeElement).toBe(screen.getByText('opener')); + }); + + it('leaves the focus in the field while the page behind renders again', () => { + // A background request renders the page again, and the dialog gets a + // new onEscape: the field the user is typing in keeps the focus. + const { rerender } = render( {}} />); + const field = screen.getByLabelText('name'); + field.focus(); + rerender( {}} />); + rerender( {}} />); + expect(document.activeElement).toBe(field); + }); + + it('answers Escape with the onEscape of the latest render', () => { + const first = vi.fn(); + const latest = vi.fn(); + const { rerender } = render(); + rerender(); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(latest).toHaveBeenCalledTimes(1); + expect(first).not.toHaveBeenCalled(); + }); + + it('keeps the focus in the dialog when a view change removes the element that had it', async () => { + render(); + const edit = screen.getByText('edit'); + edit.focus(); + fireEvent.click(edit); + await waitFor(() => expect(document.activeElement).toBe(screen.getByText('close'))); + }); + + it('leaves a focus the user moved away alone when the dialog changes', async () => { + const { rerender } = render( {}} />); + const field = screen.getByLabelText('name'); + field.focus(); + field.blur(); + rerender( {}} note="saved" />); + await screen.findByText('saved'); + await Promise.resolve(); + expect(document.activeElement).toBe(document.body); + }); +}); diff --git a/hooks/use-focus-trap.ts b/hooks/use-focus-trap.ts index 0bec6891c..60dfe32a9 100644 --- a/hooks/use-focus-trap.ts +++ b/hooks/use-focus-trap.ts @@ -13,6 +13,14 @@ export function useFocusTrap({ }: UseFocusTrapOptions) { const containerRef = useRef(null); const previousActiveElement = useRef(null); + // Callers pass a new onEscape on every render. The trap reads the latest + // one from here instead of starting over for it: each new start moved the + // focus back to the opener and then to the first element, out of the field + // the user was typing in whenever the page behind rendered again. + const onEscapeRef = useRef(onEscape); + useEffect(() => { + onEscapeRef.current = onEscape; + }); useEffect(() => { if (!isActive || !containerRef.current) return; @@ -29,6 +37,13 @@ export function useFocusTrap({ ); }; + // The element in the dialog that had the focus last. + let lastFocused: HTMLElement | null = null; + const handleFocusIn = (e: FocusEvent) => { + lastFocused = e.target as HTMLElement; + }; + container.addEventListener('focusin', handleFocusIn); + // Focus first element const focusableElements = getFocusableElements(); const firstElement = focusableElements[0]; @@ -39,8 +54,8 @@ export function useFocusTrap({ // Handle Tab key to trap focus const handleKeyDown = (e: KeyboardEvent) => { // Handle Escape - if (e.key === 'Escape' && onEscape) { - onEscape(); + if (e.key === 'Escape' && onEscapeRef.current) { + onEscapeRef.current(); return; } @@ -68,8 +83,22 @@ export function useFocusTrap({ container.addEventListener('keydown', handleKeyDown); + // A view change in the dialog (a list giving way to a form) can remove + // the element that had the focus, which then drops to the page behind, + // out of the trap. Bring it back to the first element - only then, so a + // focus the user moved on purpose stays where it is. + const observer = new MutationObserver(() => { + const active = document.activeElement; + if (lastFocused && !lastFocused.isConnected && (!active || active === document.body)) { + getFocusableElements()[0]?.focus(); + } + }); + observer.observe(container, { childList: true, subtree: true }); + // Cleanup return () => { + observer.disconnect(); + container.removeEventListener('focusin', handleFocusIn); container.removeEventListener('keydown', handleKeyDown); // Restore focus to previous element @@ -77,7 +106,7 @@ export function useFocusTrap({ previousActiveElement.current.focus(); } }; - }, [isActive, onEscape, restoreFocus]); + }, [isActive, restoreFocus]); return containerRef; } From 0f7e6d0c16ee750ae4cd085d63575f62861d3107 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 2 Oct 2026 12:42:40 +0200 Subject: [PATCH 03/35] fix: keep an open filter dialog through a reload in the background A change to the Sieve script from another device or tab reloads the filters, and the page showed its loading or error state instead of its content. That took an open rule dialog or the Sieve editor with it, together with whatever was typed there. While a dialog is open, the page keeps what it has. --- .../__tests__/filter-settings-focus.test.tsx | 34 +++++++++++++++++-- components/settings/filter-settings.tsx | 9 +++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/components/settings/__tests__/filter-settings-focus.test.tsx b/components/settings/__tests__/filter-settings-focus.test.tsx index a5372fc9e..ebe473313 100644 --- a/components/settings/__tests__/filter-settings-focus.test.tsx +++ b/components/settings/__tests__/filter-settings-focus.test.tsx @@ -8,8 +8,9 @@ import { useFilterStore } from '@/stores/filter-store'; import { FilterSettings } from '../filter-settings'; // The real settings page and its dialogs, against what happens in the -// background while the user types: the connection check every 30 seconds -// and a mailbox refresh. +// background while the user types: the connection check every 30 seconds, +// a mailbox refresh, and a reload of the filters (another device changed +// the script). vi.mock('@/stores/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })); @@ -81,4 +82,33 @@ describe('typing in a dialog on the filter page while things happen in the backg act(() => useAuthStore.setState({ connectionLost: false })); expect(document.activeElement).toBe(script); }); + + it('keeps the rule dialog and what was typed through a reload of the filters', () => { + typeNewRule(); + act(() => useFilterStore.setState({ isLoading: true })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + act(() => useFilterStore.setState({ isLoading: false })); + expect(screen.getByPlaceholderText('rule_name_placeholder')).toHaveValue('News'); + }); + + it('keeps the Sieve editor and what was typed through a reload of the filters', () => { + render(); + fireEvent.click(screen.getByText('raw_editor')); + fireEvent.change(screen.getByLabelText('script_content'), { target: { value: 'keep;' } }); + act(() => useFilterStore.setState({ isLoading: true })); + act(() => useFilterStore.setState({ isLoading: false })); + expect(screen.getByLabelText('script_content')).toHaveValue('keep;'); + }); + + it('keeps the rule dialog through a reload that fails', () => { + typeNewRule(); + act(() => useFilterStore.setState({ error: 'offline' })); + expect(screen.getByPlaceholderText('rule_name_placeholder')).toHaveValue('News'); + }); + + it('still shows the loading state when no dialog is open', () => { + render(); + act(() => useFilterStore.setState({ isLoading: true })); + expect(screen.getByText('loading')).toBeInTheDocument(); + }); }); diff --git a/components/settings/filter-settings.tsx b/components/settings/filter-settings.tsx index 855515c61..5826234d6 100644 --- a/components/settings/filter-settings.tsx +++ b/components/settings/filter-settings.tsx @@ -432,7 +432,12 @@ export function FilterSettings() { ); } - if (isLoading) { + // A reload in the background (another device or tab changed the script) + // must not take away a dialog the user is typing in: while one is open, + // the page keeps what it has. + const editing = showRuleModal || showSieveEditor; + + if (isLoading && !editing) { return (
@@ -443,7 +448,7 @@ export function FilterSettings() { ); } - if (error) { + if (error && !editing) { return (
From 6025da0388104fcfaa5f47fa997a393485e4b179 Mon Sep 17 00:00:00 2001 From: owgreen-dev Date: Sat, 3 Oct 2026 11:40:11 -0400 Subject: [PATCH 04/35] fix(i18n): restore dropped and renamed placeholders in fa, he, hu, mn and sk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hu: {elválasztó} → {delimiter} (3 strings) - mn: {from}/{to} → {before}/{after}; account email/server/last-sync values held the label text instead of {email}/{server}/{time}; user+tag → user{delimiter}tag (2) - he: user+tag → user{delimiter}tag, with the missing spaces around the address - fa, sk: descriptions were cut short and lost {delimiter}, {appName}, {filename} and {ext}; completed them --- locales/fa/common.json | 6 +++--- locales/he/common.json | 2 +- locales/hu/common.json | 6 +++--- locales/mn/common.json | 12 ++++++------ locales/sk/common.json | 4 ++-- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/locales/fa/common.json b/locales/fa/common.json index 8492e987e..48aab407c 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1423,7 +1423,7 @@ }, "sub_address_delimiter": { "label": "جداکننده زیرآدرس", - "description": "کاراکتر جداکننده", + "description": "کاراکتری که نام کاربری را از برچسب زیرآدرس جدا می‌کند. با جداکننده‌ای که سرور ایمیل شما استفاده می‌کند مطابقت دهید (مثلاً user{delimiter}tag@domain.com).", "option": "{delimiter} (user{delimiter}tag@domain.com)", "custom": "سفارشی…", "custom_input_label": "کاراکتر سفارشی" @@ -1503,7 +1503,7 @@ }, "default_mail_program": { "label": "برنامه ایمیل پیش‌فرض", - "description": "ثبت برای mailto:", + "description": "{appName} را به‌عنوان برنامهٔ پیش‌فرض ایمیل برای پیوندهای mailto: ثبت کنید", "button": "تنظیم به عنوان پیش‌فرض", "success": "ثبت شد", "error": "پشتیبانی نمی‌شود" @@ -2049,7 +2049,7 @@ }, "attachment_template": { "label": "نام فایل پیوست", - "description": "قالب استفاده شده هنگام دانلود یا کشیدن پیوست." + "description": "قالبی که هنگام دانلود یا بیرون کشیدن پیوست استفاده می‌شود. اگر '{filename}' و '{ext}' را حذف کنید، پسوند اصلی فایل حفظ می‌شود." }, "bundle_template": { "label": "نام فایل .zip چند ایمیل", diff --git a/locales/he/common.json b/locales/he/common.json index 52252c30c..a59156144 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2464,7 +2464,7 @@ "use_address": "השתמש בכתובת זו", "invalid_tag": "התג חייב להיות אלפאנומרי ומקפים בלבד", "tag_too_long": "התג חייב להכיל 30 תווים או פחות", - "help_text": "הודעות דוא\"ל שנשלחות אלuser+tag@domain.comיגיעו לתיבת הדואר הנכנס שלך", + "help_text": "הודעות דוא\"ל שנשלחות אל user{delimiter}tag@domain.com יגיעו לתיבת הדואר הנכנס שלך", "validation": { "empty": "התג לא יכול להיות ריק", "too_long": "התג חייב להיות{max}תווים או פחות", diff --git a/locales/hu/common.json b/locales/hu/common.json index d0f3b6949..e248e4262 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1423,8 +1423,8 @@ }, "sub_address_delimiter": { "label": "Alcím elválasztó", - "description": "Karakter, amely elválasztja a felhasználónevet az alcím címkétől. Illeszkedjen a levelezőszerver által használt elválasztóhoz (pl. felhasználó{elválasztó}cimke@domain.com).", - "option": "{elválasztó} (felhasználó{elválasztó}cimke@domain.com)", + "description": "Karakter, amely elválasztja a felhasználónevet az alcím címkétől. Illeszkedjen a levelezőszerver által használt elválasztóhoz (pl. felhasználó{delimiter}cimke@domain.com).", + "option": "{delimiter} (felhasználó{delimiter}cimke@domain.com)", "custom": "Egyéni...", "custom_input_label": "Egyéni elválasztó karakter" }, @@ -2540,7 +2540,7 @@ "use_address": "Cím használata", "invalid_tag": "A címke csak betűket, számokat és kötőjeleket tartalmazhat", "tag_too_long": "A címke legfeljebb 30 karakter lehet", - "help_text": "A felhasználó{elválasztó}cimke@domain.com címre küldött e-mailek a beérkeződbe érkeznek", + "help_text": "A felhasználó{delimiter}cimke@domain.com címre küldött e-mailek a beérkeződbe érkeznek", "validation": { "empty": "A címke nem lehet üres", "too_long": "A címke legfeljebb {max} karakter lehet", diff --git a/locales/mn/common.json b/locales/mn/common.json index 6a78853fb..dd8599051 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -543,7 +543,7 @@ "change_location": "Байршил", "change_description": "Тодорхойлолт", "change_empty": "Байхгүй", - "change_from_to": "{from} → {to}", + "change_from_to": "{before} → {after}", "apply_proposal": "Санал болгож буй өөрчлөлтийг хэрэгжүүлэх", "proposal_applied": "Санал болгож буй өөрчлөлтүүдийг хэрэгжүүлсэн.", "review_proposal": "Саналыг хянан үзэх", @@ -1423,7 +1423,7 @@ }, "sub_address_delimiter": { "label": "Дэд хаяг хязгаарлагч", - "description": "Хэрэглэгчийн нэрийг дэд хаягийн шошгоос тусгаарлах тэмдэгт. Таны мэйл сервер ашигладаг хязгаарлагчтай тааруулна уу (жишээ нь user+tag@domain.com).", + "description": "Хэрэглэгчийн нэрийг дэд хаягийн шошгоос тусгаарлах тэмдэгт. Таны мэйл сервер ашигладаг хязгаарлагчтай тааруулна уу (жишээ нь user{delimiter}tag@domain.com).", "option": "{delimiter} (user{delimiter}tag@domain.com)", "custom": "Захиалгат…", "custom_input_label": "Захиалгат хязгаарлагч тэмдэгт" @@ -1623,11 +1623,11 @@ "demo_account": "Демо данс", "email": { "label": "Имэйл хаяг", - "value": "Имэйл" + "value": "{email}" }, "server": { "label": "JMAP сервер", - "value": "Сервер" + "value": "{server}" }, "storage": { "label": "Хадгалалтын ашиглалт", @@ -1636,7 +1636,7 @@ }, "last_sync": { "label": "Сүүлийн синк", - "value": "Сүүлд синк хийсэн" + "value": "{time}" }, "accounts": { "title": "Нэвтэрсэн бүртгэлүүд", @@ -2540,7 +2540,7 @@ "use_address": "Энэ хаягийг ашиглах", "invalid_tag": "Таг нь зөвхөн үсэг, тоо, зураастай байх ёстой", "tag_too_long": "Таг нь 30 тэмдэгт буюу түүнээс бага байх ёстой", - "help_text": "user+tag@domain.com руу ирсэн имэйлүүд таны ирсэн хайрцагт хүрнэ", + "help_text": "user{delimiter}tag@domain.com руу ирсэн имэйлүүд таны ирсэн хайрцагт хүрнэ", "validation": { "empty": "Таг хоосон байж болохгүй", "too_long": "Таг нь {max} буюу түүнээс бага тэмдэгт байх ёстой", diff --git a/locales/sk/common.json b/locales/sk/common.json index 5c978db8e..ae5f6dd87 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1423,7 +1423,7 @@ }, "sub_address_delimiter": { "label": "Oddeľovač podadresy", - "description": "Znak oddeľujúci používateľské meno od podadresy.", + "description": "Znak oddeľujúci používateľské meno od podadresy. Použite rovnaký oddeľovač, aký používa váš poštový server (napr. user{delimiter}tag@domain.com).", "option": "{delimiter} (pouzivatel{delimiter}stitok@domena.sk)", "custom": "Vlastný…", "custom_input_label": "Vlastný znak oddeľovača" @@ -2049,7 +2049,7 @@ }, "attachment_template": { "label": "Názov súboru prílohy", - "description": "Šablóna použitá pri sťahovaní alebo presunutí prílohy." + "description": "Šablóna použitá pri sťahovaní alebo presunutí prílohy. Ak vynecháte '{filename}' a '{ext}', zachová sa pôvodná prípona." }, "bundle_template": { "label": "Názov .zip súboru s viacerými e-mailmi", From 85b3bb86ed3531b0b72cf3e38d1956b1b85d2689 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:09:48 +0200 Subject: [PATCH 05/35] fix: keep the meeting link when duplicating an event #1170 --- components/calendar/calendar-app.tsx | 40 ++---------------- components/calendar/event-modal.tsx | 26 ++---------- lib/__tests__/calendar-duplicate.test.ts | 54 ++++++++++++++++++++++++ lib/calendar-duplicate.ts | 33 +++++++++++++++ 4 files changed, 93 insertions(+), 60 deletions(-) create mode 100644 lib/__tests__/calendar-duplicate.test.ts create mode 100644 lib/calendar-duplicate.ts diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index b24f5016a..4fe0a5c6d 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -50,7 +50,7 @@ import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context"; import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars"; import { ResizeHandle } from "@/components/layout/resize-handle"; -import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; +import { buildDuplicateEventData } from "@/lib/calendar-duplicate"; import { filterTasksByCalendars } from "@/lib/calendar-tasks"; import { baseEventStoreId, @@ -1171,24 +1171,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const handleDuplicateFromDetail = useCallback(async () => { if (!detailEvent || !client) return; - const start = parseISO(detailEvent.start); - const newStart = addDays(start, 1); - const data = sanitizeOutgoingCalendarEventData>({ - title: detailEvent.title, - description: detailEvent.description, - start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), - duration: detailEvent.duration, - timeZone: detailEvent.timeZone, - showWithoutTime: detailEvent.showWithoutTime, - calendarIds: { ...detailEvent.calendarIds }, - status: "confirmed", - freeBusyStatus: detailEvent.freeBusyStatus, - privacy: detailEvent.privacy, - }); - if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations); - if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules); - if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts); - if (detailEvent.participants) data.participants = structuredClone(detailEvent.participants); + const data = buildDuplicateEventData(detailEvent); closeDetail(); try { const created = await createEvent(client, data); @@ -1219,24 +1202,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => { if (!client) { toast.error(t("notifications.event_error")); return; } - const start = parseISO(event.start); - const newStart = addDays(start, 1); - const data = sanitizeOutgoingCalendarEventData>({ - title: event.title, - description: event.description, - start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), - duration: event.duration, - timeZone: event.timeZone, - showWithoutTime: event.showWithoutTime, - calendarIds: { ...event.calendarIds }, - status: "confirmed", - freeBusyStatus: event.freeBusyStatus, - privacy: event.privacy, - }); - if (event.locations) data.locations = structuredClone(event.locations); - if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules); - if (event.alerts) data.alerts = structuredClone(event.alerts); - if (event.participants) data.participants = structuredClone(event.participants); + const data = buildDuplicateEventData(event); try { const created = await createEvent(client, data); if (created) { diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 7108cdf59..e2eccfe67 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { LinkifiedText } from "@/components/ui/linkified-text"; import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "@/components/icons"; -import { format, parseISO, addHours, addDays, isSameDay } from "date-fns"; +import { format, parseISO, addHours, isSameDay } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; @@ -28,6 +28,7 @@ import { canUserRsvp, getEventEditability, canCreateEventsIn } from "@/lib/calen import { PluginSlot } from "@/components/plugins/plugin-slot"; import { useSettingsStore } from "@/stores/settings-store"; import { generateUUID } from "@/lib/utils"; +import { buildDuplicateEventData } from "@/lib/calendar-duplicate"; import { useFormatEventDate } from "@/hooks/use-format-event-date"; import { useIsPaneScoped } from "@/hooks/use-pane-context"; import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; @@ -657,28 +658,7 @@ export function EventModal({ const handleDuplicate = useCallback(() => { if (!event || !onDuplicate) return; - const start = getEventStartDate(event); - const newStart = addDays(start, 1); - const newUid = generateUUID(); - const data: Partial = { - uid: newUid, - title: event.title, - description: event.description, - start: event.showWithoutTime ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), - duration: event.duration, - timeZone: event.timeZone, - showWithoutTime: event.showWithoutTime, - calendarIds: { ...event.calendarIds }, - status: "confirmed", - freeBusyStatus: event.freeBusyStatus, - privacy: event.privacy, - }; - if (event.locations) data.locations = structuredClone(event.locations); - if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations); - if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules); - if (event.alerts) data.alerts = structuredClone(event.alerts); - if (event.participants) data.participants = structuredClone(event.participants); - onDuplicate(data); + onDuplicate(buildDuplicateEventData(event)); }, [event, onDuplicate]); const modalRef = useRef(null); diff --git a/lib/__tests__/calendar-duplicate.test.ts b/lib/__tests__/calendar-duplicate.test.ts new file mode 100644 index 000000000..fae3b804d --- /dev/null +++ b/lib/__tests__/calendar-duplicate.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import type { CalendarEvent } from '@/lib/jmap/types'; +import { buildDuplicateEventData } from '../calendar-duplicate'; + +function makeEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'ev1', + uid: 'original-uid', + title: 'Standup', + description: 'Daily sync', + start: '2026-10-05T09:30:00', + duration: 'PT30M', + timeZone: 'Europe/Berlin', + showWithoutTime: false, + calendarIds: { cal1: true }, + status: 'tentative', + ...overrides, + } as CalendarEvent; +} + +describe('buildDuplicateEventData', () => { + it('keeps the meeting link (#1170)', () => { + const virtualLocations = { + v1: { '@type': 'VirtualLocation', name: 'Video call', uri: 'https://meet.example.org/abc' }, + } as unknown as CalendarEvent['virtualLocations']; + const data = buildDuplicateEventData(makeEvent({ virtualLocations })); + expect(data.virtualLocations).toEqual(virtualLocations); + expect(data.virtualLocations).not.toBe(virtualLocations); + }); + + it('moves a timed event one day later in its own time zone with a fresh uid', () => { + const data = buildDuplicateEventData(makeEvent()); + expect(data).toMatchObject({ + title: 'Standup', + start: '2026-10-06T09:30:00', + duration: 'PT30M', + timeZone: 'Europe/Berlin', + calendarIds: { cal1: true }, + status: 'confirmed', + }); + expect(data.uid).toBeTruthy(); + expect(data.uid).not.toBe('original-uid'); + expect(data.id).toBeUndefined(); + }); + + it('sends all-day copies as floating midnight starts', () => { + const data = buildDuplicateEventData(makeEvent({ + start: '2026-10-05T00:00:00', + duration: 'P1D', + showWithoutTime: true, + })); + expect(data).toMatchObject({ start: '2026-10-06T00:00:00', showWithoutTime: true, timeZone: null }); + }); +}); diff --git a/lib/calendar-duplicate.ts b/lib/calendar-duplicate.ts new file mode 100644 index 000000000..19895c9b5 --- /dev/null +++ b/lib/calendar-duplicate.ts @@ -0,0 +1,33 @@ +import { addDays, format, parseISO } from "date-fns"; +import type { CalendarEvent } from "@/lib/jmap/types"; +import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; +import { generateUUID } from "@/lib/utils"; + +/** + * Create payload for a copy of `event` one day later. Shared by the editor, + * the detail popover and the context menu - each used to build its own copy + * and two of them dropped the meeting link (`virtualLocations`, #1170). + * The start stays in the event's own wall clock and time zone. + */ +export function buildDuplicateEventData(event: CalendarEvent): Partial { + const newStart = addDays(parseISO(event.start), 1); + const data: Partial = { + uid: generateUUID(), + title: event.title, + description: event.description, + start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), + duration: event.duration, + timeZone: event.timeZone, + showWithoutTime: event.showWithoutTime, + calendarIds: { ...event.calendarIds }, + status: "confirmed", + freeBusyStatus: event.freeBusyStatus, + privacy: event.privacy, + }; + if (event.locations) data.locations = structuredClone(event.locations); + if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations); + if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules); + if (event.alerts) data.alerts = structuredClone(event.alerts); + if (event.participants) data.participants = structuredClone(event.participants); + return sanitizeOutgoingCalendarEventData(data); +} From 7ebdf1db998537733b8afd3c5c42590c86568a43 Mon Sep 17 00:00:00 2001 From: sud Date: Tue, 6 Oct 2026 06:20:23 +0200 Subject: [PATCH 06/35] fix(i18n): translate the remaining English strings in the Czech locale Forty strings in locales/cs/common.json were still identical to English: removing an account, tinting list rows, the date-format region and time zone settings, inbox-only notifications, cache refresh, file sharing, contact groups, and a few folder roles and labels. Placeholders are unchanged and the file keeps its CRLF line endings. Co-Authored-By: Claude Fable 5.1 --- locales/cs/common.json | 80 +++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/locales/cs/common.json b/locales/cs/common.json index 487eb7fa7..1d2db2a9b 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -150,8 +150,8 @@ "unified_all_unread": "Vše nepřečtené", "unified_all_starred": "Vše s hvězdičkou", "unified_all_mail": "Veškerá pošta", - "remove_account": "Remove account", - "remove_account_confirm": "Remove {account} from this device? You can add it back later." + "remove_account": "Odebrat účet", + "remove_account_confirm": "Odebrat účet {account} z tohoto zařízení? Později ho můžete znovu přidat." }, "protocol_handlers": { "title": "Výchozí aplikace", @@ -305,7 +305,7 @@ "forward": "Přeposlat", "delete": "Odstranit", "archive": "Archivovat", - "archive_mailbox_not_found": "Archive mailbox not found - cannot archive email", + "archive_mailbox_not_found": "Složka Archiv nebyla nalezena – e-mail nelze archivovat", "star": "Označit hvězdičkou", "unstar": "Odebrat hvězdičku", "mark_unread": "Označit jako nepřečtené", @@ -540,7 +540,7 @@ "recipients_routing": "Příjemci a směrování", "authentication_security": "Ověřování a zabezpečení", "identifiers_threading": "Identifikátory a vlákna", - "mailing_list": "Mailing list", + "mailing_list": "Diskusní skupina", "message_properties": "Vlastnosti zprávy", "sent": "Odesláno", "received": "Přijato", @@ -622,8 +622,8 @@ "send": "Odeslat", "cancel": "Zrušit", "attach": "Připojit", - "attach_photos": "Photos & Videos", - "attach_files": "Files", + "attach_photos": "Fotky a videa", + "attach_files": "Soubory", "discard": "Zahodit", "discard_draft_title": "Zahodit koncept?", "discard_draft_confirm": "Máte neuložené změny. Chcete tento koncept zahodit?", @@ -1094,8 +1094,8 @@ "label": "Sjednocená schránka", "description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů", "cross_account": { - "label": "Across all accounts", - "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + "label": "Napříč všemi účty", + "description": "Sloučit sjednocenou schránku napříč všemi připojenými účty místo omezení na aktivní účet." }, "include_group": { "label": "Zahrnout skupinové schránky", @@ -1105,20 +1105,20 @@ "all_mail": { "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", - "account_hint": "Applies to {account}.", - "no_folders": "No folders available." + "account_hint": "Platí pro účet {account}.", + "no_folders": "Nejsou k dispozici žádné složky." }, "colorful_sidebar_icons": { "label": "Barevné ikony postranního panelu", "description": "Obarví ikony složek a štítků podle typu (modré Doručené, červený Spam, zelené Odeslané atd.). Vypněte pro jednobarevný postranní panel." }, "tint_list_rows": { - "label": "Tint List Rows by Tag Color", - "description": "Shade each message row with its first tag color. Disable to keep rows plain; tag dots and chips still show the color." + "label": "Podbarvit řádky barvou štítku", + "description": "Podbarvit každý řádek zprávy barvou jejího prvního štítku. Po vypnutí zůstanou řádky bílé; tečky a chipy štítků barvu dál zobrazují." }, "tint_list_rows_by_account": { - "label": "Tint List Rows by Account Color", - "description": "In the unified view, shade each row with its account color instead of showing the account dot. Takes precedence over the tag tint; tags keep their color on their chips." + "label": "Podbarvit řádky barvou účtu", + "description": "Ve sjednoceném zobrazení podbarvit každý řádek barvou účtu místo zobrazení tečky účtu. Má přednost před podbarvením štítkem; štítky si barvu na chipech ponechají." }, "show_folder_total_count": { "label": "Zobrazit celkový počet zpráv", @@ -1214,8 +1214,8 @@ "enabled_desc": "Zobrazovat oznámení při příchodu nových e-mailů", "sound": "Zvuk oznámení", "sound_desc": "Přehrát zvukové upozornění při příchodu nových e-mailů", - "inbox_only": "Inbox only", - "inbox_only_desc": "Only notify for mail that lands in the Inbox; skip mail a filter files into another folder" + "inbox_only": "Jen Doručené", + "inbox_only_desc": "Upozorňovat jen na poštu, která přijde do Doručených; poštu, kterou filtr uloží do jiné složky, přeskočit" }, "calendar": { "title": "Oznámení kalendáře", @@ -1244,7 +1244,7 @@ "status_busy": "Pracuji…", "status_inactive": "Není povoleno na tomto zařízení", "status_unsupported": "Tento prohlížeč nepodporuje Web Push", - "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_lite": "Web Push není v Bulwark Lite k dispozici; vyžaduje server Bulwark.", "title": "Oznámení na pozadí", "devices_title": "Registrovaná zařízení", "devices_desc": "Všechna zařízení registrovaná pro příjem oznamení na pozadí u tohoto účtu. Odvolání okamžitě zastaví jejich příjem.", @@ -1284,12 +1284,12 @@ "preview_older": "Starší:" }, "date_locale": { - "label": "Date format region", - "description": "How numeric dates are ordered (day, month, year)", - "auto": "Automatic (match language)", - "iso": "ISO 8601 (YYYY-MM-DD)", - "dmy": "Day/Month/Year", - "mdy": "Month/Day/Year" + "label": "Region formátu data", + "description": "Pořadí částí číselného data (den, měsíc, rok)", + "auto": "Automaticky (podle jazyka)", + "iso": "ISO 8601 (RRRR-MM-DD)", + "dmy": "Den/měsíc/rok", + "mdy": "Měsíc/den/rok" }, "time_format": { "label": "Formát času", @@ -1304,10 +1304,10 @@ "monday": "Pondělí" }, "time_zone": { - "label": "Time zone", - "description": "Show email and calendar times in this time zone instead of the one reported by your browser", - "auto": "Automatic ({zone})", - "preview_now": "Now:" + "label": "Časové pásmo", + "description": "Zobrazovat časy e-mailů a kalendáře v tomto časovém pásmu místo pásma hlášeného prohlížečem", + "auto": "Automaticky ({zone})", + "preview_now": "Nyní:" } }, "email_behavior": { @@ -1614,7 +1614,7 @@ "username_label": "Uživatelské jméno", "account_type_label": "Typ účtu", "auth_method_label": "Ověřování", - "auth_method_oauth": "Single Sign-On (OAuth/OIDC)", + "auth_method_oauth": "Jednotné přihlášení (OAuth/OIDC)", "auth_method_basic": "Heslo", "auth_method_token": "Přístupový token", "demo_account": "Demo účet", @@ -1879,8 +1879,8 @@ "role_trash": "Koš", "role_junk": "Spam / Nevyžádaná pošta", "role_archive": "Archiv", - "role_scheduled": "Scheduled", - "role_snoozed": "Snoozed", + "role_scheduled": "Naplánováno", + "role_snoozed": "Odloženo", "role_none": "Žádná", "create_folder": "Vytvořit složku", "create_subfolder": "Vytvořit podsložku", @@ -1953,9 +1953,9 @@ "button": "Zobrazit zkratky" }, "refresh_cache": { - "label": "Refresh cached data", - "description": "Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.", - "button": "Refresh" + "label": "Obnovit data z mezipaměti", + "description": "Znovu načíst kontakty, kalendáře a složky ze serveru. Účty a přihlášení zůstanou zachovány – opraví zastaralé nebo chybné zobrazení bez odhlášení.", + "button": "Obnovit" }, "reset_settings": { "label": "Resetovat nastavení", @@ -2835,11 +2835,11 @@ "search_members": "Hledat kontakty k přidání...", "no_members": "Tato skupina nemá žádné členy", "member_count": "{count, plural, =0 {Žádní členové} one {1 člen} few {# členové} other {# členů}}", - "send_email": "Send email to group", + "send_email": "Poslat e-mail skupině", "send_email_to": "To", "send_email_cc": "Cc", "send_email_bcc": "Bcc", - "no_member_emails": "This group has no members with an email address." + "no_member_emails": "Tato skupina nemá žádné členy s e-mailovou adresou." }, "import": { "title": "Importovat kontakty", @@ -3511,10 +3511,10 @@ "duplicate": "Duplikovat", "duplicate_success": "Úspěšně duplikováno", "duplicate_error": "Duplikování selhalo", - "share": "Share", - "shared": "Shared", - "shared_with_me": "Shared with me", - "shared_by": "Shared by {name}", + "share": "Sdílet", + "shared": "Sdíleno", + "shared_with_me": "Sdíleno se mnou", + "shared_by": "Sdílí {name}", "create_file_success": "Soubor byl vytvořen", "create_file_error": "Vytvoření souboru selhalo", "favorites": "Oblíbené", @@ -3795,7 +3795,7 @@ "account_error": "Nepodařilo se prohledat {account}", "recent_searches": "Nedávná hledání", "recurring": "Opakovaná", - "no_subject": "(no subject)", + "no_subject": "(bez předmětu)", "empty_hint": "Hledejte poštu, kontakty, události a soubory ve všech účtech. Zkuste from:, subject:, has:attachment, after:2026-01-01.", "flat_by_date": "Ploše podle data", "grouped": "Seskupené", From 61fc130a74fb256806b4fa36db19b22e1346d662 Mon Sep 17 00:00:00 2001 From: sud Date: Tue, 6 Oct 2026 06:24:17 +0200 Subject: [PATCH 07/35] fix: show shared and group calendars in the Calendar Agenda The agenda sidecar queried only the session's primary calendar account, so events in a shared or group account (a family or team calendar) never reached the Calendar Agenda plugin, although the calendar app shows them. Read every account the calendar app reads: the primary account plus accounts that advertise the calendar capability or are not personal, mirroring the client's getCalendarCapableAccountIds(). Event and calendar ids repeat across accounts, so the DTO id is scoped as : and carries accountId; calendar colours are looked up per account. A shared account whose calendars cannot be read is skipped instead of failing the whole agenda; a failure of the primary account is still reported. Co-Authored-By: Claude Fable 5.1 --- app/api/calendar-agenda/route.ts | 196 ++++++++++++-------- lib/__tests__/calendar-agenda-route.test.ts | 140 +++++++++++++- 2 files changed, 258 insertions(+), 78 deletions(-) diff --git a/app/api/calendar-agenda/route.ts b/app/api/calendar-agenda/route.ts index 4415a0ed6..7c2f199ca 100644 --- a/app/api/calendar-agenda/route.ts +++ b/app/api/calendar-agenda/route.ts @@ -12,10 +12,15 @@ import type { CalendarEvent } from '@/lib/jmap/types'; * POST /api/calendar-agenda * * Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar - * account from the stored Stalwart auth context, queries upcoming + * accounts from the stored Stalwart auth context, queries upcoming * CalendarEvents over JMAP, expands recurring series server-side, and returns * a slim, structured-cloneable agenda the plugin can render directly. * + * Events come from every account that has calendars: the primary calendar + * account plus shared and group accounts (a family or team calendar), the + * same set the calendar app shows. Ids are scoped by account, since event + * and calendar ids repeat across accounts. + * * Credentials never leave the server — the sandboxed plugin only ever sees * the resulting agenda DTOs. * @@ -35,7 +40,9 @@ const EVENT_PROPERTIES = [ ] as const; interface AgendaEvent { + /** Unique across accounts: `:`. */ id: string; + accountId: string; uid: string | null; title: string; start: string; @@ -131,8 +138,8 @@ export async function POST(request: NextRequest) { if (!session) { return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 }); } - const accountId = session.primaryAccounts?.[CALENDAR_CAP]; - if (!accountId) { + const accountIds = calendarAccountIds(session); + if (accountIds.length === 0) { // No calendar account for this user — return an empty agenda, not an error. return NextResponse.json({ events: [], generatedAt: new Date().toISOString() }); } @@ -153,85 +160,94 @@ export async function POST(request: NextRequest) { const windowStart = new Date(now); windowStart.setHours(0, 0, 0, 0); - // ── 1) Query event IDs in range + load calendars (colours) ── - const queryReq = { - using, - methodCalls: [ - [ - 'CalendarEvent/query', - { - accountId, - // Mirror the app's calendar store: an { after, before } window lets - // Stalwart evaluate recurrence so masters with occurrences in range - // are returned (a `before`-only filter can drop unbounded series). - filter: { after: windowStart.toISOString(), before: horizon.toISOString() }, - limit: 1000, - }, - '0', - ], - [ - 'Calendar/get', - { accountId, ids: null, properties: ['id', 'name', 'color'] }, - 'c', - ], - ], - }; - - const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq, fetchOptions); - const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0'); - if (!queryResp) { - const err = findResponse(queryRes, 'error', '0'); - return NextResponse.json( - { error: (err?.description as string) || 'CalendarEvent/query failed' }, - { status: 502 }, - ); - } - const ids = (queryResp.ids as string[]) || []; - + // ── 1–3) Per account: query ids in range, load calendars (colours), + // fetch the events, normalize and expand recurrences server-side ── const calColors = new Map(); - const calResp = findResponse(queryRes, 'Calendar/get', 'c'); - for (const cal of ((calResp?.list as Array>) || [])) { - if (typeof cal.id === 'string') { - calColors.set(cal.id, { - name: typeof cal.name === 'string' ? cal.name : '', - color: typeof cal.color === 'string' ? cal.color : null, - }); - } - } + const expanded: Array = []; - if (ids.length === 0) { - return NextResponse.json({ events: [], generatedAt: now.toISOString() }); - } - - // ── 2) Fetch full event objects (batched) ── - const raw: Array> = []; - const BATCH = 100; - for (let i = 0; i < ids.length; i += BATCH) { - const batch = ids.slice(i, i + BATCH); - const getRes = await jmapPost(apiUrl, creds.authHeader, { + for (const accountId of accountIds) { + const queryReq = { using, methodCalls: [ - ['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'], + [ + 'CalendarEvent/query', + { + accountId, + // Mirror the app's calendar store: an { after, before } window lets + // Stalwart evaluate recurrence so masters with occurrences in range + // are returned (a `before`-only filter can drop unbounded series). + filter: { after: windowStart.toISOString(), before: horizon.toISOString() }, + limit: 1000, + }, + '0', + ], + [ + 'Calendar/get', + { accountId, ids: null, properties: ['id', 'name', 'color'] }, + 'c', + ], ], - }, fetchOptions); - const getResp = findResponse(getRes, 'CalendarEvent/get', '0'); - if (getResp?.list) raw.push(...(getResp.list as Array>)); + }; + + const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq, fetchOptions); + const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0'); + if (!queryResp) { + const err = findResponse(queryRes, 'error', '0'); + if (accountId !== accountIds[0]) { + // A shared account the caller can see but not read calendars from + // (Stalwart answers forbidden/accountNotSupported) must not empty + // the agenda for the accounts that do work. + logger.debug('Calendar agenda: skipping account', { + accountId, + error: (err?.type as string) || 'CalendarEvent/query failed', + }); + continue; + } + return NextResponse.json( + { error: (err?.description as string) || 'CalendarEvent/query failed' }, + { status: 502 }, + ); + } + const ids = (queryResp.ids as string[]) || []; + + const calResp = findResponse(queryRes, 'Calendar/get', 'c'); + for (const cal of ((calResp?.list as Array>) || [])) { + if (typeof cal.id === 'string') { + calColors.set(scopedId(accountId, cal.id), { + name: typeof cal.name === 'string' ? cal.name : '', + color: typeof cal.color === 'string' ? cal.color : null, + }); + } + } + + if (ids.length === 0) continue; + + const raw: Array> = []; + const BATCH = 100; + for (let i = 0; i < ids.length; i += BATCH) { + const batch = ids.slice(i, i + BATCH); + const getRes = await jmapPost(apiUrl, creds.authHeader, { + using, + methodCalls: [ + ['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'], + ], + }, fetchOptions); + const getResp = findResponse(getRes, 'CalendarEvent/get', '0'); + if (getResp?.list) raw.push(...(getResp.list as Array>)); + } + + const normalized = raw + .map((e) => normalizeCalendarEventLike(e as Partial)) + .filter((e) => (e['@type'] ?? 'Event') === 'Event') + // Drop malformed events without a parseable start (would crash format()/ + // parseISO downstream) — mirrors the calendar store guard (#316). + .filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[]; + + for (const e of expandRecurringEvents(normalized, windowStart.toISOString(), horizon.toISOString())) { + expanded.push({ ...e, accountId }); + } } - // ── 3) Normalize + expand recurrences server-side ── - const normalized = raw - .map((e) => normalizeCalendarEventLike(e as Partial)) - .filter((e) => (e['@type'] ?? 'Event') === 'Event') - // Drop malformed events without a parseable start (would crash format()/ - // parseISO downstream) — mirrors the calendar store guard (#316). - .filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[]; - - const expanded = expandRecurringEvents( - normalized, - windowStart.toISOString(), - horizon.toISOString(), - ); - // ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ── const agenda: AgendaEvent[] = expanded .filter((e) => eventEnd(e).getTime() >= now.getTime()) @@ -239,9 +255,10 @@ export async function POST(request: NextRequest) { .slice(0, limit) .map((e) => { const calId = firstCalendarId(e); - const cal = calId ? calColors.get(calId) : undefined; + const cal = calId ? calColors.get(scopedId(e.accountId, calId)) : undefined; return { - id: String(e.id ?? ''), + id: scopedId(e.accountId, String(e.id ?? '')), + accountId: e.accountId, uid: e.uid ?? null, title: (e.title ?? '').trim() || '(no title)', start: eventStart(e).toISOString(), @@ -268,6 +285,31 @@ export async function POST(request: NextRequest) { } } +/** + * The accounts to read calendars from, primary first. Mirrors the client's + * getCalendarCapableAccountIds(): shared and group accounts count even when + * Stalwart does not advertise the calendar capability on them. + */ +function calendarAccountIds(session: { + primaryAccounts?: Record; + accounts?: Record; +}): string[] { + const primary = session.primaryAccounts?.[CALENDAR_CAP]; + const ids: string[] = primary ? [primary] : []; + for (const [id, value] of Object.entries(session.accounts ?? {})) { + if (id === primary || !value || typeof value !== 'object') continue; + const account = value as { isPersonal?: unknown; accountCapabilities?: Record }; + if (account.accountCapabilities?.[CALENDAR_CAP] || account.isPersonal === false) { + ids.push(id); + } + } + return ids; +} + +function scopedId(accountId: string, id: string): string { + return `${accountId}:${id}`; +} + function clampInt(value: unknown, min: number, max: number, fallback: number): number { const n = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(n)) return fallback; diff --git a/lib/__tests__/calendar-agenda-route.test.ts b/lib/__tests__/calendar-agenda-route.test.ts index bb1264526..a59749083 100644 --- a/lib/__tests__/calendar-agenda-route.test.ts +++ b/lib/__tests__/calendar-agenda-route.test.ts @@ -27,14 +27,70 @@ const mockPost = postJmap as unknown as Mock; const CALENDAR_CAP = 'urn:ietf:params:jmap:calendars'; -function makeSession(capabilities: Record) { +function makeSession(capabilities: Record, accounts?: Record) { return { capabilities, primaryAccounts: { [CALENDAR_CAP]: 'acct-1' }, + accounts, apiUrl: 'https://mail.example.com/jmap/', }; } +const BASE_CAPS = { 'urn:ietf:params:jmap:core': {}, [CALENDAR_CAP]: {} }; + +/** An event tomorrow, as Stalwart returns it (local date-time plus zone). */ +function upcomingEvent(id: string, title: string, calendarId: string) { + const start = new Date(Date.now() + 24 * 60 * 60 * 1000); + start.setSeconds(0, 0); + return { + id, + '@type': 'Event', + uid: `${id}@example.com`, + title, + calendarIds: { [calendarId]: true }, + start: start.toISOString().slice(0, 19), + timeZone: 'Etc/UTC', + duration: 'PT1H', + }; +} + +type Call = [string, Record, string]; + +/** + * Answers each JMAP request from a per-account fixture; `accountId` is read + * off the method call so the test sees which account the route asked for. + */ +function answerPerAccount(fixtures: Record[]; + calendars?: Array<{ id: string; name: string; color: string | null }>; + queryError?: string; +}>) { + mockPost.mockImplementation(async (_url: string, _auth: string, body: string) => { + const { methodCalls } = JSON.parse(body) as { methodCalls: Call[] }; + const methodResponses = methodCalls.map(([name, args, callId]) => { + const fixture = fixtures[args.accountId as string] ?? {}; + if (name === 'CalendarEvent/query') { + if (fixture.queryError) return ['error', { type: fixture.queryError }, callId]; + return [name, { ids: (fixture.events ?? []).map((e) => e.id) }, callId]; + } + if (name === 'Calendar/get') return [name, { list: fixture.calendars ?? [] }, callId]; + if (name === 'CalendarEvent/get') { + const ids = args.ids as string[]; + return [name, { list: (fixture.events ?? []).filter((e) => ids.includes(e.id)) }, callId]; + } + return ['error', { type: 'unknownMethod' }, callId]; + }); + return { ok: true, json: async () => ({ methodResponses }) }; + }); +} + +function queriedAccountIds(): string[] { + return mockPost.mock.calls.flatMap((call) => { + const { methodCalls } = JSON.parse(call[2] as string) as { methodCalls: Call[] }; + return methodCalls.filter(([name]) => name === 'CalendarEvent/query').map(([, args]) => args.accountId as string); + }); +} + function makeReq(): Parameters[0] { return { method: 'POST', headers: new Headers(), json: async () => ({}) } as unknown as Parameters[0]; } @@ -86,3 +142,85 @@ describe('calendar-agenda route (principals:owner declaration)', () => { expect(using).toContain('urn:ietf:params:jmap:principals:owner'); }); }); + +describe('calendar-agenda route (shared and group accounts)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreds.mockResolvedValue({ + serverUrl: 'https://mail.example.com', + username: 'user@example.com', + authHeader: 'Basic abc', + }); + }); + + it('includes events from a shared group account next to the primary account', async () => { + mockSession.mockResolvedValue(makeSession(BASE_CAPS, { + 'acct-1': { name: 'user@example.com', isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } }, + // Stalwart does not advertise the calendar capability on group accounts + 'acct-family': { name: 'family@example.com', isPersonal: false, accountCapabilities: {} }, + })); + answerPerAccount({ + 'acct-1': { + events: [upcomingEvent('ev-1', 'Dentist', 'cal-1')], + calendars: [{ id: 'cal-1', name: 'Personal', color: '#3b82f6' }], + }, + 'acct-family': { + events: [upcomingEvent('ev-1', 'Holiday', 'cal-1')], + calendars: [{ id: 'cal-1', name: 'Family', color: '#a855f7' }], + }, + }); + + const res = await POST(makeReq()); + expect(res.status).toBe(200); + const { events } = await res.json() as { events: Array> }; + + expect(queriedAccountIds()).toEqual(['acct-1', 'acct-family']); + expect(events.map((e) => e.title).sort()).toEqual(['Dentist', 'Holiday']); + // Ids repeat across accounts, so each event is scoped by its account and + // the colour comes from that account's calendar, not a same-named one. + const holiday = events.find((e) => e.title === 'Holiday')!; + expect(holiday.id).toBe('acct-family:ev-1'); + expect(holiday.accountId).toBe('acct-family'); + expect(holiday.color).toBe('#a855f7'); + expect(events.find((e) => e.title === 'Dentist')!.color).toBe('#3b82f6'); + }); + + it('leaves out personal accounts without calendars and the primary account itself', async () => { + mockSession.mockResolvedValue(makeSession(BASE_CAPS, { + 'acct-1': { isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } }, + 'acct-mail-only': { isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } }, + 'acct-shared': { isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } }, + })); + answerPerAccount({}); + + const res = await POST(makeReq()); + expect(res.status).toBe(200); + expect(queriedAccountIds()).toEqual(['acct-1', 'acct-shared']); + }); + + it('skips a shared account whose calendars cannot be read instead of failing the agenda', async () => { + mockSession.mockResolvedValue(makeSession(BASE_CAPS, { + 'acct-1': { isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } }, + 'acct-team': { isPersonal: false }, + })); + answerPerAccount({ + 'acct-1': { events: [upcomingEvent('ev-1', 'Standup', 'cal-1')] }, + 'acct-team': { queryError: 'forbidden' }, + }); + + const res = await POST(makeReq()); + expect(res.status).toBe(200); + const { events } = await res.json() as { events: Array> }; + expect(events.map((e) => e.title)).toEqual(['Standup']); + }); + + it('still reports a failure of the primary account', async () => { + mockSession.mockResolvedValue(makeSession(BASE_CAPS, { + 'acct-1': { isPersonal: true, accountCapabilities: { [CALENDAR_CAP]: {} } }, + })); + answerPerAccount({ 'acct-1': { queryError: 'serverFail' } }); + + const res = await POST(makeReq()); + expect(res.status).toBe(502); + }); +}); From 45c7e2c3604193c8467daad61c9457b5da0303f7 Mon Sep 17 00:00:00 2001 From: sud Date: Tue, 6 Oct 2026 06:54:06 +0200 Subject: [PATCH 08/35] fix: create the Archive folder on first use when the account has none Stalwart's default folder set has no archive-role mailbox, so on a fresh account Archive did nothing but raise the not-found toast (#578). Both archive paths now go through ensureArchiveMailbox(), which creates "Archive" with the archive role in the account the action targets (own, the selected shared folder's owner, or the pinned account in unified view), reloads the mailbox list and continues with the store's own object. createMailbox() takes an optional role for this. A refused creation or a folder that does not come back still surfaces as an error. Co-Authored-By: Claude Fable 5.1 --- components/mail/mail-app.tsx | 24 +++-- lib/demo/demo-client.ts | 3 +- lib/jmap/client-interface.ts | 2 +- lib/jmap/client.ts | 6 +- .../email-store-archive-auto-create.test.ts | 99 +++++++++++++++++++ .../email-store-archive-missing.test.ts | 38 ++++++- stores/email-store.ts | 47 ++++++++- 7 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 stores/__tests__/email-store-archive-auto-create.test.ts diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index b51b830d9..81bd343a2 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -31,7 +31,7 @@ import { usePolicyStore } from "@/stores/policy-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { connectedAccountsGrew } from "@/lib/unified-mailbox"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; -import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, findArchiveMailbox, resolveUnstampedEmailAccountId, emptyFolderMovesToTrash } from "@/stores/email-store"; +import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveUnstampedEmailAccountId, emptyFolderMovesToTrash } from "@/stores/email-store"; import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; import { toast } from "@/stores/toast-store"; import { formatRejectedRecipients, type JMAPClient } from "@/lib/jmap/client"; @@ -2362,17 +2362,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const currentMailboxes = readMailboxes(); - // Scope like batchArchive: the owning account in unified view, otherwise - // the selected shared folder's owner, otherwise the user's own archive. (#889) - const archiveMailbox = findArchiveMailbox(currentMailboxes, selectedMailbox, archiveAccountId); - if (!archiveMailbox) { - toast.error(t('email_viewer.archive_mailbox_not_found')); - return; - } - const { archiveMode } = useSettingsStore.getState(); try { + // Scope like batchArchive: the owning account in unified view, otherwise + // the selected shared folder's owner, otherwise the user's own archive + // (#889); created on first use when the account has none (#578). + const archiveMailbox = await ensureArchiveMailbox({ + client: archiveClient, + mailboxes: currentMailboxes, + selectedMailboxId: selectedMailbox, + accountId: archiveAccountId, + refresh: async () => { + await refreshMailboxes(); + return readMailboxes(); + }, + }); + if (archiveMode === 'single') { await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id); } else { diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 312d408c8..f2398343f 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -140,10 +140,11 @@ export class DemoJMAPClient implements IJMAPClient { async getMailboxes(_accountId?: string): Promise { return [...this.data.mailboxes]; } async getAllMailboxes(): Promise { return [...this.data.mailboxes]; } - async createMailbox(name: string, parentId?: string, _accountId?: string): Promise { + async createMailbox(name: string, parentId?: string, _accountId?: string, options?: { role?: string }): Promise { const mb: Mailbox = { id: generateDemoId('mailbox'), name, + role: options?.role, sortOrder: 100, totalEmails: 0, unreadEmails: 0, diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 41eef9402..96cbe87f3 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -137,7 +137,7 @@ export interface IJMAPClient { getMailboxChanges?(sinceState: string, accountId?: string, maxChanges?: number): Promise; /** Email/changes since `sinceState`; null when the server cannot compute the delta. */ getEmailChanges?(sinceState: string, accountId?: string, maxChanges?: number): Promise; - createMailbox(name: string, parentId?: string, accountId?: string): Promise; + createMailbox(name: string, parentId?: string, accountId?: string, options?: { role?: string }): Promise; updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, accountId?: string): Promise; // `removeEmails` destroys the folder's messages too (onDestroyRemoveEmails, // RFC 8621 §2.5) instead of failing with mailboxHasEmail. diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 63aa729b9..fbbcfd04c 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -3167,7 +3167,7 @@ export class JMAPClient implements IJMAPClient { this.assertEmailSetSucceeded(response, "mark as not spam"); } - async createMailbox(name: string, parentId?: string, accountId?: string): Promise { + async createMailbox(name: string, parentId?: string, accountId?: string, options?: { role?: string }): Promise { const targetAccountId = accountId || this.accountId; const createId = `new-${Date.now()}`; // Subscribe explicitly: IMAP clients that list folders via LSUB @@ -3177,6 +3177,9 @@ export class JMAPClient implements IJMAPClient { if (parentId) { createData.parentId = parentId; } + if (options?.role) { + createData.role = options.role; + } const response = await this.request([ ["Mailbox/set", { @@ -3205,6 +3208,7 @@ export class JMAPClient implements IJMAPClient { id: created.id, name, parentId, + role: options?.role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, diff --git a/stores/__tests__/email-store-archive-auto-create.test.ts b/stores/__tests__/email-store-archive-auto-create.test.ts new file mode 100644 index 000000000..9e4f20e47 --- /dev/null +++ b/stores/__tests__/email-store-archive-auto-create.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ensureArchiveMailbox, ArchiveMailboxNotFoundError } from '../email-store'; +import type { Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// A fresh Stalwart account has no archive-role mailbox, so Archive used to +// stop at the not-found toast. The folder is now created on first use, in the +// account the action targets. (#578) + +function makeMailbox(overrides: Partial = {}): Mailbox { + return { + id: 'inbox', + name: 'Inbox', + sortOrder: 0, + totalEmails: 0, + unreadEmails: 0, + totalThreads: 0, + unreadThreads: 0, + isSubscribed: true, + myRights: { + mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, + maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true, + }, + ...overrides, + }; +} + +const ownInbox = makeMailbox({ id: 'a-inbox', role: 'inbox' }); +const ownArchive = makeMailbox({ id: 'a-archive', name: 'Archive', role: 'archive' }); +const sharedInbox = makeMailbox({ + id: 'owner-x:x-inbox', originalId: 'x-inbox', role: 'inbox', isShared: true, accountId: 'owner-x', +}); +const sharedArchive = makeMailbox({ + id: 'owner-x:x-archive', originalId: 'x-archive', name: 'Archive', role: 'archive', isShared: true, accountId: 'owner-x', +}); + +function makeClient(): IJMAPClient & { createMailbox: ReturnType } { + return { createMailbox: vi.fn(async () => ownArchive) } as unknown as IJMAPClient & { createMailbox: ReturnType }; +} + +describe('ensureArchiveMailbox', () => { + it('returns the existing archive folder without creating one', async () => { + const client = makeClient(); + const refresh = vi.fn(); + const result = await ensureArchiveMailbox({ + client, mailboxes: [ownInbox, ownArchive], selectedMailboxId: 'a-inbox', refresh, + }); + expect(result).toBe(ownArchive); + expect(client.createMailbox).not.toHaveBeenCalled(); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('creates an archive-role folder in the own account and returns it from the refreshed list', async () => { + const client = makeClient(); + const refresh = vi.fn(async () => [ownInbox, ownArchive]); + const result = await ensureArchiveMailbox({ + client, mailboxes: [ownInbox], selectedMailboxId: 'a-inbox', refresh, + }); + expect(client.createMailbox).toHaveBeenCalledWith('Archive', undefined, undefined, { role: 'archive' }); + expect(refresh).toHaveBeenCalledTimes(1); + expect(result).toBe(ownArchive); + }); + + it('creates the folder in the selected shared folder\'s owner account', async () => { + const client = makeClient(); + const refresh = vi.fn(async () => [ownInbox, ownArchive, sharedInbox, sharedArchive]); + const result = await ensureArchiveMailbox({ + client, mailboxes: [ownInbox, ownArchive, sharedInbox], selectedMailboxId: 'owner-x:x-inbox', refresh, + }); + expect(client.createMailbox).toHaveBeenCalledWith('Archive', undefined, 'owner-x', { role: 'archive' }); + // The store's namespaced object, not the bare one Mailbox/set returned. + expect(result).toBe(sharedArchive); + }); + + it('pins the account in unified view', async () => { + const client = makeClient(); + const refresh = vi.fn(async () => [ownInbox, ownArchive, sharedInbox, sharedArchive]); + await ensureArchiveMailbox({ + client, mailboxes: [ownInbox, ownArchive, sharedInbox], selectedMailboxId: 'unified-inbox', accountId: 'owner-x', refresh, + }); + expect(client.createMailbox).toHaveBeenCalledWith('Archive', undefined, 'owner-x', { role: 'archive' }); + }); + + it('still reports not-found when the created folder does not come back', async () => { + const client = makeClient(); + const refresh = vi.fn(async () => [ownInbox]); + await expect(ensureArchiveMailbox({ + client, mailboxes: [ownInbox], selectedMailboxId: 'a-inbox', refresh, + })).rejects.toBeInstanceOf(ArchiveMailboxNotFoundError); + }); + + it('surfaces a server refusal instead of swallowing it', async () => { + const client = makeClient(); + client.createMailbox.mockRejectedValueOnce(new Error('Failed to create mailbox: forbidden')); + await expect(ensureArchiveMailbox({ + client, mailboxes: [ownInbox], selectedMailboxId: 'a-inbox', refresh: vi.fn(), + })).rejects.toThrow('forbidden'); + }); +}); diff --git a/stores/__tests__/email-store-archive-missing.test.ts b/stores/__tests__/email-store-archive-missing.test.ts index cce2d45f5..3a4924d04 100644 --- a/stores/__tests__/email-store-archive-missing.test.ts +++ b/stores/__tests__/email-store-archive-missing.test.ts @@ -6,7 +6,8 @@ import type { Email, Mailbox } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; // #578: archiving with no archive folder used to return silently, leaving the -// user with a shortcut/button that did nothing. The store must now report it. +// user with a shortcut/button that did nothing. The store now creates the +// folder on first use and archives into it; a refused creation is reported. function makeMailbox(overrides: Partial = {}): Mailbox { return { @@ -50,10 +51,17 @@ describe('batchArchive without an archive mailbox (#578)', () => { let client: IJMAPClient; beforeEach(() => { + const archive = makeMailbox({ id: 'a-archive', name: 'Archive', role: 'archive' }); client = { batchArchiveEmails: vi.fn().mockResolvedValue(undefined), + createMailbox: vi.fn().mockResolvedValue(archive), getEmails: vi.fn().mockResolvedValue({ emails: [], hasMore: false, total: 0 }), - getMailboxes: vi.fn().mockResolvedValue([]), + // The reload after creation is what brings the new folder into the store. + getMailboxes: vi.fn().mockResolvedValue([ + makeMailbox({ id: 'a-inbox', role: 'inbox' }), + makeMailbox({ id: 'a-trash', name: 'Trash', role: 'trash' }), + archive, + ]), } as unknown as IJMAPClient; useAuthStore.setState({ @@ -78,7 +86,22 @@ describe('batchArchive without an archive mailbox (#578)', () => { }); }); - it('rejects with ArchiveMailboxNotFoundError and records the error', async () => { + it('creates the archive folder and archives into it', async () => { + await useEmailStore.getState().batchArchive(client); + + expect(client.createMailbox).toHaveBeenCalledWith('Archive', undefined, undefined, { role: 'archive' }); + expect(client.batchArchiveEmails).toHaveBeenCalledWith( + [expect.objectContaining({ id: 'e1' })], 'a-archive', 'single', expect.any(Array), undefined, + ); + expect(useEmailStore.getState().error).toBeNull(); + expect(useEmailStore.getState().selectedEmailIds.size).toBe(0); + }); + + it('rejects with ArchiveMailboxNotFoundError when the created folder does not come back', async () => { + (client.getMailboxes as ReturnType).mockResolvedValue([ + makeMailbox({ id: 'a-inbox', role: 'inbox' }), + ]); + await expect(useEmailStore.getState().batchArchive(client)).rejects.toBeInstanceOf( ArchiveMailboxNotFoundError, ); @@ -88,4 +111,13 @@ describe('batchArchive without an archive mailbox (#578)', () => { // Nothing was archived, so the selection must survive. expect(useEmailStore.getState().selectedEmailIds.has('e1')).toBe(true); }); + + it('reports a refused creation and keeps the selection', async () => { + (client.createMailbox as ReturnType).mockRejectedValue(new Error('Failed to create mailbox: forbidden')); + + await expect(useEmailStore.getState().batchArchive(client)).rejects.toThrow('forbidden'); + expect(useEmailStore.getState().error).toMatch(/forbidden/); + expect(client.batchArchiveEmails).not.toHaveBeenCalled(); + expect(useEmailStore.getState().selectedEmailIds.has('e1')).toBe(true); + }); }); diff --git a/stores/email-store.ts b/stores/email-store.ts index 0a1d291e0..f40ca2d04 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -733,6 +733,36 @@ export function findArchiveMailbox( ); } +/** + * Archive folder for the action, created when the account has none. + * + * Stalwart's default folder set has no archive-role mailbox, so on a fresh + * account the Archive button and shortcut only raised the not-found toast. + * The folder is created in the account the action targets (see + * findArchiveMailbox) with the `archive` role, so the sidebar shows it under + * its translated name and other clients recognise it. `refresh` reloads the + * mailbox list so the caller gets the store's own object (namespaced id, + * `originalId`) rather than the bare one Mailbox/set returned. (#578) + */ +export async function ensureArchiveMailbox(opts: { + client: IJMAPClient; + mailboxes: Mailbox[]; + selectedMailboxId: string | null | undefined; + accountId?: string; + refresh: () => Promise; +}): Promise { + const existing = findArchiveMailbox(opts.mailboxes, opts.selectedMailboxId, opts.accountId); + if (existing) return existing; + + const viewMailbox = opts.mailboxes.find(m => m.id === opts.selectedMailboxId); + const scopeId = opts.accountId ?? (viewMailbox?.isShared ? viewMailbox.accountId : undefined); + await opts.client.createMailbox('Archive', undefined, scopeId, { role: 'archive' }); + + const created = findArchiveMailbox(await opts.refresh(), opts.selectedMailboxId, opts.accountId); + if (!created) throw new ArchiveMailboxNotFoundError(); + return created; +} + /** * JMAP accountId for opening an email that carries no source stamps. * @@ -3862,10 +3892,19 @@ export const useEmailStore = create((set, get) => ({ // Scope the archive folder to the viewed shared/group account (if any) so the // move lands on the owner account, not the user's own archive (which appears // first in the merged list); see resolveViewAccountId. Own view is unchanged. - const archiveMailbox = findArchiveMailbox(mailboxes, get().selectedMailbox); - if (!archiveMailbox) { - const error = new ArchiveMailboxNotFoundError(); - set({ error: error.message }); + let archiveMailbox: Mailbox; + try { + archiveMailbox = await ensureArchiveMailbox({ + client: resolveActionClient(client), + mailboxes, + selectedMailboxId: get().selectedMailbox, + refresh: async () => { + await refreshMailboxesForViewingAccount(client); + return resolveActionMailboxes(); + }, + }); + } catch (error) { + set({ error: error instanceof Error ? error.message : 'Failed to archive emails' }); throw error; } From adc9d61aba6129c0cf92c56591349c9bd3a05a89 Mon Sep 17 00:00:00 2001 From: sud Date: Tue, 6 Oct 2026 06:56:10 +0200 Subject: [PATCH 09/35] fix: use the configured name and logo for the TOTP issuer, About card and Files preview On a branded deployment (APP_NAME, APP_LOGO_*) three places after sign-in still showed Bulwark or Stalwart (#1160): - the TOTP issuer was 'Stalwart', so the authenticator app listed the mail server instead of the service; it is now the configured app name, - the About card used the Bulwark wordmark and title; with APP_NAME set it shows the configured name and logo (light/dark as configured), and keeps the Bulwark mark otherwise, - the Files settings preview thumbnail used the bundled logo; it now uses the configured one when there is one. Co-Authored-By: Claude Fable 5.1 --- components/settings/about-data-settings.tsx | 19 ++++++++++++------- .../settings/account-security-settings.tsx | 9 ++++++--- components/settings/files-settings.tsx | 9 +++++++-- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/components/settings/about-data-settings.tsx b/components/settings/about-data-settings.tsx index 37c89e7cc..2d20dcb0a 100644 --- a/components/settings/about-data-settings.tsx +++ b/components/settings/about-data-settings.tsx @@ -10,7 +10,7 @@ import { usePolicyStore } from '@/stores/policy-store'; import { useUpdateStore } from '@/stores/update-store'; import { ExternalLink } from '@/components/icons'; import { cn } from '@/lib/utils'; -import { getPathPrefix } from '@/lib/browser-navigation'; +import { getPathPrefix, withBasePath } from '@/lib/browser-navigation'; import { clearCachedData } from '@/lib/clear-cached-data'; import { SpamSiegeGame } from './spam-siege-game'; @@ -57,7 +57,12 @@ export function AboutDataSettings() { useSettingsStore(); // A modified build must offer its own source (AGPL-3.0 §13), so a // configured URL replaces the upstream repository link. - const { settingsSyncEnabled, sourceCodeUrl } = useConfig(); + const { settingsSyncEnabled, sourceCodeUrl, appName, appLogoLightUrl, appLogoDarkUrl } = useConfig(); + // A branded deployment (APP_NAME / APP_LOGO_*) shows its own name and logo + // here; without them the card keeps the Bulwark mark and title. (#1160) + const branded = appName && appName !== 'Webmail'; + const logoLight = withBasePath(appLogoLightUrl || appLogoDarkUrl) || `${getPathPrefix()}/branding/Bulwark_Logo_Color.svg`; + const logoDark = withBasePath(appLogoDarkUrl || appLogoLightUrl) || `${getPathPrefix()}/branding/Bulwark_Logo_White.svg`; const [showResetConfirm, setShowResetConfirm] = useState(false); const [showRefreshConfirm, setShowRefreshConfirm] = useState(false); const fileInputRef = useRef(null); @@ -139,19 +144,19 @@ export function AboutDataSettings() { + {filesEnabled && composerClient?.supportsFiles?.() && ( + + )} + ); + + const fileRow = (node: FileNode, detail?: string) => { + const checked = selected.has(node.id); + return ( + + ); + }; + + const sharedBy = (node: FileNode) => + here === SHARED_WITH_ME && node.accountName ? t("shared_by", { name: node.accountName }) : undefined; + + let body: ReactNode; + if (failed) { + body = ( +
+ +

{tComposer("files_picker.load_failed")}

+ +
+ ); + } else if (!nodes) { + body = ( +
+ +
+ ); + } else if (entries.length === 0 && !(here === null && !needle && sharedRoots.length > 0)) { + body = ( +

+ {needle ? t("no_results") : here === null ? t("empty_state_title") : tComposer("files_picker.empty_folder")} +

+ ); + } else { + body = ( +
+ {here === null && !needle && sharedRoots.length > 0 && folderRow( + SHARED_WITH_ME, + t("shared_with_me"), + , + () => openFolder(SHARED_WITH_ME, t("shared_with_me")), + )} + {entries.map((node) => + isFolder(node) + ? folderRow( + node.id, + node.name, + , + () => openFolder(node.id, node.name), + sharedBy(node), + ) + : fileRow(node, needle ? folderPath(node) : sharedBy(node)), + )} +
+ ); + } + + return ( +
+
+
+

+ {tComposer("attach_from_files")} +

+ +
+ +
+
+ + setQuery(e.target.value)} + placeholder={t("search_placeholder")} + className="ps-9 h-9" + /> +
+ {!needle && ( + + )} +
+ +
{body}
+ +
+ + +
+
+
+ ); +} diff --git a/locales/de/common.json b/locales/de/common.json index 46a6edd21..e5fc9aedc 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -624,6 +624,12 @@ "attach": "Anhängen", "attach_photos": "Fotos & Videos", "attach_files": "Dateien", + "attach_from_files": "Aus Dateien anhängen", + "files_picker": { + "attach": "{count, plural, =0 {Anhängen} one {1 Datei anhängen} other {# Dateien anhängen}}", + "empty_folder": "Dieser Ordner ist leer", + "load_failed": "Ihre Dateien konnten nicht geladen werden" + }, "discard": "Verwerfen", "discard_draft_title": "Entwurf verwerfen?", "discard_draft_confirm": "Sie haben ungespeicherte Änderungen. Möchten Sie diesen Entwurf verwerfen?", diff --git a/locales/en/common.json b/locales/en/common.json index d1d2115fa..771da8d65 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -624,6 +624,12 @@ "attach": "Attach", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "Attach from Files", + "files_picker": { + "attach": "{count, plural, =0 {Attach} one {Attach 1 file} other {Attach # files}}", + "empty_folder": "This folder is empty", + "load_failed": "Your files could not be loaded" + }, "discard": "Discard", "discard_draft_title": "Discard draft?", "discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?", From 1e10aa9bd6af6c75499b9408c35e47950647130c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:49:53 +0200 Subject: [PATCH 13/35] feat: move several folders at once from the folder settings #1173 --- .../folder-settings-bulk-move.test.tsx | 152 ++++++++++++++++ components/settings/folder-settings.tsx | 168 +++++++++++++++++- lib/__tests__/jmap-update-mailboxes.test.ts | 96 ++++++++++ lib/demo/demo-client.ts | 10 ++ lib/jmap/client-interface.ts | 3 + lib/jmap/client.ts | 22 +++ locales/de/common.json | 9 + locales/en/common.json | 9 + .../email-store-move-mailboxes.test.ts | 107 +++++++++++ stores/email-store.ts | 61 +++++++ 10 files changed, 634 insertions(+), 3 deletions(-) create mode 100644 components/settings/__tests__/folder-settings-bulk-move.test.tsx create mode 100644 lib/__tests__/jmap-update-mailboxes.test.ts create mode 100644 stores/__tests__/email-store-move-mailboxes.test.ts diff --git a/components/settings/__tests__/folder-settings-bulk-move.test.tsx b/components/settings/__tests__/folder-settings-bulk-move.test.tsx new file mode 100644 index 000000000..b90ce5d83 --- /dev/null +++ b/components/settings/__tests__/folder-settings-bulk-move.test.tsx @@ -0,0 +1,152 @@ +import { render, screen, within, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { FolderSettings } from '../folder-settings'; +import { useEmailStore } from '@/stores/email-store'; +import { useAuthStore } from '@/stores/auth-store'; +import type { Mailbox } from '@/lib/jmap/types'; + +// #1173: tick several folders (Shift+click for a range) and move them under +// one parent in a single step. + +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +function makeMailbox(overrides: Partial): Mailbox { + return { + id: 'mb', + name: 'Folder', + sortOrder: 0, + totalEmails: 0, + unreadEmails: 0, + totalThreads: 0, + unreadThreads: 0, + myRights: { + mayReadItems: true, + mayAddItems: true, + mayRemoveItems: true, + maySetSeen: true, + maySetKeywords: true, + mayCreateChild: true, + mayRename: true, + mayDelete: true, + maySubmit: true, + }, + isSubscribed: true, + isShared: false, + ...overrides, + } as Mailbox; +} + +const client = {}; +let moveMailboxes: ReturnType; + +// The folder name appears in the row and in the role dropdowns; the row's +// label is the span next to the checkbox. +function rowFor(name: string): HTMLElement { + const label = screen.getAllByText(name).find(el => el.tagName === 'SPAN')!; + return label.parentElement!; +} + +const checkboxFor = (name: string) => within(rowFor(name)).getByRole('checkbox'); +const isTicked = (name: string) => checkboxFor(name).getAttribute('aria-checked') === 'true'; + +function destinationOptions(): string[] { + const select = screen.getByRole('combobox', { name: 'move_to' }); + return within(select).getAllByRole('option').map(o => (o as HTMLOptionElement).value); +} + +describe('FolderSettings bulk move (#1173)', () => { + beforeEach(() => { + moveMailboxes = vi.fn().mockResolvedValue([]); + useAuthStore.setState({ client } as never); + useEmailStore.setState({ + mailboxes: [ + makeMailbox({ id: 'inbox', name: 'Inbox', role: 'inbox' }), + makeMailbox({ id: 'a1', name: 'A1' }), + makeMailbox({ id: 'a2', name: 'A2' }), + makeMailbox({ id: 'a2-child', name: 'A2 child', parentId: 'a2' }), + makeMailbox({ id: 'a3', name: 'A3' }), + makeMailbox({ id: 'a4', name: 'A4' }), + makeMailbox({ id: 'old', name: 'Old mailbox' }), + ], + fetchMailboxes: vi.fn(), + moveMailboxes, + } as never); + }); + + it('ticks the visible range on Shift+click', () => { + render(); + expect(screen.queryByRole('combobox', { name: 'move_to' })).toBeNull(); + + fireEvent.click(checkboxFor('A1')); + fireEvent.click(checkboxFor('A3'), { shiftKey: true }); + + expect(['A1', 'A2', 'A3'].map(isTicked)).toEqual([true, true, true]); + expect(['Inbox', 'A4', 'Old mailbox'].map(isTicked)).toEqual([false, false, false]); + expect(screen.getByText('selected_count')).toBeTruthy(); + }); + + it('unticks the range when the Shift+clicked folder was ticked', () => { + render(); + + fireEvent.click(checkboxFor('A1')); + fireEvent.click(checkboxFor('A4'), { shiftKey: true }); + fireEvent.click(checkboxFor('A3')); + fireEvent.click(checkboxFor('A2'), { shiftKey: true }); + + expect(['A1', 'A2', 'A3', 'A4'].map(isTicked)).toEqual([true, false, false, true]); + }); + + it('moves the ticked folders and leaves them out of the destinations', async () => { + render(); + + fireEvent.click(checkboxFor('A1')); + fireEvent.click(checkboxFor('A3'), { shiftKey: true }); + + // A2's child would put A2 inside itself. + expect(destinationOptions()).toEqual(['', '__top__', 'inbox', 'a4', 'old']); + + fireEvent.change(screen.getByRole('combobox', { name: 'move_to' }), { target: { value: 'old' } }); + fireEvent.click(screen.getByRole('button', { name: 'move' })); + + await waitFor(() => expect(moveMailboxes).toHaveBeenCalledWith(client, ['a1', 'a2', 'a3'], 'old')); + await waitFor(() => expect(isTicked('A1')).toBe(false)); + }); + + it('moves a ticked subfolder together with its ticked parent', async () => { + render(); + + fireEvent.click(within(rowFor('A2')).getAllByRole('button')[0]); // expand + fireEvent.click(checkboxFor('A2')); + fireEvent.click(checkboxFor('A2 child')); + fireEvent.change(screen.getByRole('combobox', { name: 'move_to' }), { target: { value: 'old' } }); + fireEvent.click(screen.getByRole('button', { name: 'move' })); + + await waitFor(() => expect(moveMailboxes).toHaveBeenCalledWith(client, ['a2'], 'old')); + }); + + it('keeps the refused folders ticked', async () => { + moveMailboxes.mockResolvedValue(['a2']); + render(); + + fireEvent.click(checkboxFor('A1')); + fireEvent.click(checkboxFor('A2'), { shiftKey: true }); + fireEvent.change(screen.getByRole('combobox', { name: 'move_to' }), { target: { value: '__top__' } }); + fireEvent.click(screen.getByRole('button', { name: 'move' })); + + // Both are already top-level, so nothing needs to move. + await waitFor(() => expect(screen.queryByRole('combobox', { name: 'move_to' })).toBeNull()); + expect(moveMailboxes).not.toHaveBeenCalled(); + + fireEvent.click(checkboxFor('A1')); + fireEvent.click(checkboxFor('A2'), { shiftKey: true }); + fireEvent.change(screen.getByRole('combobox', { name: 'move_to' }), { target: { value: 'old' } }); + fireEvent.click(screen.getByRole('button', { name: 'move' })); + + await waitFor(() => expect(moveMailboxes).toHaveBeenCalledWith(client, ['a1', 'a2'], 'old')); + await waitFor(() => expect(isTicked('A1')).toBe(false)); + expect(isTicked('A2')).toBe(true); + }); +}); diff --git a/components/settings/folder-settings.tsx b/components/settings/folder-settings.tsx index bb6831542..8c6ae53b9 100644 --- a/components/settings/folder-settings.tsx +++ b/components/settings/folder-settings.tsx @@ -13,7 +13,7 @@ import { Inbox, Send, FileText, Trash, ShieldAlert, Archive, Star, Heart, Bookmark, Tag, Flag, Briefcase, Users, Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail, - AlertTriangle, NotebookPen, CalendarClock, BellOff, + AlertTriangle, NotebookPen, CalendarClock, BellOff, CheckSquare, Square, type AppIcon, } from '@/components/icons'; import { cn, buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils'; @@ -39,6 +39,9 @@ const INDENTATION_WIDTH = 16; const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const; +// "Move to" value for the root of the folder tree. +const TOP_LEVEL = '__top__'; + const ROLE_ICONS: Record = { inbox: Inbox, drafts: FileText, @@ -154,7 +157,7 @@ function SortableFolderRow({ id, title, children }: { id: string; title: string; export function FolderSettings() { const t = useTranslations('settings.folders'); const { client } = useAuthStore(); - const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole, reorderMailboxes, moveMailbox } = useEmailStore(); + const { mailboxes, fetchMailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole, reorderMailboxes, moveMailbox, moveMailboxes } = useEmailStore(); const sensors = useSensors( // Small activation distance so clicking the row's buttons still works. @@ -189,6 +192,13 @@ export function FolderSettings() { const [overId, setOverId] = useState(null); const [offsetLeft, setOffsetLeft] = useState(0); + // Folders ticked for a bulk move (#1173). Shift-click ticks or unticks the + // visible rows between the last clicked checkbox and this one. + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [selectionAnchor, setSelectionAnchor] = useState(null); + const [moveTarget, setMoveTarget] = useState(''); + const [isMoving, setIsMoving] = useState(false); + const ownMailboxes = mailboxes.filter(mb => !mb.isShared); const folderTree = buildMailboxTree(ownMailboxes); @@ -263,6 +273,98 @@ export function FolderSettings() { }); }; + const selectedCount = ownMailboxes.filter(mb => selectedIds.has(mb.id)).length; + + // A ticked folder inside another ticked folder travels with it, so only the + // topmost ticked folders get a new parent; the rest keep their place. + const moveRoots: MailboxNode[] = []; + const collectMoveRoots = (nodes: MailboxNode[]) => { + for (const n of nodes) { + if (selectedIds.has(n.id)) moveRoots.push(n); + else collectMoveRoots(n.children); + } + }; + collectMoveRoots(folderTree); + + // A folder can't move into itself or below itself. + const blockedTargets = new Set(); + const blockSubtree = (n: MailboxNode) => { + blockedTargets.add(n.id); + n.children.forEach(blockSubtree); + }; + moveRoots.forEach(blockSubtree); + + const getFolderPath = (mb: { parentId?: string; name: string }): string => { + const parts = [mb.name]; + const seen = new Set(); + let parentId = mb.parentId; + while (parentId && !seen.has(parentId)) { + seen.add(parentId); + const parent = ownMailboxes.find(p => p.id === parentId); + if (!parent) break; + parts.unshift(parent.name); + parentId = parent.parentId; + } + return parts.join(' / '); + }; + + const moveTargetOptions = flattenMailboxTree(folderTree) + .filter(mb => !blockedTargets.has(mb.id) && !(mb.myRights && !mb.myRights.mayCreateChild)) + .map(mb => ({ value: mb.id, label: getFolderPath(mb) })); + + const handleSelectClick = (id: string, e: React.MouseEvent) => { + const tick = !selectedIds.has(id); + let ids = [id]; + if (e.shiftKey && selectionAnchor) { + const order = visibleItems.filter(i => i.node.myRights?.mayRename).map(i => i.id); + const from = order.indexOf(selectionAnchor); + const to = order.indexOf(id); + if (from >= 0 && to >= 0) ids = order.slice(Math.min(from, to), Math.max(from, to) + 1); + } + setSelectedIds(prev => { + const next = new Set(prev); + for (const x of ids) { + if (tick) next.add(x); + else next.delete(x); + } + return next; + }); + setSelectionAnchor(id); + }; + + const clearSelection = () => { + setSelectedIds(new Set()); + setSelectionAnchor(null); + setMoveTarget(''); + }; + + const handleMoveSelected = async () => { + if (!client || !moveTarget) return; + const parentId = moveTarget === TOP_LEVEL ? null : moveTarget; + const ids = moveRoots.filter(n => (n.parentId ?? null) !== parentId).map(n => n.id); + if (ids.length === 0) { + clearSelection(); + return; + } + setIsMoving(true); + try { + const failed = await moveMailboxes(client, ids, parentId); + if (parentId) setExpandedFolders(prev => new Set(prev).add(parentId)); + if (failed.length === 0) { + toast.success(t('folders_moved', { count: ids.length })); + clearSelection(); + } else { + toast.error(t('move_partial_error', { count: failed.length })); + // Leave the refused folders ticked so they are easy to find. + setSelectedIds(new Set(failed)); + } + } catch { + toast.error(t('move_error')); + } finally { + setIsMoving(false); + } + }; + const getRoleMailboxId = (role: string): string => { const mb = ownMailboxes.find(m => m.role === role); return mb?.id ?? ''; @@ -451,6 +553,7 @@ export function FolderSettings() { const mb = item.node; const hasChildren = mb.children.length > 0; const isExpanded = expandedFolders.has(item.id); + const isSelected = selectedIds.has(item.id); // The dragged row previews its projected nesting depth, so a horizontal // drag shows which parent the folder will land under. const depth = activeDragId === item.id && projected ? projected.depth : item.depth; @@ -523,10 +626,36 @@ export function FolderSettings() {
+ {mb.myRights?.mayRename ? ( + + ) : ( + + )} {/* Expand/collapse toggle for folders with children */} {hasChildren ? (
)} - {displayResources.length === 0 && searchQuery ? ( + {displayResources.length === 0 && sharedFolders.length === 0 && searchQuery ? (

{t("no_results")}

- ) : ( + ) : displayResources.length === 0 ? null : (
{ @@ -1400,6 +1436,31 @@ export function FileBrowser({ ))}
)} + {sharedFolders.length > 0 && ( +
0 && "mt-4")}> +

+ + {t("shared_with_me")} +

+
+ {sharedFolders.map((resource) => ( + + ))} +
+
+ )}
) : ( /* ======= LIST VIEW ======= */ @@ -1482,7 +1543,7 @@ export function FileBrowser({ )} - {displayResources.length === 0 && searchQuery ? ( + {displayResources.length === 0 && sharedFolders.length === 0 && searchQuery ? ( {t("no_results")} @@ -1565,6 +1626,44 @@ export function FileBrowser({ ))} + {sharedFolders.length > 0 && ( + + + + + {t("shared_with_me")} + + + + )} + {sharedFolders.map((resource) => ( + openSharedFolder(resource)} + title={resource.ownerName ? t("shared_by", { name: resource.ownerName }) : undefined} + > + +
+
+ {getFileIcon(resource)} + + +
+ + - + + {formatDate(resource.lastModified)} + + + + ))} )} @@ -1927,9 +2026,11 @@ export function FileBrowser({ collectionName={shareTarget.name} shareWith={shareTarget.shareWith} ownAccountId={ownAccountId || ""} - onShare={(principalId, rights) => - onShare(shareTarget.id, principalId, rights as FileNodeRights | null)} - onClose={() => setShareTargetId(null)} + onShare={async (principalId, rights) => { + await onShare(shareTarget.id, principalId, rights as FileNodeRights | null); + if (treeShareTarget?.id === shareTarget.id) await reloadTreeShareTarget(shareTarget); + }} + onClose={() => { setShareTargetId(null); setTreeShareTarget(null); }} /> )}
diff --git a/components/files/folder-tree-sidebar.tsx b/components/files/folder-tree-sidebar.tsx index af8339c69..d6810a820 100644 --- a/components/files/folder-tree-sidebar.tsx +++ b/components/files/folder-tree-sidebar.tsx @@ -11,12 +11,15 @@ import { Share2, } from "@/components/icons"; import { cn } from "@/lib/utils"; +import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu"; +import { useContextMenu } from "@/hooks/use-context-menu"; import { useFileStore, type FileResource } from "@/stores/file-store"; interface FolderNode { id: string; name: string; path: string; + resource: FileResource; } interface FolderTreeSidebarProps { @@ -25,13 +28,17 @@ interface FolderTreeSidebarProps { listByParentId: (parentId: string | null) => Promise; width?: number; isResizing?: boolean; + /** Whether the viewer may share this folder; gates the tree's context menu. */ + canShare?: (resource: FileResource) => boolean; + onShare?: (resource: FileResource) => void; } -export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing }: FolderTreeSidebarProps) { +export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing, canShare, onShare }: FolderTreeSidebarProps) { const t = useTranslations("files"); + const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); const client = useFileStore(s => s.client); + // Loaded by the file browser, which shows them in either layout. const sharedRoots = useFileStore(s => s.sharedRoots); - const loadSharedRoots = useFileStore(s => s.loadSharedRoots); const [rootChildren, setRootChildren] = useState(null); const [expandedIds, setExpandedIds] = useState>(new Set(["root"])); const [loadingIds, setLoadingIds] = useState>(new Set()); @@ -58,6 +65,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid id: r.id, name: r.name, path: folderPath, + resource: r, }; }); @@ -84,8 +92,6 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid useEffect(() => { if (client) { loadChildren(null, "/"); - // Discover folders shared with the user by other principals. - loadSharedRoots(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [client]); @@ -132,6 +138,14 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid onNavigate(path, id); }, [onNavigate]); + // Folders are not listed in the file list in this layout, so the tree is + // the only place to reach a folder's actions. Leave the browser menu alone + // when there is nothing to offer. + const handleFolderContextMenu = useCallback((e: React.MouseEvent, resource: FileResource) => { + if (!onShare || !canShare?.(resource)) return; + openContextMenu(e, resource); + }, [canShare, onShare, openContextMenu]); + return (
)) )} @@ -220,6 +235,19 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
)}
+ + {onShare && ( + + { + if (contextMenu.data) onShare(contextMenu.data); + closeContextMenu(); + }} + /> + + )}
); } @@ -234,6 +262,7 @@ function FolderTreeItem({ onToggleExpand, onFolderClick, onLoadChildren, + onContextMenu, }: { node: FolderNode; depth: number; @@ -244,6 +273,7 @@ function FolderTreeItem({ onToggleExpand: (folderId: string, folderPath: string) => void; onFolderClick: (path: string, id: string | null) => void; onLoadChildren: (parentId: string, parentPath: string) => Promise; + onContextMenu: (e: React.MouseEvent, resource: FileResource) => void; }) { const isExpanded = expandedIds.has(node.id); const isSelected = currentPath === node.path; @@ -272,6 +302,7 @@ function FolderTreeItem({ : "hover:bg-muted text-foreground", depth === 0 && "font-medium" )} + onContextMenu={(e) => onContextMenu(e, node.resource)} > {/* Expand/collapse chevron */} {hasChildren ? ( @@ -327,6 +358,7 @@ function FolderTreeItem({ onToggleExpand={onToggleExpand} onFolderClick={onFolderClick} onLoadChildren={onLoadChildren} + onContextMenu={onContextMenu} /> ))}
From 1829bd8ee493ffd76bfabb1692222c3d47dce5d9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:26:27 +0200 Subject: [PATCH 17/35] fix: stop a previous account's settings leaking into the next sign-in #1185 --- lib/sign-out-cleanup.ts | 3 +- .../settings-store-account-leak.test.ts | 184 ++++++++++++++++++ stores/auth-store.ts | 7 + stores/settings-store.ts | 106 +++++++++- 4 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 stores/__tests__/settings-store-account-leak.test.ts diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 6c70a84b7..da2212907 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -8,7 +8,8 @@ import { fileStorage } from '@/lib/plugin-storage'; * Kept on purpose: device preferences (`settings-storage`, `theme-storage`, * `locale-storage`, the Files view settings) and `template-storage`. With * settings sync off they exist only here, so deleting them would destroy - * the user's own work rather than a cache. + * the user's own work rather than a cache. With sync on, the settings store + * resets settings and templates itself (forgetSyncedSettings). */ const SIGNED_OUT_STORAGE_KEYS = [ 'email-snapshot', diff --git a/stores/__tests__/settings-store-account-leak.test.ts b/stores/__tests__/settings-store-account-leak.test.ts new file mode 100644 index 000000000..d8702b317 --- /dev/null +++ b/stores/__tests__/settings-store-account-leak.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiFetch = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/browser-navigation', () => ({ apiFetch })); + +import { useSettingsStore } from '../settings-store'; +import { useTemplateStore } from '../template-store'; + +// #1185: settings-storage is one store for the whole browser. After signing +// out of A, an account B without server settings showed A's tags, templates +// and preferences, and the next push wrote them into B's server file. + +const A = { username: 'a@example.com', serverUrl: 'https://mail.example.com' }; +const B = { username: 'b@example.com', serverUrl: 'https://mail.example.com' }; +const SECRET_TAG = { id: 'customer-x', label: 'Customer X', color: 'red' }; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +/** Server files by username; a missing entry means "no settings yet". */ +let serverFiles: Record = {}; +let failLoads = false; + +function posted(): Array<{ username: string; settings: Record }> { + return apiFetch.mock.calls + .filter(([, init]) => (init as RequestInit | undefined)?.method === 'POST') + .map(([, init]) => JSON.parse((init as RequestInit).body as string)); +} + +async function signIn(account: typeof A) { + const settings = useSettingsStore.getState(); + await settings.loadFromServer(account.username, account.serverUrl).finally(() => { + useSettingsStore.getState().enableSync(account.username, account.serverUrl); + }); +} + +async function signOut() { + await useSettingsStore.getState().flushSync(); + useSettingsStore.getState().disableSync(); + useSettingsStore.getState().forgetSyncedSettings(); +} + +/** Signed in as A, with a custom tag and a template that reached A's file. */ +async function setUpAccountA() { + serverFiles[A.username] = { emailKeywords: [SECRET_TAG], fontSize: 'large' }; + await signIn(A); + useTemplateStore.getState().addTemplate({ + name: 'A only', subject: '', body: '', category: '', isFavorite: false, + }); + await useSettingsStore.getState().flushSync(); + expect(useSettingsStore.getState().emailKeywords).toEqual([SECRET_TAG]); +} + +beforeEach(async () => { + localStorage.clear(); + serverFiles = {}; + failLoads = false; + apiFetch.mockReset(); + apiFetch.mockImplementation(async (_url: string, init?: RequestInit) => { + if (init?.method === 'POST') return json({ ok: true }); + if (failLoads) return json({ error: 'boom' }, 500); + const username = (init?.headers as Record)['x-settings-username']; + return json({ settings: serverFiles[username] ?? null }); + }); + useSettingsStore.getState().disableSync(); + useSettingsStore.getState().resetToDefaults(); + useTemplateStore.setState({ templates: [], recentTemplateIds: [], deletedTemplateIds: {} }); +}); + +describe('switching people in one browser (#1185)', () => { + it('starts the next account from defaults after a sign-out', async () => { + await setUpAccountA(); + await signOut(); + + expect(useSettingsStore.getState().emailKeywords).not.toContainEqual(SECRET_TAG); + expect(useTemplateStore.getState().templates).toEqual([]); + + apiFetch.mockClear(); + await signIn(B); + useSettingsStore.getState().updateSetting('sendDelaySeconds', 10); + await useSettingsStore.getState().flushSync(); + + const pushes = posted(); + expect(pushes.map((p) => p.username)).toEqual([B.username]); + expect(pushes[0].settings.emailKeywords).not.toContainEqual(SECRET_TAG); + expect(pushes[0].settings.templates).toEqual([]); + expect(pushes[0].settings.fontSize).toBe('medium'); + }); + + it("drops A's state when B signs in without a sign-out in between", async () => { + await setUpAccountA(); + useSettingsStore.getState().disableSync(); // e.g. the session expired + + apiFetch.mockClear(); + await signIn(B); + useSettingsStore.getState().updateSetting('sendDelaySeconds', 10); + await useSettingsStore.getState().flushSync(); + + expect(useSettingsStore.getState().emailKeywords).not.toContainEqual(SECRET_TAG); + const pushes = posted(); + expect(pushes.map((p) => p.username)).toEqual([B.username]); + expect(pushes[0].settings.emailKeywords).not.toContainEqual(SECRET_TAG); + expect(pushes[0].settings.templates).toEqual([]); + }); + + it("does not merge A's templates into B's server file", async () => { + await setUpAccountA(); + useSettingsStore.getState().disableSync(); + serverFiles[B.username] = { fontSize: 'small', templates: [], deletedTemplateIds: {} }; + + apiFetch.mockClear(); + await signIn(B); + await useSettingsStore.getState().flushSync(); + + expect(useTemplateStore.getState().templates).toEqual([]); + expect(useSettingsStore.getState().emailKeywords).not.toContainEqual(SECRET_TAG); + expect(useSettingsStore.getState().fontSize).toBe('small'); + expect(posted()).toEqual([]); + }); + + it("keeps sync off when B's settings cannot be loaded over A's", async () => { + await setUpAccountA(); + useSettingsStore.getState().disableSync(); + failLoads = true; + + apiFetch.mockClear(); + await signIn(B); + useSettingsStore.getState().updateSetting('sendDelaySeconds', 10); + await useSettingsStore.getState().flushSync(); + + expect(useSettingsStore.getState().emailKeywords).not.toContainEqual(SECRET_TAG); + expect(posted()).toEqual([]); + }); + + it('lets the same account pick up where it left off', async () => { + await setUpAccountA(); + useSettingsStore.getState().disableSync(); + failLoads = true; + + await signIn(A); + useSettingsStore.getState().updateSetting('sendDelaySeconds', 10); + await useSettingsStore.getState().flushSync(); + + expect(useSettingsStore.getState().emailKeywords).toEqual([SECRET_TAG]); + expect(posted().at(-1)?.username).toBe(A.username); + }); +}); + +describe('settings that never reached a server', () => { + it('lets the first synced sign-in adopt the device settings', async () => { + useSettingsStore.getState().addKeyword(SECRET_TAG); + + await signIn(A); + expect(useSettingsStore.getState().emailKeywords).toContainEqual(SECRET_TAG); + useSettingsStore.getState().updateSetting('sendDelaySeconds', 10); + await useSettingsStore.getState().flushSync(); + expect(posted()[0].settings.emailKeywords).toContainEqual(SECRET_TAG); + + // From here on they live in A's file, so a sign-out may drop them. + await signOut(); + expect(useSettingsStore.getState().emailKeywords).not.toContainEqual(SECRET_TAG); + }); + + it('keeps them on sign-out when sync was never used', () => { + useSettingsStore.getState().addKeyword(SECRET_TAG); + useSettingsStore.getState().forgetSyncedSettings(); + expect(useSettingsStore.getState().emailKeywords).toContainEqual(SECRET_TAG); + }); + + it('keeps them on sign-out when the user opted out of sync', async () => { + await setUpAccountA(); + useSettingsStore.setState({ settingsSyncDisabled: true }); + await signOut(); + expect(useSettingsStore.getState().emailKeywords).toEqual([SECRET_TAG]); + }); + + it('keeps device-only settings through the reset', async () => { + useSettingsStore.setState({ proInterface: true }); + await setUpAccountA(); + await signOut(); + expect(useSettingsStore.getState().proInterface).toBe(true); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 3d9fde2e6..788cf7b76 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1196,6 +1196,10 @@ function forgetCalendarSubscriptions(client: IJMAPClient): void { function performFullLogout(set: (state: Partial) => void): void { useSettingsStore.getState().disableSync(); + // With settings sync on, the local settings and templates are a copy of the + // signed-out account's server file. Left in place, the next account to sign + // in here would show them and push them to its own file (#1185). + useSettingsStore.getState().forgetSyncedSettings(); set({ isAuthenticated: false, @@ -2235,6 +2239,9 @@ export const useAuthStore = create()( // while the clients are torn down. const cleanup = clearAllCredentials(endSessionSlot); + // The settings are reset below; save the last edits first. + await useSettingsStore.getState().flushSync(); + // Disconnect all clients set({ client: null }); for (const c of clients.values()) { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 07e49541f..ac068bdf6 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -36,6 +36,35 @@ let isLoadingFromServer = false; let pushTemplatesAfterLoad = false; // Wired up in the window-only init block below; null during SSR. let requestSync: (() => void) | null = null; +// Account whose settings could not be loaded while this browser still held +// another account's. enableSync leaves sync off for it, or the next change +// would push the other account's settings under its name (#1185). +let blockedSyncAccountId: string | null = null; + +// settings-storage is one store for the whole browser. This key names the +// account whose server copy it currently mirrors, so a different account +// signing in can tell the local settings are not its own (#1185). Unset in +// browsers that never synced, where the local settings are the user's own +// device settings and the first sign-in adopts them. +const SETTINGS_OWNER_KEY = 'settings-sync-owner'; + +function readSettingsOwner(): string | null { + try { + return typeof window === 'undefined' ? null : window.localStorage.getItem(SETTINGS_OWNER_KEY); + } catch { + return null; + } +} + +function writeSettingsOwner(accountId: string | null): void { + try { + if (typeof window === 'undefined') return; + if (accountId) window.localStorage.setItem(SETTINGS_OWNER_KEY, accountId); + else window.localStorage.removeItem(SETTINGS_OWNER_KEY); + } catch { + /* storage unavailable */ + } +} const SYNC_DEBOUNCE_MS = 2000; @@ -590,6 +619,12 @@ interface SettingsState { flushSync: () => Promise; disableSync: () => void; loadFromServer: (username: string, serverUrl: string) => Promise; + /** + * Called on a full sign-out: drops the settings and templates this browser + * mirrored from an account's server copy, so the next person to sign in + * does not start from them (#1185). Device-only settings are kept. + */ + forgetSyncedSettings: () => void; } const DEFAULT_SETTINGS = { @@ -804,7 +839,29 @@ const DEFAULT_SETTINGS = { export const useSettingsStore = create()( persist( - (set, get) => ({ + (set, get) => { + /** + * Back to defaults for everything an account's server copy holds: + * settings (device-only ones kept) and templates. Never pushed, since + * sync may still point at the account the state came from. + */ + const resetSyncedState = () => { + const defaults: Record = { ...DEFAULT_SETTINGS }; + for (const key of DEVICE_LOCAL_SETTING_KEYS) delete defaults[key]; + const wasLoading = isLoadingFromServer; + isLoadingFromServer = true; + try { + set(defaults as Partial); + templateSyncBridge?.applySyncedState([], {}, { merge: false }); + } finally { + isLoadingFromServer = wasLoading; + } + applyFontSize(DEFAULT_SETTINGS.fontSize); + applyDensity(DEFAULT_SETTINGS.density); + applyAnimations(DEFAULT_SETTINGS.animationsEnabled); + }; + + return { ...DEFAULT_SETTINGS, updateSetting: (key, value) => { @@ -1175,6 +1232,12 @@ export const useSettingsStore = create()( // Settings sync methods enableSync: (username: string, serverUrl: string) => { + const accountId = generateAccountId(username, serverUrl); + if (blockedSyncAccountId === accountId) { + syncWarn('Settings sync stays off for', username, '- its settings could not be loaded'); + return; + } + writeSettingsOwner(accountId); syncUsername = username; syncServerUrl = serverUrl; syncEnabled = true; @@ -1208,6 +1271,21 @@ export const useSettingsStore = create()( }, loadFromServer: async (username: string, serverUrl: string) => { + const accountId = generateAccountId(username, serverUrl); + const owner = readSettingsOwner(); + const heldByOther = owner !== null && owner !== accountId; + // The local settings belong to another account and this one's could + // not be read: show defaults and keep sync off, rather than showing + // the other account's settings or pushing them under this name. + const failWithoutForeignState = () => { + if (heldByOther) { + resetSyncedState(); + writeSettingsOwner(null); + blockedSyncAccountId = accountId; + } + return false; + }; + blockedSyncAccountId = null; try { syncLog('Loading settings from server for', username); pushTemplatesAfterLoad = false; @@ -1220,22 +1298,31 @@ export const useSettingsStore = create()( if (!res.ok) { const body = await res.json().catch(() => ({})); syncLog('Settings fetch failed:', body.error || `status ${res.status}`); - return false; + return failWithoutForeignState(); } const { settings } = await res.json(); if (!settings) { syncLog('No server settings found yet'); + // A new account starts from defaults, not from whatever the + // previous account left in this browser (#1185). + if (heldByOther) resetSyncedState(); + writeSettingsOwner(accountId); pushTemplatesAfterLoad = localTemplatesAhead(null); return false; } if (settings && typeof settings === 'object') { + // Importing merges templates and leaves keys the blob lacks as + // they are, so another account's state would carry over into + // this one and be pushed back to its server copy. + if (heldByOther) resetSyncedState(); isLoadingFromServer = true; // Merge (not replace) per-account maps for the account being loaded, // so multi-account logins don't clobber each other by login order. get().importSettings(JSON.stringify(settings), { - serverAccountId: generateAccountId(username, serverUrl), + serverAccountId: accountId, }); isLoadingFromServer = false; + writeSettingsOwner(accountId); pushTemplatesAfterLoad = localTemplatesAhead(settings); syncLog('Settings loaded from server successfully'); // The per-account preferred sender identity (#507) is re-applied by @@ -1248,10 +1335,19 @@ export const useSettingsStore = create()( } catch (error) { syncError('Failed to load settings from server:', error); isLoadingFromServer = false; - return false; + return failWithoutForeignState(); } }, - }), + + forgetSyncedSettings: () => { + // Never synced: the local settings are this device's own and exist + // nowhere else. Synced but opted out: they were never uploaded either. + if (readSettingsOwner() === null || get().settingsSyncDisabled) return; + resetSyncedState(); + writeSettingsOwner(null); + }, + }; + }, { name: 'settings-storage', version: 7, From 9881c20ddd7aaf8f49c9eeec61a438d1a9f6ce3b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:29:42 +0200 Subject: [PATCH 18/35] fix: keep Stalwart as the authenticator entry name on unbranded installs #1177 --- components/settings/account-security-settings.tsx | 5 ++--- lib/__tests__/totp-issuer.test.ts | 15 +++++++++++++++ lib/totp-issuer.ts | 8 ++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 lib/__tests__/totp-issuer.test.ts create mode 100644 lib/totp-issuer.ts diff --git a/components/settings/account-security-settings.tsx b/components/settings/account-security-settings.tsx index 17f49c72a..24b5b81ff 100644 --- a/components/settings/account-security-settings.tsx +++ b/components/settings/account-security-settings.tsx @@ -18,6 +18,7 @@ import { cn } from '@/lib/utils'; import { sanitizeI18nHtml } from '@/lib/email-sanitization'; import { IS_LITE } from '@/lib/lite'; import { useConfig } from '@/hooks/use-config'; +import { totpIssuer } from '@/lib/totp-issuer'; function PasswordChangeSection() { const t = useTranslations('settings.security'); @@ -195,8 +196,6 @@ function DisplayNameSection() { ); } -// The issuer is what the authenticator app lists the entry under, so it is -// the configured service name (APP_NAME), not the mail server. (#1160) function generateTotp(accountLabel: string, issuer: string): { totp: OTPAuth.TOTP; url: string } { const totp = new OTPAuth.TOTP({ issuer, @@ -233,7 +232,7 @@ function TotpSection() { }, [setupUrl]); const startSetup = () => { - const { totp, url } = generateTotp(client?.getUsername() ?? 'account', appName); + const { totp, url } = generateTotp(client?.getUsername() ?? 'account', totpIssuer(appName)); setSetupTotp(totp); setSetupUrl(url); setPassword(''); diff --git a/lib/__tests__/totp-issuer.test.ts b/lib/__tests__/totp-issuer.test.ts new file mode 100644 index 000000000..ea0e78f52 --- /dev/null +++ b/lib/__tests__/totp-issuer.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { totpIssuer } from '../totp-issuer'; + +describe('totpIssuer', () => { + it('uses the configured app name', () => { + expect(totpIssuer('Acme Mail')).toBe('Acme Mail'); + }); + + it('keeps Stalwart on a stock install', () => { + expect(totpIssuer('Webmail')).toBe('Stalwart'); + expect(totpIssuer('')).toBe('Stalwart'); + expect(totpIssuer(' ')).toBe('Stalwart'); + expect(totpIssuer(undefined)).toBe('Stalwart'); + }); +}); diff --git a/lib/totp-issuer.ts b/lib/totp-issuer.ts new file mode 100644 index 000000000..94eb2585d --- /dev/null +++ b/lib/totp-issuer.ts @@ -0,0 +1,8 @@ +// The issuer is what the authenticator app lists the entry under. A branded +// deployment (APP_NAME) uses its own name; a stock install has the default +// "Webmail", which says nothing in a list of entries, so it keeps naming the +// account's server. (#1160) +export function totpIssuer(appName: string | undefined | null): string { + const name = appName?.trim(); + return name && name !== 'Webmail' ? name : 'Stalwart'; +} From 7d08353c80129ccaabf3d0637946cf617d0512aa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:31:03 +0200 Subject: [PATCH 19/35] fix: keep the filter script editable elsewhere after switching out-of-office forwarding off #1152 --- lib/sieve/__tests__/generator-compat.test.ts | 7 +++++-- lib/sieve/generator.ts | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/sieve/__tests__/generator-compat.test.ts b/lib/sieve/__tests__/generator-compat.test.ts index ace7e3026..94b0a67bd 100644 --- a/lib/sieve/__tests__/generator-compat.test.ts +++ b/lib/sieve/__tests__/generator-compat.test.ts @@ -133,12 +133,15 @@ describe('the metadata version', () => { expect(versionOf(generate([{ ...rules[1], ...period }]))).toBe(2); // A rule that is off keeps its period for when it is on again. expect(versionOf(generate([{ ...rules[2], activeUntil: period.activeUntil }]))).toBe(2); - // Forwarding is remembered while it is off. - expect(versionOf(generate([], { vacationForward: { enabled: false, to: 'kollege@example.com', keepCopy: false } }))).toBe(2); + expect(versionOf(generate([], { vacationForward: { enabled: true, to: 'kollege@example.com', keepCopy: false } }))).toBe(2); expect(versionOf(generate([], { includeVacation: true, vacationAudience: { only: 'internal', domains: ['example.com'] } }))).toBe(2); expect(versionOf(generate(rules, { includeVacation: true }))).toBe(1); }); + it('stays 1 for a forward that is off, so turning it on and off again does not lock older editors out', () => { + expect(versionOf(generate([], { vacationForward: { enabled: false, to: 'kollege@example.com', keepCopy: false } }))).toBe(1); + }); + it('reads version 2 back as its own', () => { const options: GenerateOptions = { includeVacation: true, diff --git a/lib/sieve/generator.ts b/lib/sieve/generator.ts index dcc326fc6..efebf6799 100644 --- a/lib/sieve/generator.ts +++ b/lib/sieve/generator.ts @@ -324,8 +324,10 @@ export function generateScript( } // An older build would take a version 1 script as its own and write it back // without what it does not know: a rule without its period forwards for - // good, the auto-reply goes to everyone. It leaves version 2 alone. - if (metadata.rules.some(hasPeriod) || metadata.vacationForward || metadata.vacationAudience) { + // good, the auto-reply goes to everyone. It leaves version 2 alone. A + // forward that is off only loses its remembered address that way, so it + // stays version 1: the native app still edits version 1 only. + if (metadata.rules.some(hasPeriod) || metadata.vacationForward?.enabled || metadata.vacationAudience) { metadata.version = 2; } // The JSON sits inside a /* ... */ comment: a "*/" in any string (a rule From 7f78d4837a47a0728d9d9db2ccbd67819cb59273 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:31:03 +0200 Subject: [PATCH 20/35] fix: don't offer the auto-reply audience on a shared account #1152 --- .../settings/__tests__/vacation-forward.test.tsx | 10 ++++++++++ components/settings/vacation-settings.tsx | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/components/settings/__tests__/vacation-forward.test.tsx b/components/settings/__tests__/vacation-forward.test.tsx index ffc1cc568..3c1fed339 100644 --- a/components/settings/__tests__/vacation-forward.test.tsx +++ b/components/settings/__tests__/vacation-forward.test.tsx @@ -8,6 +8,7 @@ import { STALWART_VACATION_SCRIPT, mockStalwartAccount } from '@/lib/filters/__t import { useAuthStore } from '@/stores/auth-store'; import { useFilterStore } from '@/stores/filter-store'; import { useIdentityStore } from '@/stores/identity-store'; +import { useManagedAccountStore } from '@/stores/managed-account-store'; import { useVacationStore } from '@/stores/vacation-store'; import { VacationSettings } from '../vacation-settings'; @@ -62,6 +63,7 @@ const initial = { vacation: useVacationStore.getState(), filter: useFilterStore.getState(), identity: useIdentityStore.getState(), + managed: useManagedAccountStore.getState(), }; beforeEach(() => { toast.error.mockClear(); @@ -72,6 +74,7 @@ afterEach(() => { useVacationStore.setState(initial.vacation, true); useFilterStore.setState(initial.filter, true); useIdentityStore.setState(initial.identity, true); + useManagedAccountStore.setState(initial.managed, true); }); async function openCard(server: ReturnType) { @@ -390,6 +393,13 @@ describe('who gets the auto-reply, in the vacation card', () => { expect(screen.queryByRole('combobox', { name: 'audience.label' })).toBeNull(); }); + it('is not offered on a shared account, whose domains the signed-in identities do not tell', async () => { + withIdentities(); + useManagedAccountStore.setState({ managedAccountId: 'b' } as never); + await openCard(stalwart([rule('a')])); + expect(screen.queryByRole('combobox', { name: 'audience.label' })).toBeNull(); + }); + it('answers external senders only', async () => { withIdentities(); const server = stalwart([rule('a')]); diff --git a/components/settings/vacation-settings.tsx b/components/settings/vacation-settings.tsx index 20c48a0f9..523f61534 100644 --- a/components/settings/vacation-settings.tsx +++ b/components/settings/vacation-settings.tsx @@ -167,8 +167,10 @@ export function VacationSettings() { }; // Who gets the auto-reply as a save stores it: everyone, or the senders - // from (or not from) the account's domains as they are now. - const canNarrow = audienceAvailable && domains.length > 0; + // from (or not from) the account's domains as they are now. The domains + // come from the signed-in user's identities, so a managed (shared) account + // offers no narrowing rather than one built from the wrong domains. + const canNarrow = audienceAvailable && !managedAccountId && domains.length > 0; const audienceSettings = !canNarrow ? undefined : audienceOnly === 'all' ? null : { only: audienceOnly, domains }; From 506f47e1550740d778ced12dca8094d13471a9fa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:31:20 +0200 Subject: [PATCH 21/35] fix: show the translated message when no Archive folder can be created #1176 --- components/mail/mail-app.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 81bd343a2..c145bb99a 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -2420,6 +2420,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { void refreshMailboxes(); } catch (error) { console.error("Failed to archive email:", error); + if (error instanceof ArchiveMailboxNotFoundError) { + toast.error(t('email_viewer.archive_mailbox_not_found')); + return; + } toast.error(t('notifications.error_archiving'), error instanceof Error ? error.message : undefined); } }; From 8cc53e63d9e4178666ad20676954d5f52621cb04 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:02:20 +0200 Subject: [PATCH 22/35] chore: translate files picker and bulk folder move strings --- locales/ar/common.json | 15 +++++++++++++++ locales/ca/common.json | 15 +++++++++++++++ locales/cs/common.json | 15 +++++++++++++++ locales/da/common.json | 15 +++++++++++++++ locales/es/common.json | 15 +++++++++++++++ locales/fa/common.json | 15 +++++++++++++++ locales/fr/common.json | 15 +++++++++++++++ locales/he/common.json | 15 +++++++++++++++ locales/hu/common.json | 15 +++++++++++++++ locales/it/common.json | 15 +++++++++++++++ locales/ja/common.json | 15 +++++++++++++++ locales/ko/common.json | 15 +++++++++++++++ locales/lv/common.json | 15 +++++++++++++++ locales/mn/common.json | 15 +++++++++++++++ locales/nb/common.json | 15 +++++++++++++++ locales/nl/common.json | 15 +++++++++++++++ locales/pl/common.json | 15 +++++++++++++++ locales/pt/common.json | 15 +++++++++++++++ locales/ro/common.json | 15 +++++++++++++++ locales/ru/common.json | 15 +++++++++++++++ locales/sk/common.json | 15 +++++++++++++++ locales/tr/common.json | 15 +++++++++++++++ locales/uk/common.json | 15 +++++++++++++++ locales/zh-TW/common.json | 15 +++++++++++++++ locales/zh/common.json | 15 +++++++++++++++ 25 files changed, 375 insertions(+) diff --git a/locales/ar/common.json b/locales/ar/common.json index 9248fb471..d4c1d5c99 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -624,6 +624,12 @@ "attach": "إرفاق", "attach_photos": "الصور ومقاطع الفيديو", "attach_files": "ملفات", + "attach_from_files": "إرفاق من الملفات", + "files_picker": { + "attach": "{count, plural, =0 {إرفاق} one {إرفاق ملف واحد} other {إرفاق # ملفات}}", + "empty_folder": "هذا المجلد فارغ", + "load_failed": "تعذّر تحميل ملفاتك" + }, "discard": "تجاهل", "discard_draft_title": "تجاهل المسودة؟", "discard_draft_confirm": "لديك تغييرات غير محفوظة. هل تريد تجاهل هذه المسودة؟", @@ -1894,6 +1900,15 @@ "reorder": "اسحب لإعادة الترتيب", "reorder_error": "فشل في إعادة ترتيب المجلدات", "move_error": "فشل في نقل المجلد", + "select_folder": "تحديد {name}", + "select_hint": "تحديد. Shift+نقر يحدد كل المجلدات حتى آخر مجلد نقرت عليه.", + "selected_count": "{count, plural, one {# مجلد} other {# مجلدات}} محددة", + "move_to": "نقل إلى...", + "move_to_top_level": "المستوى الأعلى", + "move": "نقل", + "clear_selection": "إلغاء التحديد", + "folders_moved": "{count, plural, one {تم نقل # مجلد} other {تم نقل # مجلدات}}", + "move_partial_error": "{count, plural, one {تعذّر نقل # مجلد} other {تعذّر نقل # مجلدات}}", "subfolder_of": "داخل {name}", "subfolder_name": "اسم المجلد الفرعي", "new_folder_name": "اسم المجلد", diff --git a/locales/ca/common.json b/locales/ca/common.json index 7c0058a89..4f57a57f8 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -624,6 +624,12 @@ "attach": "Adjunta", "attach_photos": "Fotos i vídeos", "attach_files": "Fitxers", + "attach_from_files": "Adjunta des de Fitxers", + "files_picker": { + "attach": "{count, plural, =0 {Adjunta} one {Adjunta 1 fitxer} other {Adjunta # fitxers}}", + "empty_folder": "Aquesta carpeta és buida", + "load_failed": "No s'han pogut carregar els teus fitxers" + }, "discard": "Descarta", "discard_draft_title": "Voleu descartar l'esborrany?", "discard_draft_confirm": "Teniu canvis sense desar. Voleu descartar aquest esborrany?", @@ -1894,6 +1900,15 @@ "reorder": "Arrossegueu per reordenar", "reorder_error": "No s'han pogut reordenar les carpetes", "move_error": "No s'ha pogut moure la carpeta", + "select_folder": "Selecciona {name}", + "select_hint": "Selecciona. Maj+clic selecciona totes les carpetes fins a l'última on has fet clic.", + "selected_count": "{count, plural, one {# carpeta seleccionada} other {# carpetes seleccionades}}", + "move_to": "Mou a...", + "move_to_top_level": "Nivell superior", + "move": "Mou", + "clear_selection": "Esborra la selecció", + "folders_moved": "{count, plural, one {# carpeta moguda} other {# carpetes mogudes}}", + "move_partial_error": "{count, plural, one {No s'ha pogut moure # carpeta} other {No s'han pogut moure # carpetes}}", "subfolder_of": "Dins de {name}", "subfolder_name": "Nom de la subcarpeta", "new_folder_name": "Nom de la carpeta", diff --git a/locales/cs/common.json b/locales/cs/common.json index 487eb7fa7..cc4a9ac1f 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -624,6 +624,12 @@ "attach": "Připojit", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "Připojit ze Souborů", + "files_picker": { + "attach": "{count, plural, =0 {Připojit} one {Připojit 1 soubor} few {Připojit # soubory} other {Připojit # souborů}}", + "empty_folder": "Tato složka je prázdná", + "load_failed": "Vaše soubory se nepodařilo načíst" + }, "discard": "Zahodit", "discard_draft_title": "Zahodit koncept?", "discard_draft_confirm": "Máte neuložené změny. Chcete tento koncept zahodit?", @@ -1887,6 +1893,15 @@ "reorder": "Přetažením změníte pořadí", "reorder_error": "Nepodařilo se změnit pořadí složek", "move_error": "Nepodařilo se přesunout složku", + "select_folder": "Vybrat {name}", + "select_hint": "Vybrat. Shift+kliknutí vybere všechny složky až po naposledy kliknutou.", + "selected_count": "{count, plural, one {Vybrána # složka} few {Vybrány # složky} other {Vybráno # složek}}", + "move_to": "Přesunout do...", + "move_to_top_level": "Nejvyšší úroveň", + "move": "Přesunout", + "clear_selection": "Zrušit výběr", + "folders_moved": "{count, plural, one {Přesunuta # složka} few {Přesunuty # složky} other {Přesunuto # složek}}", + "move_partial_error": "{count, plural, one {# složku} few {# složky} other {# složek}} se nepodařilo přesunout", "subfolder_of": "Ve složce {name}", "subfolder_name": "Název podsložky", "new_folder_name": "Název složky", diff --git a/locales/da/common.json b/locales/da/common.json index 4787b5624..45e8fd293 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -624,6 +624,12 @@ "attach": "Vedhæft", "attach_photos": "Fotos & videoer", "attach_files": "Filer", + "attach_from_files": "Vedhæft fra Filer", + "files_picker": { + "attach": "{count, plural, =0 {Vedhæft} one {Vedhæft 1 fil} other {Vedhæft # filer}}", + "empty_folder": "Denne mappe er tom", + "load_failed": "Dine filer kunne ikke indlæses" + }, "discard": "Kassér", "discard_draft_title": "Kassér kladde?", "discard_draft_confirm": "Du har ugemte ændringer. Vil du kassere denne kladde?", @@ -1890,6 +1896,15 @@ "reorder": "Træk for at ændre rækkefølgen", "reorder_error": "Kunne ikke ændre rækkefølgen af mapper", "move_error": "Kunne ikke flytte mappen", + "select_folder": "Vælg {name}", + "select_hint": "Vælg. Shift+klik vælger alle mapper op til den sidste, du klikkede på.", + "selected_count": "{count, plural, one {# mappe} other {# mapper}} valgt", + "move_to": "Flyt til...", + "move_to_top_level": "Øverste niveau", + "move": "Flyt", + "clear_selection": "Ryd markering", + "folders_moved": "{count, plural, one {# mappe flyttet} other {# mapper flyttet}}", + "move_partial_error": "{count, plural, one {# mappe} other {# mapper}} kunne ikke flyttes", "subfolder_of": "Inde i {name}", "subfolder_name": "Undermappenavn", "new_folder_name": "Mappenavn", diff --git a/locales/es/common.json b/locales/es/common.json index 111980e64..b7e312de2 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -624,6 +624,12 @@ "attach": "Adjuntar", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "Adjuntar desde Archivos", + "files_picker": { + "attach": "{count, plural, =0 {Adjuntar} one {Adjuntar 1 archivo} other {Adjuntar # archivos}}", + "empty_folder": "Esta carpeta está vacía", + "load_failed": "No se pudieron cargar tus archivos" + }, "discard": "Descartar", "discard_draft_title": "¿Descartar borrador?", "discard_draft_confirm": "Tiene cambios sin guardar. ¿Desea descartar este borrador?", @@ -1887,6 +1893,15 @@ "reorder": "Arrastra para reordenar", "reorder_error": "No se pudieron reordenar las carpetas", "move_error": "No se pudo mover la carpeta", + "select_folder": "Seleccionar {name}", + "select_hint": "Seleccionar. Mayús+clic selecciona todas las carpetas hasta la última en la que hiciste clic.", + "selected_count": "{count, plural, one {# carpeta seleccionada} other {# carpetas seleccionadas}}", + "move_to": "Mover a...", + "move_to_top_level": "Nivel superior", + "move": "Mover", + "clear_selection": "Borrar selección", + "folders_moved": "{count, plural, one {# carpeta movida} other {# carpetas movidas}}", + "move_partial_error": "{count, plural, one {No se pudo mover # carpeta} other {No se pudieron mover # carpetas}}", "subfolder_of": "Dentro de {name}", "subfolder_name": "Nombre de la subcarpeta", "new_folder_name": "Nombre de la carpeta", diff --git a/locales/fa/common.json b/locales/fa/common.json index 8492e987e..97d39d9b7 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -624,6 +624,12 @@ "attach": "پیوست", "attach_photos": "عکس و ویدیو", "attach_files": "فایل‌ها", + "attach_from_files": "پیوست از فایل‌ها", + "files_picker": { + "attach": "{count, plural, =0 {پیوست} one {پیوست ۱ فایل} other {پیوست # فایل}}", + "empty_folder": "این پوشه خالی است", + "load_failed": "فایل‌های شما بارگیری نشد" + }, "discard": "دور انداختن", "discard_draft_title": "دور انداختن پیش‌نویس؟", "discard_draft_confirm": "تغییرات ذخیره نشده دارید. پیش‌نویس دور انداخته شود؟", @@ -1894,6 +1900,15 @@ "reorder": "برای تغییر ترتیب بکشید", "reorder_error": "تغییر ترتیب پوشه‌ها ناموفق بود", "move_error": "انتقال پوشه ناموفق بود", + "select_folder": "انتخاب {name}", + "select_hint": "انتخاب. Shift+کلیک همه پوشه‌ها را تا آخرین پوشه‌ای که کلیک کردید انتخاب می‌کند.", + "selected_count": "{count, plural, one {# پوشه} other {# پوشه}} انتخاب شد", + "move_to": "انتقال به...", + "move_to_top_level": "سطح بالا", + "move": "انتقال", + "clear_selection": "پاک کردن انتخاب", + "folders_moved": "{count, plural, one {# پوشه منتقل شد} other {# پوشه منتقل شد}}", + "move_partial_error": "{count, plural, one {# پوشه} other {# پوشه}} منتقل نشد", "subfolder_of": "داخل {name}", "subfolder_name": "نام زیرپوشه", "new_folder_name": "نام پوشه", diff --git a/locales/fr/common.json b/locales/fr/common.json index 6c3d64b57..e45954d09 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -624,6 +624,12 @@ "attach": "Joindre", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "Joindre depuis Fichiers", + "files_picker": { + "attach": "{count, plural, =0 {Joindre} one {Joindre 1 fichier} other {Joindre # fichiers}}", + "empty_folder": "Ce dossier est vide", + "load_failed": "Impossible de charger vos fichiers" + }, "discard": "Supprimer", "discard_draft_title": "Supprimer le brouillon ?", "discard_draft_confirm": "Vous avez des modifications non enregistrées. Voulez-vous supprimer ce brouillon ?", @@ -1887,6 +1893,15 @@ "reorder": "Glissez pour réorganiser", "reorder_error": "Échec de la réorganisation des dossiers", "move_error": "Échec du déplacement du dossier", + "select_folder": "Sélectionner {name}", + "select_hint": "Sélectionner. Maj+clic sélectionne tous les dossiers jusqu'au dernier sur lequel vous avez cliqué.", + "selected_count": "{count, plural, one {# dossier sélectionné} other {# dossiers sélectionnés}}", + "move_to": "Déplacer vers...", + "move_to_top_level": "Niveau supérieur", + "move": "Déplacer", + "clear_selection": "Effacer la sélection", + "folders_moved": "{count, plural, one {# dossier déplacé} other {# dossiers déplacés}}", + "move_partial_error": "{count, plural, one {# dossier n'a pas pu être déplacé} other {# dossiers n'ont pas pu être déplacés}}", "subfolder_of": "Dans {name}", "subfolder_name": "Nom du sous-dossier", "new_folder_name": "Nom du dossier", diff --git a/locales/he/common.json b/locales/he/common.json index 52252c30c..c3845c12d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -649,6 +649,12 @@ "require_tls_off": "דרישת מסירה מוצפנת (TLS)", "attach_photos": "תמונות וסרטונים", "attach_files": "קבצים", + "attach_from_files": "צירוף מקבצים", + "files_picker": { + "attach": "{count, plural, =0 {צירוף} one {צירוף קובץ 1} other {צירוף # קבצים}}", + "empty_folder": "התיקייה הזו ריקה", + "load_failed": "לא ניתן היה לטעון את הקבצים שלך" + }, "sending": "שולח…", "add_link": "הוסף קישור", "link_url_prompt": "הזן את כתובת ה־URL", @@ -1851,6 +1857,15 @@ "reorder": "גרור לשינוי הסדר", "reorder_error": "שינוי סדר התיקיות נכשל", "move_error": "העברת התיקייה נכשלה", + "select_folder": "בחירת {name}", + "select_hint": "בחירה. Shift+לחיצה בוחרת את כל התיקיות עד האחרונה שלחצת עליה.", + "selected_count": "{count, plural, one {נבחרה תיקייה #} other {נבחרו # תיקיות}}", + "move_to": "העברה אל...", + "move_to_top_level": "רמה עליונה", + "move": "העברה", + "clear_selection": "ניקוי הבחירה", + "folders_moved": "{count, plural, one {תיקייה # הועברה} other {# תיקיות הועברו}}", + "move_partial_error": "{count, plural, one {לא ניתן היה להעביר תיקייה #} other {לא ניתן היה להעביר # תיקיות}}", "subfolder_of": "בתוך{name}", "subfolder_name": "שם תיקיית משנה", "new_folder_name": "שם התיקיה", diff --git a/locales/hu/common.json b/locales/hu/common.json index d0f3b6949..b9c3f4b30 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -624,6 +624,12 @@ "attach": "Csatolás", "attach_photos": "Fényképek és videók", "attach_files": "Fájlok", + "attach_from_files": "Csatolás a Fájlokból", + "files_picker": { + "attach": "{count, plural, =0 {Csatolás} one {1 fájl csatolása} other {# fájl csatolása}}", + "empty_folder": "Ez a mappa üres", + "load_failed": "A fájlokat nem sikerült betölteni" + }, "discard": "Elvetés", "discard_draft_title": "Piszkozat elvetése?", "discard_draft_confirm": "Nem mentett változtatások vannak. El akarod vetni ezt a piszkozatot?", @@ -1890,6 +1896,15 @@ "reorder": "Húzza az átrendezéshez", "reorder_error": "Nem sikerült átrendezni a mappákat", "move_error": "Nem sikerült áthelyezni a mappát", + "select_folder": "{name} kijelölése", + "select_hint": "Kijelölés. Shift+kattintás kijelöli az összes mappát az utoljára kattintottig.", + "selected_count": "{count, plural, one {# mappa} other {# mappa}} kijelölve", + "move_to": "Áthelyezés ide...", + "move_to_top_level": "Legfelső szint", + "move": "Áthelyezés", + "clear_selection": "Kijelölés törlése", + "folders_moved": "{count, plural, one {# mappa áthelyezve} other {# mappa áthelyezve}}", + "move_partial_error": "{count, plural, one {# mappát} other {# mappát}} nem sikerült áthelyezni", "subfolder_of": "Itt: {name}", "subfolder_name": "Almappa neve", "new_folder_name": "Mappa neve", diff --git a/locales/it/common.json b/locales/it/common.json index 639257deb..7abad91d0 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -624,6 +624,12 @@ "attach": "Allega", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "Allega da File", + "files_picker": { + "attach": "{count, plural, =0 {Allega} one {Allega 1 file} other {Allega # file}}", + "empty_folder": "Questa cartella è vuota", + "load_failed": "Impossibile caricare i tuoi file" + }, "discard": "Scarta", "discard_draft_title": "Eliminare la bozza?", "discard_draft_confirm": "Ci sono modifiche non salvate. Vuoi scartare questa bozza?", @@ -1887,6 +1893,15 @@ "reorder": "Trascina per riordinare", "reorder_error": "Impossibile riordinare le cartelle", "move_error": "Impossibile spostare la cartella", + "select_folder": "Seleziona {name}", + "select_hint": "Seleziona. Maiusc+clic seleziona tutte le cartelle fino all'ultima su cui hai fatto clic.", + "selected_count": "{count, plural, one {# cartella selezionata} other {# cartelle selezionate}}", + "move_to": "Sposta in...", + "move_to_top_level": "Livello superiore", + "move": "Sposta", + "clear_selection": "Annulla selezione", + "folders_moved": "{count, plural, one {# cartella spostata} other {# cartelle spostate}}", + "move_partial_error": "{count, plural, one {Impossibile spostare # cartella} other {Impossibile spostare # cartelle}}", "subfolder_of": "Dentro {name}", "subfolder_name": "Nome della sottocartella", "new_folder_name": "Nome della cartella", diff --git a/locales/ja/common.json b/locales/ja/common.json index 434eead72..7c46df1d0 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -624,6 +624,12 @@ "attach": "添付", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "ファイルから添付", + "files_picker": { + "attach": "{count, plural, =0 {添付} other {# 件のファイルを添付}}", + "empty_folder": "このフォルダーは空です", + "load_failed": "ファイルを読み込めませんでした" + }, "discard": "破棄", "discard_draft_title": "下書きを破棄しますか?", "discard_draft_confirm": "未保存の変更があります。この下書きを破棄しますか?", @@ -1887,6 +1893,15 @@ "reorder": "ドラッグして並べ替え", "reorder_error": "フォルダーの並べ替えに失敗しました", "move_error": "フォルダーの移動に失敗しました", + "select_folder": "{name} を選択", + "select_hint": "選択。Shift+クリックで最後にクリックしたフォルダーまでをすべて選択します。", + "selected_count": "{count, plural, other {# 個のフォルダー}}を選択中", + "move_to": "移動先...", + "move_to_top_level": "最上位", + "move": "移動", + "clear_selection": "選択を解除", + "folders_moved": "{count, plural, other {# 個のフォルダーを移動しました}}", + "move_partial_error": "{count, plural, other {# 個のフォルダー}}を移動できませんでした", "subfolder_of": "{name} の中", "subfolder_name": "サブフォルダー名", "new_folder_name": "フォルダー名", diff --git a/locales/ko/common.json b/locales/ko/common.json index 748bd553a..23d769e49 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -624,6 +624,12 @@ "attach": "첨부", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "파일에서 첨부", + "files_picker": { + "attach": "{count, plural, =0 {첨부} other {파일 #개 첨부}}", + "empty_folder": "이 폴더는 비어 있습니다", + "load_failed": "파일을 불러올 수 없습니다" + }, "discard": "삭제", "discard_draft_title": "임시보관 메일을 삭제할까요?", "discard_draft_confirm": "저장되지 않은 내용이 있어요. 이 메일을 삭제할까요?", @@ -1887,6 +1893,15 @@ "reorder": "드래그하여 순서 변경", "reorder_error": "폴더 순서 변경에 실패했습니다", "move_error": "폴더 이동에 실패했습니다", + "select_folder": "{name} 선택", + "select_hint": "선택. Shift+클릭하면 마지막으로 클릭한 폴더까지 모두 선택됩니다.", + "selected_count": "{count, plural, other {폴더 #개}} 선택됨", + "move_to": "이동할 위치...", + "move_to_top_level": "최상위", + "move": "이동", + "clear_selection": "선택 해제", + "folders_moved": "{count, plural, other {폴더 #개를 이동했습니다}}", + "move_partial_error": "{count, plural, other {폴더 #개}}를 이동할 수 없습니다", "subfolder_of": "{name} 내부에 생성", "subfolder_name": "하위 폴더 이름", "new_folder_name": "폴더 이름", diff --git a/locales/lv/common.json b/locales/lv/common.json index e0d64b230..da99f69bd 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -624,6 +624,12 @@ "attach": "Pievienot", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "Pievienot no Failiem", + "files_picker": { + "attach": "{count, plural, =0 {Pievienot} one {Pievienot # failu} other {Pievienot # failus}}", + "empty_folder": "Šī mape ir tukša", + "load_failed": "Jūsu failus neizdevās ielādēt" + }, "discard": "Atmest", "discard_draft_title": "Atmest melnrakstu?", "discard_draft_confirm": "Ir nesaglabātas izmaiņas. Vai tiešām vēlaties atmest šo melnrakstu?", @@ -1887,6 +1893,15 @@ "reorder": "Velciet, lai pārkārtotu", "reorder_error": "Neizdevās pārkārtot mapes", "move_error": "Neizdevās pārvietot mapi", + "select_folder": "Atlasīt {name}", + "select_hint": "Atlasīt. Shift+klikšķis atlasa visas mapes līdz pēdējai, uz kuras noklikšķinājāt.", + "selected_count": "{count, plural, zero {Atlasītas # mapes} one {Atlasīta # mape} other {Atlasītas # mapes}}", + "move_to": "Pārvietot uz...", + "move_to_top_level": "Augšējais līmenis", + "move": "Pārvietot", + "clear_selection": "Notīrīt atlasi", + "folders_moved": "{count, plural, zero {Pārvietotas # mapes} one {Pārvietota # mape} other {Pārvietotas # mapes}}", + "move_partial_error": "{count, plural, zero {# mapes neizdevās pārvietot} one {# mapi neizdevās pārvietot} other {# mapes neizdevās pārvietot}}", "subfolder_of": "Mapē {name}", "subfolder_name": "Apakšmapes nosaukums", "new_folder_name": "Mapes nosaukums", diff --git a/locales/mn/common.json b/locales/mn/common.json index 6a78853fb..3003334ba 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -624,6 +624,12 @@ "attach": "Хавсаргах", "attach_photos": "Зураг, Видео", "attach_files": "Файл", + "attach_from_files": "Файлаас хавсаргах", + "files_picker": { + "attach": "{count, plural, =0 {Хавсаргах} one {1 файл хавсаргах} other {# файл хавсаргах}}", + "empty_folder": "Энэ фолдер хоосон байна", + "load_failed": "Таны файлуудыг ачаалж чадсангүй" + }, "discard": "Хаях", "discard_draft_title": "Ноорог устгах уу?", "discard_draft_confirm": "Танд хадгалагдаагүй өөрчлөлтүүд байна. Та энэ ноорогоос татгалзахыг хүсэж байна уу?", @@ -1894,6 +1900,15 @@ "reorder": "Дахин эрэмбэлэхийн тулд чирнэ үү", "reorder_error": "Фолдеруудыг дахин эрэмбэлж чадсангүй", "move_error": "Фолдерыг зөөж чадсангүй", + "select_folder": "{name}-г сонгох", + "select_hint": "Сонгох. Shift+товшилт нь хамгийн сүүлд товшсон фолдер хүртэлх бүх фолдерыг сонгоно.", + "selected_count": "{count, plural, one {# фолдер} other {# фолдер}} сонгогдсон", + "move_to": "Зөөх...", + "move_to_top_level": "Дээд түвшин", + "move": "Зөөх", + "clear_selection": "Сонголтыг цуцлах", + "folders_moved": "{count, plural, one {# фолдер зөөгдлөө} other {# фолдер зөөгдлөө}}", + "move_partial_error": "{count, plural, one {# фолдерыг} other {# фолдерыг}} зөөж чадсангүй", "subfolder_of": "{name}", "subfolder_name": "Дэд хавтасны нэр", "new_folder_name": "Хавтасны нэр", diff --git a/locales/nb/common.json b/locales/nb/common.json index 71041689f..6becc920c 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -624,6 +624,12 @@ "attach": "Legg ved", "attach_photos": "Bilder og videoer", "attach_files": "Filer", + "attach_from_files": "Legg ved fra Filer", + "files_picker": { + "attach": "{count, plural, =0 {Legg ved} one {Legg ved 1 fil} other {Legg ved # filer}}", + "empty_folder": "Denne mappen er tom", + "load_failed": "Filene dine kunne ikke lastes inn" + }, "discard": "Forkast", "discard_draft_title": "Vil du forkaste utkastet?", "discard_draft_confirm": "Du har ulagrede endringer. Vil du forkaste dette utkastet?", @@ -1894,6 +1900,15 @@ "reorder": "Dra for å endre rekkefølgen", "reorder_error": "Kunne ikke endre rekkefølgen på mappene", "move_error": "Kunne ikke flytte mappen", + "select_folder": "Velg {name}", + "select_hint": "Velg. Shift+klikk velger alle mapper frem til den siste du klikket på.", + "selected_count": "{count, plural, one {# mappe} other {# mapper}} valgt", + "move_to": "Flytt til...", + "move_to_top_level": "Øverste nivå", + "move": "Flytt", + "clear_selection": "Fjern valg", + "folders_moved": "{count, plural, one {# mappe flyttet} other {# mapper flyttet}}", + "move_partial_error": "{count, plural, one {# mappe} other {# mapper}} kunne ikke flyttes", "subfolder_of": "I {name}", "subfolder_name": "Navn på undermappe", "new_folder_name": "Mappenavn", diff --git a/locales/nl/common.json b/locales/nl/common.json index f2b8b35f2..0d5270990 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -624,6 +624,12 @@ "attach": "Bijlage toevoegen", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "Bijvoegen uit Bestanden", + "files_picker": { + "attach": "{count, plural, =0 {Bijvoegen} one {1 bestand bijvoegen} other {# bestanden bijvoegen}}", + "empty_folder": "Deze map is leeg", + "load_failed": "Je bestanden konden niet worden geladen" + }, "discard": "Verwijderen", "discard_draft_title": "Concept verwijderen?", "discard_draft_confirm": "Je hebt niet-opgeslagen wijzigingen. Wil je dit concept verwijderen?", @@ -1887,6 +1893,15 @@ "reorder": "Sleep om te herschikken", "reorder_error": "Herschikken van mappen is mislukt", "move_error": "Verplaatsen van map is mislukt", + "select_folder": "{name} selecteren", + "select_hint": "Selecteren. Shift+klik selecteert alle mappen tot en met de laatste waarop je hebt geklikt.", + "selected_count": "{count, plural, one {# map} other {# mappen}} geselecteerd", + "move_to": "Verplaatsen naar...", + "move_to_top_level": "Hoogste niveau", + "move": "Verplaatsen", + "clear_selection": "Selectie wissen", + "folders_moved": "{count, plural, one {# map verplaatst} other {# mappen verplaatst}}", + "move_partial_error": "{count, plural, one {# map kon} other {# mappen konden}} niet worden verplaatst", "subfolder_of": "In {name}", "subfolder_name": "Naam van submap", "new_folder_name": "Mapnaam", diff --git a/locales/pl/common.json b/locales/pl/common.json index 5947f1aa4..6237e5776 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -624,6 +624,12 @@ "attach": "Załącz", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "Dołącz z Plików", + "files_picker": { + "attach": "{count, plural, =0 {Dołącz} one {Dołącz 1 plik} few {Dołącz # pliki} many {Dołącz # plików} other {Dołącz # pliku}}", + "empty_folder": "Ten folder jest pusty", + "load_failed": "Nie udało się wczytać plików" + }, "discard": "Odrzuć", "discard_draft_title": "Odrzucić szkic?", "discard_draft_confirm": "Masz niezapisane zmiany. Czy chcesz odrzucić ten szkic?", @@ -1887,6 +1893,15 @@ "reorder": "Przeciągnij, aby zmienić kolejność", "reorder_error": "Nie udało się zmienić kolejności folderów", "move_error": "Nie udało się przenieść folderu", + "select_folder": "Zaznacz {name}", + "select_hint": "Zaznacz. Shift+kliknięcie zaznacza wszystkie foldery aż do ostatnio klikniętego.", + "selected_count": "{count, plural, one {Zaznaczono # folder} few {Zaznaczono # foldery} many {Zaznaczono # folderów} other {Zaznaczono # folderu}}", + "move_to": "Przenieś do...", + "move_to_top_level": "Najwyższy poziom", + "move": "Przenieś", + "clear_selection": "Wyczyść zaznaczenie", + "folders_moved": "{count, plural, one {Przeniesiono # folder} few {Przeniesiono # foldery} many {Przeniesiono # folderów} other {Przeniesiono # folderu}}", + "move_partial_error": "{count, plural, one {Nie udało się przenieść # folderu} few {Nie udało się przenieść # folderów} many {Nie udało się przenieść # folderów} other {Nie udało się przenieść # folderu}}", "subfolder_of": "W folderze {name}", "subfolder_name": "Nazwa podfolderu", "new_folder_name": "Nazwa folderu", diff --git a/locales/pt/common.json b/locales/pt/common.json index 5951f05e4..69efe3b76 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -624,6 +624,12 @@ "attach": "Anexar", "attach_photos": "Fotos & Vídeos", "attach_files": "Arquivos", + "attach_from_files": "Anexar de Arquivos", + "files_picker": { + "attach": "{count, plural, =0 {Anexar} one {Anexar 1 arquivo} other {Anexar # arquivos}}", + "empty_folder": "Esta pasta está vazia", + "load_failed": "Não foi possível carregar seus arquivos" + }, "discard": "Descartar", "discard_draft_title": "Descartar rascunho?", "discard_draft_confirm": "Você tem alterações não salvas. Deseja descartar este rascunho?", @@ -1887,6 +1893,15 @@ "reorder": "Arraste para reordenar", "reorder_error": "Falha ao reordenar as pastas", "move_error": "Falha ao mover a pasta", + "select_folder": "Selecionar {name}", + "select_hint": "Selecionar. Shift+clique seleciona todas as pastas até a última em que você clicou.", + "selected_count": "{count, plural, one {# pasta selecionada} other {# pastas selecionadas}}", + "move_to": "Mover para...", + "move_to_top_level": "Nível superior", + "move": "Mover", + "clear_selection": "Limpar seleção", + "folders_moved": "{count, plural, one {# pasta movida} other {# pastas movidas}}", + "move_partial_error": "{count, plural, one {Não foi possível mover # pasta} other {Não foi possível mover # pastas}}", "subfolder_of": "Dentro de {name}", "subfolder_name": "Nome da subpasta", "new_folder_name": "Nome da pasta", diff --git a/locales/ro/common.json b/locales/ro/common.json index cb025d88e..4549a9622 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -624,6 +624,12 @@ "attach": "Atașați", "attach_photos": "Fotografii și videoclipuri", "attach_files": "Fișiere", + "attach_from_files": "Atașează din Fișiere", + "files_picker": { + "attach": "{count, plural, =0 {Atașează} one {Atașează 1 fișier} few {Atașează # fișiere} other {Atașează # de fișiere}}", + "empty_folder": "Acest dosar este gol", + "load_failed": "Fișierele tale nu au putut fi încărcate" + }, "discard": "Ignorați", "discard_draft_title": "Doriți să ștergeți schița?", "discard_draft_confirm": "Aveți modificări nesalvate. Doriți să renunțați la această schiță?", @@ -1894,6 +1900,15 @@ "reorder": "Trageți pentru a reordona", "reorder_error": "Reordonarea dosarelor a eșuat", "move_error": "Mutarea dosarului a eșuat", + "select_folder": "Selectează {name}", + "select_hint": "Selectează. Shift+clic selectează toate dosarele până la ultimul pe care ai dat clic.", + "selected_count": "{count, plural, one {# dosar selectat} few {# dosare selectate} other {# de dosare selectate}}", + "move_to": "Mută în...", + "move_to_top_level": "Nivelul superior", + "move": "Mută", + "clear_selection": "Golește selecția", + "folders_moved": "{count, plural, one {# dosar mutat} few {# dosare mutate} other {# de dosare mutate}}", + "move_partial_error": "{count, plural, one {# dosar nu a putut fi mutat} few {# dosare nu au putut fi mutate} other {# de dosare nu au putut fi mutate}}", "subfolder_of": "În interiorul {name}", "subfolder_name": "Numele subfolderului", "new_folder_name": "Numele folderului", diff --git a/locales/ru/common.json b/locales/ru/common.json index 7c03e8485..aad9673c9 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -624,6 +624,12 @@ "attach": "Прикрепить", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "Прикрепить из Файлов", + "files_picker": { + "attach": "{count, plural, =0 {Прикрепить} one {Прикрепить # файл} few {Прикрепить # файла} many {Прикрепить # файлов} other {Прикрепить # файла}}", + "empty_folder": "Эта папка пуста", + "load_failed": "Не удалось загрузить ваши файлы" + }, "discard": "Удалить", "discard_draft_title": "Удалить черновик?", "discard_draft_confirm": "Есть несохранённые изменения. Удалить этот черновик?", @@ -1887,6 +1893,15 @@ "reorder": "Перетащите, чтобы изменить порядок", "reorder_error": "Не удалось изменить порядок папок", "move_error": "Не удалось переместить папку", + "select_folder": "Выбрать {name}", + "select_hint": "Выбрать. Shift+щелчок выбирает все папки до последней, по которой вы щёлкнули.", + "selected_count": "{count, plural, one {Выбрана # папка} few {Выбрано # папки} many {Выбрано # папок} other {Выбрано # папки}}", + "move_to": "Переместить в...", + "move_to_top_level": "Верхний уровень", + "move": "Переместить", + "clear_selection": "Снять выделение", + "folders_moved": "{count, plural, one {Перемещена # папка} few {Перемещено # папки} many {Перемещено # папок} other {Перемещено # папки}}", + "move_partial_error": "{count, plural, one {Не удалось переместить # папку} few {Не удалось переместить # папки} many {Не удалось переместить # папок} other {Не удалось переместить # папки}}", "subfolder_of": "Внутри {name}", "subfolder_name": "Название подпапки", "new_folder_name": "Название папки", diff --git a/locales/sk/common.json b/locales/sk/common.json index 5c978db8e..992919983 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -624,6 +624,12 @@ "attach": "Priložiť", "attach_photos": "Fotky a videá", "attach_files": "Súbory", + "attach_from_files": "Priložiť zo Súborov", + "files_picker": { + "attach": "{count, plural, =0 {Priložiť} one {Priložiť 1 súbor} few {Priložiť # súbory} other {Priložiť # súborov}}", + "empty_folder": "Tento priečinok je prázdny", + "load_failed": "Vaše súbory sa nepodarilo načítať" + }, "discard": "Zahoditť", "discard_draft_title": "Zahoditť koncept?", "discard_draft_confirm": "Máte neuložené zmeny. Chcete zahoditť tento koncept?", @@ -1894,6 +1900,15 @@ "reorder": "Potiahnutím zmeníte poradie", "reorder_error": "Nepodarilo sa zmeniť poradie priečinkov", "move_error": "Nepodarilo sa presunúť priečinok", + "select_folder": "Vybrať {name}", + "select_hint": "Vybrať. Shift+kliknutie vyberie všetky priečinky až po naposledy kliknutý.", + "selected_count": "{count, plural, one {Vybraný # priečinok} few {Vybrané # priečinky} other {Vybraných # priečinkov}}", + "move_to": "Presunúť do...", + "move_to_top_level": "Najvyššia úroveň", + "move": "Presunúť", + "clear_selection": "Zrušiť výber", + "folders_moved": "{count, plural, one {Presunutý # priečinok} few {Presunuté # priečinky} other {Presunutých # priečinkov}}", + "move_partial_error": "{count, plural, one {# priečinok sa nepodarilo presunúť} few {# priečinky sa nepodarilo presunúť} other {# priečinkov sa nepodarilo presunúť}}", "subfolder_of": "V priečinku {name}", "subfolder_name": "Názov podpriečinka", "new_folder_name": "Názov priečinka", diff --git a/locales/tr/common.json b/locales/tr/common.json index 6aaefc6b9..6149ba9c1 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -624,6 +624,12 @@ "attach": "Ekle", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "Dosyalar'dan ekle", + "files_picker": { + "attach": "{count, plural, =0 {Ekle} one {1 dosya ekle} other {# dosya ekle}}", + "empty_folder": "Bu klasör boş", + "load_failed": "Dosyalarınız yüklenemedi" + }, "discard": "Vazgeç", "discard_draft_title": "Taslaktan vazgeçilsin mi?", "discard_draft_confirm": "Kaydedilmemiş değişiklikleriniz var. Bu taslaktan vazgeçmek istiyor musunuz?", @@ -1887,6 +1893,15 @@ "reorder": "Yeniden sıralamak için sürükleyin", "reorder_error": "Klasörler yeniden sıralanamadı", "move_error": "Klasör taşınamadı", + "select_folder": "{name} öğesini seç", + "select_hint": "Seç. Shift+tıklama, en son tıkladığınız klasöre kadar tüm klasörleri seçer.", + "selected_count": "{count, plural, one {# klasör} other {# klasör}} seçildi", + "move_to": "Taşı...", + "move_to_top_level": "En üst düzey", + "move": "Taşı", + "clear_selection": "Seçimi temizle", + "folders_moved": "{count, plural, one {# klasör taşındı} other {# klasör taşındı}}", + "move_partial_error": "{count, plural, one {# klasör} other {# klasör}} taşınamadı", "subfolder_of": "{name} içinde", "subfolder_name": "Alt klasör adı", "new_folder_name": "Klasör adı", diff --git a/locales/uk/common.json b/locales/uk/common.json index 7859ac4b1..18cd07214 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -624,6 +624,12 @@ "attach": "Прикріпити", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "Прикріпити з Файлів", + "files_picker": { + "attach": "{count, plural, =0 {Прикріпити} one {Прикріпити # файл} few {Прикріпити # файли} many {Прикріпити # файлів} other {Прикріпити # файлу}}", + "empty_folder": "Ця папка порожня", + "load_failed": "Не вдалося завантажити ваші файли" + }, "discard": "Відкинути", "discard_draft_title": "Відхилити чернетку?", "discard_draft_confirm": "У вас є незбережені зміни. Ви хочете відхилити цю чернетку?", @@ -1887,6 +1893,15 @@ "reorder": "Перетягніть, щоб змінити порядок", "reorder_error": "Не вдалося змінити порядок папок", "move_error": "Не вдалося перемістити папку", + "select_folder": "Вибрати {name}", + "select_hint": "Вибрати. Shift+клацання вибирає всі папки до останньої, яку ви клацнули.", + "selected_count": "{count, plural, one {Вибрано # папку} few {Вибрано # папки} many {Вибрано # папок} other {Вибрано # папки}}", + "move_to": "Перемістити до...", + "move_to_top_level": "Верхній рівень", + "move": "Перемістити", + "clear_selection": "Зняти виділення", + "folders_moved": "{count, plural, one {Переміщено # папку} few {Переміщено # папки} many {Переміщено # папок} other {Переміщено # папки}}", + "move_partial_error": "{count, plural, one {Не вдалося перемістити # папку} few {Не вдалося перемістити # папки} many {Не вдалося перемістити # папок} other {Не вдалося перемістити # папки}}", "subfolder_of": "Всередині {name}", "subfolder_name": "Назва вкладеної папки", "new_folder_name": "Назва папки", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 0e9d6dc1c..2c38b2344 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -624,6 +624,12 @@ "attach": "附加", "attach_photos": "照片與影片", "attach_files": "檔案", + "attach_from_files": "從檔案附加", + "files_picker": { + "attach": "{count, plural, =0 {附加} other {附加 # 個檔案}}", + "empty_folder": "此資料夾是空的", + "load_failed": "無法載入你的檔案" + }, "discard": "捨棄", "discard_draft_title": "要捨棄草稿嗎?", "discard_draft_confirm": "您有尚未儲存的變更。要捨棄這封草稿嗎?", @@ -1894,6 +1900,15 @@ "reorder": "上下拖曳可調整順序,左右拖曳可變更上層信件匣", "reorder_error": "無法重新排序信件匣", "move_error": "無法移動信件匣", + "select_folder": "選取 {name}", + "select_hint": "選取。按住 Shift 點按可選取直到上次點按的所有資料夾。", + "selected_count": "已選取 {count, plural, other {# 個資料夾}}", + "move_to": "移動到...", + "move_to_top_level": "最上層", + "move": "移動", + "clear_selection": "清除選取", + "folders_moved": "{count, plural, other {已移動 # 個資料夾}}", + "move_partial_error": "{count, plural, other {# 個資料夾}}無法移動", "subfolder_of": "位於 {name} 內", "subfolder_name": "子信件匣名稱", "new_folder_name": "信件匣名稱", diff --git a/locales/zh/common.json b/locales/zh/common.json index f43ec5e65..5c2d58af0 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -624,6 +624,12 @@ "attach": "附件", "attach_photos": "Photos & Videos", "attach_files": "Files", + "attach_from_files": "从文件添加附件", + "files_picker": { + "attach": "{count, plural, =0 {添加附件} other {添加 # 个文件}}", + "empty_folder": "此文件夹为空", + "load_failed": "无法加载你的文件" + }, "discard": "丢弃", "discard_draft_title": "丢弃草稿?", "discard_draft_confirm": "您有未保存的更改。确定要丢弃这封草稿吗?", @@ -1887,6 +1893,15 @@ "reorder": "拖动以重新排序", "reorder_error": "文件夹重新排序失败", "move_error": "移动文件夹失败", + "select_folder": "选择 {name}", + "select_hint": "选择。按住 Shift 点击可选中直到上次点击的所有文件夹。", + "selected_count": "已选择 {count, plural, other {# 个文件夹}}", + "move_to": "移动到...", + "move_to_top_level": "顶层", + "move": "移动", + "clear_selection": "清除选择", + "folders_moved": "{count, plural, other {已移动 # 个文件夹}}", + "move_partial_error": "{count, plural, other {# 个文件夹}}无法移动", "subfolder_of": "{name} 内", "subfolder_name": "子文件夹名称", "new_folder_name": "文件夹名称", From 8ca7dbbe9be0369d9f196087e959983182169816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuz=20Y=C4=B1lmaz?= <33634879+oguzyilmaznet@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:04:54 +0300 Subject: [PATCH 23/35] feat: name the active account in the browser-tab title The tab title was " - ", so two mailboxes open side by side read the same in the tab strip and in the browser's session-restore list. It is now " - - ", the order Gmail and Outlook Web use: browsers cut a long title from the end, so the part that changes comes first and the app name last. There is no setting for it; the account is what tells the tabs apart. The formatting, and the mail view's choice of context (composer, then message, then mailbox), live in lib/tab-title.ts with tests. The mail view sets its title through useDocumentTitle(), which other surfaces can use too. Next renders the root layout's metadata again on every client-side navigation, after the surface has mounted; the hook puts its title back when that happens. Only the surface that set the title last does so, so two titles never fight. Refs #1159 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- components/mail/mail-app.tsx | 56 +++++++-------- hooks/__tests__/use-document-title.test.tsx | 80 +++++++++++++++++++++ hooks/use-document-title.ts | 45 ++++++++++++ lib/__tests__/tab-title.test.ts | 44 ++++++++++++ lib/tab-title.ts | 27 +++++++ 5 files changed, 222 insertions(+), 30 deletions(-) create mode 100644 hooks/__tests__/use-document-title.test.tsx create mode 100644 hooks/use-document-title.ts create mode 100644 lib/__tests__/tab-title.test.ts create mode 100644 lib/tab-title.ts diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index c145bb99a..c64d8a1bd 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -99,7 +99,8 @@ import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-iden import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle, ArrowLeft } from "@/components/icons"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; -import { useConfig } from "@/hooks/use-config"; +import { useDocumentTitle } from "@/hooks/use-document-title"; +import { mailTitleContext } from "@/lib/tab-title"; import { usePluginStore } from "@/stores/plugin-store"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { useThemeStore } from "@/stores/theme-store"; @@ -165,7 +166,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const t = useTranslations(); const tCommon = useTranslations('common'); const tQuote = useTranslations('quote_header'); - const { appName } = useConfig(); const mailLayout = useSettingsStore((state) => state.mailLayout); // Phones present search full-screen from the header field rather than // giving it a permanent second bar under the header. @@ -943,37 +943,33 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return () => clearTimeout(timer); }, [clearPendingUndoSend, pendingUndoSend]); - // Update page title based on context - useEffect(() => { - let title = appName; + // Page title: "<context> - <account> - <app>" (see useDocumentTitle) + const titleContext = useMemo(() => { + // Composing email + const composer = showComposer + ? { + compose: t('email_composer.new_message'), + reply: t('email_composer.reply'), + replyAll: t('email_composer.reply_all'), + forward: t('email_composer.forward'), + }[composerMode] || t('email_composer.new_message') + : null; - if (showComposer) { - // Composing email - const modeText = { - compose: t('email_composer.new_message'), - reply: t('email_composer.reply'), - replyAll: t('email_composer.reply_all'), - forward: t('email_composer.forward'), - }[composerMode] || t('email_composer.new_message'); - title = `${modeText} - ${appName}`; - } else if (selectedEmail) { - // Reading email - const subject = selectedEmail.subject || t('email_viewer.no_subject'); - title = `${subject} - ${appName}`; - } else if (selectedMailbox && mailboxes.length > 0) { - // Mailbox view - const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); - if (mailbox) { - const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`)); - const unreadCount = mailbox.unreadEmails || 0; - title = unreadCount > 0 - ? `${mailboxName} (${unreadCount}) - ${appName}` - : `${mailboxName} - ${appName}`; - } + // Reading email + const subject = selectedEmail ? selectedEmail.subject || t('email_viewer.no_subject') : null; + + // Mailbox view + let mailboxLine: string | null = null; + const mailbox = selectedMailbox ? mailboxes.find(mb => mb.id === selectedMailbox) : undefined; + if (mailbox) { + const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`)); + const unreadCount = mailbox.unreadEmails || 0; + mailboxLine = unreadCount > 0 ? `${mailboxName} (${unreadCount})` : mailboxName; } - document.title = title; - }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, appName]); + return mailTitleContext({ composer, subject, mailbox: mailboxLine }); + }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t]); + useDocumentTitle(titleContext); // When this page is rendered inside the Pro shell as the Mail tab body, // we hoist every "show composer" intent into its own Pro tab and reset diff --git a/hooks/__tests__/use-document-title.test.tsx b/hooks/__tests__/use-document-title.test.tsx new file mode 100644 index 000000000..91bd8f569 --- /dev/null +++ b/hooks/__tests__/use-document-title.test.tsx @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Acme Mail' }) })); + +import { useDocumentTitle } from '@/hooks/use-document-title'; +import { useAccountStore, type AccountEntry } from '@/stores/account-store'; + +const account = (id: string, email: string) => ({ id, email }) as AccountEntry; + +/** + * What Next does on a client-side navigation: it renders the root layout's + * metadata <title> again, after the new surface has mounted. + */ +function rerenderMetadataTitle(title = 'Acme Mail'): void { + document.head.querySelectorAll('title').forEach((el) => el.remove()); + const el = document.createElement('title'); + el.textContent = title; + document.head.appendChild(el); +} + +/** Drains MutationObserver callbacks. */ +async function settle(): Promise<void> { + for (let i = 0; i < 10; i++) await Promise.resolve(); +} + +describe('useDocumentTitle', () => { + beforeEach(() => { + document.title = ''; + useAccountStore.setState({ + accounts: [account('a', 'jane@example.com'), account('b', 'joe@example.com')], + activeAccountId: 'a', + }); + }); + + it('names the active account between the context and the app', () => { + renderHook(() => useDocumentTitle('Calendar')); + expect(document.title).toBe('Calendar - jane@example.com - Acme Mail'); + }); + + it('follows an account switch', () => { + renderHook(() => useDocumentTitle('Calendar')); + act(() => useAccountStore.setState({ activeAccountId: 'b' })); + expect(document.title).toBe('Calendar - joe@example.com - Acme Mail'); + }); + + it('shows the account and the app when the surface adds no context', () => { + renderHook(() => useDocumentTitle()); + expect(document.title).toBe('jane@example.com - Acme Mail'); + }); + + it('shows the context and the app when no account is in view', () => { + useAccountStore.setState({ accounts: [], activeAccountId: null }); + renderHook(() => useDocumentTitle('Settings')); + expect(document.title).toBe('Settings - Acme Mail'); + }); + + it('puts its title back when the metadata title is rendered again', async () => { + renderHook(() => useDocumentTitle('Settings')); + rerenderMetadataTitle(); + await waitFor(() => expect(document.title).toBe('Settings - jane@example.com - Acme Mail')); + }); + + it('leaves the title to the surface that set it last', async () => { + renderHook(() => useDocumentTitle('Inbox')); + renderHook(() => useDocumentTitle('Settings')); + rerenderMetadataTitle(); + await waitFor(() => expect(document.title).toBe('Settings - jane@example.com - Acme Mail')); + await settle(); + expect(document.title).toBe('Settings - jane@example.com - Acme Mail'); + }); + + it('stops re-applying once unmounted', async () => { + const { unmount } = renderHook(() => useDocumentTitle('Settings')); + unmount(); + rerenderMetadataTitle('Sign in'); + await settle(); + expect(document.title).toBe('Sign in'); + }); +}); diff --git a/hooks/use-document-title.ts b/hooks/use-document-title.ts new file mode 100644 index 000000000..841410bc9 --- /dev/null +++ b/hooks/use-document-title.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from 'react'; +import { useConfig } from '@/hooks/use-config'; +import { useAccountStore } from '@/stores/account-store'; +import { formatTabTitle } from '@/lib/tab-title'; + +// The surface that set the title last owns it. One that is still mounted +// (during a route transition) stops re-applying, so two titles never fight. +let owner: symbol | null = null; + +/** + * Sets the browser-tab title for a surface: "<context> - <account> - <app>" + * (see formatTabTitle). Pass a context that is already localized, or nothing + * for a surface that has nothing to add. + * + * Next renders the root layout's metadata <title> again on every client-side + * navigation, after the new surface has mounted, which puts the bare app name + * back. So the title is re-applied whenever <head> changes and it is not ours. + */ +export function useDocumentTitle(context?: string | null): void { + const { appName } = useConfig(); + // The account actually in view, so two windows on two accounts stay apart. + const account = useAccountStore( + (s) => s.accounts.find((a) => a.id === s.activeAccountId)?.email || null, + ); + const title = formatTabTitle(context, account, appName); + + useEffect(() => { + const self = Symbol('document-title'); + owner = self; + // Termination: setting the title is itself a <head> mutation; on that run + // the title is already ours and nothing is written. + const apply = () => { + if (owner === self && document.title !== title) document.title = title; + }; + apply(); + const observer = new MutationObserver(apply); + observer.observe(document.head, { childList: true, subtree: true, characterData: true }); + return () => { + observer.disconnect(); + if (owner === self) owner = null; + }; + }, [title]); +} diff --git a/lib/__tests__/tab-title.test.ts b/lib/__tests__/tab-title.test.ts new file mode 100644 index 000000000..d8c67c4c0 --- /dev/null +++ b/lib/__tests__/tab-title.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { formatTabTitle, mailTitleContext } from '../tab-title'; + +describe('formatTabTitle', () => { + it('puts the context first, the account next and the app name last', () => { + expect(formatTabTitle('Inbox (3)', 'jane@example.com', 'Acme Mail')).toBe( + 'Inbox (3) - jane@example.com - Acme Mail', + ); + }); + + it('leaves out a missing account', () => { + expect(formatTabTitle('Inbox', null, 'Acme Mail')).toBe('Inbox - Acme Mail'); + expect(formatTabTitle('Inbox', '', 'Acme Mail')).toBe('Inbox - Acme Mail'); + }); + + it('leaves out a missing context', () => { + expect(formatTabTitle(undefined, 'jane@example.com', 'Acme Mail')).toBe('jane@example.com - Acme Mail'); + expect(formatTabTitle('', 'jane@example.com', 'Acme Mail')).toBe('jane@example.com - Acme Mail'); + }); + + it('falls back to the app name alone', () => { + expect(formatTabTitle(null, null, 'Acme Mail')).toBe('Acme Mail'); + }); +}); + +describe('mailTitleContext', () => { + const view = { composer: 'Reply', subject: 'Quarterly report', mailbox: 'Inbox (3)' }; + + it('names the composer while it is open', () => { + expect(mailTitleContext(view)).toBe('Reply'); + }); + + it('names the open message otherwise', () => { + expect(mailTitleContext({ ...view, composer: null })).toBe('Quarterly report'); + }); + + it('names the mailbox when no message is open', () => { + expect(mailTitleContext({ mailbox: 'Inbox (3)' })).toBe('Inbox (3)'); + }); + + it('has nothing to add before a mailbox is selected', () => { + expect(mailTitleContext({})).toBeNull(); + }); +}); diff --git a/lib/tab-title.ts b/lib/tab-title.ts new file mode 100644 index 000000000..37bd7e216 --- /dev/null +++ b/lib/tab-title.ts @@ -0,0 +1,27 @@ +// Browser-tab titles read "<context> - <account> - <app>", the order Gmail and +// Outlook Web use. Browsers cut a long title from the end, so the part that +// changes (folder, unread count, subject) goes first and the app name last. +// The account sits in between, so two mailboxes open side by side stay apart +// in the tab strip and in session restore. Empty parts are left out. (#1159) +export function formatTabTitle( + context: string | null | undefined, + account: string | null | undefined, + appName: string, +): string { + return [context, account, appName].filter(Boolean).join(' - '); +} + +/** What the mail view is showing, already localized. */ +export interface MailTitleView { + /** Composer label while the composer is open ("New message", "Reply", ...). */ + composer?: string | null; + /** Subject of the open message. */ + subject?: string | null; + /** Selected mailbox, with its unread count when there is one. */ + mailbox?: string | null; +} + +/** The context part of the mail view's tab title: composer, then message, then mailbox. */ +export function mailTitleContext(view: MailTitleView): string | null { + return view.composer || view.subject || view.mailbox || null; +} From 13e2855484b208d6681d10b5aa34cd2cdd9d5192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuz=20Y=C4=B1lmaz?= <33634879+oguzyilmaznet@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:01:51 +0300 Subject: [PATCH 24/35] feat: set the tab title on Settings, Calendar, Contacts and Files Only the mail shell updated document.title, so every other surface sat at the static value the root layout renders: the tab could not tell you where you were, and on a multi-account setup it could not tell you whose mailbox you were in either. Each surface now sets "<screen> - <account> - <app>" through the shared useDocumentTitle hook. Labels reuse the sidebar navigation translations, so the tab and the rail never disagree about what a screen is called - no new translation keys. Refs #1159 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- components/calendar/calendar-app.tsx | 3 +++ components/contacts/contacts-app.tsx | 3 +++ components/files/files-app.tsx | 3 +++ components/settings/settings-app.tsx | 2 ++ 4 files changed, 11 insertions(+) diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 4fe0a5c6d..cfbce5667 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -91,6 +91,7 @@ import { } from "@/lib/calendar-scroll-window"; import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; import { findMeetingLink } from "@/lib/event-links"; +import { useDocumentTitle } from "@/hooks/use-document-title"; type PendingScopeAction = | { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean } @@ -111,6 +112,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const linkSegments = useLiteLinkSegments('calendar', routeSegments); const router = useRouter(); const t = useTranslations("calendar"); + const tSidebar = useTranslations("sidebar"); + useDocumentTitle(tSidebar("calendar")); const tWebcalAction = useTranslations("calendar.webcal_action"); const tDeepLink = useTranslations("deep_link"); const isMobile = useIsMobile(); diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 2a82490da..011a4afd0 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -43,6 +43,7 @@ import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/dee import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; +import { useDocumentTitle } from "@/hooks/use-document-title"; type View = | "list" @@ -63,6 +64,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = // Static Lite build: the route params are empty, read the link from the URL. const linkSegments = useLiteLinkSegments('contacts', routeSegments); const t = useTranslations("contacts"); + const tSidebar = useTranslations("sidebar"); + useDocumentTitle(tSidebar("contacts")); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 0a15510ec..3ea94c2eb 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -38,6 +38,7 @@ import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/dee import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; +import { useDocumentTitle } from "@/hooks/use-document-title"; export interface FilesAppProps { /** Path segments after `/files` - the folder path, one segment per level. */ @@ -49,6 +50,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const linkSegments = useLiteLinkSegments('files', routeSegments); const router = useRouter(); const t = useTranslations("files"); + const tSidebar = useTranslations("sidebar"); + useDocumentTitle(tSidebar("files")); const tDeepLink = useTranslations("deep_link"); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index ceae16365..ed29a460c 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -102,6 +102,7 @@ import { tabSearchPaths, } from '@/lib/settings-search'; import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments'; +import { useDocumentTitle } from '@/hooks/use-document-title'; type Tab = SettingsSearchTab; @@ -217,6 +218,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = const router = useRouter(); const t = useTranslations('settings'); const tSidebar = useTranslations('sidebar'); + useDocumentTitle(tSidebar('settings')); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const isEmbedded = useIsEmbedded(); From 54ec8c6584ef8b10da699b6c7f64c29caad726a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuz=20Y=C4=B1lmaz?= <33634879+oguzyilmaznet@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:13:06 +0300 Subject: [PATCH 25/35] feat: let operators keep the message subject out of the tab title Opening a message puts its subject in the browser-tab title. The title does not stay in the tab strip: browsers keep it in history and in the session-restore list, and screen shares show it. For mailboxes that hold other people's correspondence that is a leak nobody audits. A new feature gate, tabTitleSubjectEnabled ("Subject in Tab Title" under Admin -> Policy), is on by default, so nothing changes for existing installs. With it off, the title names the folder instead, so the tab still says where you are. It is an admin gate rather than a user setting: whose mail the boxes hold is a deployment decision. The gate counts as off until the policy has loaded, so a subject never slips into history while the setting is still unknown. Refs #1159 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- app/(main)/admin/_tabs/policy.tsx | 1 + components/mail/mail-app.tsx | 7 +++++-- lib/__tests__/tab-title.test.ts | 8 ++++++++ lib/admin/types.ts | 2 ++ lib/tab-title.ts | 15 ++++++++++++--- 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index 29c1a6a56..bab81c8ad 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -32,6 +32,7 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' }, folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' }, hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' }, + tabTitleSubjectEnabled: { label: 'Subject in Tab Title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' }, filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' }, crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' }, crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' }, diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index c64d8a1bd..99221ebbb 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -944,6 +944,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }, [clearPendingUndoSend, pendingUndoSend]); // Page title: "<context> - <account> - <app>" (see useDocumentTitle) + // The subject is kept out until the policy has loaded: a gate that keeps + // subjects out of browser history must not let one through while unknown. + const subjectInTabTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled('tabTitleSubjectEnabled')); const titleContext = useMemo(() => { // Composing email const composer = showComposer @@ -967,8 +970,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { mailboxLine = unreadCount > 0 ? `${mailboxName} (${unreadCount})` : mailboxName; } - return mailTitleContext({ composer, subject, mailbox: mailboxLine }); - }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t]); + return mailTitleContext({ composer, subject, mailbox: mailboxLine }, { showSubject: subjectInTabTitle }); + }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle]); useDocumentTitle(titleContext); // When this page is rendered inside the Pro shell as the Mail tab body, diff --git a/lib/__tests__/tab-title.test.ts b/lib/__tests__/tab-title.test.ts index d8c67c4c0..1b0073fe8 100644 --- a/lib/__tests__/tab-title.test.ts +++ b/lib/__tests__/tab-title.test.ts @@ -41,4 +41,12 @@ describe('mailTitleContext', () => { it('has nothing to add before a mailbox is selected', () => { expect(mailTitleContext({})).toBeNull(); }); + + it('names the mailbox instead of the open message when the subject is kept out', () => { + expect(mailTitleContext({ ...view, composer: null }, { showSubject: false })).toBe('Inbox (3)'); + }); + + it('still names the composer when the subject is kept out', () => { + expect(mailTitleContext(view, { showSubject: false })).toBe('Reply'); + }); }); diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 25f7a1e7a..437712c37 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -58,6 +58,7 @@ export interface FeatureGates { debugModeEnabled: boolean; folderIconsEnabled: boolean; hoverActionsConfigEnabled: boolean; + tabTitleSubjectEnabled: boolean; filesEnabled: boolean; contactsEnabled: boolean; /** @deprecated Folded into `crossAllViewEnabled`; normalized forward on policy load. */ @@ -85,6 +86,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = { debugModeEnabled: true, folderIconsEnabled: true, hoverActionsConfigEnabled: true, + tabTitleSubjectEnabled: true, filesEnabled: true, contactsEnabled: true, allMailViewEnabled: false, diff --git a/lib/tab-title.ts b/lib/tab-title.ts index 37bd7e216..87fa1ce2f 100644 --- a/lib/tab-title.ts +++ b/lib/tab-title.ts @@ -21,7 +21,16 @@ export interface MailTitleView { mailbox?: string | null; } -/** The context part of the mail view's tab title: composer, then message, then mailbox. */ -export function mailTitleContext(view: MailTitleView): string | null { - return view.composer || view.subject || view.mailbox || null; +/** + * The context part of the mail view's tab title: composer, then message, then + * mailbox. The subject does not stay in the tab strip - browsers keep it in + * history and in the session-restore list, and screen shares show it - so an + * operator can keep it out (`tabTitleSubjectEnabled`). The mailbox line is + * shown instead, so the tab still says where you are. (#1159) + */ +export function mailTitleContext( + view: MailTitleView, + { showSubject = true }: { showSubject?: boolean } = {}, +): string | null { + return view.composer || (showSubject ? view.subject : null) || view.mailbox || null; } From bb3ef6d9fc3ba727cb2308d67483bc6598a81038 Mon Sep 17 00:00:00 2001 From: VasilisNtovolos <v.ntovolos@hotmail.com> Date: Wed, 7 Oct 2026 15:57:45 +0300 Subject: [PATCH 26/35] fix: route shared-account blob downloads by message owner --- components/email/email-viewer.tsx | 32 +- .../mail/__tests__/blob-routing.test.tsx | 358 ++++++++++++++++++ components/mail/mail-app.tsx | 86 +---- stores/email-store.ts | 46 ++- 4 files changed, 433 insertions(+), 89 deletions(-) create mode 100644 components/mail/__tests__/blob-routing.test.tsx diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index cdf91d2d0..5c0a04fdf 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -132,6 +132,8 @@ const SUB_MENU_ITEM_SELECTOR = '[role="menuitem"],[role="menuitemcheckbox"],[rol interface EmailViewerProps { email: Email | null; + /** Captured message routing supplied by the standard mail UI. */ + blobSource?: { client: IJMAPClient | null; accountId?: string }; isLoading?: boolean; onReply?: (draftText?: string) => void; onReplyAll?: () => void; @@ -601,7 +603,7 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl getBlobUrl: async () => { if (attachment.blobId && client) { try { - return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type, accountId); + return await client.fetchBlobAsObjectUrl(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId); } catch { return null; } @@ -638,6 +640,7 @@ function SidebarSection({ icon: Icon, title, children }: { icon: React.Component export function EmailViewer({ email, + blobSource, isLoading = false, onReply, onReplyAll, @@ -767,18 +770,17 @@ export function EmailViewer({ const { tabletListVisible } = useUIStore(); const { identities, client, isDemoMode, activeAccountId } = useAuthStore(); const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId)); - // Blobs (inline images, drag-out, TNEF, embedded messages, thumbnails, bundle - // downloads) are account-scoped. In the unified / All-Mail view the open - // message may belong to another login (route to its client) or a delegated - // shared account (same client, owner accountId in the URL). Resolve both from - // the message's source so cross-account blob fetches don't 404 against the - // active account. + // The standard mail UI supplies message routing for preview/download, + // inline images, export and attachment drag-out. Other viewer hosts keep + // their existing fallback routing. const isUnifiedView = useEmailStore((s) => s.isUnifiedView); - const blobClient = useMemo(() => { + const legacyBlobClient = useMemo(() => { const scid = isUnifiedView ? email?.sourceClientAccountId : undefined; return (scid ? useAuthStore.getState().getClientForAccount(scid) : null) ?? client; }, [isUnifiedView, email?.sourceClientAccountId, client]); - const blobAccountId = isUnifiedView ? email?.sourceAccountId : undefined; + const legacyBlobAccountId = isUnifiedView ? email?.sourceAccountId : undefined; + const blobClient = blobSource ? blobSource.client : legacyBlobClient; + const blobAccountId = blobSource ? blobSource.accountId : legacyBlobAccountId; // List-Unsubscribe mailto: send the message ourselves - this is a webmail // client, handing a mailto: URL to the OS mail handler goes nowhere for @@ -1368,7 +1370,7 @@ export function EmailViewer({ // TNEF (winmail.dat) detection and processing useEffect(() => { - if (!email?.attachments || !client) return; + if (!email?.attachments || !blobClient) return; const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type)); if (!tnefAtt?.blobId) { @@ -1460,7 +1462,7 @@ export function EmailViewer({ // often empty Word boilerplate and the real content is inside a message/rfc822 // attachment. Detect this pattern and unwrap the embedded email. useEffect(() => { - if (!email?.attachments || !client) return; + if (!email?.attachments || !blobClient) return; // Find message/rfc822 attachment const rfc822Att = email.attachments.find( @@ -1567,7 +1569,7 @@ export function EmailViewer({ }; } - if (!client || !email?.attachments) { + if (!blobClient || !email?.attachments) { setCidBlobUrls({}); return; } @@ -2660,9 +2662,11 @@ export function EmailViewer({ // Export email as .eml file const handleExportEmail = async () => { - if (!email?.blobId || !client) return; + const exportClient = blobSource ? blobSource.client : client; + const exportAccountId = blobSource?.accountId; + if (!email?.blobId || !exportClient) return; try { - await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822'); + await exportClient.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822', exportAccountId); } catch { toast.error(tNotifications('export_email_error')); return; diff --git a/components/mail/__tests__/blob-routing.test.tsx b/components/mail/__tests__/blob-routing.test.tsx new file mode 100644 index 000000000..3fd259410 --- /dev/null +++ b/components/mail/__tests__/blob-routing.test.tsx @@ -0,0 +1,358 @@ +import React, { Suspense, lazy } from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { JMAPClient } from '@/lib/jmap/client'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; + +// Keep the real MailApp selection/download handlers and EmailViewer. Stub +// unrelated shell UI and bootstrap requests so no server is contacted. +vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentType<Record<string, unknown>>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record<string, unknown>) { + return <Suspense fallback={null}><Component {...props} /></Suspense>; + }; +} })); +vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: ({ emails, onEmailSelect, onOpenAttachment }: { + emails: Email[]; onEmailSelect: (email: Email) => void; onOpenAttachment: (email: Email, attachment: NonNullable<Email['attachments']>[number]) => void; +}) => <div>{emails.map((email, i) => <div key={i}> + <button onClick={() => onEmailSelect(email)}>Select {i}</button> + <button onClick={() => onOpenAttachment(email, email.attachments![0])}>List attachment {i}</button> +</div>)}</div> })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: ({ onDownload, getFileContent }: { + onDownload: () => void; getFileContent: () => Promise<unknown>; +}) => <div><button onClick={onDownload}>Preview download</button><button onClick={() => void getFileContent()}>Preview fetch</button></div> })); + +const { MailApp } = await import('../mail-app'); + +const sharedMailbox = { id: 'nrichmond:inbox', originalId: 'inbox', name: 'Shared', isShared: true, accountId: 'nrichmond' } as Mailbox; +const primaryMailbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; +function message(overrides: Partial<Email> = {}): Email { + return { + id: 'email', threadId: 'thread', blobId: 'raw-blob', subject: 'Hello', + mailboxIds: {}, keywords: { $seen: true }, size: 100, + receivedAt: '2026-01-01T00:00:00Z', from: [{ email: 'sender@example.com' }], to: [], + preview: 'Body', hasAttachment: true, + textBody: [{ partId: '1', type: 'text/plain', size: 4 }], htmlBody: [], + bodyValues: { '1': { value: 'Body', isEncodingProblem: false, isTruncated: false } }, + attachments: [{ id: '3', partId: '3', blobId: 'same-blob', name: 'attached.eml', type: 'message/rfc822', size: 50 }], + ...overrides, + } as Email; +} +function makeClient(accountId: string) { + return { + getAccountId: () => accountId, getUsername: () => `${accountId}@example.com`, + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + getEmail: vi.fn().mockResolvedValue(message()), + downloadBlob: vi.fn().mockResolvedValue(undefined), + fetchBlob: vi.fn().mockResolvedValue(new Blob(['image'], { type: 'image/png' })), + }; +} +const connectedClients: JMAPClient[] = []; +afterEach(() => { + connectedClients.splice(0).forEach(client => client.disconnect()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +let primary: ReturnType<typeof makeClient>; +let other: ReturnType<typeof makeClient>; +const asClient = (c: ReturnType<typeof makeClient>) => c as unknown as IJMAPClient; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + URL.createObjectURL = vi.fn(() => 'blob:test'); + URL.revokeObjectURL = vi.fn(); + primary = makeClient('postal12345'); + other = makeClient('other-primary'); + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: asClient(primary) as never, + activeAccountId: 'login-primary', identities: [], + getClientForAccount: (id) => (id === 'login-primary' ? asClient(primary) : id === 'login-other' ? asClient(other) : undefined) as never, + getAllConnectedClients: () => new Map([['login-primary', asClient(primary)], ['login-other', asClient(other)]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailAttachmentAction: 'download', mailLayout: 'split', attachmentImagePreviewsEnabled: false, postExportAction: 'none', attachmentDownloadTemplate: '{filename}', filenameLowercase: false } as never); + useEmailStore.setState({ + emails: [message()], selectedEmail: message(), selectedMailbox: sharedMailbox.id, + mailboxes: [primaryMailbox, sharedMailbox], viewingAccountId: null, accountMailboxes: {}, + isUnifiedView: false, unifiedRole: null, isScheduledView: false, isLoadingEmail: false, + isLoading: false, searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', + selectedKeyword: null, selectedEmailIds: new Set(), + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + }); +}); + +async function downloadAttachment() { + // The viewer renders responsive copies; any Download button exercises the + // same production handler. The raw RFC822 part is the live reproduction. + const buttons = await screen.findAllByTitle('download'); + fireEvent.click(buttons[0]); +} +async function exportEmail() { + fireEvent.click((await screen.findAllByRole('button', { name: 'more_actions' }))[0]); + fireEvent.click((await screen.findAllByText('export_email'))[0]); +} + +async function connectDownloadClient(serverUrl: string, username: string, accountId: string) { + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ + capabilities: { 'urn:ietf:params:jmap:core': {} }, + accounts: { + [accountId]: { name: 'Primary', isPersonal: true, accountCapabilities: {} }, + nrichmond: { name: 'Shared', isPersonal: false, accountCapabilities: {} }, + }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': accountId }, + apiUrl: `${serverUrl}/jmap/api`, + downloadUrl: `${serverUrl}/download/{accountId}/{blobId}/{name}?accept={type}`, + uploadUrl: `${serverUrl}/upload/{accountId}`, eventSourceUrl: '', + }), { status: 200 })); + const client = new JMAPClient(serverUrl, username, 'test-password'); + connectedClients.push(client); + await client.connect(); + return client; +} + +describe('standard mail UI blob routing', () => { + it.each([sharedMailbox.id, primaryMailbox.id])('uses the session downloadUrl for %s', async (mailboxId) => { + const fetchMock = vi.mocked(fetch); + const realClient = await connectDownloadClient('https://mail.example.com', 'test-login', 'postal12345'); + fetchMock.mockClear(); + fetchMock.mockResolvedValue(new Response('attachment', { status: 200 })); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + useAuthStore.setState({ + client: realClient, + getClientForAccount: id => id === 'login-primary' ? realClient : undefined, + getAllConnectedClients: () => new Map([['login-primary', realClient]]), + }); + useEmailStore.setState({ selectedMailbox: mailboxId }); + render(<MailApp />); + await downloadAttachment(); + const owner = mailboxId === sharedMailbox.id ? 'nrichmond' : 'postal12345'; + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(String(fetchMock.mock.calls[0][0])).toMatch(new RegExp(`/download/${owner}/same-blob/`)); + expect(String(fetchMock.mock.calls[0][0])).toContain('accept=message%2Frfc822'); + expect(fetchMock.mock.calls[0][1]?.headers).toEqual(expect.objectContaining({ Authorization: `Basic ${btoa('test-login:test-password')}` })); + }); + + it.each([ + { label: 'direct shared folder', owner: 'nrichmond', login: 'login-primary', filename: 'attachment' }, + { label: 'stamped shared message on another login', owner: 'nrichmond', login: 'login-other', filename: 'attachment' }, + { label: 'primary account', owner: 'postal12345', login: 'login-primary', filename: 'attachment' }, + { label: 'formatted unnamed attachment', owner: 'nrichmond', login: 'login-primary', filename: 'hello-attachment', formatted: true }, + ])('routes Chromium hover and Download for $label', async ({ owner, login, filename, formatted }) => { + // Enable the real browser capability gate before the viewer mounts. + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 Chrome/130.0.0.0'); + const primaryClient = await connectDownloadClient('https://primary.example.com', 'primary-login', 'postal12345'); + const secondaryClient = await connectDownloadClient('https://secondary.example.com', 'secondary-login', 'secondary-primary'); + useAuthStore.setState({ + client: primaryClient, + getClientForAccount: id => id === 'login-primary' ? primaryClient : id === 'login-other' ? secondaryClient : undefined, + getAllConnectedClients: () => new Map([['login-primary', primaryClient], ['login-other', secondaryClient]]), + }); + useSettingsStore.setState({ + attachmentDownloadTemplate: formatted ? '{subject}-{filename}' : '{filename}', + filenameLowercase: !!formatted, + }); + const email = message({ + attachments: [{ partId: '3', blobId: 'same-blob', type: 'message/rfc822', size: 50 }], + ...(login === 'login-other' ? { sourceClientAccountId: login, sourceAccountId: owner } : {}), + }); + // message() has a nonempty outer body, so automatic RFC822 unwrapping + // cannot contribute a third request to this interaction. + useEmailStore.setState({ selectedEmail: email, selectedMailbox: owner === 'postal12345' ? primaryMailbox.id : sharedMailbox.id }); + const fetchMock = vi.mocked(fetch); + fetchMock.mockClear(); + fetchMock.mockImplementation(async () => new Response('attachment', { status: 200, headers: { 'Content-Type': 'message/rfc822' } })); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + render(<MailApp />); + const downloadButton = (await screen.findAllByTitle('download'))[0]; + const chip = downloadButton.closest('[draggable="true"]'); + expect(chip).not.toBeNull(); + + fireEvent.pointerOver(downloadButton, { pointerType: 'mouse' }); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(1)); + const host = login === 'login-other' ? 'https://secondary.example.com' : 'https://primary.example.com'; + const expectedUrl = `${host}/download/${owner}/same-blob/${filename}?accept=message%2Frfc822`; + const authorization = `Basic ${btoa(`${login === 'login-other' ? 'secondary-login' : 'primary-login'}:test-password`)}`; + expect(String(fetchMock.mock.calls[0][0])).toBe(expectedUrl); + expect(fetchMock.mock.calls[0][1]?.headers).toEqual(expect.objectContaining({ Authorization: authorization })); + + // Hover still prepares a working DownloadURL for a real drag start. + const dataTransfer = { setData: vi.fn(), effectAllowed: '' }; + fireEvent.dragStart(chip!, { dataTransfer }); + expect(dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', `message/rfc822:${filename}:blob:test`); + expect(dataTransfer.effectAllowed).toBe('copyMove'); + + fireEvent.click(downloadButton); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([expectedUrl, expectedUrl]); + expect(fetchMock.mock.calls[1][1]?.headers).toEqual(expect.objectContaining({ Authorization: authorization })); + }); + + it('downloads a direct shared-folder message/rfc822 part through its owner', async () => { + render(<MailApp />); + await downloadAttachment(); + expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond'); + expect(other.downloadBlob).not.toHaveBeenCalled(); + }); + + it('keeps primary-account downloads compatible', async () => { + useEmailStore.setState({ selectedMailbox: 'inbox' }); + render(<MailApp />); + await downloadAttachment(); + expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', undefined); + }); + + it('exports the raw shared message through its owner', async () => { + render(<MailApp />); + await exportEmail(); + expect(primary.downloadBlob).toHaveBeenCalledWith('raw-blob', expect.stringMatching(/\.eml$/), 'message/rfc822', 'nrichmond'); + }); + + it.each([false, true])('routes Unified selection (automatic=%s) after Email/get replaces the stub', async (automatic) => { + const stub = message({ bodyValues: undefined, accountId: 'nrichmond', accountLabel: 'Richmond', sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond' }); + useEmailStore.setState({ emails: [stub], selectedEmail: automatic ? stub : null, isUnifiedView: true, selectedMailbox: '' }); + render(<MailApp />); + if (!automatic) fireEvent.click(screen.getByText('Select 0')); + await waitFor(() => expect(other.getEmail).toHaveBeenCalledWith('email', 'nrichmond')); + await waitFor(() => expect(useEmailStore.getState().selectedEmail?.bodyValues).toBeDefined()); + await downloadAttachment(); + await exportEmail(); + expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond'); + expect(other.downloadBlob).toHaveBeenCalledWith('raw-blob', expect.any(String), 'message/rfc822', 'nrichmond'); + expect(primary.downloadBlob).not.toHaveBeenCalled(); + expect(useEmailStore.getState().selectedEmail).toMatchObject({ sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond', accountLabel: 'Richmond' }); + }); + + it('keeps preview fetch and download on the originating owner after navigation', async () => { + useSettingsStore.setState({ mailAttachmentAction: 'preview' }); + const email = message({ attachments: [{ partId: '3', blobId: 'same-blob', name: 'photo.png', type: 'image/png', size: 50 }] }); + useEmailStore.setState({ selectedEmail: email }); + render(<MailApp />); + fireEvent.click((await screen.findAllByText('photo.png'))[0]); + await screen.findByText('Preview download'); + act(() => { + useEmailStore.setState({ selectedMailbox: 'inbox', selectedEmail: message() }); + useAuthStore.setState({ client: asClient(other) as never, activeAccountId: 'login-other' }); + }); + fireEvent.click(screen.getByText('Preview fetch')); + fireEvent.click(screen.getByText('Preview download')); + expect(primary.fetchBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'image/png', 'nrichmond'); + expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'image/png', 'nrichmond'); + expect(other.downloadBlob).not.toHaveBeenCalled(); + }); + + it('honors stamps outside Unified view and isolates overlapping blob ids', async () => { + const first = message({ sourceClientAccountId: 'login-other', sourceAccountId: 'owner-a' }); + const second = message({ sourceClientAccountId: 'login-primary', sourceAccountId: 'owner-b' }); + useEmailStore.setState({ emails: [first, second], selectedEmail: first }); + render(<MailApp />); + await downloadAttachment(); + act(() => useEmailStore.setState({ selectedEmail: second })); + await downloadAttachment(); + expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'owner-a'); + expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'owner-b'); + }); + + it('keeps a direct folder source when Email/get completes after an account switch', async () => { + let finish!: (email: Email) => void; + primary.getEmail.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); + useEmailStore.setState({ selectedEmail: message({ bodyValues: undefined }) }); + render(<MailApp />); + await waitFor(() => expect(primary.getEmail).toHaveBeenCalledWith('email', 'nrichmond')); + act(() => { + useEmailStore.setState({ selectedMailbox: 'inbox' }); + useAuthStore.setState({ client: asClient(other) as never, activeAccountId: 'login-other' }); + }); + await act(async () => finish(message())); + await downloadAttachment(); + expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond'); + expect(other.downloadBlob).not.toHaveBeenCalled(); + }); + + it('keeps an unstamped all-folders search hit on the primary account', async () => { + useEmailStore.setState({ searchQuery: 'Hello', searchMailboxId: '*' }); + render(<MailApp />); + await downloadAttachment(); + expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', undefined); + }); + + it('uses the browsing login for a direct shared folder on another login', async () => { + useEmailStore.setState({ viewingAccountId: 'login-other', accountMailboxes: { 'login-other': [sharedMailbox] } }); + render(<MailApp />); + await downloadAttachment(); + expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond'); + expect(primary.downloadBlob).not.toHaveBeenCalled(); + }); + + it('downloads a list attachment using that row rather than the selected message', async () => { + const row = message({ sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond' }); + useEmailStore.setState({ emails: [row], selectedEmail: message() }); + render(<MailApp />); + fireEvent.click(screen.getByText('List attachment 0')); + expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', 'attached.eml', 'message/rfc822', 'nrichmond'); + expect(primary.downloadBlob).not.toHaveBeenCalled(); + }); + + it('does not route a disconnected stamped login through the primary client', async () => { + useEmailStore.setState({ selectedEmail: message({ sourceClientAccountId: 'missing-login', sourceAccountId: 'nrichmond' }) }); + render(<MailApp />); + await downloadAttachment(); + await exportEmail(); + expect(primary.downloadBlob).not.toHaveBeenCalled(); + expect(other.downloadBlob).not.toHaveBeenCalled(); + }); + + it('fetches an automatically selected owner even when its email id overlaps the previous selection', async () => { + const first = message({ sourceClientAccountId: 'login-primary', sourceAccountId: 'owner-a' }); + const second = message({ bodyValues: undefined, sourceClientAccountId: 'login-other', sourceAccountId: 'owner-b' }); + useEmailStore.setState({ emails: [first, second], selectedEmail: first, isUnifiedView: true }); + render(<MailApp />); + await downloadAttachment(); + act(() => useEmailStore.getState().selectEmail(second)); + await waitFor(() => expect(other.getEmail).toHaveBeenCalledWith('email', 'owner-b')); + await waitFor(() => expect(useEmailStore.getState().selectedEmail?.bodyValues).toBeDefined()); + await downloadAttachment(); + expect(other.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'owner-b'); + }); + + it('fetches inline images with the shared owner', async () => { + useEmailStore.setState({ selectedEmail: message({ attachments: [{ partId: 'image', blobId: 'same-blob', name: 'inline.png', cid: 'cid-image', type: 'image/png', size: 50 }] }) }); + render(<MailApp />); + await waitFor(() => expect(primary.fetchBlob).toHaveBeenCalledWith('same-blob', 'inline.png', 'image/png', 'nrichmond')); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index c145bb99a..6a3bc4c05 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -31,7 +31,7 @@ import { usePolicyStore } from "@/stores/policy-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { connectedAccountsGrew } from "@/lib/unified-mailbox"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; -import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveUnstampedEmailAccountId, emptyFolderMovesToTrash } from "@/stores/email-store"; +import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveUnstampedEmailAccountId, resolveEmailBlobContext, emptyFolderMovesToTrash } from "@/stores/email-store"; import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; import { toast } from "@/stores/toast-store"; import { formatRejectedRecipients, type JMAPClient } from "@/lib/jmap/client"; @@ -197,7 +197,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const [conversationEmails, setConversationEmails] = useState<Email[]>([]); const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null); - const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null); + const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; client: NonNullable<typeof client> } | null>(null); // Office attachments open read-only in the configured WOPI editor (#1047). const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null); const wopiStatus = useWopiStatus(true); @@ -1641,17 +1641,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before // calling selectEmail on the stub), to avoid a duplicate request. if (!selectedEmail.bodyValues && !isLoadingEmail) { - const perAccountClient = isUnifiedView && selectedEmail.sourceClientAccountId - ? useAuthStore.getState().getClientForAccount(selectedEmail.sourceClientAccountId) - : undefined; - const fetchClient = perAccountClient ?? client; setLoadingEmail(true); - fetchEmailContent(fetchClient, selectedEmail.id).finally(() => { + fetchEmailContent(client, selectedEmail.id).finally(() => { setLoadingEmail(false); }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedEmail?.id]); + }, [selectedEmail?.id, selectedEmail?.sourceClientAccountId, selectedEmail?.sourceAccountId]); // Opening a message hides the list on tablet, but plenty of paths clear the // reading pane without going through a "back" handler (delete/move/archive, @@ -3154,20 +3150,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }; }, []); - // Blobs are scoped per JMAP account. In the unified/All-Mail view the open - // message may belong to another login (route to its client) or to a delegated - // shared account (same client, but the owner's accountId in the download URL). - // Resolve both from the email's source so attachments on cross-account - // messages can be viewed/downloaded instead of 404ing against the active - // account. + // Resolve from the originating message, including direct shared folders. const resolveBlobSource = useCallback((email: typeof selectedEmail) => { - const clientAccountId = isUnifiedView ? email?.sourceClientAccountId : undefined; - const blobClient = clientAccountId - ? (useAuthStore.getState().getClientForAccount(clientAccountId) ?? client) - : client; - const accountId = isUnifiedView ? email?.sourceAccountId : undefined; + const { client: blobClient, accountId, clientAccountId } = resolveEmailBlobContext(email, client); return { blobClient, accountId, clientAccountId }; - }, [isUnifiedView, client]); + }, [client]); // Opens an attachment the built-in preview cannot render but the office // editor can (docx, odt, xlsx, ...). The launch route runs server-side with @@ -3198,7 +3185,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { try { const { mailAttachmentAction } = useSettingsStore.getState(); if (mailAttachmentAction === 'preview' && isFilePreviewable(name, attachment.type)) { - setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, clientAccountId }); + setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, client: blobClient }); return; } if (mailAttachmentAction === 'preview' && openOfficeAttachment( @@ -3258,7 +3245,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const { mailAttachmentAction } = useSettingsStore.getState(); if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) { - setPreviewAttachment({ blobId, name, type, accountId, clientAccountId }); + setPreviewAttachment({ blobId, name, type, accountId, client: blobClient }); return; } if (!forceDownload && mailAttachmentAction === 'preview' && openOfficeAttachment( @@ -3271,20 +3258,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } }; - const previewBlobClient = useCallback(() => { - const id = previewAttachment?.clientAccountId; - return id ? (useAuthStore.getState().getClientForAccount(id) ?? client) : client; - }, [previewAttachment, client]); - const handlePreviewAttachmentDownload = useCallback(async () => { - const c = previewBlobClient(); + const c = previewAttachment?.client; if (!c || !previewAttachment) return; await c.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId); - }, [previewBlobClient, previewAttachment]); + }, [previewAttachment]); const getPreviewAttachmentContent = useCallback(async () => { - const c = previewBlobClient(); + const c = previewAttachment?.client; if (!c || !previewAttachment) { throw new Error('No attachment selected'); } @@ -3295,7 +3277,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { blob, contentType: previewAttachment.type || blob.type || 'application/octet-stream', }; - }, [previewBlobClient, previewAttachment]); + }, [previewAttachment]); const handleQuickReply = async (body: string) => { if (!client || !selectedEmail) return; @@ -3525,40 +3507,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Fetch the full content try { - // Emails fetched through an aggregate view carry their source reference: - // the login they are reachable through (`sourceClientAccountId`) and their - // owning JMAP account (`sourceAccountId`). Honor the stamps whenever they - // are present — not only while `isUnifiedView` is set, since a stamped list - // can outlive that flag — so the fetch goes to the server that actually owns - // the mail. Works uniformly for personal and shared/group sources: for - // personal the owning account equals the client's primary (no-op). (#281) - // - // Unstamped emails belong to the account being browsed: the per-account - // sidebar's `viewingAccountId` when set, otherwise the active login. A - // shared folder on that client still needs its owner accountId. (#847) - const sourceClientId = listEmail?.sourceClientAccountId; - const sourceClient = sourceClientId - ? useAuthStore.getState().getClientForAccount(sourceClientId) - : undefined; - if (sourceClientId && !sourceClient) { - // No silent fallback to the active client: with colliding ids that would - // render another account's mail under this row. - console.warn('[mail-app] No connected client for source account', sourceClientId); + const { client: fetchClient, accountId, clientAccountId } = resolveEmailBlobContext(listEmail ?? null, client); + if (!fetchClient) { + console.warn('[mail-app] No connected client for email source'); return; } - const fetchClient = sourceClient - ?? (viewingAccountId ? useAuthStore.getState().getClientForAccount(viewingAccountId) : undefined) - ?? client; - - // During an unscoped search the hit is the primary account's, not the - // selected shared folder's owner - see resolveUnstampedEmailAccountId. (#923) - const accountId = listEmail?.sourceAccountId - ?? resolveUnstampedEmailAccountId({ - mailboxes: viewMailboxes, - selectedMailbox, - searchActive: !!searchQuery || !isFilterEmpty(searchFilters), - searchMailboxId, - }); const fullEmail = await fetchClient.getEmail(email.id, accountId); if (fullEmail) { @@ -3575,9 +3528,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (listEmail?.sourceClientAccountId) { fullEmail.accountId = listEmail.accountId; fullEmail.accountLabel = listEmail.accountLabel; - fullEmail.sourceClientAccountId = listEmail.sourceClientAccountId; - fullEmail.sourceAccountId = listEmail.sourceAccountId; + fullEmail.sourceFolder = listEmail.sourceFolder; } + // Keep a direct shared folder's owner after navigation as well. + fullEmail.sourceAccountId = listEmail?.sourceAccountId ?? accountId; + fullEmail.sourceClientAccountId = listEmail?.sourceClientAccountId ?? (accountId ? clientAccountId : undefined); selectEmail(fullEmail); // Mark-as-read logic is now handled by useEffect } @@ -4521,6 +4476,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { <ErrorBoundary fallback={EmailViewerErrorFallback}> <EmailViewer email={selectedEmail} + blobSource={resolveEmailBlobContext(selectedEmail, client)} isLoading={isLoadingEmail} onReply={handleReply} onReplyAll={handleReplyAll} diff --git a/stores/email-store.ts b/stores/email-store.ts index 253c09fcc..33d29c3f0 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -799,6 +799,31 @@ function resolveActionMailboxes(): Mailbox[] { return mailboxesInView(useEmailStore.getState()); } +/** Resolve blobs from the message's source, or the folder being browsed. */ +export function resolveEmailBlobContext( + email: Pick<Email, 'sourceClientAccountId' | 'sourceAccountId'> | null, + passedClient: IJMAPClient | null, +): { client: IJMAPClient | null; accountId?: string; clientAccountId?: string } { + const state = useEmailStore.getState(); + const auth = useAuthStore.getState(); + const clientAccountId = email?.sourceClientAccountId ?? state.viewingAccountId ?? undefined; + // A stamped login must never fall back to another account: blob ids can collide. + const client = clientAccountId + ? auth.getClientForAccount(clientAccountId) ?? null + : passedClient; + const accountId = email?.sourceAccountId ?? resolveUnstampedEmailAccountId({ + mailboxes: mailboxesInView(state), + selectedMailbox: state.selectedMailbox, + searchActive: !!state.searchQuery || !isFilterEmpty(state.searchFilters), + searchMailboxId: state.searchMailboxId, + }); + return { + client, + accountId, + clientAccountId: clientAccountId ?? (client === auth.client ? auth.activeAccountId ?? undefined : undefined), + }; +} + // List requests can finish after navigation. Never apply an old folder/tag // response (or error) to the view the user has since selected. function captureEmailListView(): () => boolean { @@ -2415,21 +2440,22 @@ export const useEmailStore = create<EmailStore>((set, get) => ({ // cross-account) the selected mailbox is virtual, so derive the client + // accountId from the email itself (handles shared/group accounts); fall // back to the selected-mailbox shared-folder logic for normal views. - const listEmail = get().emails.find(e => e.id === emailId); - let actionClient: IJMAPClient; - let accountId: string | undefined; - if (listEmail) { - ({ client: actionClient, accountId } = resolveEmailActionContext(listEmail, client)); - } else { - const mailbox = resolveActionMailboxes().find(mb => mb.id === get().selectedMailbox); - actionClient = resolveActionClient(client); - accountId = mailbox?.isShared ? mailbox.accountId : undefined; - } + const selected = get().selectedEmail; + const listEmail = selected?.id === emailId ? selected : get().emails.find(e => e.id === emailId); + const { client: actionClient, accountId, clientAccountId } = resolveEmailBlobContext(listEmail ?? null, client); + if (!actionClient) throw new Error('No connected client for email source'); const email = await actionClient.getEmail(emailId, accountId); if (email) { const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId); + // Email/get does not return client-only source metadata. Keep the source + // captured before the await, including automatic selection after actions. + annotatedEmail.accountId = listEmail?.accountId; + annotatedEmail.accountLabel = listEmail?.accountLabel; + annotatedEmail.sourceFolder = listEmail?.sourceFolder; + annotatedEmail.sourceClientAccountId = listEmail?.sourceClientAccountId ?? (accountId ? clientAccountId : undefined); + annotatedEmail.sourceAccountId = listEmail?.sourceAccountId ?? accountId; set({ selectedEmail: annotatedEmail }); return annotatedEmail; } From 8889346371e9f8ffa882435ee37f4eef929c6990 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:04:15 +0200 Subject: [PATCH 27/35] feat: warn when a message's sender can't be verified --- components/email/email-viewer.tsx | 59 +++++++++++++++++++++-- lib/__tests__/email-headers.test.ts | 59 +++++++++++++++++++++++ lib/email-headers.ts | 72 ++++++++++++++++++++++++++--- lib/jmap/types.ts | 9 ++++ locales/de/common.json | 9 ++++ locales/en/common.json | 9 ++++ 6 files changed, 208 insertions(+), 9 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index cdf91d2d0..d32220999 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -22,7 +22,7 @@ import { TagPicker } from "./tag-picker"; import { useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { getEmailTagIds } from "@/lib/thread-utils"; -import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; +import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; import { generateEmailSource } from "@/lib/email-source"; import { @@ -3020,6 +3020,19 @@ export function EmailViewer({ } const sender = email.from?.[0]; + const senderVerification = getSenderVerification(email.authenticationResults, sender?.email); + const senderVerificationLabel = senderVerification?.status === 'failed' + ? t('sender_check.failed_label') + : t('sender_check.unverified_label'); + const senderVerificationMessage = !senderVerification + ? '' + : senderVerification.status === 'failed' + ? senderVerification.sentFrom + ? t('sender_check.failed_sent_from', { domain: senderVerification.domain, host: senderVerification.sentFrom }) + : t('sender_check.failed', { domain: senderVerification.domain }) + : senderVerification.sentFrom + ? t('sender_check.unverified_sent_from', { domain: senderVerification.domain, host: senderVerification.sentFrom }) + : t('sender_check.unverified', { domain: senderVerification.domain }); const isStarred = email.keywords?.$flagged; const isUnread = !email.keywords?.$seen; const isImportant = email.keywords?.["$important"]; @@ -4511,6 +4524,20 @@ export function EmailViewer({ onViewContact={handleViewContactSidebar} className="text-sm text-start" /> + {senderVerification && ( + <span + className={cn( + "inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md border text-xs whitespace-nowrap cursor-help", + senderVerification.status === 'failed' + ? "bg-red-500/[0.07] border-red-500/30 text-red-700 dark:text-red-400" + : "bg-amber-500/[0.07] border-amber-500/30 text-amber-700 dark:text-amber-400", + )} + title={senderVerificationMessage} + > + <AlertTriangle className="w-3 h-3 flex-shrink-0" /> + {senderVerificationLabel} + </span> + )} </div> </Row> {replyToDifferent && ( @@ -4816,12 +4843,36 @@ export function EmailViewer({ )} {/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */} - {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || + {(senderVerification || + (hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || hasCalendarInvitation || (readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && ( <div className="border-b border-border bg-muted/30 isolate"> <div className="px-6 py-1.5"> <div className="flex flex-col gap-3 isolate"> + {/* Sender the server's checks don't back */} + {senderVerification && ( + <div className="flex items-start gap-3 py-1"> + <div className={cn( + "w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm", + senderVerification.status === 'failed' ? "bg-destructive/15 text-destructive" : "bg-warning/15 text-warning", + )}> + <ShieldAlert className="w-5 h-5" /> + </div> + <div className="flex-1 min-w-0"> + <div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"> + {senderVerificationLabel} + </div> + <div className="text-sm font-medium text-foreground break-words"> + {senderVerificationMessage} + </div> + <div className="text-sm text-muted-foreground break-words"> + {t('sender_check.caution')} + </div> + </div> + </div> + )} + {/* External Content Controls */} {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( <div className="flex items-start gap-3 py-1"> @@ -4847,7 +4898,9 @@ export function EmailViewer({ {t('load_external_content')} </button> )} - {email.from?.[0]?.email && ( + {/* Trusting a forged address would load remote content + for the next forgery too. */} + {email.from?.[0]?.email && !senderVerification && ( <button onClick={() => { const senderEmail = email.from?.[0]?.email; diff --git a/lib/__tests__/email-headers.test.ts b/lib/__tests__/email-headers.test.ts index edf78a8ec..7a7068546 100644 --- a/lib/__tests__/email-headers.test.ts +++ b/lib/__tests__/email-headers.test.ts @@ -8,6 +8,7 @@ import { parseSpamLLM, extractListHeaders, isAuthenticationSpoofed, + getSenderVerification, } from '../email-headers'; describe('parseAuthenticationResults', () => { @@ -393,3 +394,61 @@ describe('parseAuthenticationResults - sender-controlled text', () => { expect(result.dmarc?.result).toBe('fail'); }); }); + +describe('getSenderVerification', () => { + // Shape of a real phish: forged bank From, sent by a web host's PHP + // mailer, no signature. mail-auth reports dmarc=none because neither SPF + // nor DKIM passed, although the From domain publishes a DMARC record. + const phish = parseAuthenticationResults( + 'mx.example.org;\r\n\tspf=none (mx.example.org: no SPF records found for www-data@web1.hoster.example) smtp.mailfrom=www-data@web1.hoster.example;\r\n\tiprev=permerror policy.iprev=203.0.113.7;\r\n\tdmarc=none header.from=bank.example policy.dmarc=none', + ); + + it('flags a message that passes neither SPF nor DKIM', () => { + expect(isAuthenticationSpoofed(phish)).toBe(false); + expect(getSenderVerification(phish, 'support@bank.example')).toEqual({ + status: 'unverified', + domain: 'bank.example', + sentFrom: 'web1.hoster.example', + }); + }); + + it('leaves out the envelope host when it is the From domain', () => { + const auth = parseAuthenticationResults('mx; spf=softfail smtp.mailfrom=billing@Bank.Example; dmarc=none header.from=bank.example'); + expect(getSenderVerification(auth, 'billing@bank.example')).toEqual({ status: 'unverified', domain: 'bank.example' }); + }); + + it('reports a DMARC fail as failed', () => { + const auth = parseAuthenticationResults('mx; spf=pass smtp.mailfrom=bounce@evil.example; dmarc=fail header.from=bank.example'); + expect(getSenderVerification(auth, 'support@bank.example')).toEqual({ + status: 'failed', + domain: 'bank.example', + sentFrom: 'evil.example', + }); + }); + + it('accepts any passing SPF, DKIM or DMARC result', () => { + const from = 'news@shop.example'; + expect(getSenderVerification(parseAuthenticationResults('mx; spf=pass smtp.mailfrom=bounces.esp.example'), from)).toBeNull(); + expect(getSenderVerification(parseAuthenticationResults('mx; spf=none smtp.mailfrom=x.example; dkim=pass header.d=esp.example'), from)).toBeNull(); + expect(getSenderVerification(parseAuthenticationResults('mx; dmarc=pass header.from=shop.example'), from)).toBeNull(); + }); + + it('counts a passing signature that is not the first one', () => { + const auth = parseAuthenticationResults('mx; dkim=fail header.d=shop.example; dkim=pass header.d=esp.example; spf=none smtp.mailfrom=x.example'); + expect(auth.dkim?.result).toBe('fail'); + expect(auth.dkim?.all).toHaveLength(2); + expect(getSenderVerification(auth, 'news@shop.example')).toBeNull(); + }); + + it('does not take a HELO pass for a MAIL FROM pass', () => { + const auth = parseAuthenticationResults('mx; spf=pass smtp.helo=web1.hoster.example; spf=none smtp.mailfrom=www-data@web1.hoster.example'); + expect(getSenderVerification(auth, 'support@bank.example')?.status).toBe('unverified'); + }); + + it('says nothing without results or a From address', () => { + expect(getSenderVerification(undefined, 'a@b.example')).toBeNull(); + expect(getSenderVerification({}, 'a@b.example')).toBeNull(); + expect(getSenderVerification({ iprev: { result: 'fail' } }, 'a@b.example')).toBeNull(); + expect(getSenderVerification(phish, undefined)).toBeNull(); + }); +}); diff --git a/lib/email-headers.ts b/lib/email-headers.ts index 2128e6336..177d6e3ed 100644 --- a/lib/email-headers.ts +++ b/lib/email-headers.ts @@ -33,10 +33,62 @@ export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean { if (auth.dmarc?.result === 'fail') return true; // Otherwise a hard SPF fail with no valid DKIM signature means the sender // isn't authorized for the envelope domain. - if (auth.spf?.result === 'fail' && auth.dkim?.result !== 'pass') return true; + if (auth.spf?.result === 'fail' && !hasDkimPass(auth)) return true; return false; } +function hasDkimPass(auth: AuthenticationResults): boolean { + return auth.dkim?.result === 'pass' || !!auth.dkim?.all?.some((entry) => entry.result === 'pass'); +} + +function domainOf(address: string): string | undefined { + const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, ''); + return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined; +} + +export interface SenderVerification { + /** + * `failed`: the message fails the From domain's checks (see + * isAuthenticationSpoofed). `unverified`: neither SPF nor DKIM passes, so + * nothing ties the message to any domain, let alone the one in From. + */ + status: 'failed' | 'unverified'; + /** Domain of the visible From address. */ + domain: string; + /** Envelope (MAIL FROM) host, when it differs from `domain`. */ + sentFrom?: string; +} + +/** + * Whether the receiving server's checks back the visible From domain. + * Returns null when they do, or when there are no results to judge by. + * + * DMARC alone can't answer this: mail-auth (Stalwart) only checks alignment + * once SPF or DKIM passes, so a message that passes neither reports + * `dmarc=none` even when the From domain publishes a policy. That is the + * plainest kind of forgery, so it gets its own verdict here. + */ +export function getSenderVerification( + auth: AuthenticationResults | undefined, + fromEmail: string | undefined, +): SenderVerification | null { + if (!auth || !fromEmail || (!auth.spf && !auth.dkim && !auth.dmarc)) return null; + const domain = domainOf(fromEmail); + if (!domain) return null; + + // DMARC only counts the MAIL FROM identity; a HELO pass proves nothing + // about who wrote the message. + const mailFrom = auth.spf?.all?.find((entry) => entry.identity === 'mailfrom'); + const spfPass = auth.spf?.all ? mailFrom?.result === 'pass' : auth.spf?.result === 'pass'; + const envelope = mailFrom?.domain ?? auth.spf?.domain; + const envelopeDomain = envelope ? domainOf(envelope) : undefined; + const sentFrom = envelopeDomain && envelopeDomain !== domain ? envelopeDomain : undefined; + + if (isAuthenticationSpoofed(auth)) return { status: 'failed', domain, sentFrom }; + if (auth.dmarc?.result === 'pass' || spfPass || hasDkimPass(auth)) return null; + return { status: 'unverified', domain, sentFrom }; +} + interface ResInfo { method: string; result: string; @@ -182,12 +234,20 @@ export function parseAuthenticationResults(headers: string | readonly string[]): }; } - const dkim = own.find((info) => info.method === 'dkim'); - if (dkim) { + // A message can carry several signatures (the author's domain and the + // sending service's). The first one stays the headline; keep them all so + // a pass further down still counts. + const dkimResults = own + .filter((info) => info.method === 'dkim') + .map((info) => ({ + result: info.result as DkimResult, + domain: info.props['header.d'], + selector: info.props['header.s'], + })); + if (dkimResults.length > 0) { results.dkim = { - result: dkim.result as DkimResult, - domain: dkim.props['header.d'], - selector: dkim.props['header.s'], + ...dkimResults[0], + ...(dkimResults.length > 1 ? { all: dkimResults } : {}), }; } diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 44a78a29d..99a7aa4b1 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -163,6 +163,15 @@ export interface AuthenticationResults { result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror'; domain?: string; selector?: string; + /** + * Every DKIM result when the message carried more than one signature; + * `result` above is the first. + */ + all?: Array<{ + result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror'; + domain?: string; + selector?: string; + }>; }; dmarc?: { result: 'pass' | 'fail' | 'none'; diff --git a/locales/de/common.json b/locales/de/common.json index d94bd9bd4..d43a9bcd4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Bilder und externe Inhalte wurden blockiert", "load_external_content": "Bilder laden", "trust_sender": "Diesem Absender immer vertrauen", + "sender_check": { + "unverified_label": "Absender nicht bestätigt", + "failed_label": "Absenderprüfung fehlgeschlagen", + "unverified": "Diese Nachricht gibt vor, von {domain} zu kommen, aber nichts bestätigt das. Sie trägt keine gültige Signatur von {domain} und kam nicht von einem Server, den {domain} zulässt.", + "unverified_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und trägt keine gültige Signatur von {domain}.", + "failed": "Diese Nachricht gibt vor, von {domain} zu kommen, hat aber die Absenderprüfung dieser Domain nicht bestanden. Sie ist möglicherweise gefälscht.", + "failed_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und hat die Absenderprüfung von {domain} nicht bestanden. Sie ist möglicherweise gefälscht.", + "caution": "Seien Sie vorsichtig mit Links, Anhängen und Fragen nach Passwörtern oder Zahlungsdaten." + }, "back_to_list": "Zurück zur Liste", "view_contact": "Kontakt anzeigen", "message_details": "Nachrichtendetails", diff --git a/locales/en/common.json b/locales/en/common.json index 8ed9e0441..4721cb6cd 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Images and external content have been blocked", "load_external_content": "Load images", "trust_sender": "Always trust this sender", + "sender_check": { + "unverified_label": "Unverified sender", + "failed_label": "Sender check failed", + "unverified": "This message claims to be from {domain}, but nothing confirms that. It has no valid signature from {domain} and didn't come from a server {domain} authorizes.", + "unverified_sent_from": "This message claims to be from {domain}, but it was sent from {host} and has no valid signature from {domain}.", + "failed": "This message claims to be from {domain}, but it failed that domain's sender checks. It may be forged.", + "failed_sent_from": "This message claims to be from {domain}, but it was sent from {host} and failed {domain}'s sender checks. It may be forged.", + "caution": "Be careful with links, attachments and requests for passwords or payment details." + }, "back_to_list": "Back to list", "view_contact": "View contact", "message_details": "Message Details", From 832651bcaf51c3ec3881b4c6f0be2415c2f4dd42 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:28:44 +0200 Subject: [PATCH 28/35] fix: include sent mail when searching from unified all mail --- lib/__tests__/unified-mailbox-cross.test.ts | 75 +++++++++++++++++++-- lib/unified-mailbox.ts | 16 ++++- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/lib/__tests__/unified-mailbox-cross.test.ts b/lib/__tests__/unified-mailbox-cross.test.ts index 5da42061f..9c261a546 100644 --- a/lib/__tests__/unified-mailbox-cross.test.ts +++ b/lib/__tests__/unified-mailbox-cross.test.ts @@ -6,6 +6,7 @@ import { buildCrossFilter, getCrossUnreadTotal, fetchCrossViewEmails, + searchCrossViewEmails, advancedSearchCrossViewEmails, resolveSourceFolderName, type UnifiedAccountClient, @@ -205,17 +206,79 @@ describe('fetchCrossViewEmails', () => { }); }); -describe('advancedSearchCrossViewEmails', () => { - it('ANDs the advanced filter onto the cross-view membership', async () => { - const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false }); - const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails }); +describe('searchCrossViewEmails', () => { + const folders = [mb('inbox', 'inbox'), mb('sent', 'sent'), mb('archive', 'archive'), mb('trash', 'trash'), mb('junk', 'junk')]; - await advancedSearchCrossViewEmails([a], 'all', { hasKeyword: '$flagged' }, 50, 0); + it('searches every folder but Trash and Junk from All mail, Sent included', async () => { + const advancedSearchEmails = vi.fn().mockResolvedValue({ + emails: [{ id: 's1', receivedAt: '2026-01-01T10:00:00Z', mailboxIds: { sent: true } } as unknown as Email], + total: 1, + hasMore: false, + }); + const a = makeAccount({ accountId: 'a', mailboxes: folders }, { advancedSearchEmails }); + + const result = await searchCrossViewEmails([a], 'all', 'invoice', 50, 0); const [filter] = advancedSearchEmails.mock.calls[0]; expect(filter).toEqual({ operator: 'AND', - conditions: [{ inMailbox: 'inbox' }, { hasKeyword: '$flagged' }], + conditions: [{ text: 'invoice' }, { inMailboxOtherThan: ['trash', 'junk'] }], + }); + expect(result.emails.map((e) => e.sourceFolder)).toEqual(['sent']); + }); + + it('still narrows Starred to its own list', async () => { + const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false }); + const a = makeAccount({ accountId: 'a', mailboxes: folders }, { advancedSearchEmails }); + + await searchCrossViewEmails([a], 'starred', 'invoice', 50, 0); + + const [filter] = advancedSearchEmails.mock.calls[0]; + expect(filter).toEqual({ + operator: 'AND', + conditions: [ + { operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { hasKeyword: '$flagged' }] }, + { text: 'invoice' }, + ], + }); + }); +}); + +describe('advancedSearchCrossViewEmails', () => { + it('ANDs the advanced filter onto the Unread membership', async () => { + const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false }); + const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails }); + + await advancedSearchCrossViewEmails([a], 'unread', { from: 'bob' }, 50, 0); + + const [filter] = advancedSearchEmails.mock.calls[0]; + expect(filter).toEqual({ + operator: 'AND', + conditions: [ + { operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { notKeyword: '$seen' }] }, + { from: 'bob' }, + ], + }); + }); + + it('searches every folder but Trash and Junk from All mail', async () => { + const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false }); + const shared = makeAccount( + { + accountId: 'owner-1', + isShared: true, + mailboxes: [mb('ns:inbox', 'inbox', 0, 'orig-inbox'), mb('ns:sent', 'sent', 0, 'orig-sent'), mb('ns:trash', 'trash', 0, 'orig-trash')], + }, + { advancedSearchEmails }, + ); + + await advancedSearchCrossViewEmails([shared], 'all', { from: 'bob' }, 50, 0); + + const [filter, accountId] = advancedSearchEmails.mock.calls[0]; + expect(accountId).toBe('owner-1'); + expect(filter).toEqual({ + operator: 'AND', + conditions: [{ from: 'bob' }, { inMailboxOtherThan: ['orig-trash'] }], }); }); diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts index 3dc8162d0..e324610bb 100644 --- a/lib/unified-mailbox.ts +++ b/lib/unified-mailbox.ts @@ -496,6 +496,12 @@ export async function fetchCrossViewEmails( /** * Text search within a cross-account view: the view filter AND a free-text * condition, fanned out across accounts. + * + * Searching from "All mail" is the exception: it searches every folder of + * every account except Trash and Junk, the standard search panel's default + * scope. The All mail list leaves Sent, Archive and Drafts out (or whatever + * the folder picker excludes), and narrowing the search to that list hid + * every sent reply from it. Unread and Starred still narrow to their list. */ export async function searchCrossViewEmails( accounts: UnifiedAccountClient[], @@ -504,6 +510,9 @@ export async function searchCrossViewEmails( limit: number, position: FanOutPosition, ): Promise<UnifiedFetchResult> { + if (view === 'all') { + return searchAcrossAccounts(accounts, query, limit, position, { excludeTrashAndJunk: true }); + } return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) => account.client.advancedSearchEmails( { operator: 'AND', conditions: [buildCrossFilter(view, ids), { text: query }] }, @@ -517,7 +526,9 @@ export async function searchCrossViewEmails( * Like `searchCrossViewEmails`, but applies an advanced filter (text + field * conditions from `buildJMAPFilter`, built WITHOUT an `inMailbox` clause) on top * of the cross-view membership. `extraFilter` may be empty ({}), in which case - * only the membership filter is used (equivalent to a plain browse). + * only the membership filter is used (equivalent to a plain browse). A + * non-empty filter on "All mail" searches every folder except Trash and Junk, + * as in `searchCrossViewEmails`. */ export async function advancedSearchCrossViewEmails( accounts: UnifiedAccountClient[], @@ -527,6 +538,9 @@ export async function advancedSearchCrossViewEmails( position: FanOutPosition, ): Promise<UnifiedFetchResult> { const hasExtra = Object.keys(extraFilter).length > 0; + if (view === 'all' && hasExtra) { + return advancedSearchAcrossAccounts(accounts, extraFilter, limit, position, { excludeTrashAndJunk: true }); + } return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) => { const membership = buildCrossFilter(view, ids); const filter = hasExtra From 2e262b9d5c86ea9d4ad4b3d0472d5e89233a8027 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:34:19 +0200 Subject: [PATCH 29/35] fix: title the browser tab after the Pro tab in view #1186 --- hooks/__tests__/use-document-title.test.tsx | 32 ++++++++++++++++++++- hooks/use-document-title.ts | 9 +++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/hooks/__tests__/use-document-title.test.tsx b/hooks/__tests__/use-document-title.test.tsx index 91bd8f569..7d359ab61 100644 --- a/hooks/__tests__/use-document-title.test.tsx +++ b/hooks/__tests__/use-document-title.test.tsx @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, renderHook, waitFor } from '@testing-library/react'; +import { act, render, renderHook, waitFor } from '@testing-library/react'; vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Acme Mail' }) })); import { useDocumentTitle } from '@/hooks/use-document-title'; import { useAccountStore, type AccountEntry } from '@/stores/account-store'; +import { PaneIdContext, ProTabFocusContext } from '@/hooks/use-pane-context'; const account = (id: string, email: string) => ({ id, email }) as AccountEntry; @@ -19,6 +20,21 @@ function rerenderMetadataTitle(title = 'Acme Mail'): void { document.head.appendChild(el); } +/** A surface in a Pro shell tab, which stays mounted while hidden. */ +function TitleProbe({ context }: { context: string }) { + useDocumentTitle(context); + return null; +} +function ProTab({ context, focused }: { context: string; focused: boolean }) { + return ( + <PaneIdContext.Provider value="main"> + <ProTabFocusContext.Provider value={focused}> + <TitleProbe context={context} /> + </ProTabFocusContext.Provider> + </PaneIdContext.Provider> + ); +} + /** Drains MutationObserver callbacks. */ async function settle(): Promise<void> { for (let i = 0; i < 10; i++) await Promise.resolve(); @@ -70,6 +86,20 @@ describe('useDocumentTitle', () => { expect(document.title).toBe('Settings - jane@example.com - Acme Mail'); }); + it('in the Pro shell, leaves the title to the focused tab', () => { + const mail = render(<ProTab context="Inbox" focused />); + const settings = render(<ProTab context="Settings" focused={false} />); + expect(document.title).toBe('Inbox - jane@example.com - Acme Mail'); + + mail.rerender(<ProTab context="Inbox" focused={false} />); + settings.rerender(<ProTab context="Settings" focused />); + expect(document.title).toBe('Settings - jane@example.com - Acme Mail'); + + // New mail in the hidden Mail tab must not take the title back. + mail.rerender(<ProTab context="Inbox (5)" focused={false} />); + expect(document.title).toBe('Settings - jane@example.com - Acme Mail'); + }); + it('stops re-applying once unmounted', async () => { const { unmount } = renderHook(() => useDocumentTitle('Settings')); unmount(); diff --git a/hooks/use-document-title.ts b/hooks/use-document-title.ts index 841410bc9..dc361bfe6 100644 --- a/hooks/use-document-title.ts +++ b/hooks/use-document-title.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useConfig } from '@/hooks/use-config'; +import { useIsFocusedProTab, usePaneId } from '@/hooks/use-pane-context'; import { useAccountStore } from '@/stores/account-store'; import { formatTabTitle } from '@/lib/tab-title'; @@ -25,8 +26,14 @@ export function useDocumentTitle(context?: string | null): void { (s) => s.accounts.find((a) => a.id === s.activeAccountId)?.email || null, ); const title = formatTabTitle(context, account, appName); + // The Pro shell keeps every opened tab mounted (hidden), so only the + // focused tab may claim the title; outside Pro every surface is in view. + const paneId = usePaneId(); + const focusedProTab = useIsFocusedProTab(); + const inView = paneId === null || focusedProTab; useEffect(() => { + if (!inView) return; const self = Symbol('document-title'); owner = self; // Termination: setting the title is itself a <head> mutation; on that run @@ -41,5 +48,5 @@ export function useDocumentTitle(context?: string | null): void { observer.disconnect(); if (owner === self) owner = null; }; - }, [title]); + }, [title, inView]); } From 7bf5e0d802eab8865ce567e79f3504323647bf13 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:34:20 +0200 Subject: [PATCH 30/35] fix: keep a shared-folder message's thread key in line with its row #1188 --- components/mail/__tests__/blob-routing.test.tsx | 14 +++++--------- components/mail/mail-app.tsx | 9 +++++---- stores/email-store.ts | 8 +++++--- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/components/mail/__tests__/blob-routing.test.tsx b/components/mail/__tests__/blob-routing.test.tsx index 3fd259410..5a2989a42 100644 --- a/components/mail/__tests__/blob-routing.test.tsx +++ b/components/mail/__tests__/blob-routing.test.tsx @@ -10,6 +10,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { usePluginStore } from '@/stores/plugin-store'; import { useThemeStore } from '@/stores/theme-store'; import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { threadKeyFor } from '@/lib/thread-utils'; // Keep the real MailApp selection/download handlers and EmailViewer. Stub // unrelated shell UI and bootstrap requests so no server is contacted. @@ -288,20 +289,15 @@ describe('standard mail UI blob routing', () => { expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'owner-b'); }); - it('keeps a direct folder source when Email/get completes after an account switch', async () => { - let finish!: (email: Email) => void; - primary.getEmail.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); + it('keeps a direct shared-folder message unstamped so its thread key matches the row', async () => { useEmailStore.setState({ selectedEmail: message({ bodyValues: undefined }) }); render(<MailApp />); await waitFor(() => expect(primary.getEmail).toHaveBeenCalledWith('email', 'nrichmond')); - act(() => { - useEmailStore.setState({ selectedMailbox: 'inbox' }); - useAuthStore.setState({ client: asClient(other) as never, activeAccountId: 'login-other' }); - }); - await act(async () => finish(message())); + await waitFor(() => expect(useEmailStore.getState().selectedEmail?.bodyValues).toBeDefined()); + const selected = useEmailStore.getState().selectedEmail!; + expect(threadKeyFor(selected)).toBe(threadKeyFor(useEmailStore.getState().emails[0])); await downloadAttachment(); expect(primary.downloadBlob).toHaveBeenCalledWith('same-blob', expect.any(String), 'message/rfc822', 'nrichmond'); - expect(other.downloadBlob).not.toHaveBeenCalled(); }); it('keeps an unstamped all-folders search hit on the primary account', async () => { diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 87f7e8b03..677531014 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -3506,7 +3506,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Fetch the full content try { - const { client: fetchClient, accountId, clientAccountId } = resolveEmailBlobContext(listEmail ?? null, client); + const { client: fetchClient, accountId } = resolveEmailBlobContext(listEmail ?? null, client); if (!fetchClient) { console.warn('[mail-app] No connected client for email source'); return; @@ -3529,9 +3529,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { fullEmail.accountLabel = listEmail.accountLabel; fullEmail.sourceFolder = listEmail.sourceFolder; } - // Keep a direct shared folder's owner after navigation as well. - fullEmail.sourceAccountId = listEmail?.sourceAccountId ?? accountId; - fullEmail.sourceClientAccountId = listEmail?.sourceClientAccountId ?? (accountId ? clientAccountId : undefined); + // Only stamped rows are re-stamped. A direct-folder row stays + // unstamped, since threadKeyFor scopes keys by these stamps. + fullEmail.sourceAccountId = listEmail?.sourceAccountId; + fullEmail.sourceClientAccountId = listEmail?.sourceClientAccountId; selectEmail(fullEmail); // Mark-as-read logic is now handled by useEffect } diff --git a/stores/email-store.ts b/stores/email-store.ts index 33d29c3f0..38e881cad 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -2442,7 +2442,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({ // back to the selected-mailbox shared-folder logic for normal views. const selected = get().selectedEmail; const listEmail = selected?.id === emailId ? selected : get().emails.find(e => e.id === emailId); - const { client: actionClient, accountId, clientAccountId } = resolveEmailBlobContext(listEmail ?? null, client); + const { client: actionClient, accountId } = resolveEmailBlobContext(listEmail ?? null, client); if (!actionClient) throw new Error('No connected client for email source'); const email = await actionClient.getEmail(emailId, accountId); @@ -2454,8 +2454,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({ annotatedEmail.accountId = listEmail?.accountId; annotatedEmail.accountLabel = listEmail?.accountLabel; annotatedEmail.sourceFolder = listEmail?.sourceFolder; - annotatedEmail.sourceClientAccountId = listEmail?.sourceClientAccountId ?? (accountId ? clientAccountId : undefined); - annotatedEmail.sourceAccountId = listEmail?.sourceAccountId ?? accountId; + // Only carry stamps the list row has. A direct-folder row stays + // unstamped, since threadKeyFor scopes keys by these stamps. + annotatedEmail.sourceClientAccountId = listEmail?.sourceClientAccountId; + annotatedEmail.sourceAccountId = listEmail?.sourceAccountId; set({ selectedEmail: annotatedEmail }); return annotatedEmail; } From a60768eb171d6e5b2cc24cc30a0a12a83d5372e5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:49:37 +0200 Subject: [PATCH 31/35] test: wait for the lazy viewer in the blob routing tests #1188 --- components/mail/__tests__/blob-routing.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/mail/__tests__/blob-routing.test.tsx b/components/mail/__tests__/blob-routing.test.tsx index 5a2989a42..dde585e22 100644 --- a/components/mail/__tests__/blob-routing.test.tsx +++ b/components/mail/__tests__/blob-routing.test.tsx @@ -120,7 +120,7 @@ beforeEach(() => { async function downloadAttachment() { // The viewer renders responsive copies; any Download button exercises the // same production handler. The raw RFC822 part is the live reproduction. - const buttons = await screen.findAllByTitle('download'); + const buttons = await screen.findAllByTitle('download', {}, { timeout: 5000 }); fireEvent.click(buttons[0]); } async function exportEmail() { @@ -199,7 +199,7 @@ describe('standard mail UI blob routing', () => { fetchMock.mockImplementation(async () => new Response('attachment', { status: 200, headers: { 'Content-Type': 'message/rfc822' } })); vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); render(<MailApp />); - const downloadButton = (await screen.findAllByTitle('download'))[0]; + const downloadButton = (await screen.findAllByTitle('download', {}, { timeout: 5000 }))[0]; const chip = downloadButton.closest('[draggable="true"]'); expect(chip).not.toBeNull(); From eb84810f1ec972f17f08a88966272a6f057a8fd4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:06:35 +0200 Subject: [PATCH 32/35] fix: translate the unverified sender warning into all languages --- locales/ar/common.json | 9 +++++++++ locales/ca/common.json | 9 +++++++++ locales/cs/common.json | 9 +++++++++ locales/da/common.json | 9 +++++++++ locales/es/common.json | 9 +++++++++ locales/fa/common.json | 9 +++++++++ locales/fr/common.json | 9 +++++++++ locales/he/common.json | 9 +++++++++ locales/hu/common.json | 9 +++++++++ locales/it/common.json | 9 +++++++++ locales/ja/common.json | 9 +++++++++ locales/ko/common.json | 9 +++++++++ locales/lv/common.json | 9 +++++++++ locales/mn/common.json | 9 +++++++++ locales/nb/common.json | 9 +++++++++ locales/nl/common.json | 9 +++++++++ locales/pl/common.json | 9 +++++++++ locales/pt/common.json | 9 +++++++++ locales/ro/common.json | 9 +++++++++ locales/ru/common.json | 9 +++++++++ locales/sk/common.json | 9 +++++++++ locales/tr/common.json | 9 +++++++++ locales/uk/common.json | 9 +++++++++ locales/zh-TW/common.json | 9 +++++++++ locales/zh/common.json | 9 +++++++++ 25 files changed, 225 insertions(+) diff --git a/locales/ar/common.json b/locales/ar/common.json index 906798841..6de5854db 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -345,6 +345,15 @@ "external_content_warning": "تم حظر الصور والمحتوى الخارجي", "load_external_content": "تحميل الصور", "trust_sender": "الوثوق بهذا المرسل دائمًا", + "sender_check": { + "unverified_label": "مرسل غير موثّق", + "failed_label": "فشل التحقق من المرسل", + "unverified": "تدّعي هذه الرسالة أنها من {domain}، لكن لا شيء يؤكد ذلك. فهي لا تحمل توقيعًا صالحًا من {domain} ولم تأتِ من خادم مخوّل من قِبل {domain}.", + "unverified_sent_from": "تدّعي هذه الرسالة أنها من {domain}، لكنها أُرسلت من {host} ولا تحمل توقيعًا صالحًا من {domain}.", + "failed": "تدّعي هذه الرسالة أنها من {domain}، لكنها لم تجتز فحوصات المرسل الخاصة بهذا النطاق. قد تكون مزوّرة.", + "failed_sent_from": "تدّعي هذه الرسالة أنها من {domain}، لكنها أُرسلت من {host} ولم تجتز فحوصات المرسل الخاصة بـ {domain}. قد تكون مزوّرة.", + "caution": "توخَّ الحذر مع الروابط والمرفقات وطلبات كلمات المرور أو بيانات الدفع." + }, "back_to_list": "العودة إلى القائمة", "view_contact": "عرض جهة الاتصال", "message_details": "تفاصيل الرسالة", diff --git a/locales/ca/common.json b/locales/ca/common.json index 6751337db..01ab88b36 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -345,6 +345,15 @@ "external_content_warning": "S'han bloquejat les imatges i el contingut extern", "load_external_content": "Carrega les imatges", "trust_sender": "Confia sempre en aquest remitent", + "sender_check": { + "unverified_label": "Remitent no verificat", + "failed_label": "La verificació del remitent ha fallat", + "unverified": "Aquest missatge diu que és de {domain}, però res no ho confirma. No té cap signatura vàlida de {domain} i no prové de cap servidor autoritzat per {domain}.", + "unverified_sent_from": "Aquest missatge diu que és de {domain}, però s'ha enviat des de {host} i no té cap signatura vàlida de {domain}.", + "failed": "Aquest missatge diu que és de {domain}, però no ha superat les comprovacions de remitent d'aquest domini. Podria estar falsificat.", + "failed_sent_from": "Aquest missatge diu que és de {domain}, però s'ha enviat des de {host} i no ha superat les comprovacions de remitent de {domain}. Podria estar falsificat.", + "caution": "Aneu amb compte amb els enllaços, els fitxers adjunts i les sol·licituds de contrasenyes o dades de pagament." + }, "back_to_list": "Torna a la llista", "view_contact": "Mostra el contacte", "message_details": "Detalls del missatge", diff --git a/locales/cs/common.json b/locales/cs/common.json index c1601f881..04c10f6e6 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Obrázky a externí obsah byly zablokovány", "load_external_content": "Načíst obrázky", "trust_sender": "Vždy důvěřovat tomuto odesílateli", + "sender_check": { + "unverified_label": "Neověřený odesílatel", + "failed_label": "Kontrola odesílatele selhala", + "unverified": "Tato zpráva tvrdí, že pochází z domény {domain}, ale nic to nepotvrzuje. Nemá platný podpis domény {domain} a nepřišla ze serveru, který doména {domain} povoluje.", + "unverified_sent_from": "Tato zpráva tvrdí, že pochází z domény {domain}, ale byla odeslána z domény {host} a nemá platný podpis domény {domain}.", + "failed": "Tato zpráva tvrdí, že pochází z domény {domain}, ale neprošla kontrolami odesílatele této domény. Může být podvržená.", + "failed_sent_from": "Tato zpráva tvrdí, že pochází z domény {domain}, ale byla odeslána z domény {host} a neprošla kontrolami odesílatele domény {domain}. Může být podvržená.", + "caution": "Buďte opatrní u odkazů, příloh a žádostí o hesla nebo platební údaje." + }, "back_to_list": "Zpět na seznam", "view_contact": "Zobrazit kontakt", "message_details": "Podrobnosti zprávy", diff --git a/locales/da/common.json b/locales/da/common.json index 76efdddeb..0dd7e6e5a 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Billeder og eksternt indhold er blevet blokeret", "load_external_content": "Indlæs billeder", "trust_sender": "Stol altid på denne afsender", + "sender_check": { + "unverified_label": "Ubekræftet afsender", + "failed_label": "Afsenderkontrol mislykkedes", + "unverified": "Denne besked udgiver sig for at være fra {domain}, men intet bekræfter det. Den har ingen gyldig signatur fra {domain} og kom ikke fra en server, som {domain} har godkendt.", + "unverified_sent_from": "Denne besked udgiver sig for at være fra {domain}, men den blev sendt fra {host} og har ingen gyldig signatur fra {domain}.", + "failed": "Denne besked udgiver sig for at være fra {domain}, men den bestod ikke domænets afsenderkontrol. Den kan være forfalsket.", + "failed_sent_from": "Denne besked udgiver sig for at være fra {domain}, men den blev sendt fra {host} og bestod ikke afsenderkontrollen for {domain}. Den kan være forfalsket.", + "caution": "Vær forsigtig med links, vedhæftede filer og anmodninger om adgangskoder eller betalingsoplysninger." + }, "back_to_list": "Tilbage til liste", "view_contact": "Vis kontakt", "message_details": "Beskeddetaljer", diff --git a/locales/es/common.json b/locales/es/common.json index 08069917d..93e017212 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Las imágenes y el contenido externo han sido bloqueados", "load_external_content": "Cargar imágenes", "trust_sender": "Confiar siempre en este remitente", + "sender_check": { + "unverified_label": "Remitente no verificado", + "failed_label": "Falló la verificación del remitente", + "unverified": "Este mensaje dice proceder de {domain}, pero nada lo confirma. No tiene una firma válida de {domain} ni procede de un servidor autorizado por {domain}.", + "unverified_sent_from": "Este mensaje dice proceder de {domain}, pero se envió desde {host} y no tiene una firma válida de {domain}.", + "failed": "Este mensaje dice proceder de {domain}, pero no superó las comprobaciones de remitente de ese dominio. Podría estar falsificado.", + "failed_sent_from": "Este mensaje dice proceder de {domain}, pero se envió desde {host} y no superó las comprobaciones de remitente de {domain}. Podría estar falsificado.", + "caution": "Tenga cuidado con los enlaces, los archivos adjuntos y las solicitudes de contraseñas o datos de pago." + }, "back_to_list": "Volver a la lista", "view_contact": "Ver contacto", "message_details": "Detalles del Mensaje", diff --git a/locales/fa/common.json b/locales/fa/common.json index 6a6f4080e..342711bd2 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -345,6 +345,15 @@ "external_content_warning": "تصاویر و محتوای خارجی مسدود شده‌اند", "load_external_content": "بارگذاری تصاویر", "trust_sender": "همیشه به این فرستنده اعتماد کن", + "sender_check": { + "unverified_label": "فرستنده تأییدنشده", + "failed_label": "بررسی فرستنده ناموفق بود", + "unverified": "این پیام ادعا می‌کند از {domain} است، اما هیچ چیزی این را تأیید نمی‌کند. امضای معتبری از {domain} ندارد و از سروری که {domain} مجاز دانسته ارسال نشده است.", + "unverified_sent_from": "این پیام ادعا می‌کند از {domain} است، اما از {host} ارسال شده و امضای معتبری از {domain} ندارد.", + "failed": "این پیام ادعا می‌کند از {domain} است، اما در بررسی‌های فرستنده برای این دامنه رد شده است. ممکن است جعلی باشد.", + "failed_sent_from": "این پیام ادعا می‌کند از {domain} است، اما از {host} ارسال شده و در بررسی‌های فرستنده برای {domain} رد شده است. ممکن است جعلی باشد.", + "caution": "مراقب پیوندها، پیوست‌ها و درخواست‌های رمز عبور یا اطلاعات پرداخت باشید." + }, "back_to_list": "بازگشت به لیست", "view_contact": "مشاهده مخاطب", "message_details": "جزئیات پیام", diff --git a/locales/fr/common.json b/locales/fr/common.json index b96b0f075..7c8abdbd1 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Les images et le contenu externe ont été bloqués", "load_external_content": "Charger les images", "trust_sender": "Toujours faire confiance à cet expéditeur", + "sender_check": { + "unverified_label": "Expéditeur non vérifié", + "failed_label": "Échec de la vérification de l'expéditeur", + "unverified": "Ce message prétend venir de {domain}, mais rien ne le confirme. Il ne porte aucune signature valide de {domain} et ne provient pas d'un serveur autorisé par {domain}.", + "unverified_sent_from": "Ce message prétend venir de {domain}, mais il a été envoyé depuis {host} et ne porte aucune signature valide de {domain}.", + "failed": "Ce message prétend venir de {domain}, mais il a échoué aux vérifications d'expéditeur de ce domaine. Il est peut-être falsifié.", + "failed_sent_from": "Ce message prétend venir de {domain}, mais il a été envoyé depuis {host} et a échoué aux vérifications d'expéditeur de {domain}. Il est peut-être falsifié.", + "caution": "Méfiez-vous des liens, des pièces jointes et des demandes de mots de passe ou d'informations de paiement." + }, "back_to_list": "Retour à la liste", "view_contact": "Voir le contact", "message_details": "Détails du message", diff --git a/locales/he/common.json b/locales/he/common.json index de0ce2295..af83e9046 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -292,6 +292,15 @@ "external_content_warning": "תמונות ותוכן חיצוני נחסמו", "load_external_content": "טען תמונות", "trust_sender": "בטח תמיד בשולח הזה", + "sender_check": { + "unverified_label": "שולח לא מאומת", + "failed_label": "בדיקת השולח נכשלה", + "unverified": "ההודעה הזו טוענת שהיא מהדומיין {domain}, אבל שום דבר לא מאשר זאת. אין בה חתימה תקפה של {domain} והיא לא הגיעה משרת שמורשה על ידי {domain}.", + "unverified_sent_from": "ההודעה הזו טוענת שהיא מהדומיין {domain}, אבל היא נשלחה מהדומיין {host} ואין בה חתימה תקפה של {domain}.", + "failed": "ההודעה הזו טוענת שהיא מהדומיין {domain}, אבל היא נכשלה בבדיקות השולח של הדומיין הזה. ייתכן שהיא מזויפת.", + "failed_sent_from": "ההודעה הזו טוענת שהיא מהדומיין {domain}, אבל היא נשלחה מהדומיין {host} ונכשלה בבדיקות השולח של {domain}. ייתכן שהיא מזויפת.", + "caution": "היזהר מקישורים, מקבצים מצורפים ומבקשות לסיסמאות או לפרטי תשלום." + }, "back_to_list": "חזרה לרשימה", "view_contact": "הצג איש קשר", "message_details": "פרטי הודעה", diff --git a/locales/hu/common.json b/locales/hu/common.json index f7f88e78a..1850382fc 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Képek és külső tartalmak blokkolva", "load_external_content": "Képek betöltése", "trust_sender": "Feladó mindig megbízható", + "sender_check": { + "unverified_label": "Nem ellenőrzött feladó", + "failed_label": "Sikertelen feladóellenőrzés", + "unverified": "Ez az üzenet azt állítja, hogy a(z) {domain} tartományból jött, de ezt semmi sem igazolja. Nincs érvényes aláírása a(z) {domain} tartománytól, és nem olyan szerverről érkezett, amelyet a(z) {domain} tartomány engedélyez.", + "unverified_sent_from": "Ez az üzenet azt állítja, hogy a(z) {domain} tartományból jött, de a(z) {host} tartományból küldték, és nincs érvényes aláírása a(z) {domain} tartománytól.", + "failed": "Ez az üzenet azt állítja, hogy a(z) {domain} tartományból jött, de nem ment át a tartomány feladóellenőrzésén. Lehet, hogy hamisított.", + "failed_sent_from": "Ez az üzenet azt állítja, hogy a(z) {domain} tartományból jött, de a(z) {host} tartományból küldték, és nem ment át a(z) {domain} tartomány feladóellenőrzésén. Lehet, hogy hamisított.", + "caution": "Légy óvatos a linkekkel, a mellékletekkel, és ha jelszót vagy fizetési adatokat kérnek tőled." + }, "back_to_list": "Vissza a listához", "view_contact": "Névjegy megtekintése", "message_details": "Üzenet részletei", diff --git a/locales/it/common.json b/locales/it/common.json index fc6170c32..c40767c5a 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Le immagini e i contenuti esterni sono stati bloccati", "load_external_content": "Carica immagini", "trust_sender": "Considera sempre attendibile questo mittente", + "sender_check": { + "unverified_label": "Mittente non verificato", + "failed_label": "Verifica del mittente non riuscita", + "unverified": "Questo messaggio dichiara di provenire da {domain}, ma nulla lo conferma. Non ha una firma valida di {domain} e non proviene da un server autorizzato da {domain}.", + "unverified_sent_from": "Questo messaggio dichiara di provenire da {domain}, ma è stato inviato da {host} e non ha una firma valida di {domain}.", + "failed": "Questo messaggio dichiara di provenire da {domain}, ma non ha superato i controlli sul mittente di quel dominio. Potrebbe essere falsificato.", + "failed_sent_from": "Questo messaggio dichiara di provenire da {domain}, ma è stato inviato da {host} e non ha superato i controlli sul mittente di {domain}. Potrebbe essere falsificato.", + "caution": "Fai attenzione a link, allegati e richieste di password o dati di pagamento." + }, "back_to_list": "Torna all'elenco", "view_contact": "Visualizza contatto", "message_details": "Dettagli del messaggio", diff --git a/locales/ja/common.json b/locales/ja/common.json index be5b78459..b5b3bb008 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -345,6 +345,15 @@ "external_content_warning": "画像と外部コンテンツがブロックされました", "load_external_content": "画像を読み込む", "trust_sender": "常にこの送信者を信頼する", + "sender_check": { + "unverified_label": "未確認の送信者", + "failed_label": "送信者の確認に失敗", + "unverified": "このメッセージは{domain}からのものとされていますが、それを裏付けるものはありません。{domain}の有効な署名がなく、{domain}が許可したサーバーから送信されたものでもありません。", + "unverified_sent_from": "このメッセージは{domain}からのものとされていますが、実際には{host}から送信されており、{domain}の有効な署名がありません。", + "failed": "このメッセージは{domain}からのものとされていますが、このドメインの送信者チェックに失敗しました。なりすましの可能性があります。", + "failed_sent_from": "このメッセージは{domain}からのものとされていますが、実際には{host}から送信されており、{domain}の送信者チェックにも失敗しました。なりすましの可能性があります。", + "caution": "リンクや添付ファイル、パスワードや支払い情報の要求には注意してください。" + }, "back_to_list": "リストに戻る", "view_contact": "連絡先を表示", "message_details": "メッセージの詳細", diff --git a/locales/ko/common.json b/locales/ko/common.json index 352589410..c13698a8e 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -345,6 +345,15 @@ "external_content_warning": "이미지와 외부 콘텐츠가 차단되었어요", "load_external_content": "이미지 불러오기", "trust_sender": "이 보낸 사람 항상 신뢰하기", + "sender_check": { + "unverified_label": "확인되지 않은 보낸 사람", + "failed_label": "보낸 사람 확인 실패", + "unverified": "이 메시지는 {domain}에서 보냈다고 하지만 이를 뒷받침하는 근거가 없어요. {domain}의 유효한 서명이 없고, {domain}에서 허용한 서버에서 보낸 것도 아니에요.", + "unverified_sent_from": "이 메시지는 {domain}에서 보냈다고 하지만 실제로는 {host}에서 발송되었고 {domain}의 유효한 서명이 없어요.", + "failed": "이 메시지는 {domain}에서 보냈다고 하지만 해당 도메인의 보낸 사람 확인을 통과하지 못했어요. 위조된 메시지일 수 있어요.", + "failed_sent_from": "이 메시지는 {domain}에서 보냈다고 하지만 실제로는 {host}에서 발송되었고 {domain}의 보낸 사람 확인을 통과하지 못했어요. 위조된 메시지일 수 있어요.", + "caution": "링크, 첨부 파일, 비밀번호나 결제 정보 요청에 주의하세요." + }, "back_to_list": "목록으로", "view_contact": "연락처 보기", "message_details": "메시지 상세 정보", diff --git a/locales/lv/common.json b/locales/lv/common.json index dfc337f12..0938cc474 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Attēli un ārējais saturs ir bloķēts", "load_external_content": "Ielādēt attēlus", "trust_sender": "Vienmēr uzticēties šim sūtītājam", + "sender_check": { + "unverified_label": "Nepārbaudīts sūtītājs", + "failed_label": "Sūtītāja pārbaude neizdevās", + "unverified": "Šis ziņojums apgalvo, ka ir no domēna {domain}, taču nekas to neapstiprina. Tam nav derīga domēna {domain} paraksta, un tas nav nācis no servera, ko domēns {domain} ir atļāvis.", + "unverified_sent_from": "Šis ziņojums apgalvo, ka ir no domēna {domain}, taču tas nosūtīts no domēna {host}, un tam nav derīga domēna {domain} paraksta.", + "failed": "Šis ziņojums apgalvo, ka ir no domēna {domain}, taču tas neizturēja šī domēna sūtītāja pārbaudes. Tas var būt viltots.", + "failed_sent_from": "Šis ziņojums apgalvo, ka ir no domēna {domain}, taču tas nosūtīts no domēna {host} un neizturēja domēna {domain} sūtītāja pārbaudes. Tas var būt viltots.", + "caution": "Esiet piesardzīgi ar saitēm, pielikumiem un paroļu vai maksājumu datu pieprasījumiem." + }, "back_to_list": "Atpakaļ uz sarakstu", "view_contact": "Skatīt kontaktu", "message_details": "Informācija par ziņojumu", diff --git a/locales/mn/common.json b/locales/mn/common.json index 84e371015..57cb87378 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Зураг болон гадаад контентыг хаасан", "load_external_content": "Зураг ачаалах", "trust_sender": "Энэ илгээгчид үргэлж итгэ", + "sender_check": { + "unverified_label": "Баталгаажаагүй илгээгч", + "failed_label": "Илгээгчийн шалгалт амжилтгүй болсон", + "unverified": "Энэ зурвас {domain} домэйнээс ирсэн гэж мэдэгдэж байгаа ч үүнийг баталгаажуулах зүйл алга. Үүнд {domain} домэйны хүчинтэй гарын үсэг байхгүй бөгөөд {domain} домэйны зөвшөөрсөн серверээс ирээгүй байна.", + "unverified_sent_from": "Энэ зурвас {domain} домэйнээс ирсэн гэж мэдэгдэж байгаа ч {host} домэйнээс илгээгдсэн бөгөөд {domain} домэйны хүчинтэй гарын үсэг байхгүй байна.", + "failed": "Энэ зурвас {domain} домэйнээс ирсэн гэж мэдэгдэж байгаа ч тухайн домэйны илгээгчийн шалгалтыг давж чадаагүй. Хуурамч байж магадгүй.", + "failed_sent_from": "Энэ зурвас {domain} домэйнээс ирсэн гэж мэдэгдэж байгаа ч {host} домэйнээс илгээгдсэн бөгөөд {domain} домэйны илгээгчийн шалгалтыг давж чадаагүй. Хуурамч байж магадгүй.", + "caution": "Холбоос, хавсралт болон нууц үг, төлбөрийн мэдээлэл асуусан хүсэлтэд болгоомжтой хандаарай." + }, "back_to_list": "Жагсаалт руу буцах", "view_contact": "Харилцагчийг харах", "message_details": "Зурвасны дэлгэрэнгүй мэдээлэл", diff --git a/locales/nb/common.json b/locales/nb/common.json index d8b274882..d803d5ca7 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Bilder og eksternt innhold er blokkert", "load_external_content": "Last inn bilder", "trust_sender": "Stol alltid på denne avsenderen", + "sender_check": { + "unverified_label": "Ubekreftet avsender", + "failed_label": "Avsenderkontrollen mislyktes", + "unverified": "Denne meldingen utgir seg for å være fra {domain}, men ingenting bekrefter det. Den har ingen gyldig signatur fra {domain} og kom ikke fra en server som {domain} har godkjent.", + "unverified_sent_from": "Denne meldingen utgir seg for å være fra {domain}, men den ble sendt fra {host} og har ingen gyldig signatur fra {domain}.", + "failed": "Denne meldingen utgir seg for å være fra {domain}, men den besto ikke domenets avsenderkontroller. Den kan være forfalsket.", + "failed_sent_from": "Denne meldingen utgir seg for å være fra {domain}, men den ble sendt fra {host} og besto ikke avsenderkontrollene til {domain}. Den kan være forfalsket.", + "caution": "Vær forsiktig med lenker, vedlegg og forespørsler om passord eller betalingsopplysninger." + }, "back_to_list": "Tilbake til listen", "view_contact": "Se kontakt", "message_details": "Meldingsdetaljer", diff --git a/locales/nl/common.json b/locales/nl/common.json index 49ddc97b4..00a6caf4d 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Afbeeldingen en externe inhoud zijn geblokkeerd", "load_external_content": "Afbeeldingen laden", "trust_sender": "Deze afzender altijd vertrouwen", + "sender_check": { + "unverified_label": "Niet-geverifieerde afzender", + "failed_label": "Afzendercontrole mislukt", + "unverified": "Dit bericht beweert van {domain} te komen, maar niets bevestigt dat. Het heeft geen geldige handtekening van {domain} en kwam niet van een server die {domain} toestaat.", + "unverified_sent_from": "Dit bericht beweert van {domain} te komen, maar is verzonden vanaf {host} en heeft geen geldige handtekening van {domain}.", + "failed": "Dit bericht beweert van {domain} te komen, maar is niet door de afzendercontroles van dat domein gekomen. Het kan vervalst zijn.", + "failed_sent_from": "Dit bericht beweert van {domain} te komen, maar is verzonden vanaf {host} en is niet door de afzendercontroles van {domain} gekomen. Het kan vervalst zijn.", + "caution": "Wees voorzichtig met links, bijlagen en verzoeken om wachtwoorden of betaalgegevens." + }, "back_to_list": "Terug naar lijst", "view_contact": "Contact bekijken", "message_details": "Berichtdetails", diff --git a/locales/pl/common.json b/locales/pl/common.json index 46bd1da99..4da811ece 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Obrazy i treści zewnętrzne zostały zablokowane", "load_external_content": "Załaduj obrazy", "trust_sender": "Zawsze ufaj temu nadawcy", + "sender_check": { + "unverified_label": "Niezweryfikowany nadawca", + "failed_label": "Weryfikacja nadawcy nie powiodła się", + "unverified": "Ta wiadomość twierdzi, że pochodzi z domeny {domain}, ale nic tego nie potwierdza. Nie ma ważnego podpisu domeny {domain} i nie przyszła z serwera upoważnionego przez domenę {domain}.", + "unverified_sent_from": "Ta wiadomość twierdzi, że pochodzi z domeny {domain}, ale została wysłana z domeny {host} i nie ma ważnego podpisu domeny {domain}.", + "failed": "Ta wiadomość twierdzi, że pochodzi z domeny {domain}, ale nie przeszła weryfikacji nadawcy tej domeny. Może być sfałszowana.", + "failed_sent_from": "Ta wiadomość twierdzi, że pochodzi z domeny {domain}, ale została wysłana z domeny {host} i nie przeszła weryfikacji nadawcy domeny {domain}. Może być sfałszowana.", + "caution": "Uważaj na linki, załączniki i prośby o hasła lub dane płatnicze." + }, "back_to_list": "Powrót do listy", "view_contact": "Pokaż kontakt", "message_details": "Szczegóły wiadomości", diff --git a/locales/pt/common.json b/locales/pt/common.json index 7260ebf1a..cb0256721 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Imagens e conteúdo externo foram bloqueados", "load_external_content": "Carregar imagens", "trust_sender": "Sempre confiar neste remetente", + "sender_check": { + "unverified_label": "Remetente não verificado", + "failed_label": "Falha na verificação do remetente", + "unverified": "Esta mensagem afirma ser de {domain}, mas nada confirma isso. Ela não tem uma assinatura válida de {domain} e não veio de um servidor autorizado por {domain}.", + "unverified_sent_from": "Esta mensagem afirma ser de {domain}, mas foi enviada de {host} e não tem uma assinatura válida de {domain}.", + "failed": "Esta mensagem afirma ser de {domain}, mas não passou nas verificações de remetente desse domínio. Pode ter sido falsificada.", + "failed_sent_from": "Esta mensagem afirma ser de {domain}, mas foi enviada de {host} e não passou nas verificações de remetente de {domain}. Pode ter sido falsificada.", + "caution": "Tenha cuidado com links, anexos e pedidos de senhas ou dados de pagamento." + }, "back_to_list": "Voltar para a lista", "view_contact": "Ver contato", "message_details": "Detalhes da Mensagem", diff --git a/locales/ro/common.json b/locales/ro/common.json index 3561f8f7c..fb85bab41 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Imaginile și conținutul extern au fost blocate", "load_external_content": "Încărcați imagini", "trust_sender": "Aveți întotdeauna încredere în acest expeditor", + "sender_check": { + "unverified_label": "Expeditor neverificat", + "failed_label": "Verificarea expeditorului a eșuat", + "unverified": "Acest mesaj pretinde că provine de la {domain}, dar nimic nu confirmă acest lucru. Nu are o semnătură validă de la {domain} și nu a venit de la un server autorizat de {domain}.", + "unverified_sent_from": "Acest mesaj pretinde că provine de la {domain}, dar a fost trimis de pe {host} și nu are o semnătură validă de la {domain}.", + "failed": "Acest mesaj pretinde că provine de la {domain}, dar nu a trecut de verificările de expeditor ale acestui domeniu. Este posibil să fie falsificat.", + "failed_sent_from": "Acest mesaj pretinde că provine de la {domain}, dar a fost trimis de pe {host} și nu a trecut de verificările de expeditor ale domeniului {domain}. Este posibil să fie falsificat.", + "caution": "Fiți atenți la linkuri, atașamente și solicitări de parole sau date de plată." + }, "back_to_list": "Înapoi la listă", "view_contact": "Vizualizare contact", "message_details": "Detalii mesaj", diff --git a/locales/ru/common.json b/locales/ru/common.json index 869446395..d6b37b4fc 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Изображения и внешнее содержимое заблокированы", "load_external_content": "Загрузить изображения", "trust_sender": "Всегда доверять этому отправителю", + "sender_check": { + "unverified_label": "Отправитель не подтверждён", + "failed_label": "Проверка отправителя не пройдена", + "unverified": "Это сообщение якобы отправлено с домена {domain}, но ничто этого не подтверждает. У него нет действительной подписи домена {domain}, и оно пришло не с сервера, которому домен {domain} разрешает отправку.", + "unverified_sent_from": "Это сообщение якобы отправлено с домена {domain}, но на самом деле оно отправлено с домена {host} и не имеет действительной подписи домена {domain}.", + "failed": "Это сообщение якобы отправлено с домена {domain}, но оно не прошло проверку отправителя этого домена. Возможно, оно поддельное.", + "failed_sent_from": "Это сообщение якобы отправлено с домена {domain}, но на самом деле оно отправлено с домена {host} и не прошло проверку отправителя домена {domain}. Возможно, оно поддельное.", + "caution": "Будьте осторожны со ссылками, вложениями и просьбами указать пароль или платёжные данные." + }, "back_to_list": "Вернуться к списку", "view_contact": "Просмотреть контакт", "message_details": "Детали сообщения", diff --git a/locales/sk/common.json b/locales/sk/common.json index 39378f25c..08eb583a0 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Obrázky a externý obsah boli zablokované", "load_external_content": "Načítať obrázky", "trust_sender": "Vždy dôverovať tomuto odosielateľovi", + "sender_check": { + "unverified_label": "Neoverený odosielateľ", + "failed_label": "Kontrola odosielateľa zlyhala", + "unverified": "Táto správa tvrdí, že pochádza z domény {domain}, ale nič to nepotvrdzuje. Nemá platný podpis domény {domain} a neprišla zo servera, ktorý doména {domain} povoľuje.", + "unverified_sent_from": "Táto správa tvrdí, že pochádza z domény {domain}, ale bola odoslaná z domény {host} a nemá platný podpis domény {domain}.", + "failed": "Táto správa tvrdí, že pochádza z domény {domain}, ale neprešla kontrolami odosielateľa tejto domény. Môže byť podvrhnutá.", + "failed_sent_from": "Táto správa tvrdí, že pochádza z domény {domain}, ale bola odoslaná z domény {host} a neprešla kontrolami odosielateľa domény {domain}. Môže byť podvrhnutá.", + "caution": "Buďte opatrní pri odkazoch, prílohách a žiadostiach o heslá alebo platobné údaje." + }, "back_to_list": "Späť na zoznam", "view_contact": "Zobraziť kontakt", "message_details": "Podrobnosti správy", diff --git a/locales/tr/common.json b/locales/tr/common.json index cfdc592c7..4f69291a0 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Görseller ve harici içerik engellendi", "load_external_content": "Görselleri yükle", "trust_sender": "Bu gönderene her zaman güven", + "sender_check": { + "unverified_label": "Doğrulanmamış gönderen", + "failed_label": "Gönderen denetimi başarısız", + "unverified": "Bu ileti {domain} etki alanından geldiğini iddia ediyor, ancak bunu doğrulayan hiçbir şey yok. {domain} etki alanından geçerli bir imzası yok ve {domain} etki alanının yetkilendirdiği bir sunucudan gelmedi.", + "unverified_sent_from": "Bu ileti {domain} etki alanından geldiğini iddia ediyor, ancak {host} etki alanından gönderildi ve {domain} etki alanından geçerli bir imzası yok.", + "failed": "Bu ileti {domain} etki alanından geldiğini iddia ediyor, ancak bu etki alanının gönderen denetimlerinden geçemedi. Sahte olabilir.", + "failed_sent_from": "Bu ileti {domain} etki alanından geldiğini iddia ediyor, ancak {host} etki alanından gönderildi ve {domain} etki alanının gönderen denetimlerinden geçemedi. Sahte olabilir.", + "caution": "Bağlantılara, eklere ve parola ya da ödeme bilgisi isteklerine karşı dikkatli olun." + }, "back_to_list": "Listeye dön", "view_contact": "Kişiyi görüntüle", "message_details": "İleti Ayrıntıları", diff --git a/locales/uk/common.json b/locales/uk/common.json index efdecbe58..b53f0f5dd 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -345,6 +345,15 @@ "external_content_warning": "Зображення та зовнішній вміст заблоковано", "load_external_content": "Завантажити зображення", "trust_sender": "Завжди довіряйте цьому відправнику", + "sender_check": { + "unverified_label": "Неперевірений відправник", + "failed_label": "Перевірку відправника не пройдено", + "unverified": "Це повідомлення нібито надіслано з домену {domain}, але ніщо цього не підтверджує. Воно не має дійсного підпису домену {domain} і надійшло не з сервера, якому домен {domain} дозволяє надсилання.", + "unverified_sent_from": "Це повідомлення нібито надіслано з домену {domain}, але насправді його надіслано з домену {host}, і воно не має дійсного підпису домену {domain}.", + "failed": "Це повідомлення нібито надіслано з домену {domain}, але воно не пройшло перевірку відправника цього домену. Можливо, воно підроблене.", + "failed_sent_from": "Це повідомлення нібито надіслано з домену {domain}, але насправді його надіслано з домену {host}, і воно не пройшло перевірку відправника домену {domain}. Можливо, воно підроблене.", + "caution": "Будьте обережні з посиланнями, вкладеннями та запитами паролів чи платіжних даних." + }, "back_to_list": "Повернутися до списку", "view_contact": "Переглянути контакт", "message_details": "Деталі повідомлення", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index f8b0956e9..d5318d90f 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -345,6 +345,15 @@ "external_content_warning": "圖片與外部內容已遭封鎖", "load_external_content": "載入圖片", "trust_sender": "一律信任此寄件者", + "sender_check": { + "unverified_label": "寄件者未經驗證", + "failed_label": "寄件者驗證失敗", + "unverified": "這封郵件聲稱來自 {domain},但沒有任何證據可以證實。它沒有 {domain} 的有效簽章,也不是從 {domain} 授權的伺服器寄出。", + "unverified_sent_from": "這封郵件聲稱來自 {domain},但它是從 {host} 寄出的,而且沒有 {domain} 的有效簽章。", + "failed": "這封郵件聲稱來自 {domain},但未通過該網域的寄件者驗證,可能是偽造的。", + "failed_sent_from": "這封郵件聲稱來自 {domain},但它是從 {host} 寄出的,而且未通過 {domain} 的寄件者驗證,可能是偽造的。", + "caution": "請小心其中的連結、附件,以及要求提供密碼或付款資訊的請求。" + }, "back_to_list": "回到清單", "view_contact": "檢視聯絡人", "message_details": "郵件詳細資料", diff --git a/locales/zh/common.json b/locales/zh/common.json index d7a3f0ff9..68562edb8 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -345,6 +345,15 @@ "external_content_warning": "图片和外部内容已被屏蔽", "load_external_content": "加载图片", "trust_sender": "始终信任此发件人", + "sender_check": { + "unverified_label": "发件人未经验证", + "failed_label": "发件人验证失败", + "unverified": "此邮件声称来自 {domain},但没有任何证据能证实这一点。它没有 {domain} 的有效签名,也不是从 {domain} 授权的服务器发出的。", + "unverified_sent_from": "此邮件声称来自 {domain},但它是从 {host} 发出的,并且没有 {domain} 的有效签名。", + "failed": "此邮件声称来自 {domain},但未通过该域名的发件人验证。它可能是伪造的。", + "failed_sent_from": "此邮件声称来自 {domain},但它是从 {host} 发出的,并且未通过 {domain} 的发件人验证。它可能是伪造的。", + "caution": "请谨慎对待其中的链接、附件,以及索要密码或付款信息的请求。" + }, "back_to_list": "返回列表", "view_contact": "查看联系人", "message_details": "邮件详情", From fbde506454dec70e3d59c0308a461137d378cf3b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:12:53 +0200 Subject: [PATCH 33/35] chore: update sharp and source-map-js for security fixes --- package-lock.json | 243 +++++++++++++++++++++++----------------------- 1 file changed, 122 insertions(+), 121 deletions(-) diff --git a/package-lock.json b/package-lock.json index 05761189a..506f769c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1456,9 +1456,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -1474,13 +1474,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -1496,20 +1496,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1519,9 +1519,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -1535,9 +1535,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -1551,9 +1551,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -1567,9 +1567,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -1583,9 +1583,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -1599,9 +1599,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -1615,9 +1615,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -1631,9 +1631,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -1647,9 +1647,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -1663,9 +1663,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -1679,9 +1679,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -1697,13 +1697,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -1719,13 +1719,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -1741,13 +1741,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -1763,13 +1763,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -1785,13 +1785,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -1807,13 +1807,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -1829,13 +1829,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -1851,13 +1851,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -1881,16 +1881,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -1900,9 +1900,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -1919,9 +1919,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -1938,9 +1938,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -6244,6 +6244,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8927,9 +8928,9 @@ "license": "MIT" }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", @@ -8943,31 +8944,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -9097,9 +9098,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" From f87be32388c6a5ff3bf672c9a797a7fec34714d2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:22:51 +0200 Subject: [PATCH 34/35] fix: keep an empty draft subject empty so send still warns #1189 --- app/api/dev-jmap/[...path]/route.ts | 2 +- .../__tests__/empty-subject-warning.test.tsx | 37 ++++++++++++++++++- components/email/email-composer.tsx | 5 ++- components/mail/mail-app.tsx | 4 +- components/pro/pro-email-tab-body.tsx | 4 +- 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 00b2c1572..f4069ded6 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -1773,7 +1773,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult { from: (data.from as MockEmail['from']) || [{ name: 'Dev User', email: 'dev@localhost' }], to: (data.to as MockEmail['to']) || [], cc: (data.cc as MockEmail['cc']) || [], - subject: (data.subject as string) || '(no subject)', + subject: typeof data.subject === 'string' ? data.subject : '', preview: (previewText || (data.subject as string) || '').slice(0, 120), hasAttachment: false, textBody: [], diff --git a/components/email/__tests__/empty-subject-warning.test.tsx b/components/email/__tests__/empty-subject-warning.test.tsx index 43a55dab4..6d4d09496 100644 --- a/components/email/__tests__/empty-subject-warning.test.tsx +++ b/components/email/__tests__/empty-subject-warning.test.tsx @@ -1,7 +1,8 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; +import { useAuthStore } from '@/stores/auth-store'; // ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ──────────────── @@ -146,6 +147,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ getRecipientSuggestions: { call: async () => [] }, onRecipientChipsChange: { transform: async (chips: unknown) => chips }, onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true }, onComposeSend: { intercept: async () => true }, onTransformOutgoingEmail: { transform: async (email: unknown) => email }, @@ -272,4 +274,37 @@ describe('composer empty subject warning', () => { await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); expect(screen.queryByText('empty_subject.title')).not.toBeInTheDocument(); }); + + // A draft saved with a "(No Subject)" placeholder came back with that as + // its subject, so sending the resumed draft skipped the warning (#1189). + it('saves a draft without a subject as an empty subject', async () => { + const createDraft = vi.fn().mockResolvedValue('draft-8'); + useAuthStore.setState({ + client: { + createDraft, + getEmail: vi.fn().mockResolvedValue({ id: 'draft-8', attachments: [] }), + hasDelayedSend: () => false, + getMaxDelayedSend: () => 0, + } as never, + activeAccountId: 'acct-A' as never, + }); + + const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>; + render( + <EmailComposer + initialData={{ ...DRAFT_WITHOUT_SUBJECT, subject: 'Lunch' }} + requestCloseRef={requestCloseRef} + onClose={vi.fn()} + /> + ); + fireEvent.change(screen.getByDisplayValue('Lunch'), { target: { value: '' } }); + + act(() => requestCloseRef.current!(vi.fn())); + const dialog = await screen.findByRole('alertdialog'); + fireEvent.click(within(dialog).getByText('save')); + + await waitFor(() => expect(createDraft).toHaveBeenCalled()); + expect(createDraft.mock.calls[0][1]).toBe(''); + useAuthStore.setState({ client: null, activeAccountId: null }); + }); }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index afaaee5cb..ab9f926b0 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2038,7 +2038,10 @@ export function EmailComposer({ const previousDraftId = draftIdRef.current; let savedDraft : AlmostSavedDraft = { to: toAddresses, - subject: subject || t('no_subject'), + // Save an empty subject as empty. A "(No Subject)" placeholder here + // came back as a real subject on reopen, and Send then skipped the + // empty-subject warning (#1189). + subject, body: draftTextBody, cc: ccAddresses, bcc: bccAddresses, diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 677531014..e58501dd4 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -2039,7 +2039,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '', cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '', bcc: draft.bcc?.map(a => a.email).filter(Boolean).join(', ') || '', - subject: draft.subject || '', + // Drafts saved before #1189 stored an empty subject as the composer's + // "(No Subject)" placeholder; reopen those with an empty field. + subject: draft.subject && draft.subject !== t('email_composer.no_subject') ? draft.subject : '', body: htmlBody || bodyText, // Re-open in the format the draft was written in: a text-only draft // used to land raw in the rich-text editor (newlines collapsed) and an diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 73b961338..46060b624 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -347,7 +347,9 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa to: email.to?.map((a) => a.email).filter(Boolean).join(', ') || '', cc: email.cc?.map((a) => a.email).filter(Boolean).join(', ') || '', bcc: email.bcc?.map((a) => a.email).filter(Boolean).join(', ') || '', - subject: email.subject || '', + // Drafts saved before #1189 stored an empty subject as the composer's + // "(No Subject)" placeholder; reopen those with an empty field. + subject: email.subject && email.subject !== t('email_composer.no_subject') ? email.subject : '', body: htmlBody || bodyText, showCc: (email.cc?.length || 0) > 0, showBcc: (email.bcc?.length || 0) > 0, From 3d48b122046a5898189012e610bf35e2a7ddab3e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:37:49 +0200 Subject: [PATCH 35/35] chore: release 1.13.0 --- CHANGELOG.md | 98 +++++++++++++++++++++++++++++++++++++++++++++++ VERSION | 2 +- package-lock.json | 4 +- package.json | 2 +- 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7777999..daada1fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,103 @@ # Changelog +## 1.13.0 (2026-10-07) + +1.13.0 turns the vacation settings into an Out of Office page that can also forward mail, lets filter rules run only for a date range, and copies messages into another connected account. Files from the Files app can be attached in the composer, tags can be set on many messages at once and folders moved in bulk. Messages whose sender can't be verified now carry a warning. It also stops one account's settings and templates from carrying over to the next account signed in on the same browser. + +Thank you for your donations: + +- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_ + +**One-time** + +- Anonymous +- Anonymous +- Anonymous + +**Monthly** + +- [@naokton](https://github.com/naokton) +- [@yerTools](https://github.com/yerTools) +- [@jsaathof](https://github.com/jsaathof) +- [@berkersal](https://github.com/berkersal) +- [@NABarnes](https://github.com/NABarnes) +- [@felixzieger](https://github.com/felixzieger) +- [@pr0ton11](https://github.com/pr0ton11) +- [@fpauser](https://github.com/fpauser) +- [@proxforge](https://github.com/proxforge) +- [@spss20](https://github.com/spss20) +- [@elgringoYan](https://github.com/elgringoYan) +- [@pauladams8](https://github.com/pauladams8) +- [@djpriest](https://github.com/djpriest) +- [@umakers](https://github.com/umakers) +- [@zplizzi](https://github.com/zplizzi) +- [@jeremiah](https://github.com/jeremiah) +- [@Theoretisch1337](https://github.com/Theoretisch1337) +- [@svandive](https://github.com/svandive) +- [@HiltMundell](https://github.com/HiltMundell) + +### Security + +- **Settings**: Signing out and then signing in with another account in the same browser no longer shows the previous account's settings, tags and templates, and no longer saves them to the new account's synced settings (#1185) + +### Features + +- **Mail**: A message whose sender fails the From domain's checks, or that passes neither SPF nor DKIM, shows a warning above the message and a badge next to the sender. "Always trust this sender" is not offered for it +- **Mail**: "Copy to account..." in the message menu files a copy in a folder of another connected account and leaves the original where it is. Copies keep their original date (#1150, thanks @lucamzanon) +- **Mail**: The selection toolbar and the right-click menu put a tag on, or take it off, every selected message at once (#1077) +- **Out of office**: The vacation settings are now an Out of Office page: the period on top, then the automatic reply and forwarding, each with its own switch. Forwarding works with or without the reply, never forwards spam and can keep a copy in the inbox. The reply can go to all senders, internal senders only or external senders only (#1152, thanks @dealerweb) +- **Filters**: A rule can be limited to a period, and the rule list shows whether it is scheduled, active or expired (#1152, thanks @dealerweb) +- **Filters**: The new "All messages" condition matches every message (#1154, thanks @dealerweb) +- **Composer**: "Attach from Files" attaches files from the Files app, including files shared with you, without downloading and uploading them again (#1179) +- **Composer**: Typing @ in the message body offers the To and Cc recipients and inserts the chosen one's first name. Bcc recipients are never offered. "Mention recipients with @" in the composer settings turns it off (#1134, thanks @dealerweb) +- **Folders**: The folder settings can select several folders, with Shift+click for a range, and move them together under another folder or to the top level (#1173) +- **Calendar**: The day and week views can be limited to working hours, and the week view can leave out non-working days. Events outside the visible hours are counted at the top and bottom of each day and open from there, and a button in the view shows all hours (#1164) +- **Login**: `LOGIN_SHOW_TOKEN_LOGIN=true` offers "Sign in with an access token", for JMAP servers that take API tokens instead of passwords, such as Fastmail +- **Login**: A server in `JMAP_SERVERS` whose `oauth` block has both a `clientId` and a `buttonLabel` gets its own sign-in button on the login page, even while OAuth is off globally. This allows a "Sign in with Google" button for a Gmail bridge next to servers that use passwords (#1135, thanks @lucamzanon) +- **UI**: The browser tab title names the active account, as in "Inbox - alice@example.com - Webmail", and Settings, Calendar, Contacts and Files set their own title. Turning off "Subject in Tab Title" under Admin > Policy keeps message subjects out of the title, and with it out of the browser history (#1186, thanks @oguzyilmaznet) +- **Unified**: While logins reconnect after a page load, the "All accounts" header shows how many are already counted, such as "4/10" (#1140, thanks @lucamzanon) +- **Push**: A message that reaches several of your accounts rings once. For 30 seconds after an alert, notifications from your other accounts arrive silently (#1141, thanks @lucamzanon) +- **Server**: `SOURCE_CODE_URL` points the source code link in Settings > About at the code of a modified build, as AGPL section 13 asks of anyone running one (#1161) + +### Changes + +- **Calendar**: The day and week views show 08:00 to 20:00 by default. "Limit visible hours" in the calendar settings changes the range or turns it off (#1164) +- **Tags**: Tag views and their counts leave out messages in Trash and Spam (#1156) +- **Mail**: In a shared mailbox, the Move menus list that mailbox's folders first and your own folders after them, under your account name (#1149) +- **Dependencies**: sharp 0.35.5 and source-map-js 1.2.2 (npm audit) +- **Docs**: CONTRIBUTING.md explains what belongs in core and what in a plugin, and which changes need an agreed issue before a pull request + +### Fixes + +- **Mail**: Attachments, inline images and .eml downloads of messages in shared folders are fetched from the account that owns them. On Cyrus they failed with "failed to find blob by id" (#1188, thanks @VasilisNtovolos) +- **Mail**: Archive creates an Archive folder when the account has none, instead of only reporting that it is missing (#1176, thanks @cybersmurf) +- **Mail**: Opening the webmail starts in the inbox instead of a folder from an earlier visit, such as Trash +- **Mail**: Opening another folder, tag, account or search starts the message list at the top instead of where the last one was scrolled to (#1138, thanks @dealerweb) +- **Mail**: Previews no longer start with a style sheet ("@media screen and ...") in the message list and in push notifications (#1136, thanks @lucamzanon) +- **Search**: Searching from the unified All mail view finds sent mail too +- **Send**: A draft saved without a subject reopens with an empty subject, so Send warns about it again instead of sending "(No Subject)" (#1189) +- **Send**: When the server does not confirm that a message was sent, the draft is kept and the composer asks you to check Sent before sending again (#1019, thanks @lucamzanon) +- **Send**: A message the server refuses shows the server's reason instead of "Invalid reference to non-existing object" +- **Send**: A forward sent from another account's address no longer fails with `blobNotFound`. Its attachments and inline images are copied to the sending account first (#1142, thanks @lucamzanon) +- **Push**: Push turns itself back on when the browser lost its subscription, retrying at most every 15 minutes (#1147, thanks @lucamzanon) +- **Push**: The notification settings check push again when you come back to the tab, so push turned off in the browser or another tab no longer shows as on (#1148, thanks @lucamzanon) +- **Push**: When the new message cannot be read, the notification no longer announces another unread message from the Inbox in its place, and a failed lookup shows a generic notification instead of none (#1041, thanks @lucamzanon) +- **Filters**: "Stop processing further rules" also stops after "Delete silently" and "Reject", so later rules no longer file the message. Rules whose name contains double spaces, a tab or a trailing space no longer gain a copy on every save, and the copies earlier saves left are removed (#1153, thanks @dealerweb) +- **Filters**: The forwarding limit counts the forwards one message can collect, instead of adding up every enabled rule, and warns when the rule order lets one message go over it (#1155, thanks @dealerweb) +- **Filters**: A condition with several values wraps inside its card in the expanded rule view (#1139, thanks @dealerweb) +- **Filters**: An open rule dialog or Sieve editor, and what was typed in it, survives a filter change from another tab or device (#1162, thanks @dealerweb) +- **Templates**: A tab or device that had not reloaded no longer deletes templates created elsewhere when it saves its settings +- **Calendar**: Duplicating an event keeps its meeting link (#1170) +- **Calendar**: The Calendar Agenda plugin shows events from shared and group calendars (#1175, thanks @cybersmurf) +- **Files**: Folders shared with you can be opened, and your own folders shared, in both folder layouts (#1181) +- **Files**: Office editing works behind a reverse proxy where the editor and the browser reach the webmail at different addresses. The WOPI client URL may be an internal address (#1130) +- **Login**: When Stalwart accepts a password and TOTP code but refuses to issue tokens, the login page points at the OAuth client settings instead of opening the browser's own sign-in dialog and reporting a wrong code +- **Branding**: With `APP_NAME` set, the authenticator app entry for two-factor sign-in, the About card and the Files preview use the configured name and logo (#1177, thanks @cybersmurf) +- **UI**: Text fields in dialogs keep the focus when the page behind updates, which happened every 30 seconds and on new mail (#1162, thanks @dealerweb) +- **UI**: Buttons, menu items, tabs and other controls show the pointer cursor again (#1184, thanks @gwku) +- **UI**: The account group labels in the From dropdown are readable in dark themes (#1157, thanks @dealerweb) +- **Translations**: Czech is fully translated (#1174, thanks @cybersmurf), and missing placeholders are restored in Persian, Hebrew, Hungarian, Mongolian and Slovak (#1165, thanks @owgreen-dev) + ## 1.12.0 (2026-09-30) 1.12.0 lets you create filter rules straight from a message, offers the code from sign-in mail for one-click copying, and gives a phone linked with "Link Mobile App" a sign-in of its own, for every kind of account. Search now leaves Spam and Trash out by default, and emptying an ordinary folder moves its mail to the Trash. Browsers with many logins start up much faster. It also contains three security fixes, so please update. diff --git a/VERSION b/VERSION index 0eed1a29e..feaae22ba 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.0 +1.13.0 diff --git a/package-lock.json b/package-lock.json index 506f769c9..5e003cbe4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-webmail", - "version": "1.12.0", + "version": "1.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-webmail", - "version": "1.12.0", + "version": "1.13.0", "license": "AGPL-3.0-only", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 9665146b1..a5a2f7d0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.12.0", + "version": "1.13.0", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Mail <dev@bulwarkmail.org>", "license": "AGPL-3.0-only",