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 11336f99f..3fca0201f 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 5826234d6..13657222f 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, ); @@ -732,6 +775,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 9248fb471..b2edf1adc 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -835,8 +835,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": "تم حذف قاعدة المرشح", @@ -858,7 +858,8 @@ "rule_undone": "تم التراجع عن تغيير القاعدة", "rule_undo_failed": "تعذّر التراجع عن تغيير القاعدة", "rule_undo_conflict": "تغيّرت مرشحاتك منذ ذلك الحين. تراجع عن التغيير من إعدادات المرشحات.", - "rule_folder_failed": "تعذّر إنشاء المجلد" + "rule_folder_failed": "تعذّر إنشاء المجلد", + "vacation_filters_save_failed": "تم حفظ إعدادات الغياب، لكن تعذّر حفظ إعادة التوجيه ومستلمي الرد" }, "date": { "today": "اليوم", @@ -946,7 +947,7 @@ "privacy": "الخصوصية والأمان", "account": "الحساب", "identities": "الهويات", - "vacation": "الرد التلقائي أثناء الغياب", + "vacation": "الغياب", "advanced": "متقدم", "calendar": "التقويم", "filters": "المرشحات", @@ -1825,28 +1826,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": "خارج المكتب", @@ -1866,10 +1863,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": { @@ -2238,7 +2257,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 7c0058a89..be1237e83 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -835,8 +835,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", @@ -858,7 +858,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", @@ -946,7 +947,7 @@ "privacy": "Privadesa i seguretat", "account": "Compte", "identities": "Identitats", - "vacation": "Resposta automàtica", + "vacation": "Absència", "advanced": "Avançat", "calendar": "Calendari", "filters": "Filtres", @@ -1825,28 +1826,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", @@ -1866,10 +1863,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": { @@ -2238,7 +2257,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 1d2db2a9b..aaf4d5755 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -835,8 +835,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", @@ -858,7 +858,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", @@ -943,7 +944,7 @@ "privacy": "Soukromí a bezpečnost", "account": "Účet", "identities": "Identity", - "vacation": "Automatická odpověď", + "vacation": "Nepřítomnost", "advanced": "Pokročilé", "calendar": "Kalendář", "filters": "Filtry", @@ -1818,28 +1819,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ář", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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 4787b5624..bc05de3aa 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -835,8 +835,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", @@ -858,7 +858,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", @@ -946,7 +947,7 @@ "privacy": "Privatliv & sikkerhed", "account": "Konto", "identities": "Identiteter", - "vacation": "Fraværsbeskeder", + "vacation": "Fravær", "advanced": "Avanceret", "calendar": "Kalender", "filters": "Filtre", @@ -1821,28 +1822,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", @@ -1862,10 +1859,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": { @@ -2234,7 +2253,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 a2be020bc..d94bd9bd4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -841,8 +841,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", @@ -864,7 +864,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", @@ -949,7 +950,7 @@ "privacy": "Datenschutz & Sicherheit", "account": "Konto", "identities": "Identitäten", - "vacation": "Abwesenheitsnotiz", + "vacation": "Abwesenheit", "advanced": "Erweitert", "calendar": "Kalender", "filters": "Filter", @@ -1824,28 +1825,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", @@ -1865,10 +1862,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": { @@ -2246,7 +2265,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 b27f4cecb..8ed9e0441 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -841,8 +841,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", @@ -864,7 +864,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", @@ -952,7 +953,7 @@ "privacy": "Privacy & Security", "account": "Account", "identities": "Identities", - "vacation": "Vacation Responder", + "vacation": "Out of Office", "advanced": "Advanced", "calendar": "Calendar", "filters": "Filters", @@ -1831,28 +1832,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", @@ -1872,10 +1869,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": { @@ -2253,7 +2272,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 111980e64..583e1cd32 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -835,8 +835,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", @@ -858,7 +858,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", @@ -943,7 +944,7 @@ "privacy": "Privacidad y Seguridad", "account": "Cuenta", "identities": "Identidades", - "vacation": "Respuesta automática", + "vacation": "Ausencia", "advanced": "Avanzado", "calendar": "Calendario", "filters": "Filtros", @@ -1818,28 +1819,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", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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 48aab407c..ef358355d 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -835,8 +835,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": "قانون فیلتر حذف شد", @@ -858,7 +858,8 @@ "rule_undone": "تغییر قانون بازگردانده شد", "rule_undo_failed": "بازگردانی تغییر قانون ممکن نشد", "rule_undo_conflict": "فیلترهای شما از آن زمان تغییر کرده‌اند. تغییر را در تنظیمات فیلترها بازگردانید.", - "rule_folder_failed": "ایجاد پوشه ممکن نشد" + "rule_folder_failed": "ایجاد پوشه ممکن نشد", + "vacation_filters_save_failed": "تنظیمات غیبت ذخیره شد، اما ذخیره بازارسال و گیرندگان پاسخ ناموفق بود" }, "date": { "today": "امروز", @@ -946,7 +947,7 @@ "privacy": "حریم خصوصی و امنیت", "account": "حساب", "identities": "هویت‌ها", - "vacation": "پاسخ‌گوی خودکار", + "vacation": "غیبت", "advanced": "پیشرفته", "calendar": "تقویم", "filters": "فیلترها", @@ -1825,28 +1826,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": "خارج از دفتر", @@ -1866,10 +1863,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": { @@ -2238,7 +2257,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 6c3d64b57..10155148c 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -835,8 +835,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", @@ -858,7 +858,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", @@ -943,7 +944,7 @@ "privacy": "Confidentialité et sécurité", "account": "Compte", "identities": "Identités", - "vacation": "Répondeur d'absence", + "vacation": "Absence", "advanced": "Avancé", "calendar": "Calendrier", "filters": "Filtres", @@ -1818,28 +1819,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", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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 a59156144..ca0d0b248 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -800,8 +800,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": "כלל הסינון נמחק", @@ -823,7 +823,8 @@ "rule_undone": "שינוי הכלל בוטל", "rule_undo_failed": "לא ניתן היה לבטל את שינוי הכלל", "rule_undo_conflict": "המסננים שלך השתנו מאז. בטל את השינוי בהגדרות המסננים.", - "rule_folder_failed": "לא ניתן היה ליצור את התיקייה" + "rule_folder_failed": "לא ניתן היה ליצור את התיקייה", + "vacation_filters_save_failed": "הגדרות ההיעדרות נשמרו, אך שמירת ההעברה ונמעני המענה נכשלה" }, "date": { "today": "היום", @@ -908,7 +909,7 @@ "privacy": "פרטיות ואבטחה", "account": "חשבון", "identities": "זהויות", - "vacation": "משיב נופש", + "vacation": "היעדרות", "advanced": "מתקדם", "calendar": "לוּחַ שָׁנָה", "filters": "מסננים", @@ -1783,28 +1784,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": "מחוץ למשרד", @@ -1824,10 +1821,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": { @@ -2153,7 +2172,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 e248e4262..061cb41de 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -835,8 +835,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", @@ -858,7 +858,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", @@ -946,7 +947,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", @@ -1821,28 +1822,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", @@ -1862,10 +1859,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": { @@ -2234,7 +2253,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 639257deb..273e75056 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -835,8 +835,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", @@ -858,7 +858,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", @@ -943,7 +944,7 @@ "privacy": "Privacy e sicurezza", "account": "Account", "identities": "Identità", - "vacation": "Risponditore automatico", + "vacation": "Assenza", "advanced": "Avanzate", "calendar": "Calendario", "filters": "Filtri", @@ -1818,28 +1819,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", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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 434eead72..cced07d42 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -835,8 +835,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": "フィルタールールが削除されました", @@ -858,7 +858,8 @@ "rule_undone": "ルールの変更を元に戻しました", "rule_undo_failed": "ルールの変更を元に戻せませんでした", "rule_undo_conflict": "その後フィルターが変更されています。フィルター設定で変更を元に戻してください。", - "rule_folder_failed": "フォルダを作成できませんでした" + "rule_folder_failed": "フォルダを作成できませんでした", + "vacation_filters_save_failed": "不在の設定を保存しましたが、転送と返信する相手の保存に失敗しました" }, "date": { "today": "今日", @@ -943,7 +944,7 @@ "privacy": "プライバシーとセキュリティ", "account": "アカウント", "identities": "送信者情報", - "vacation": "不在応答", + "vacation": "不在", "advanced": "詳細設定", "calendar": "カレンダー", "filters": "フィルター", @@ -1818,28 +1819,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": "不在通知", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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 748bd553a..b234c2573 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -835,8 +835,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": "필터 규칙이 삭제되었어요", @@ -858,7 +858,8 @@ "rule_undone": "규칙 변경을 취소했어요", "rule_undo_failed": "규칙 변경을 취소하지 못했어요", "rule_undo_conflict": "그 사이에 필터가 바뀌었어요. 필터 설정에서 변경을 취소하세요.", - "rule_folder_failed": "폴더를 만들지 못했어요" + "rule_folder_failed": "폴더를 만들지 못했어요", + "vacation_filters_save_failed": "부재중 설정은 저장되었지만 전달 및 응답 대상 설정을 저장하지 못했어요" }, "date": { "today": "오늘", @@ -943,7 +944,7 @@ "privacy": "개인정보 및 보안", "account": "계정", "identities": "보내는 사람", - "vacation": "부재중 자동 응답", + "vacation": "부재중", "advanced": "고급 설정", "calendar": "캘린더", "filters": "필터", @@ -1818,28 +1819,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": "부재중 알림", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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 e0d64b230..98619458a 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -835,8 +835,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", @@ -858,7 +858,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", @@ -943,7 +944,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", @@ -1818,28 +1819,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", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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 dd8599051..63163a41c 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -835,8 +835,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": "Шүүлтүүрийн дүрмийг устгасан", @@ -858,7 +858,8 @@ "rule_undone": "Дүрмийн өөрчлөлтийг буцаалаа", "rule_undo_failed": "Дүрмийн өөрчлөлтийг буцааж чадсангүй", "rule_undo_conflict": "Үүнээс хойш таны шүүлтүүр өөрчлөгдсөн. Өөрчлөлтийг шүүлтүүрийн тохиргооноос буцаана уу.", - "rule_folder_failed": "Хавтас үүсгэж чадсангүй" + "rule_folder_failed": "Хавтас үүсгэж чадсангүй", + "vacation_filters_save_failed": "Эзгүй байх тохиргоог хадгалсан боловч дамжуулалт болон хариу хүлээн авагчдыг хадгалж чадсангүй" }, "date": { "today": "Өнөөдөр", @@ -946,7 +947,7 @@ "privacy": "Нууцлал ба аюулгүй байдал", "account": " Данс", "identities": "Тэмдэгтүүд", - "vacation": "Амралтын хариулагч", + "vacation": "Эзгүй байх", "advanced": "Дэвшилтэт", "calendar": "Хуанли", "filters": "Шүүлтүүр", @@ -1825,28 +1826,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": "Оффисоос гадуур", @@ -1866,10 +1863,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": { @@ -2238,7 +2257,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 71041689f..47bee195b 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -835,8 +835,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", @@ -858,7 +858,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", @@ -946,7 +947,7 @@ "privacy": "Personvern og sikkerhet", "account": "Konto", "identities": "Identiteter", - "vacation": "Feriesvar", + "vacation": "Fravær", "advanced": "Avansert", "calendar": "Kalender", "filters": "Filtre", @@ -1825,28 +1826,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", @@ -1866,10 +1863,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": { @@ -2238,7 +2257,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 f2b8b35f2..2b29558f6 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -858,7 +858,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", @@ -943,7 +944,7 @@ "privacy": "Privacy & Beveiliging", "account": "Account", "identities": "Identiteiten", - "vacation": "Afwezigheidsmelder", + "vacation": "Afwezigheid", "advanced": "Geavanceerd", "calendar": "Agenda", "filters": "Filters", @@ -1818,28 +1819,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", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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 5947f1aa4..3ad5f491c 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -835,8 +835,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", @@ -858,7 +858,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", @@ -943,7 +944,7 @@ "privacy": "Prywatność i bezpieczeństwo", "account": "Konto", "identities": "Tożsamości", - "vacation": "Autoresponder urlopowy", + "vacation": "Nieobecność", "advanced": "Zaawansowane", "calendar": "Kalendarz", "filters": "Filtry", @@ -1818,28 +1819,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", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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 5951f05e4..2b99037f9 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -835,8 +835,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", @@ -858,7 +858,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", @@ -943,7 +944,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", @@ -1818,28 +1819,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", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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 cb025d88e..96d449068 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -835,8 +835,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ă", @@ -858,7 +858,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", @@ -946,7 +947,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", @@ -1825,28 +1826,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", @@ -1866,10 +1863,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": { @@ -2238,7 +2257,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 7c03e8485..a8cb598f4 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -835,8 +835,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": "Правило фильтра удалено", @@ -858,7 +858,8 @@ "rule_undone": "Изменение правила отменено", "rule_undo_failed": "Не удалось отменить изменение правила", "rule_undo_conflict": "Ваши фильтры с тех пор изменились. Отмените изменение в настройках фильтров.", - "rule_folder_failed": "Не удалось создать папку" + "rule_folder_failed": "Не удалось создать папку", + "vacation_filters_save_failed": "Настройки отсутствия сохранены, но пересылку и получателей ответа сохранить не удалось" }, "date": { "today": "Сегодня", @@ -943,7 +944,7 @@ "privacy": "Конфиденциальность и безопасность", "account": "Аккаунт", "identities": "Удостоверения", - "vacation": "Автоответ на отпуск", + "vacation": "Отсутствие", "advanced": "Дополнительно", "calendar": "Календарь", "filters": "Фильтры", @@ -1818,28 +1819,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": "Вне офиса", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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 ae5f6dd87..36f5418b6 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -835,8 +835,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é", @@ -858,7 +858,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", @@ -946,7 +947,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", @@ -1825,28 +1826,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", @@ -1866,10 +1863,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": { @@ -2238,7 +2257,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 6aaefc6b9..41ce7a8d7 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -835,8 +835,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", @@ -858,7 +858,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", @@ -943,7 +944,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", @@ -1818,28 +1819,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", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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 7859ac4b1..86bb82f86 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -835,8 +835,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": "Правило фільтра видалено", @@ -858,7 +858,8 @@ "rule_undone": "Зміну правила скасовано", "rule_undo_failed": "Не вдалося скасувати зміну правила", "rule_undo_conflict": "Ваші фільтри відтоді змінилися. Скасуйте зміну в налаштуваннях фільтрів.", - "rule_folder_failed": "Не вдалося створити папку" + "rule_folder_failed": "Не вдалося створити папку", + "vacation_filters_save_failed": "Налаштування відсутності збережено, але пересилання та отримувачів відповіді зберегти не вдалося" }, "date": { "today": "Сьогодні", @@ -943,7 +944,7 @@ "privacy": "Конфіденційність і безпека", "account": "Обліковий запис", "identities": "Ідентичності", - "vacation": "Відповідач у відпустці", + "vacation": "Відсутність", "advanced": "Просунутий", "calendar": "Календар", "filters": "Фільтри", @@ -1818,28 +1819,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": "Поза офісом", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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 0e9d6dc1c..e95e40717 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -835,8 +835,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": "篩選規則已刪除", @@ -858,7 +858,8 @@ "rule_undone": "已復原規則變更", "rule_undo_failed": "無法復原規則變更", "rule_undo_conflict": "篩選規則在那之後已變更。請在篩選規則設定中復原該變更。", - "rule_folder_failed": "無法建立資料夾" + "rule_folder_failed": "無法建立資料夾", + "vacation_filters_save_failed": "外出設定已儲存,但轉寄和回覆對象設定未能儲存" }, "date": { "today": "今天", @@ -946,7 +947,7 @@ "privacy": "隱私權與安全", "account": "帳號", "identities": "寄件身分", - "vacation": "休假自動回覆", + "vacation": "外出", "advanced": "進階", "calendar": "行事曆", "filters": "篩選規則", @@ -1825,28 +1826,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": "不在辦公室", @@ -1866,10 +1863,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": { @@ -2238,7 +2257,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 f43ec5e65..e4f31b5fa 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -835,8 +835,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": "过滤规则已删除", @@ -858,7 +858,8 @@ "rule_undone": "已撤销规则更改", "rule_undo_failed": "无法撤销规则更改", "rule_undo_conflict": "过滤器在此之后已更改。请在过滤器设置中撤销该更改。", - "rule_folder_failed": "无法创建文件夹" + "rule_folder_failed": "无法创建文件夹", + "vacation_filters_save_failed": "外出设置已保存,但转发和回复对象设置未能保存" }, "date": { "today": "今天", @@ -943,7 +944,7 @@ "privacy": "隐私与安全", "account": "账户", "identities": "身份", - "vacation": "假期自动回复", + "vacation": "外出", "advanced": "高级", "calendar": "日历", "filters": "过滤器", @@ -1818,28 +1819,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": "外出办公", @@ -1859,10 +1856,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": { @@ -2231,7 +2250,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, + }); + }, }));