Merge pull request #1152 from dealerweb/feat/rule-periods-out-of-office

feat: out of office forwarding and recipients, filter rule periods
This commit is contained in:
Linus Rath
2026-10-06 23:28:58 +02:00
62 changed files with 4881 additions and 466 deletions
@@ -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(<FilterRuleModal mailboxes={[]} rule={rule} periodsSupported onSave={onSave} onClose={vi.fn()} />);
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');
});
@@ -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> = {}): 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<React.ComponentProps<typeof FilterRuleModal>> = {}) {
const onSave = vi.fn();
render(<FilterRuleModal mailboxes={[]} onSave={onSave} onClose={vi.fn()} {...props} />);
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(<VisualRuleSummary rule={rule(period)} />);
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(<RuleSummary rule={rule(period)} />);
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(<VisualRuleSummary rule={off} />);
expect(screen.getByText('5 Oct 2026, 08:00 – 16 Oct 2026, 18:00')).toBeInTheDocument();
expect(screen.queryByText(/period_status_/)).toBeNull();
unmount();
render(<RuleSummary rule={off} />);
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(<VisualRuleSummary rule={rule()} />);
expect(screen.queryByText('period_title')).toBeNull();
unmount();
const { container } = render(<RuleSummary rule={rule()} />);
expect(container.textContent).not.toContain('·');
});
});
@@ -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<typeof account>, 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(<QuickRuleHost />);
}
/** Once the account's script has been read and counted. */
async function counted(server: ReturnType<typeof account>) {
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();
});
});
+121 -5
View File
@@ -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<ReadonlySet<string>>(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<HTMLInputElement>(null);
const periodEndRef = useRef<HTMLInputElement>(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({
</div>
)}
{/* Offered where the server supports it; a rule that already has a
period always shows it, so it can be removed. */}
{(periodsSupported || hadPeriod) && (
<div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="limitToPeriod"
checked={periodOn}
onChange={(e) => setPeriodOn(e.target.checked)}
className="rounded border-input"
/>
<label htmlFor="limitToPeriod" className="text-sm text-foreground">
{t("period_toggle")}
</label>
</div>
{periodOn && (
<div className="mt-2 ms-6 space-y-2">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
{t("period_start")}
<input
ref={periodStartRef}
type="datetime-local"
max={PERIOD_INPUT_MAX}
value={periodStart}
onChange={(e) => setPeriodStart(e.target.value)}
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"
/>
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
{t("period_end")}
<input
ref={periodEndRef}
type="datetime-local"
max={PERIOD_INPUT_MAX}
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
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"
/>
</label>
</div>
<p className="text-xs text-muted-foreground">{t("period_hint")}</p>
</div>
)}
</div>
)}
{offerApplyToExisting && (
<div>
<div className="flex items-center gap-2">
@@ -708,7 +824,7 @@ export function FilterRuleModal({
</div>
{!canApplyToExisting && (
<p id="applyToExistingHint" className="mt-1 ms-6 text-xs text-muted-foreground">
{t("apply_existing_unsupported")}
{periodOn ? t("apply_existing_period") : t("apply_existing_unsupported")}
</p>
)}
</div>
+29
View File
@@ -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);
@@ -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> = {}): 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<typeof vi.fn>;
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(<FilterSettings />);
}
function openSettings(stored: FilterRule) {
showSettings(stored);
fireEvent.click(screen.getByText(stored.name));
return within(screen.getByRole('dialog'));
}
async function saveAndReadBack(dialog: ReturnType<typeof within>) {
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<string, unknown>) => {
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<string, unknown>) => {
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 });
});
});
@@ -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<string, unknown>) => (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> = {}): 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<typeof stalwart>) {
useAuthStore.setState({ client: server.client as never });
useVacationStore.setState({ isSupported: true });
render(<VacationSettings />);
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<typeof stalwart>) {
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();
});
});
+50 -6
View File
@@ -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 }) {
</span>
))}
</span>
{period && <span className="ms-1">· {period.range}{period.status && ` (${period.status})`}</span>}
</div>
);
}
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 }) {
);
})}
</div>
{period && (
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-[10px] font-semibold uppercase tracking-wider text-violet-500 dark:text-violet-400">
{t("period_title")}
</span>
<span className="min-w-0 max-w-full px-1.5 py-px rounded-sm bg-muted/60 text-foreground wrap-anywhere">
{period.range}
</span>
{period.status && (
<span className="text-[10px] text-muted-foreground/60 italic">({period.status})</span>
)}
</div>
)}
</div>
);
}
@@ -180,6 +219,7 @@ export function FilterSettings() {
rawScript,
vacationSettings,
includeVacation,
vacationForward,
sieveCapabilities,
selectAccount,
saveFilters,
@@ -254,16 +294,19 @@ export function FilterSettings() {
const draggedIndexRef = useRef<number | null>(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}
+233 -35
View File
@@ -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 (
<div className="flex items-center gap-3">
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
checked
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-muted text-muted-foreground'
}`}>
{checked ? activeLabel : inactiveLabel}
</span>
<ToggleSwitch checked={checked} onChange={onChange} />
</div>
);
}
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<HTMLInputElement>(null);
const toRef = useRef<HTMLInputElement>(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<string[]>([]);
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 (
<div className="space-y-6">
{/* The period is the absence: the auto-reply and forwarding both keep to it. */}
<SettingsSection title={t('title')} description={t('description')}>
<SettingItem
label={t('status.label')}
description={t('status.description')}
>
<div className="flex items-center gap-3">
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
localEnabled
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-muted text-muted-foreground'
}`}>
{localEnabled ? t('status.active') : t('status.inactive')}
</span>
<ToggleSwitch checked={localEnabled} onChange={setLocalEnabled} />
</div>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('date_range.title')} description={t('date_range.description')}>
<SettingItem
label={t('date_range.start')}
description={t('date_range.start_description')}
>
<input
ref={fromRef}
type="datetime-local"
value={localFromDate ? utcToLocalDatetime(localFromDate) : ''}
onChange={(e) => 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"
/>
</SettingItem>
@@ -234,15 +373,43 @@ export function VacationSettings() {
description={t('date_range.end_description')}
>
<input
ref={toRef}
type="datetime-local"
value={localToDate ? utcToLocalDatetime(localToDate) : ''}
onChange={(e) => 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"
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('message.title')} description={t('message.description')}>
<SettingsSection title={t('auto_reply.title')} description={t('status.description')}>
<SettingItem label={t('status.label')}>
<StatusSwitch
checked={localEnabled}
onChange={setLocalEnabled}
activeLabel={t('status.active')}
inactiveLabel={t('status.inactive')}
/>
</SettingItem>
{canNarrow && (
<SettingItem
label={t('audience.label')}
description={t('audience.description', { domains: domains.join(', ') })}
>
<Select
value={audienceOnly}
onChange={(value) => setAudienceOnly(value as 'all' | 'internal' | 'external')}
options={[
{ value: 'all', label: t('audience.all') },
{ value: 'internal', label: t('audience.internal') },
{ value: 'external', label: t('audience.external') },
]}
/>
</SettingItem>
)}
<SettingItem
label={t('message.subject_label')}
description={t('message.subject_description')}
@@ -332,6 +499,37 @@ export function VacationSettings() {
);
})()}
{forwardAvailable && (
<SettingsSection title={t('forward.title')} description={t('forward.description')}>
<SettingItem label={t('forward.enabled_label')}>
<StatusSwitch
checked={forwardEnabled}
onChange={setForwardEnabled}
activeLabel={t('status.active')}
inactiveLabel={t('status.inactive')}
/>
</SettingItem>
{forwardEnabled && (
<>
<SettingItem label={t('forward.to_label')}>
<input
type="email"
value={forwardTo}
onChange={(e) => 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"
/>
</SettingItem>
<SettingItem label={t('forward.keep_label')} description={t('forward.keep_description')}>
<ToggleSwitch checked={forwardKeep} onChange={setForwardKeep} />
</SettingItem>
</>
)}
</SettingsSection>
)}
{validationWarnings.length > 0 && (
<div className="space-y-2">
{validationWarnings.map((warning, i) => (
+10
View File
@@ -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', () => {
@@ -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);
});
});
@@ -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({
@@ -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' },
+47
View File
@@ -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<typeof mockSieveAccount>[1],
extensions: string[],
options: { maxNumberRedirects?: number; vacation?: Partial<VacationFields> } = {},
) {
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<string, unknown>) => {
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 };
}
+3 -1
View File
@@ -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<AccountFilters, 'parsed' | 'includeVacation' | 'capabilities'>,
@@ -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,
});
}
+11 -1
View File
@@ -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];
}
+5 -1
View File
@@ -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))
+8 -2
View File
@@ -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<FilterRule, 'conditions' | 'actions'>): RetroSupport {
export function retroactiveSupport(
rule: Pick<FilterRule, 'conditions' | 'actions' | 'activeFrom' | 'activeUntil'>,
): 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)) {
+50 -1
View File
@@ -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
@@ -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<string, { args: [FilterRule[], VacationSieveConfig | undefined, GenerateOptions]; script: string }> = {
'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);
});
});
+1 -1
View File
@@ -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([]);
+242
View File
@@ -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<FilterRule, 'activeFrom' | 'activeUntil'>, 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> = {}): 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<string, unknown>) =>
`/* @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');
});
});
@@ -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',
]);
});
});
@@ -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> = {}): 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);
});
});
+92 -3
View File
@@ -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<string, string> = {
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<FilterRule, 'origin' | 'origi
actions: r.actions,
stopProcessing: r.stopProcessing,
...(r.includeSpam ? { includeSpam: true } : {}),
...(r.activeFrom !== undefined ? { activeFrom: r.activeFrom } : {}),
...(r.activeUntil !== undefined ? { activeUntil: r.activeUntil } : {}),
};
}
@@ -257,6 +265,16 @@ export interface GenerateOptions {
* while the filters stay active.
*/
includeVacation?: boolean;
/**
* Forwarding from the vacation card. Always kept in the metadata; it runs
* while it is switched on, with or without the auto-reply.
*/
vacationForward?: VacationForward | null;
/**
* Who gets the auto-reply when not everyone should. Always kept in the
* metadata; it narrows the vacation include (`includeVacation`).
*/
vacationAudience?: VacationAudience | null;
/**
* The server's `sieveExtensions`. Folder moves use `:mailboxid` when it
* lists "mailboxid"; without it they target the folder path only.
@@ -290,6 +308,26 @@ export function generateScript(
if (options.includeVacation) {
metadata.includeVacation = true;
}
if (options.vacationForward) {
if (isValidVacationForward(options.vacationForward)) {
metadata.vacationForward = normalizeVacationForward(options.vacationForward);
} else {
debug.warn('filters', 'Dropping unusable vacation forwarding');
}
}
if (options.vacationAudience) {
if (isValidVacationAudience(options.vacationAudience)) {
metadata.vacationAudience = normalizeVacationAudience(options.vacationAudience);
} else {
debug.warn('filters', 'Dropping unusable vacation audience');
}
}
// An older build would take a version 1 script as its own and write it back
// without what it does not know: a rule without its period forwards for
// good, the auto-reply goes to everyone. It leaves version 2 alone.
if (metadata.rules.some(hasPeriod) || metadata.vacationForward || metadata.vacationAudience) {
metadata.version = 2;
}
// The JSON sits inside a /* ... */ comment: a "*/" in any string (a rule
// name, a condition value) would end the comment and turn the rest into
// live Sieve. JSON reads "\/" back as "/", so the metadata is unchanged.
@@ -304,6 +342,19 @@ export function generateScript(
const useMailboxId = options.extensions?.includes('mailboxid') ?? false;
const bulwarkRequires = computeRequires(bulwarkRules, vacation, useMailboxId, options.extensions);
if (options.includeVacation) bulwarkRequires.push('include');
if (options.includeVacation && metadata.vacationAudience) bulwarkRequires.push('envelope');
// Forwarding from the vacation card runs whenever it is switched on, with
// or without the auto-reply, ahead of every rule. A period it cannot read
// would let it forward for good, so then it does not run.
const forward = metadata.vacationForward?.enabled ? metadata.vacationForward : null;
const forwardPeriod = forward ? periodTests(forward) : null;
const forwardSpamGuard = supportsSpamGuard(options.extensions);
if (forward && forwardPeriod) {
if (forward.keepCopy) bulwarkRequires.push('copy');
if (forwardPeriod.length > 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,
+30 -1
View File
@@ -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 } : {}),
};
}
+87
View File
@@ -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<FilterRule, 'activeFrom' | 'activeUntil'>): 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<FilterRule, 'activeFrom' | 'activeUntil'>): 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<FilterRule, 'activeFrom' | 'activeUntil'>,
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';
}
+30
View File
@@ -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<string, unknown>;
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;
}
+55
View File
@@ -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<string, unknown>;
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<VacationForward, 'enabled' | 'to' | 'keepCopy'>,
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 } : {}),
};
}
+14
View File
@@ -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.
+44 -13
View File
@@ -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": "قوالب البريد",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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": "قالب‌های ایمیل",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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": "תבניות דואר אלקטרוני",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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": "メールテンプレート",
+44 -13
View File
@@ -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": "이메일 템플릿",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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": "И-мэйл загварууд",
+44 -13
View File
@@ -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",
+42 -11
View File
@@ -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",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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": "Шаблоны писем",
+44 -13
View File
@@ -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",
+44 -13
View File
@@ -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ı",
+44 -13
View File
@@ -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": "Шаблони електронної пошти",
+44 -13
View File
@@ -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": "郵件範本",
+44 -13
View File
@@ -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": "邮件模板",
+3 -3
View File
@@ -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);
});
});
});
+168
View File
@@ -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<typeof generateScript>[2] = {}) =>
generateScript(rules, undefined, { extensions: EXTENSIONS, ...options });
const stalwart = (initial: Parameters<typeof mockStalwartAccount>[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);
});
});
+465
View File
@@ -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> = {}): 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> = {}): 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<typeof generateScript>[2] = {}) =>
generateScript(rules, undefined, { extensions: EXTENSIONS, ...options });
const VACATION_SCRIPT = STALWART_VACATION_SCRIPT;
const stalwart = (
initial: Parameters<typeof mockStalwartAccount>[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<typeof other>((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<typeof other>((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 });
});
});
+177 -24
View File
@@ -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<FilterStore>()((set, get) => ({
vacationSettings: null,
externalRequires: [],
includeVacation: false,
vacationForward: null,
vacationAudience: null,
availableAccounts: [],
selectedAccountId: null,
@@ -105,6 +115,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
rawScript: '',
isOpaque: false,
includeVacation: vacationActive,
vacationForward: null,
vacationAudience: null,
});
return;
}
@@ -126,6 +138,8 @@ export const useFilterStore = create<FilterStore>()((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<FilterStore>()((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<FilterStore>()((set, get) => ({
vacationSettings: null,
externalRequires: [],
includeVacation: false,
vacationForward: null,
vacationAudience: null,
});
await get().fetchFilters(client, accountId);
},
@@ -169,7 +187,7 @@ export const useFilterStore = create<FilterStore>()((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<FilterStore>()((set, get) => ({
content = generateScript(rules, vacationSettings || undefined, {
externalRequires,
includeVacation,
vacationForward,
vacationAudience,
extensions: sieveCapabilities?.sieveExtensions,
});
}
@@ -272,6 +292,8 @@ export const useFilterStore = create<FilterStore>()((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<boolean> {
/** 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<VacationFilters> {
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<T>(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<void> {
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();
+153 -33
View File
@@ -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<VacationForward, 'enabled' | 'to' | 'keepCopy'>;
/**
* 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<void>;
/** 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<void>;
setSupported: (supported: boolean) => void;
clearState: () => void;
}
export const useVacationStore = create<VacationStore>()((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<VacationStore>()((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<VacationStore>()((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<VacationStore>()((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<VacationStore> = {};
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,
});
},
}));