mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "邮件模板",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user