From f5fb7f9db917e914cc3b3612c04116954b4cb44c Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 2 Oct 2026 11:29:32 +0200 Subject: [PATCH] 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, + }); + }, }));