{validationWarnings.map((warning, i) => (
diff --git a/lib/__tests__/timezone.test.ts b/lib/__tests__/timezone.test.ts
index 5fcc01f18..c73da6ee7 100644
--- a/lib/__tests__/timezone.test.ts
+++ b/lib/__tests__/timezone.test.ts
@@ -3,6 +3,7 @@ import {
AUTO_TIME_ZONE,
displayNow,
fromDisplayDate,
+ fromWallClock,
fromZonedDisplayDate,
getBrowserTimeZone,
getTimeZoneOffsetMs,
@@ -114,6 +115,15 @@ describe('wall-clock helpers', () => {
expect(isNaN(toZonedDisplayDate(bad, 'Europe/Berlin').getTime())).toBe(true);
expect(isNaN(fromZonedDisplayDate(bad, 'Europe/Berlin').getTime())).toBe(true);
});
+
+ it('reads a wall-clock in a zone straight from its fields', () => {
+ const at = (year: number, month: number, day: number, hour: number, minute: number, zone: string) =>
+ fromWallClock({ year, month, day, hour, minute, second: 0 }, zone).toISOString();
+ expect(at(2026, 10, 5, 8, 0, 'Europe/Berlin')).toBe('2026-10-05T06:00:00.000Z');
+ expect(at(2026, 10, 26, 18, 0, 'Europe/Berlin')).toBe('2026-10-26T17:00:00.000Z');
+ // 02:30 on 29 March is skipped in Berlin, but exists in New York.
+ expect(at(2026, 3, 29, 2, 30, 'America/New_York')).toBe('2026-03-29T06:30:00.000Z');
+ });
});
describe('store-aware display dates', () => {
diff --git a/lib/filters/__tests__/forward-limit-vacation.test.ts b/lib/filters/__tests__/forward-limit-vacation.test.ts
new file mode 100644
index 000000000..b0c094d29
--- /dev/null
+++ b/lib/filters/__tests__/forward-limit-vacation.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+import type { FilterAction, FilterRule, VacationForward } from '@/lib/jmap/sieve-types';
+import { generateScript } from '@/lib/sieve/generator';
+import { VACATION_FORWARD_MARKER } from '@/lib/sieve/vacation-forward';
+import { forwardsAround, inRunOrder, ruleForwards, ruleStops, worstCaseForwards } from '../forward-limit';
+
+const forward: FilterAction = { type: 'forward', value: 'office@example.com' };
+const rule = (actions: FilterAction[], stopProcessing = false): FilterRule => ({
+ id: `r-${Math.random()}`,
+ name: 'R',
+ enabled: true,
+ matchType: 'all',
+ conditions: [{ field: 'from', comparator: 'contains', value: 'a' }],
+ actions,
+ stopProcessing,
+});
+const vacationForward = (keepCopy: boolean, enabled = true): VacationForward => ({
+ enabled,
+ to: 'deputy@example.com',
+ keepCopy,
+});
+
+describe('the out of office forwarding in the run order', () => {
+ const rules = [rule([forward])];
+
+ it('goes ahead of the rules, and stops unless it keeps a copy', () => {
+ expect(inRunOrder(rules, vacationForward(true))).toEqual([
+ { enabled: true, actions: [{ type: 'forward', value: 'deputy@example.com' }], stopProcessing: false },
+ ...rules,
+ ]);
+ expect(inRunOrder(rules, vacationForward(false))[0].stopProcessing).toBe(true);
+ });
+
+ it('is left out while it is off', () => {
+ expect(inRunOrder(rules, vacationForward(true, false))).toBe(rules);
+ expect(inRunOrder(rules, null)).toBe(rules);
+ expect(inRunOrder(rules, undefined)).toBe(rules);
+ });
+
+ it('agrees with the script: one redirect ahead of every rule, and a stop unless a copy is kept', () => {
+ for (const keepCopy of [true, false]) {
+ const script = generateScript(rules, undefined, { vacationForward: vacationForward(keepCopy) });
+ expect(script.indexOf(VACATION_FORWARD_MARKER)).toBeGreaterThan(-1);
+ expect(script.indexOf(VACATION_FORWARD_MARKER)).toBeLessThan(script.indexOf('# Rule:'));
+ const lines = script.split(VACATION_FORWARD_MARKER)[1].split('# Rule:')[0].split('\n').map((line) => line.trim());
+ const [ahead] = inRunOrder([], vacationForward(keepCopy));
+ expect(lines.filter((line) => line.startsWith('redirect ')).length, `keepCopy ${keepCopy}`).toBe(ruleForwards(ahead));
+ expect(lines.includes('stop;'), `keepCopy ${keepCopy}`).toBe(ruleStops(ahead));
+ }
+ });
+
+ it('adds its forward to every rule only when it keeps a copy', () => {
+ // A new rule goes behind Bulwark's rules, here behind the one rule.
+ expect(forwardsAround(inRunOrder(rules, vacationForward(true)), 2, false)).toEqual({ before: 2, after: 0 });
+ expect(forwardsAround(inRunOrder(rules, vacationForward(false)), 2, false)).toEqual({ before: 1, after: 0 });
+ expect(worstCaseForwards(inRunOrder(rules, vacationForward(true)))).toBe(2);
+ expect(worstCaseForwards(inRunOrder(rules, vacationForward(false)))).toBe(1);
+ });
+});
diff --git a/lib/filters/__tests__/quick-rules.test.ts b/lib/filters/__tests__/quick-rules.test.ts
index c3070c574..6f3161e5a 100644
--- a/lib/filters/__tests__/quick-rules.test.ts
+++ b/lib/filters/__tests__/quick-rules.test.ts
@@ -240,6 +240,15 @@ describe('applyQuickRule', () => {
}
});
+ it('leaves a rule with a period alone, since the new values would only act within it', () => {
+ for (const period of [{ activeFrom: '2026-10-05T06:00:00.000Z' }, { activeUntil: '2026-10-16T16:00:00.000Z' }]) {
+ const existing = [rule({ id: 'target', ...period })];
+ // Neither merged into it nor counted as covered by it.
+ expect(applyQuickRule(existing, candidate('bob@acme.com')).kind).toBe('created');
+ expect(applyQuickRule(existing, candidate('anna@acme.com')).kind).toBe('created');
+ }
+ });
+
it('merges List-Id rules by header name, whatever its case', () => {
const existing = [rule({ id: 'lists', conditions: [{ field: 'header', headerName: 'list-id', comparator: 'contains', value: 'a.example.org' }] })];
const outcome = applyQuickRule(existing, rule({
diff --git a/lib/filters/__tests__/retroactive.test.ts b/lib/filters/__tests__/retroactive.test.ts
index daecf9176..1f9955aa1 100644
--- a/lib/filters/__tests__/retroactive.test.ts
+++ b/lib/filters/__tests__/retroactive.test.ts
@@ -144,6 +144,14 @@ describe('retroactiveSupport', () => {
}
});
+ it('never runs a rule with a period on old mail', () => {
+ for (const period of [{ activeFrom: '2026-10-05T06:00:00.000Z' }, { activeUntil: '2026-10-16T16:00:00.000Z' }]) {
+ const r = rule([{ field: 'from', comparator: 'address_is', value: 'anna@acme.com' }], [{ type: 'mark_read' }], period);
+ expect(retroactiveSupport(r)).toEqual({ ok: false, reason: 'period' });
+ expect(planRetroactive(r, [message()])).toEqual({ ids: [], steps: [] });
+ }
+ });
+
it('refuses conditions it cannot evaluate like Sieve', () => {
for (const condition of [
{ field: 'body', comparator: 'contains', value: 'x' },
diff --git a/lib/filters/__tests__/sieve-mock.ts b/lib/filters/__tests__/sieve-mock.ts
index 265bfbb27..bac77da20 100644
--- a/lib/filters/__tests__/sieve-mock.ts
+++ b/lib/filters/__tests__/sieve-mock.ts
@@ -80,3 +80,50 @@ export function mockSieveAccount(
},
};
}
+
+export const STALWART_VACATION_SCRIPT = 'require "vacation";\nvacation "Ich bin nicht da.";\n';
+
+interface VacationFields {
+ fromDate: string | null;
+ toDate: string | null;
+ subject: string;
+ textBody: string;
+ htmlBody: string | null;
+}
+
+/**
+ * A Sieve account that behaves like Stalwart for VacationResponse: turning
+ * the auto-reply on activates its own "vacation" script, which switches
+ * every other script off, and it reads as on only while that script is the
+ * active one. Like Stalwart, an update that leaves isEnabled out switches
+ * that script off, and one redirect per message is the default limit.
+ */
+export function mockStalwartAccount(
+ accountId: string,
+ initial: Parameters[1],
+ extensions: string[],
+ options: { maxNumberRedirects?: number; vacation?: Partial } = {},
+) {
+ const account = mockSieveAccount(accountId, initial, extensions);
+ let vacation: VacationFields = { fromDate: null, toDate: null, subject: '', textBody: 'away', htmlBody: null, ...options.vacation };
+ const maxNumberRedirects = 'maxNumberRedirects' in options ? options.maxNumberRedirects : 1;
+ const client = Object.assign(account.client, {
+ supportsSieve: () => true,
+ hasAccountCapability: () => false,
+ getSieveAccounts: () => [{ id: accountId, name: 'Me', isPrimary: true }],
+ getSieveCapabilities: () => ({ sieveExtensions: extensions, maxNumberRedirects }) as never,
+ getVacationResponse: vi.fn(async () => ({ ...vacation, isEnabled: account.active() === 'vacation' })),
+ setVacationResponse: vi.fn(async (updates: Record) => {
+ const { isEnabled, ...fields } = updates;
+ vacation = { ...vacation, ...fields };
+ if (isEnabled === true) {
+ const own = account.scripts.find((s) => s.name === 'vacation');
+ if (own) await client.activateSieveScript(own.id, accountId);
+ else await client.createSieveScript('vacation', STALWART_VACATION_SCRIPT, true, accountId);
+ } else if (account.active() === 'vacation') {
+ await client.deactivateSieveScript(accountId);
+ }
+ }),
+ });
+ return { ...account, client };
+}
diff --git a/lib/filters/account-filters.ts b/lib/filters/account-filters.ts
index 88c71abd4..449bc8d50 100644
--- a/lib/filters/account-filters.ts
+++ b/lib/filters/account-filters.ts
@@ -94,7 +94,7 @@ export async function readAccountFilters(client: IJMAPClient, accountId: string)
};
}
-/** The script for `rules`, keeping the account's vacation and external requires. */
+/** The script for `rules`, keeping the account's vacation, its forwarding and external requires. */
export function renderFiltersScript(
rules: FilterRule[],
filters: Pick,
@@ -102,6 +102,8 @@ export function renderFiltersScript(
return generateScript(rules, filters.parsed.vacation, {
externalRequires: filters.parsed.externalRequires,
includeVacation: filters.includeVacation,
+ vacationForward: filters.parsed.vacationForward,
+ vacationAudience: filters.parsed.vacationAudience,
extensions: filters.capabilities?.sieveExtensions,
});
}
diff --git a/lib/filters/forward-limit.ts b/lib/filters/forward-limit.ts
index 227132bf0..2a3674c6d 100644
--- a/lib/filters/forward-limit.ts
+++ b/lib/filters/forward-limit.ts
@@ -1,4 +1,4 @@
-import type { FilterRule } from '@/lib/jmap/sieve-types';
+import type { FilterRule, VacationForward } from '@/lib/jmap/sieve-types';
/*
* A server lets one message trigger only so many redirects
@@ -61,3 +61,13 @@ export function forwardsAround(
const after = worstCaseForwards(rules.slice(replaces ? index + 1 : index));
return { before, after };
}
+
+/**
+ * The rules in the order the script runs them: forwarding from the out of
+ * office card goes ahead of every rule, and ends the script unless it keeps
+ * a copy here.
+ */
+export function inRunOrder(rules: RuleForwards[], forward: VacationForward | null | undefined): RuleForwards[] {
+ if (!forward?.enabled) return rules;
+ return [{ enabled: true, actions: [{ type: 'forward', value: forward.to }], stopProcessing: !forward.keepCopy }, ...rules];
+}
diff --git a/lib/filters/quick-rules.ts b/lib/filters/quick-rules.ts
index 7f14ccbca..ceee0da9a 100644
--- a/lib/filters/quick-rules.ts
+++ b/lib/filters/quick-rules.ts
@@ -1,5 +1,6 @@
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { FilterAction, FilterCondition, FilterRule } from '@/lib/jmap/sieve-types';
+import { hasPeriod } from '@/lib/sieve/period';
/**
* Rules made from a message: "Always move messages from Anna to…", "Block
@@ -350,7 +351,9 @@ export type QuickRuleOutcome =
/**
* Save a one-click rule without piling up near-duplicates: an enabled
* Bulwark rule with a single condition of the same kind and the same actions
- * takes the new values into its value list and keeps its name and place.
+ * takes the new values into its value list and keeps its name and place. A
+ * rule with a period only acts within it, and the new values would too, so
+ * it is never the one.
* `added` is the part of the rule that is new (the candidate itself, or the
* merged rule narrowed to the added values), which is what the "apply to
* existing messages" pass runs.
@@ -361,6 +364,7 @@ export function applyQuickRule(rules: FilterRule[], candidate: FilterRule): Quic
? rules.find(r =>
isBulwarkRule(r)
&& r.enabled
+ && !hasPeriod(r)
&& r.conditions.length === 1
&& sameConditionKind(r.conditions[0], condition)
&& sameActions(r.actions, candidate.actions))
diff --git a/lib/filters/retroactive.ts b/lib/filters/retroactive.ts
index ebb7ac6d5..006e2b291 100644
--- a/lib/filters/retroactive.ts
+++ b/lib/filters/retroactive.ts
@@ -1,5 +1,6 @@
import type { EmailAddress } from '@/lib/jmap/types';
import type { FilterAction, FilterCondition, FilterRule } from '@/lib/jmap/sieve-types';
+import { hasPeriod } from '@/lib/sieve/period';
import { unfoldHeader } from './quick-rules';
/**
@@ -38,7 +39,9 @@ export type RetroSupport =
/** A condition the client cannot evaluate like Sieve does (body, size, attachment). */
| { ok: false; reason: 'condition' }
/** An action that must never run on old mail (forward, reject, discard) or has no meaning there (keep). */
- | { ok: false; reason: 'action' };
+ | { ok: false; reason: 'action' }
+ /** A period: the rule acts on mail as it arrives within it, which says nothing about the mail already there. */
+ | { ok: false; reason: 'period' };
/** A header condition the client can evaluate: which header it reads. */
function conditionHeader(condition: FilterCondition): string | null {
@@ -55,7 +58,10 @@ function usesAddressTest(condition: FilterCondition): boolean {
&& ADDRESS_FIELDS.has(condition.field);
}
-export function retroactiveSupport(rule: Pick): RetroSupport {
+export function retroactiveSupport(
+ rule: Pick,
+): RetroSupport {
+ if (hasPeriod(rule)) return { ok: false, reason: 'period' };
if (rule.conditions.length === 0) return { ok: false, reason: 'condition' };
for (const condition of rule.conditions) {
if (!TEXT_COMPARATORS.has(condition.comparator) || !conditionHeader(condition)) {
diff --git a/lib/jmap/sieve-types.ts b/lib/jmap/sieve-types.ts
index 53ea76e1b..8713f585f 100644
--- a/lib/jmap/sieve-types.ts
+++ b/lib/jmap/sieve-types.ts
@@ -92,6 +92,15 @@ export interface FilterRule {
* folder rule does not pull spam out of Junk.
*/
includeSpam?: boolean;
+ /**
+ * The rule only acts on mail that arrives from `activeFrom` until
+ * `activeUntil`, both inclusive, either one open-ended. Absolute moments
+ * as ISO 8601 UTC ("2026-10-05T06:00:00.000Z"), so the script does not
+ * depend on the time zone of whoever saves it next. Compiled to Sieve
+ * `currentdate` tests (RFC 5260), which need "date" and "relational".
+ */
+ activeFrom?: string;
+ activeUntil?: string;
origin?: FilterOrigin;
originLabel?: string;
rawBlock?: string;
@@ -103,10 +112,50 @@ export interface VacationSieveConfig {
textBody: string;
}
+/**
+ * Forwarding set up in the vacation card: mail goes on to one address, with
+ * or without the auto-reply, in the vacation's period. It is not a filter
+ * rule and the filter list does not show it; it runs ahead of the rules.
+ */
+export interface VacationForward {
+ /** The forwarding switch, independent of the auto-reply's. */
+ enabled: boolean;
+ /** The one address mail is forwarded to. */
+ to: string;
+ /**
+ * Keep the message in this mailbox too (`redirect :copy`). Otherwise it
+ * is only forwarded, and no filter rule runs on it.
+ */
+ keepCopy: boolean;
+ /** The auto-reply's period when it was saved, as for FilterRule.activeFrom. */
+ activeFrom?: string;
+ activeUntil?: string;
+}
+
+/**
+ * Who gets the auto-reply when not everyone should: only senders from the
+ * account's own domains, or only senders from elsewhere. It is decided on
+ * the From address while the auto-reply runs from the filters script.
+ */
+export interface VacationAudience {
+ only: 'internal' | 'external';
+ /** The account's own domains (from its identities) when it was saved. */
+ domains: string[];
+}
+
export interface FilterMetadata {
- version: 1;
+ /**
+ * 2 once a rule has a period or the vacation's forwarding or recipients are
+ * stored. Older builds only take version 1 as their own and would write
+ * such a script back without these fields; anything else they leave alone.
+ */
+ version: 1 | 2;
rules: FilterRule[];
vacation?: VacationSieveConfig;
+ /** See VacationAudience; absent when everyone gets the auto-reply. */
+ vacationAudience?: VacationAudience;
+ /** See VacationForward; kept while switched off, so the card remembers it. */
+ vacationForward?: VacationForward;
/**
* The script runs the server-managed "vacation" script via `include`.
* Servers like Stalwart keep one active script, so a VacationResponse
diff --git a/lib/sieve/__tests__/generator-compat.test.ts b/lib/sieve/__tests__/generator-compat.test.ts
new file mode 100644
index 000000000..ace7e3026
--- /dev/null
+++ b/lib/sieve/__tests__/generator-compat.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, it } from 'vitest';
+import type { FilterRule, VacationSieveConfig } from '@/lib/jmap/sieve-types';
+import { generateScript, type GenerateOptions } from '../generator';
+import { parseScript } from '../parser';
+
+const EXTENSIONS = [
+ 'fileinto', 'mailbox', 'mailboxid', 'imap4flags', 'include', 'copy', 'date', 'relational',
+ 'spamtestplus', 'comparator-i;ascii-numeric', 'body', 'vacation', 'envelope', 'reject',
+];
+
+const rules: FilterRule[] = [
+ {
+ id: 'r1', name: 'Boersen', enabled: true, matchType: 'any',
+ conditions: [
+ { field: 'from', comparator: 'contains', value: ['autoscout24', 'mobile.de'] },
+ { field: 'subject', comparator: 'starts_with', value: 'Anfrage' },
+ ],
+ actions: [{ type: 'move', value: 'INBOX/Boersen', mailboxId: 'mb-1' }, { type: 'mark_read' }],
+ stopProcessing: true,
+ },
+ {
+ id: 'r2', name: 'Chef', enabled: true, matchType: 'all',
+ conditions: [{ field: 'from', comparator: 'address_is', value: 'chef@example.com' }],
+ actions: [{ type: 'forward', value: 'kollege@example.com', keepCopy: true }, { type: 'star' }],
+ stopProcessing: false,
+ },
+ {
+ id: 'r3', name: 'Gross', enabled: false, matchType: 'all',
+ conditions: [{ field: 'size', comparator: 'greater_than', value: '1000000' }],
+ actions: [{ type: 'discard' }],
+ stopProcessing: false, includeSpam: true,
+ },
+];
+const external: FilterRule = {
+ id: 'ext-1', name: 'Hand', enabled: true, matchType: 'all', conditions: [], actions: [], stopProcessing: false,
+ origin: 'external', rawBlock: '# Hand\nif header :contains "subject" "hand" {\n keep;\n}',
+};
+const vacation: VacationSieveConfig = { isEnabled: true, subject: 'Weg', textBody: 'Bin weg' };
+
+const RULE_BLOCKS = [
+ '# Rule: Boersen',
+ 'if allof(anyof(header :contains "From" ["autoscout24", "mobile.de"], header :matches "Subject" "Anfrage*"), not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {',
+ ' addflag "\\\\Seen";',
+ ' fileinto :mailboxid "mb-1" "INBOX/Boersen";',
+ ' stop;',
+ '}',
+ '',
+ '# Rule: Chef',
+ 'if address :is "From" "chef@example.com" {',
+ ' addflag "\\\\Flagged";',
+ ' redirect :copy "kollege@example.com";',
+ '}',
+ '',
+];
+
+// What the generator wrote for these before rules had periods and the
+// vacation card had forwarding and reply recipients, taken from it as it was
+// then (0409bde8). Every account's script is rewritten on its next save, so a
+// script without the new fields must still come out byte for byte the same.
+const BEFORE: Record = {
+ 'rules next to the vacation include': {
+ args: [rules, undefined, { includeVacation: true, extensions: EXTENSIONS }],
+ script: [
+ '/* @metadata:begin',
+ JSON.stringify({ version: 1, rules, includeVacation: true }),
+ '@metadata:end */',
+ '',
+ 'require ["comparator-i;ascii-numeric", "copy", "fileinto", "imap4flags", "include", "mailbox", "mailboxid", "relational", "spamtestplus"];',
+ '',
+ '# Vacation auto-reply',
+ 'include :personal :optional "vacation";',
+ '',
+ ...RULE_BLOCKS,
+ ].join('\n'),
+ },
+ 'rules, an external block and the vacation written in': {
+ args: [[...rules, external], vacation, { externalRequires: ['body'], extensions: EXTENSIONS }],
+ script: [
+ '/* @metadata:begin',
+ JSON.stringify({ version: 1, rules, vacation }),
+ '@metadata:end */',
+ '',
+ 'require ["body", "comparator-i;ascii-numeric", "copy", "fileinto", "imap4flags", "mailbox", "mailboxid", "relational", "spamtestplus", "vacation"];',
+ '',
+ '# Vacation auto-reply',
+ 'vacation :subject "Weg" "Bin weg";',
+ '',
+ ...RULE_BLOCKS,
+ '# --- External rules (managed outside Bulwark) ---',
+ '# Hand',
+ 'if header :contains "subject" "hand" {',
+ ' keep;',
+ '}',
+ '',
+ ].join('\n'),
+ },
+ 'only the vacation include': {
+ args: [[], undefined, { includeVacation: true, extensions: EXTENSIONS }],
+ script: [
+ '/* @metadata:begin',
+ '{"version":1,"rules":[],"includeVacation":true}',
+ '@metadata:end */',
+ '',
+ 'require ["include"];',
+ '',
+ '# Vacation auto-reply',
+ 'include :personal :optional "vacation";',
+ '',
+ ].join('\n'),
+ },
+};
+
+describe('a script without periods, forwarding or reply recipients', () => {
+ it('comes out exactly as before these existed', () => {
+ for (const [name, { args: [r, v, options], script }] of Object.entries(BEFORE)) {
+ expect(generateScript(r, v, options), name).toBe(script);
+ // The stores pass "none" for both.
+ expect(generateScript(r, v, { ...options, vacationForward: null, vacationAudience: null }), name).toBe(script);
+ }
+ });
+});
+
+describe('the metadata version', () => {
+ const period = { activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' };
+ const versionOf = (script: string) => (JSON.parse(script.split('\n')[1]) as { version: number }).version;
+ const generate = (r: FilterRule[], options: GenerateOptions = {}) =>
+ generateScript(r, undefined, { extensions: EXTENSIONS, ...options });
+
+ it('is 2 once an older build would drop something, so that it leaves the script alone', () => {
+ // An older build takes version 1 as its own and writes it back without
+ // what it does not know: the rule would forward for good, the auto-reply
+ // would go to everyone.
+ expect(versionOf(generate([{ ...rules[1], ...period }]))).toBe(2);
+ // A rule that is off keeps its period for when it is on again.
+ expect(versionOf(generate([{ ...rules[2], activeUntil: period.activeUntil }]))).toBe(2);
+ // Forwarding is remembered while it is off.
+ expect(versionOf(generate([], { vacationForward: { enabled: false, to: 'kollege@example.com', keepCopy: false } }))).toBe(2);
+ expect(versionOf(generate([], { includeVacation: true, vacationAudience: { only: 'internal', domains: ['example.com'] } }))).toBe(2);
+ expect(versionOf(generate(rules, { includeVacation: true }))).toBe(1);
+ });
+
+ it('reads version 2 back as its own', () => {
+ const options: GenerateOptions = {
+ includeVacation: true,
+ vacationForward: { enabled: true, to: 'kollege@example.com', keepCopy: true, ...period },
+ vacationAudience: { only: 'external', domains: ['example.com'] },
+ };
+ const script = generate([{ ...rules[1], ...period }], options);
+ const parsed = parseScript(script);
+ expect(parsed.isOpaque).toBe(false);
+ expect(generate(parsed.rules, {
+ includeVacation: parsed.includeVacation,
+ vacationForward: parsed.vacationForward,
+ vacationAudience: parsed.vacationAudience,
+ })).toBe(script);
+ });
+
+ it('takes a version it does not know for a script edited by hand', () => {
+ expect(parseScript('/* @metadata:begin\n{"version":3,"rules":[]}\n@metadata:end */').isOpaque).toBe(true);
+ });
+});
diff --git a/lib/sieve/__tests__/parser.test.ts b/lib/sieve/__tests__/parser.test.ts
index ddd49f3d4..18fee1b15 100644
--- a/lib/sieve/__tests__/parser.test.ts
+++ b/lib/sieve/__tests__/parser.test.ts
@@ -43,7 +43,7 @@ describe('parseScript', () => {
});
it('returns isOpaque for version mismatch', () => {
- const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */';
+ const script = '/* @metadata:begin\n{"version":3,"rules":[]}\n@metadata:end */';
const result = parseScript(script);
expect(result.isOpaque).toBe(true);
expect(result.rules).toEqual([]);
diff --git a/lib/sieve/__tests__/period.test.ts b/lib/sieve/__tests__/period.test.ts
new file mode 100644
index 000000000..948f8ff98
--- /dev/null
+++ b/lib/sieve/__tests__/period.test.ts
@@ -0,0 +1,242 @@
+import { describe, expect, it } from 'vitest';
+import type { FilterRule } from '@/lib/jmap/sieve-types';
+import { generateScript } from '../generator';
+import { parseScript } from '../parser';
+import { isPeriodBoundary, periodStatus, periodTests, supportsPeriods } from '../period';
+
+/**
+ * Evaluates the Sieve tests periodTests() writes, following RFC 5260: in zone
+ * "+0000" the "date" part is yyyy-mm-dd and "time" is hh:mm:ss, and :value
+ * compares them as strings (the default comparator). It reads only that
+ * subset, so it checks the logic, not a server's parser - the scripts were
+ * also run through Stalwart's own interpreter (sieve-rs) by hand.
+ */
+function evaluate(test: string, now: Date): boolean {
+ const split = (inner: string) => {
+ const parts: string[] = [];
+ let depth = 0;
+ let quoted = false;
+ let start = 0;
+ for (let i = 0; i < inner.length; i++) {
+ const ch = inner[i];
+ if (ch === '"') quoted = !quoted;
+ else if (!quoted && ch === '(') depth++;
+ else if (!quoted && ch === ')') depth--;
+ else if (!quoted && depth === 0 && ch === ',') {
+ parts.push(inner.slice(start, i).trim());
+ start = i + 1;
+ }
+ }
+ parts.push(inner.slice(start).trim());
+ return parts;
+ };
+ const group = test.match(/^(anyof|allof)\((.*)\)$/);
+ if (group) {
+ const results = split(group[2]).map((t) => evaluate(t, now));
+ return group[1] === 'anyof' ? results.some(Boolean) : results.every(Boolean);
+ }
+ const current = test.match(/^currentdate :zone "\+0000" (?::is|:value "(gt|ge|lt|le)") "(date|time)" "([^"]+)"$/);
+ if (!current) throw new Error(`not a period test: ${test}`);
+ const [, op, part, key] = current;
+ const iso = now.toISOString();
+ const value = part === 'date' ? iso.slice(0, 10) : iso.slice(11, 19);
+ switch (op) {
+ case undefined: return value === key;
+ case 'gt': return value > key;
+ case 'ge': return value >= key;
+ case 'lt': return value < key;
+ default: return value <= key;
+ }
+}
+
+const inPeriod = (period: Pick, at: string) =>
+ periodTests(period)!.every((t) => evaluate(t, new Date(at)));
+
+describe('period boundaries', () => {
+ it('accepts ISO moments with a zone only', () => {
+ expect(isPeriodBoundary('2026-10-05T06:00:00.000Z')).toBe(true);
+ expect(isPeriodBoundary('2026-10-05T08:00+02:00')).toBe(true);
+ // Without a zone, Date.parse would read local time: browser-dependent.
+ expect(isPeriodBoundary('2026-10-05T08:00')).toBe(false);
+ expect(isPeriodBoundary('2026-10-05')).toBe(false);
+ expect(isPeriodBoundary('2026-13-45T08:00:00Z')).toBe(false);
+ expect(isPeriodBoundary('')).toBe(false);
+ expect(isPeriodBoundary(null)).toBe(false);
+ expect(isPeriodBoundary(1_790_000_000_000)).toBe(false);
+ });
+
+ it('refuses a moment whose year in UTC has no room in four digits', () => {
+ // Year 10000 in UTC: Sieve's "date" part would read "+010000-01-01".
+ expect(isPeriodBoundary('9999-12-31T23:59:59-01:00')).toBe(false);
+ expect(isPeriodBoundary('+010000-01-01T00:59:59.000Z')).toBe(false);
+ expect(isPeriodBoundary('9999-12-31T23:59:59Z')).toBe(true);
+ });
+
+ it('needs the date and relational extensions', () => {
+ expect(supportsPeriods(['fileinto', 'date', 'relational'])).toBe(true);
+ expect(supportsPeriods(['fileinto', 'date'])).toBe(false);
+ expect(supportsPeriods(undefined)).toBe(false);
+ });
+
+ it('gives no tests without a period and null for an unusable boundary', () => {
+ expect(periodTests({})).toEqual([]);
+ expect(periodTests({ activeFrom: '2026-10-05T08:00' })).toBeNull();
+ expect(periodTests({ activeFrom: '2026-10-05T06:00:00Z', activeUntil: 'soon' })).toBeNull();
+ });
+
+ it('writes the start and end as UTC date and time tests', () => {
+ expect(periodTests({ activeFrom: '2026-10-05T08:00:00+02:00', activeUntil: '2026-10-16T16:00:00.000Z' })).toEqual([
+ 'anyof(currentdate :zone "+0000" :value "gt" "date" "2026-10-05", allof(currentdate :zone "+0000" :is "date" "2026-10-05", currentdate :zone "+0000" :value "ge" "time" "06:00:00"))',
+ 'anyof(currentdate :zone "+0000" :value "lt" "date" "2026-10-16", allof(currentdate :zone "+0000" :is "date" "2026-10-16", currentdate :zone "+0000" :value "le" "time" "16:00:00"))',
+ ]);
+ });
+});
+
+describe('period logic (RFC 5260 semantics)', () => {
+ const period = { activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' };
+
+ it.each([
+ ['one second before the start', '2026-10-05T05:59:59Z', false],
+ ['the day before, later than the start time', '2026-10-04T23:00:00Z', false],
+ ['the start', '2026-10-05T06:00:00Z', true],
+ ['later on the first day', '2026-10-05T23:59:59Z', true],
+ ['the next day, earlier than the start time', '2026-10-06T00:00:00Z', true],
+ ['the middle', '2026-10-10T12:00:00Z', true],
+ ['the last day, earlier than the end time', '2026-10-16T05:00:00Z', true],
+ ['the end', '2026-10-16T16:00:00Z', true],
+ ['one second after the end', '2026-10-16T16:00:01Z', false],
+ ['the day after, earlier than the end time', '2026-10-17T00:00:00Z', false],
+ ])('%s: %s -> %s', (_label, at, expected) => {
+ expect(inPeriod(period, at)).toBe(expected);
+ });
+
+ it('leaves either end open', () => {
+ expect(inPeriod({ activeFrom: period.activeFrom }, '2099-01-01T00:00:00Z')).toBe(true);
+ expect(inPeriod({ activeFrom: period.activeFrom }, '2026-10-05T05:59:59Z')).toBe(false);
+ expect(inPeriod({ activeUntil: period.activeUntil }, '2000-01-01T00:00:00Z')).toBe(true);
+ expect(inPeriod({ activeUntil: period.activeUntil }, '2026-10-16T16:00:01Z')).toBe(false);
+ });
+
+ it('spans a new year', () => {
+ const newYear = { activeFrom: '2026-12-31T23:30:00Z', activeUntil: '2027-01-01T00:30:00Z' };
+ expect(inPeriod(newYear, '2026-12-31T23:29:59Z')).toBe(false);
+ expect(inPeriod(newYear, '2026-12-31T23:30:00Z')).toBe(true);
+ expect(inPeriod(newYear, '2027-01-01T00:00:00Z')).toBe(true);
+ expect(inPeriod(newYear, '2027-01-01T00:30:00Z')).toBe(true);
+ expect(inPeriod(newYear, '2027-01-01T00:30:01Z')).toBe(false);
+ });
+
+ it('spans the switch from summer to winter time (Berlin, 25 Oct 2026)', () => {
+ // 24 Oct 22:00 CEST until 26 Oct 08:00 CET, written with their offsets.
+ const overSwitch = { activeFrom: '2026-10-24T22:00:00+02:00', activeUntil: '2026-10-26T08:00:00+01:00' };
+ expect(inPeriod(overSwitch, '2026-10-24T19:59:59Z')).toBe(false);
+ expect(inPeriod(overSwitch, '2026-10-24T20:00:00Z')).toBe(true);
+ expect(inPeriod(overSwitch, '2026-10-25T01:30:00Z')).toBe(true);
+ expect(inPeriod(overSwitch, '2026-10-26T07:00:00Z')).toBe(true);
+ expect(inPeriod(overSwitch, '2026-10-26T07:00:01Z')).toBe(false);
+ });
+
+ it('never acts when the period ends before it starts', () => {
+ const inverted = { activeFrom: '2026-10-16T00:00:00Z', activeUntil: '2026-10-05T00:00:00Z' };
+ for (const at of ['2026-10-01T00:00:00Z', '2026-10-10T00:00:00Z', '2026-10-20T00:00:00Z']) {
+ expect(inPeriod(inverted, at)).toBe(false);
+ }
+ });
+
+ it('reports where a moment falls', () => {
+ const now = Date.parse('2026-10-10T12:00:00Z');
+ expect(periodStatus({}, now)).toBeNull();
+ expect(periodStatus(period, now)).toBe('active');
+ expect(periodStatus({ activeFrom: '2026-10-11T00:00:00Z' }, now)).toBe('scheduled');
+ expect(periodStatus({ activeUntil: '2026-10-09T00:00:00Z' }, now)).toBe('expired');
+ });
+});
+
+const rule = (extra: Partial = {}): FilterRule => ({
+ id: 'r1',
+ name: 'Urlaub',
+ enabled: true,
+ matchType: 'all',
+ conditions: [{ field: 'from', comparator: 'contains', value: '@' }],
+ actions: [{ type: 'forward', value: 'kollege@example.com' }, { type: 'discard' }],
+ stopProcessing: false,
+ ...extra,
+});
+
+const PERIOD = { activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' };
+const EXTENSIONS = ['fileinto', 'mailbox', 'mailboxid', 'spamtestplus', 'relational', 'comparator-i;ascii-numeric', 'date', 'copy'];
+
+describe('generator with periods', () => {
+ it('confines the rule to its period and requires date and relational', () => {
+ const script = generateScript([rule(PERIOD)], undefined, { extensions: EXTENSIONS });
+ const [from, until] = periodTests(PERIOD)!;
+ expect(script).toContain('require ["date", "relational"];');
+ expect(script).toContain(`if allof(${from}, ${until}, header :contains "From" "@") {`);
+ expect(script).toContain(' redirect "kollege@example.com";');
+ });
+
+ it('keeps the conditions and the spam guard intact inside the period', () => {
+ const any = generateScript([rule({
+ ...PERIOD,
+ matchType: 'any',
+ conditions: [
+ { field: 'from', comparator: 'contains', value: 'a' },
+ { field: 'subject', comparator: 'contains', value: ['b', 'c'] },
+ ],
+ actions: [{ type: 'move', value: 'F', mailboxId: 'mb' }],
+ })], undefined, { extensions: EXTENSIONS });
+ const [from, until] = periodTests(PERIOD)!;
+ expect(any).toContain(`if allof(${from}, ${until}, allof(anyof(header :contains "From" "a", header :contains "Subject" ["b", "c"]), not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50")) {`);
+ expect(any).toContain('require ["comparator-i;ascii-numeric", "date", "fileinto", "mailbox", "mailboxid", "relational", "spamtestplus"];');
+ });
+
+ it('writes an open-ended period as one test', () => {
+ const script = generateScript([rule({ activeUntil: PERIOD.activeUntil })]);
+ const [until] = periodTests({ activeUntil: PERIOD.activeUntil })!;
+ expect(script).toContain(`if allof(${until}, header :contains "From" "@") {`);
+ });
+
+ it('drops a rule whose period is unusable instead of running it for good', () => {
+ const script = generateScript([
+ rule({ activeUntil: '2026-10-16T16:00' }),
+ rule({ id: 'r2', name: 'Other', actions: [{ type: 'star' }] }),
+ ]);
+ expect(script).not.toContain('redirect');
+ expect(script).toContain('# Rule: Other');
+ expect(script).not.toContain('"date"');
+ });
+
+ it('adds no requirement for a disabled rule with a period', () => {
+ const script = generateScript([rule({ ...PERIOD, enabled: false })]);
+ expect(script).not.toContain('require');
+ expect(script).not.toContain('currentdate');
+ });
+
+ it('stores the period in the metadata and reads it back unchanged', () => {
+ const rules = [rule(PERIOD), rule({ id: 'r2', name: 'Open', activeFrom: PERIOD.activeFrom })];
+ const script = generateScript(rules, undefined, { extensions: EXTENSIONS });
+ const parsed = parseScript(script);
+ expect(parsed.isOpaque).toBe(false);
+ expect(parsed.rules).toEqual(rules);
+ // Writing the rules read back gives the same script, byte for byte.
+ expect(generateScript(parsed.rules, undefined, { extensions: EXTENSIONS })).toBe(script);
+ });
+});
+
+describe('parser with periods', () => {
+ const withMetadata = (r: Record) =>
+ `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [r] })}\n@metadata:end */\n`;
+
+ it('leaves a script with an unreadable period alone (treated as hand-edited)', () => {
+ for (const activeUntil of ['2026-10-16T16:00', '', null, 42]) {
+ const parsed = parseScript(withMetadata({ ...rule(), activeUntil }));
+ expect(parsed.isOpaque).toBe(true);
+ }
+ });
+
+ it('reads rules without a period as before', () => {
+ const parsed = parseScript(withMetadata({ ...rule() }));
+ expect(parsed.isOpaque).toBe(false);
+ expect(parsed.rules[0]).not.toHaveProperty('activeFrom');
+ });
+});
diff --git a/lib/sieve/__tests__/vacation-audience.test.ts b/lib/sieve/__tests__/vacation-audience.test.ts
new file mode 100644
index 000000000..d8858472d
--- /dev/null
+++ b/lib/sieve/__tests__/vacation-audience.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from 'vitest';
+import type { FilterRule, VacationAudience } from '@/lib/jmap/sieve-types';
+import { generateScript } from '../generator';
+import { parseScript } from '../parser';
+import { ownDomains } from '../vacation-audience';
+
+const EXTENSIONS = ['fileinto', 'imap4flags', 'include', 'envelope'];
+const INCLUDE = 'include :personal :optional "vacation";';
+
+const rule: FilterRule = {
+ id: 'r1',
+ name: 'Boersen',
+ enabled: true,
+ matchType: 'all',
+ conditions: [{ field: 'from', comparator: 'contains', value: 'autoscout24' }],
+ actions: [{ type: 'mark_read' }],
+ stopProcessing: false,
+};
+
+const audience = (only: VacationAudience['only'], domains = ['example.com', 'example.org']): VacationAudience => ({ only, domains });
+
+const generate = (vacationAudience: VacationAudience | null, includeVacation = true) =>
+ generateScript([rule], undefined, { includeVacation, vacationAudience, extensions: EXTENSIONS });
+
+/** The auto-reply block: its marker comment up to the first rule. */
+function replyBlock(script: string): string {
+ const start = script.indexOf('# Vacation auto-reply');
+ return script.slice(start, script.indexOf('\n\n', start));
+}
+
+describe('who gets the auto-reply (generator)', () => {
+ it('answers only senders from the own domains', () => {
+ // Judged by the envelope sender, whom the reply goes to: a From header
+ // naming a colleague on mail from outside gets no reply meant for colleagues.
+ const script = generate(audience('internal'));
+ expect(replyBlock(script)).toBe([
+ '# Vacation auto-reply',
+ 'if envelope :domain :is "from" ["example.com", "example.org"] {',
+ ` ${INCLUDE}`,
+ '}',
+ ].join('\n'));
+ expect(script).toMatch(/^require \["envelope", "imap4flags", "include"\];$/m);
+ });
+
+ it('answers only senders from elsewhere', () => {
+ expect(replyBlock(generate(audience('external')))).toBe([
+ '# Vacation auto-reply',
+ 'if not envelope :domain :is "from" ["example.com", "example.org"] {',
+ ` ${INCLUDE}`,
+ '}',
+ ].join('\n'));
+ });
+
+ it('answers everyone when no one is singled out', () => {
+ // Byte for byte as before the choice existed: see generator-compat.test.ts.
+ const script = generate(null);
+ expect(replyBlock(script)).toBe(['# Vacation auto-reply', INCLUDE].join('\n'));
+ expect(script).not.toContain('vacationAudience');
+ });
+
+ it('keeps the choice while the auto-reply is off', () => {
+ const script = generate(audience('external'), false);
+ expect(script).not.toContain('include');
+ expect(script).not.toContain('envelope');
+ expect(parseScript(script).vacationAudience).toEqual(audience('external'));
+ });
+
+ it('never writes a choice it cannot read back', () => {
+ for (const unusable of [
+ audience('external', []),
+ audience('external', ['example.com"; discard; "']),
+ { only: 'everyone', domains: ['example.com'] } as unknown as VacationAudience,
+ ]) {
+ const script = generate(unusable);
+ expect(replyBlock(script)).toBe(['# Vacation auto-reply', INCLUDE].join('\n'));
+ expect(script).not.toContain('vacationAudience');
+ }
+ });
+});
+
+describe('who gets the auto-reply (parser)', () => {
+ it('reads its own narrowed include back as its own', () => {
+ const readAndWrite = (script: string) => {
+ const parsed = parseScript(script);
+ return {
+ parsed,
+ script: generateScript(parsed.rules, parsed.vacation, {
+ includeVacation: parsed.includeVacation,
+ vacationAudience: parsed.vacationAudience,
+ externalRequires: parsed.externalRequires,
+ extensions: EXTENSIONS,
+ }),
+ };
+ };
+ const first = readAndWrite(generate(audience('internal')));
+ expect(first.parsed.isOpaque).toBe(false);
+ expect(first.parsed.includeVacation).toBe(true);
+ expect(first.parsed.vacationAudience).toEqual(audience('internal'));
+ expect(first.parsed.rules.map((r) => r.origin ?? 'bulwark')).toEqual(['bulwark']);
+ expect(readAndWrite(first.script).script).toBe(first.script);
+ expect(first.script.split(INCLUDE).length).toBe(2);
+ });
+
+ it('treats a choice it cannot read as a hand-edited script', () => {
+ const script = generate(audience('internal'));
+ for (const [from, to] of [
+ ['"only":"internal"', '"only":"intern"'],
+ ['"domains":["example.com","example.org"]', '"domains":[]'],
+ ['"domains":["example.com","example.org"]', '"domains":["example com"]'],
+ ]) {
+ expect(script).toContain(from);
+ expect(parseScript(script.replace(from, to)).isOpaque).toBe(true);
+ }
+ });
+});
+
+describe('ownDomains', () => {
+ it('takes each domain of the addresses once, lower-cased, skipping what is not one', () => {
+ expect(ownDomains(['Max@Example.com', 'info@example.com', 'shop@example.org', 'broken', 'x@localhost'])).toEqual([
+ 'example.com',
+ 'example.org',
+ ]);
+ });
+});
diff --git a/lib/sieve/__tests__/vacation-forward.test.ts b/lib/sieve/__tests__/vacation-forward.test.ts
new file mode 100644
index 000000000..b12c9d18b
--- /dev/null
+++ b/lib/sieve/__tests__/vacation-forward.test.ts
@@ -0,0 +1,170 @@
+import { describe, expect, it } from 'vitest';
+import type { FilterRule, VacationForward } from '@/lib/jmap/sieve-types';
+import { generateScript } from '../generator';
+import { parseScript } from '../parser';
+import { periodTests } from '../period';
+
+const EXTENSIONS = ['fileinto', 'imap4flags', 'include', 'copy', 'date', 'relational', 'spamtestplus', 'comparator-i;ascii-numeric'];
+const SPAM_GUARD = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"';
+
+const forward = (extra: Partial = {}): VacationForward => ({
+ enabled: true,
+ to: 'kollege@example.com',
+ keepCopy: false,
+ activeFrom: '2026-10-05T06:00:00.000Z',
+ activeUntil: '2026-10-16T16:00:00.000Z',
+ ...extra,
+});
+
+const rule: FilterRule = {
+ id: 'r1',
+ name: 'Boersen',
+ enabled: true,
+ matchType: 'all',
+ conditions: [{ field: 'from', comparator: 'contains', value: 'autoscout24' }],
+ actions: [{ type: 'mark_read' }],
+ stopProcessing: false,
+};
+
+const generate = (vacationForward: VacationForward | null, includeVacation = true, extensions = EXTENSIONS) =>
+ generateScript([rule], undefined, { includeVacation, vacationForward, extensions });
+
+/** The lines of the forwarding block, from its marker to its closing brace. */
+function forwardBlock(script: string): string[] | null {
+ const lines = script.split('\n');
+ const start = lines.indexOf('# Vacation forwarding');
+ if (start === -1) return null;
+ return lines.slice(start, lines.indexOf('}', start) + 1);
+}
+
+describe('vacation forwarding (generator)', () => {
+ it('forwards ahead of the rules, within the period, without spam, and stops', () => {
+ const script = generate(forward());
+ const [from, until] = periodTests(forward())!;
+ expect(forwardBlock(script)).toEqual([
+ '# Vacation forwarding',
+ `if allof(${from}, ${until}, ${SPAM_GUARD}) {`,
+ ' redirect "kollege@example.com";',
+ ' stop;',
+ '}',
+ ]);
+ // After the auto-reply runs, before any rule can file the message.
+ expect(script.indexOf('include :personal :optional "vacation";')).toBeLessThan(script.indexOf('# Vacation forwarding'));
+ expect(script.indexOf('# Vacation forwarding')).toBeLessThan(script.indexOf('# Rule: Boersen'));
+ expect(script).toMatch(/^require \["comparator-i;ascii-numeric", "date", "imap4flags", "include", "relational", "spamtestplus"\];$/m);
+ });
+
+ it('keeps a copy and lets the rules run when asked to', () => {
+ const script = generate(forward({ keepCopy: true }));
+ const block = forwardBlock(script)!;
+ expect(block).toContain(' redirect :copy "kollege@example.com";');
+ expect(block).not.toContain(' stop;');
+ expect(script).toMatch(/^require \[.*"copy".*\];$/m);
+ });
+
+ it('forwards without a period when the vacation has none', () => {
+ expect(forwardBlock(generate(forward({ activeFrom: undefined, activeUntil: undefined })))![1]).toBe(`if ${SPAM_GUARD} {`);
+ const noSpamTest = generate(forward({ activeFrom: undefined, activeUntil: undefined }), true, ['include']);
+ expect(forwardBlock(noSpamTest)![1]).toBe('if true {');
+ });
+
+ it('forwards without the auto-reply too', () => {
+ const script = generate(forward(), false);
+ expect(script).not.toContain('include');
+ expect(forwardBlock(script)).toEqual(forwardBlock(generate(forward())));
+ expect(script.indexOf('# Vacation forwarding')).toBeLessThan(script.indexOf('# Rule: Boersen'));
+ expect(parseScript(script).vacationForward).toEqual(forward());
+ });
+
+ it('does not forward while forwarding is switched off', () => {
+ const script = generate(forward({ enabled: false }));
+ expect(forwardBlock(script)).toBeNull();
+ expect(script).not.toContain('redirect');
+ expect(parseScript(script).vacationForward).toEqual(forward({ enabled: false }));
+ });
+
+ it('never writes forwarding it cannot read back, and never forwards without its period', () => {
+ for (const unusable of [
+ forward({ to: 'not an address' }),
+ forward({ activeUntil: '16.10.2026' }),
+ { ...forward(), keepCopy: 'yes' } as unknown as VacationForward,
+ ]) {
+ const script = generate(unusable);
+ expect(forwardBlock(script)).toBeNull();
+ expect(script).not.toContain('redirect');
+ expect(script).not.toContain('vacationForward');
+ }
+ });
+
+ it('writes no forwarding when there is none', () => {
+ // Byte for byte as before forwarding existed: see generator-compat.test.ts.
+ const script = generate(null);
+ expect(forwardBlock(script)).toBeNull();
+ expect(script).not.toContain('redirect');
+ expect(script).not.toContain('vacationForward');
+ });
+});
+
+describe('vacation forwarding (parser)', () => {
+ it('reads its own block back as its own, not as an external rule', () => {
+ const external = '# Hand\nif header :contains "subject" "hand" {\n keep;\n}\n';
+ const readAndWrite = (script: string) => {
+ const parsed = parseScript(script);
+ return {
+ parsed,
+ script: generateScript(parsed.rules, parsed.vacation, {
+ includeVacation: parsed.includeVacation,
+ vacationForward: parsed.vacationForward,
+ externalRequires: parsed.externalRequires,
+ extensions: EXTENSIONS,
+ }),
+ };
+ };
+ const first = readAndWrite(`${generate(forward())}\n${external}`);
+ expect(first.parsed.isOpaque).toBe(false);
+ expect(first.parsed.vacationForward).toEqual(forward());
+ expect(first.parsed.rules.map((r) => r.origin ?? 'bulwark')).toEqual(['bulwark', 'external']);
+ // Read and written again, nothing is lost or doubled. (Each pass adds two
+ // blank lines before the external rules; that is older than forwarding.)
+ const second = readAndWrite(first.script);
+ const blankRuns = (script: string) => script.replace(/\n{3,}/g, '\n\n');
+ expect(blankRuns(second.script)).toBe(blankRuns(first.script));
+ expect(second.parsed.vacationForward).toEqual(forward());
+ expect(second.script.split('# Vacation forwarding').length).toBe(2);
+ expect(second.script.split('if header :contains "subject" "hand"').length).toBe(2);
+ });
+
+ it('treats forwarding it cannot read as a hand-edited script', () => {
+ const script = generate(forward());
+ for (const [from, to] of [
+ ['"to":"kollege@example.com"', '"to":"kollege"'],
+ ['"activeUntil":"2026-10-16T16:00:00.000Z"', '"activeUntil":"bald"'],
+ ['"enabled":true', '"enabled":"true"'],
+ ]) {
+ expect(script).toContain(from);
+ expect(parseScript(script.replace(from, to)).isOpaque).toBe(true);
+ }
+ });
+
+ it('keeps a block with the marker as an external rule when the metadata has no forwarding', () => {
+ const handWritten = '# Vacation forwarding\nif true {\n redirect "x@example.com";\n}\n';
+ const parsed = parseScript(`${generate(null)}\n${handWritten}`);
+ expect(parsed.vacationForward).toBeUndefined();
+ // Kept as someone else's block (it reads as neither rule nor Bulwark's).
+ expect(parsed.rules.some((r) => r.origin === 'opaque' && r.rawBlock?.includes('redirect "x@example.com"'))).toBe(true);
+ });
+
+ it('keeps a block of someone else that only looks like its own', () => {
+ const team = '# Vacation forwarding of the team inbox (set up by IT)\nif address :is "to" "team@example.com" {\n redirect :copy "archive@example.com";\n}\n';
+ const exact = '# Vacation forwarding\nif true {\n redirect "x@example.com";\n}\n';
+ const kept = (script: string, raw: string) =>
+ parseScript(script).rules.some((r) => r.origin !== undefined && r.origin !== 'bulwark' && r.rawBlock?.includes(raw));
+ // A comment that only begins like the marker, while forwarding is on.
+ expect(kept(`${generate(forward())}\n${team}`, 'archive@example.com')).toBe(true);
+ // The exact marker while forwarding is off: Bulwark wrote no block then.
+ expect(kept(`${generate(forward({ enabled: false }))}\n${exact}`, 'x@example.com')).toBe(true);
+ // Its own block it still takes back, and writes once.
+ const own = parseScript(generate(forward()));
+ expect(own.rules.every((r) => (r.origin ?? 'bulwark') === 'bulwark')).toBe(true);
+ });
+});
diff --git a/lib/sieve/generator.ts b/lib/sieve/generator.ts
index ebe02b016..dcc326fc6 100644
--- a/lib/sieve/generator.ts
+++ b/lib/sieve/generator.ts
@@ -1,5 +1,8 @@
-import type { FilterRule, FilterCondition, FilterAction, FilterMetadata, VacationSieveConfig } from '@/lib/jmap/sieve-types';
+import type { FilterRule, FilterCondition, FilterAction, FilterMetadata, VacationAudience, VacationForward, VacationSieveConfig } from '@/lib/jmap/sieve-types';
import { debug } from '@/lib/debug';
+import { PERIOD_REQUIRES, hasPeriod, periodTests } from '@/lib/sieve/period';
+import { VACATION_FORWARD_MARKER, isValidVacationForward, normalizeVacationForward } from '@/lib/sieve/vacation-forward';
+import { isValidVacationAudience, normalizeVacationAudience } from '@/lib/sieve/vacation-audience';
const HEADER_MAP: Record = {
from: 'From',
@@ -162,7 +165,7 @@ function generateActions(actions: FilterAction[], useMailboxId: boolean): string
const SPAM_GUARD = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"';
const SPAM_GUARD_REQUIRES = ['spamtestplus', 'relational', 'comparator-i;ascii-numeric'];
-function supportsSpamGuard(extensions: string[] | undefined): boolean {
+export function supportsSpamGuard(extensions: string[] | undefined): boolean {
return ['spamtestplus', 'relational'].every(e => extensions?.includes(e));
}
@@ -194,6 +197,9 @@ function computeRequires(
if (needsSpamGuard(rule, serverExtensions)) {
for (const e of SPAM_GUARD_REQUIRES) extensions.add(e);
}
+ if (periodTests(rule)?.length) {
+ for (const e of PERIOD_REQUIRES) extensions.add(e);
+ }
for (const condition of rule.conditions) {
if (condition.field === 'body') extensions.add('body');
if (condition.field === 'attachment') extensions.add('mime');
@@ -241,6 +247,8 @@ function stripRuleForMetadata(r: FilterRule): Omit 0) bulwarkRequires.push(...PERIOD_REQUIRES);
+ if (forwardSpamGuard) bulwarkRequires.push(...SPAM_GUARD_REQUIRES);
+ }
const externalRequires = options.externalRequires ?? [];
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
@@ -316,7 +367,20 @@ export function generateScript(
// the parser does not re-import it as an external rule.
lines.push('');
lines.push('# Vacation auto-reply');
- lines.push(`include :personal :optional "${VACATION_SCRIPT_NAME}";`);
+ const include = `include :personal :optional "${VACATION_SCRIPT_NAME}";`;
+ const audience = metadata.vacationAudience;
+ if (audience) {
+ // Only senders from (or not from) the account's own domains. The reply
+ // goes to the envelope sender, so that is the address to judge: a From
+ // header can name a colleague on mail from anywhere, and the reply
+ // meant for colleagues would go to whoever sent it.
+ const domains = audience.domains.map(d => `"${escapeString(d)}"`).join(', ');
+ lines.push(`if ${audience.only === 'external' ? 'not ' : ''}envelope :domain :is "from" [${domains}] {`);
+ lines.push(` ${include}`);
+ lines.push('}');
+ } else {
+ lines.push(include);
+ }
}
if (vacation?.isEnabled) {
@@ -330,6 +394,19 @@ export function generateScript(
lines.push(`vacation ${vacationParts.join(' ')};`);
}
+ if (forward && forwardPeriod) {
+ // Spam is not passed on: it stays here for the server's own handling.
+ const tests = [...forwardPeriod, ...(forwardSpamGuard ? [SPAM_GUARD] : [])];
+ const condition = tests.length === 0 ? 'true' : tests.length === 1 ? tests[0] : `allof(${tests.join(', ')})`;
+ lines.push('');
+ lines.push(VACATION_FORWARD_MARKER);
+ lines.push(`if ${condition} {`);
+ lines.push(` redirect ${forward.keepCopy ? ':copy ' : ''}"${escapeString(forward.to)}";`);
+ // Without a copy kept here, no rule below may file it either.
+ if (!forward.keepCopy) lines.push(' stop;');
+ lines.push('}');
+ }
+
const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled);
for (const rule of enabledBulwarkRules) {
@@ -337,6 +414,13 @@ export function generateScript(
debug.warn('filters', `Skipping rule "${rule.name}": empty conditions or actions`);
continue;
}
+ // Without its period a rule would act all the time - a forwarding rule
+ // would forward for good - so one whose period is unusable is dropped.
+ const period = periodTests(rule);
+ if (period === null) {
+ debug.warn('filters', `Skipping rule "${rule.name}": unusable period`);
+ continue;
+ }
lines.push('');
// A line break in the name would end the comment and start a command.
@@ -360,6 +444,11 @@ export function generateScript(
: `allof(${conditionStr}, ${SPAM_GUARD})`;
}
+ // The period comes first: outside it, nothing else is evaluated.
+ if (period.length > 0) {
+ conditionStr = `allof(${[...period, conditionStr].join(', ')})`;
+ }
+
const actionLines = generateActions(rule.actions, useMailboxId);
// discard and reject only cancel the implicit keep (RFC 5228 4.4,
diff --git a/lib/sieve/parser.ts b/lib/sieve/parser.ts
index f6580f8e4..847f57dcc 100644
--- a/lib/sieve/parser.ts
+++ b/lib/sieve/parser.ts
@@ -5,9 +5,14 @@ import type {
FilterConditionField,
FilterMetadata,
FilterRule,
+ VacationAudience,
+ VacationForward,
VacationSieveConfig,
} from '@/lib/jmap/sieve-types';
import { debug } from '@/lib/debug';
+import { isPeriodBoundary } from '@/lib/sieve/period';
+import { VACATION_FORWARD_MARKER_RE, isValidVacationForward } from '@/lib/sieve/vacation-forward';
+import { isValidVacationAudience } from '@/lib/sieve/vacation-audience';
export interface ParseResult {
rules: FilterRule[];
@@ -16,6 +21,10 @@ export interface ParseResult {
externalRequires: string[];
/** The script runs the server's "vacation" script via `include`. */
includeVacation?: boolean;
+ /** Forwarding from the vacation card, on or off. */
+ vacationForward?: VacationForward;
+ /** Who gets the auto-reply, when not everyone. */
+ vacationAudience?: VacationAudience;
}
const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
@@ -64,6 +73,12 @@ function isValidRule(rule: unknown): rule is FilterRule {
typeof r.stopProcessing !== 'boolean'
) return false;
+ // A period that cannot be read would be dropped on the next save and the
+ // rule would act all the time; treating the script as hand-edited leaves
+ // it untouched instead.
+ if (r.activeFrom !== undefined && !isPeriodBoundary(r.activeFrom)) return false;
+ if (r.activeUntil !== undefined && !isPeriodBoundary(r.activeUntil)) return false;
+
return r.conditions.every(isValidCondition) && r.actions.every(isValidAction);
}
@@ -812,12 +827,21 @@ export function parseScript(content: string): ParseResult {
return OPAQUE;
}
- if (!metadata || metadata.version !== 1) return OPAQUE;
+ // Version 2 only marks fields older builds do not know (see FilterMetadata).
+ if (!metadata || (metadata.version !== 1 && metadata.version !== 2)) return OPAQUE;
if (!Array.isArray(metadata.rules)) return OPAQUE;
for (const rule of metadata.rules) {
if (!isValidRule(rule)) return OPAQUE;
}
+ // Forwarding that cannot be read could be written back without its
+ // period; as with a rule, the script then counts as edited by hand.
+ if (metadata.vacationForward !== undefined && !isValidVacationForward(metadata.vacationForward)) {
+ return OPAQUE;
+ }
+ if (metadata.vacationAudience !== undefined && !isValidVacationAudience(metadata.vacationAudience)) {
+ return OPAQUE;
+ }
// Scan the portion AFTER the metadata block for external rules. A prior
// Bulwark save may have emitted its "External rules" header here; strip
@@ -858,6 +882,9 @@ export function parseScript(content: string): ParseResult {
if (bulwarkRules.some(b => oneLine(b.name) === name)) return false;
}
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
+ // The generator writes the forwarding block from the metadata again.
+ // Only written while switched on, so only then is such a block Bulwark's.
+ if (metadata.vacationForward?.enabled && VACATION_FORWARD_MARKER_RE.test(raw)) return false;
return true;
});
@@ -875,6 +902,8 @@ export function parseScript(content: string): ParseResult {
vacation: metadata.vacation,
externalRequires,
...(metadata.includeVacation === true ? { includeVacation: true } : {}),
+ ...(metadata.vacationForward ? { vacationForward: metadata.vacationForward } : {}),
+ ...(metadata.vacationAudience ? { vacationAudience: metadata.vacationAudience } : {}),
};
}
diff --git a/lib/sieve/period.ts b/lib/sieve/period.ts
new file mode 100644
index 000000000..750cdb1f1
--- /dev/null
+++ b/lib/sieve/period.ts
@@ -0,0 +1,87 @@
+import type { FilterRule } from '@/lib/jmap/sieve-types';
+
+/**
+ * Time periods for filter rules: "only act on mail that arrives from ...
+ * until ...". Sieve has no rule scheduling of its own; the date extension
+ * (RFC 5260) lets a rule test the moment the message is being filtered.
+ */
+
+/** Sieve extensions a period needs: `currentdate` and `:value` comparisons. */
+export const PERIOD_REQUIRES = ['date', 'relational'] as const;
+
+export function supportsPeriods(extensions: readonly string[] | undefined | null): boolean {
+ return PERIOD_REQUIRES.every((e) => extensions?.includes(e));
+}
+
+// A moment with its zone spelled out. Without one, Date.parse reads the value
+// as local time, which would make the script depend on the browser saving it.
+const ISO_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/;
+
+/** Whether `value` is a usable period boundary (see FilterRule.activeFrom). */
+export function isPeriodBoundary(value: unknown): value is string {
+ if (typeof value !== 'string' || !ISO_WITH_ZONE.test(value)) return false;
+ const time = Date.parse(value);
+ // In UTC the zone can push it past year 9999 ("9999-12-31T23:59-01:00"),
+ // which the four-digit year of Sieve's "date" part cannot hold.
+ return !Number.isNaN(time) && /^\d{4}-/.test(new Date(time).toISOString());
+}
+
+export function hasPeriod(rule: Pick): boolean {
+ return rule.activeFrom !== undefined || rule.activeUntil !== undefined;
+}
+
+/**
+ * The boundary as RFC 5260 "date" (yyyy-mm-dd) and "time" (hh:mm:ss) parts in
+ * UTC. The RFC fixes both formats, so comparing them as strings works on any
+ * server - unlike "iso8601", whose zone suffix is up to the implementation.
+ */
+function utcParts(boundary: string): { date: string; time: string } {
+ const iso = new Date(Date.parse(boundary)).toISOString();
+ return { date: iso.slice(0, 10), time: iso.slice(11, 19) };
+}
+
+const NOW = 'currentdate :zone "+0000"';
+
+/** now >= boundary: a later day, or the same day at or after the time. */
+function atOrAfter(boundary: string): string {
+ const { date, time } = utcParts(boundary);
+ return `anyof(${NOW} :value "gt" "date" "${date}", allof(${NOW} :is "date" "${date}", ${NOW} :value "ge" "time" "${time}"))`;
+}
+
+/** now <= boundary: an earlier day, or the same day at or before the time. */
+function atOrBefore(boundary: string): string {
+ const { date, time } = utcParts(boundary);
+ return `anyof(${NOW} :value "lt" "date" "${date}", allof(${NOW} :is "date" "${date}", ${NOW} :value "le" "time" "${time}"))`;
+}
+
+/**
+ * The Sieve tests that confine `rule` to its period: none without a period,
+ * null when a boundary is unusable. A caller must then drop the rule rather
+ * than run it without its period - a forwarding rule would otherwise forward
+ * for good.
+ */
+export function periodTests(rule: Pick): string[] | null {
+ const tests: string[] = [];
+ if (rule.activeFrom !== undefined) {
+ if (!isPeriodBoundary(rule.activeFrom)) return null;
+ tests.push(atOrAfter(rule.activeFrom));
+ }
+ if (rule.activeUntil !== undefined) {
+ if (!isPeriodBoundary(rule.activeUntil)) return null;
+ tests.push(atOrBefore(rule.activeUntil));
+ }
+ return tests;
+}
+
+export type PeriodStatus = 'scheduled' | 'active' | 'expired';
+
+/** Where `now` falls in the rule's period; null for a rule without one. */
+export function periodStatus(
+ rule: Pick,
+ now: number = Date.now(),
+): PeriodStatus | null {
+ if (!hasPeriod(rule)) return null;
+ if (isPeriodBoundary(rule.activeFrom) && now < Date.parse(rule.activeFrom)) return 'scheduled';
+ if (isPeriodBoundary(rule.activeUntil) && now > Date.parse(rule.activeUntil)) return 'expired';
+ return 'active';
+}
diff --git a/lib/sieve/vacation-audience.ts b/lib/sieve/vacation-audience.ts
new file mode 100644
index 000000000..65b987d69
--- /dev/null
+++ b/lib/sieve/vacation-audience.ts
@@ -0,0 +1,30 @@
+import type { VacationAudience } from '@/lib/jmap/sieve-types';
+
+// A host name in the form the identities give it: labels of letters, digits
+// and inner hyphens, at least two of them.
+const DOMAIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/;
+
+/** Whether `value` is an audience Bulwark can write back as it reads. */
+export function isValidVacationAudience(value: unknown): value is VacationAudience {
+ if (!value || typeof value !== 'object') return false;
+ const a = value as Record;
+ if (a.only !== 'internal' && a.only !== 'external') return false;
+ return Array.isArray(a.domains) && a.domains.length > 0
+ && a.domains.every((d) => typeof d === 'string' && DOMAIN.test(d));
+}
+
+/** The fields Bulwark stores, in a fixed order. */
+export function normalizeVacationAudience(audience: VacationAudience): VacationAudience {
+ return { only: audience.only, domains: audience.domains };
+}
+
+/** The domains of the given addresses, lower-cased, each once, in order. */
+export function ownDomains(addresses: string[]): string[] {
+ const domains: string[] = [];
+ for (const address of addresses) {
+ const at = address.lastIndexOf('@');
+ const domain = at === -1 ? '' : address.slice(at + 1).trim().toLowerCase();
+ if (DOMAIN.test(domain) && !domains.includes(domain)) domains.push(domain);
+ }
+ return domains;
+}
diff --git a/lib/sieve/vacation-forward.ts b/lib/sieve/vacation-forward.ts
new file mode 100644
index 000000000..7a63198c3
--- /dev/null
+++ b/lib/sieve/vacation-forward.ts
@@ -0,0 +1,55 @@
+import type { VacationForward } from '@/lib/jmap/sieve-types';
+import { isValidEmail } from '@/lib/validation';
+import { isPeriodBoundary } from '@/lib/sieve/period';
+
+/**
+ * Forwarding from the vacation card (see VacationForward). The generator
+ * writes it as its own block after the vacation include, marked by this
+ * comment; the parser knows the block by it and leaves it to the generator.
+ */
+export const VACATION_FORWARD_MARKER = '# Vacation forwarding';
+
+/**
+ * The marker as the parser looks for it in a block: the whole line, so a
+ * comment of someone's own that only begins like it is not taken for it.
+ */
+export const VACATION_FORWARD_MARKER_RE = /^[ \t]*# Vacation forwarding[ \t]*$/m;
+
+/** Whether `value` is forwarding Bulwark can write back as it reads. */
+export function isValidVacationForward(value: unknown): value is VacationForward {
+ if (!value || typeof value !== 'object') return false;
+ const f = value as Record;
+ if (typeof f.enabled !== 'boolean' || typeof f.keepCopy !== 'boolean') return false;
+ if (typeof f.to !== 'string' || !isValidEmail(f.to)) return false;
+ if (f.activeFrom !== undefined && !isPeriodBoundary(f.activeFrom)) return false;
+ if (f.activeUntil !== undefined && !isPeriodBoundary(f.activeUntil)) return false;
+ return true;
+}
+
+/**
+ * The forwarding with the vacation's period, whatever period it had: it runs
+ * while the vacation does (an empty date leaves that end open).
+ */
+export function withVacationPeriod(
+ forward: Pick,
+ period: { from: string | null; until: string | null },
+): VacationForward {
+ return {
+ enabled: forward.enabled,
+ to: forward.to,
+ keepCopy: forward.keepCopy,
+ ...(period.from ? { activeFrom: period.from } : {}),
+ ...(period.until ? { activeUntil: period.until } : {}),
+ };
+}
+
+/** The fields Bulwark stores, in a fixed order. */
+export function normalizeVacationForward(forward: VacationForward): VacationForward {
+ return {
+ enabled: forward.enabled,
+ to: forward.to,
+ keepCopy: forward.keepCopy,
+ ...(forward.activeFrom !== undefined ? { activeFrom: forward.activeFrom } : {}),
+ ...(forward.activeUntil !== undefined ? { activeUntil: forward.activeUntil } : {}),
+ };
+}
diff --git a/lib/timezone.ts b/lib/timezone.ts
index 220cb22c9..25fe7aebe 100644
--- a/lib/timezone.ts
+++ b/lib/timezone.ts
@@ -194,6 +194,20 @@ export function fromZonedDisplayDate(wall: Date, timeZone: string): Date {
return new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), timeZone));
}
+/**
+ * The instant at which the clock in `timeZone` reads `w`. Unlike
+ * fromZonedDisplayDate it never goes through a Date's local fields, so a
+ * time that the browser's own zone skips at a DST change is not shifted.
+ */
+export function fromWallClock(w: WallClock, timeZone: string): Date {
+ const d = new Date(0);
+ d.setUTCFullYear(w.year, w.month - 1, w.day);
+ d.setUTCHours(w.hour, w.minute, w.second, 0);
+ const asUtc = d.getTime();
+ const guess = asUtc - getTimeZoneOffsetMs(new Date(asUtc), timeZone);
+ return new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), timeZone));
+}
+
/**
* Instant -> display date for the calendar grid. Identity unless the user's
* time zone override differs from the browser zone.
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 9248fb471..b2edf1adc 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "فشل حذف الهوية: {error}",
"identity_unauthorized": "غير مخوّل لك الإرسال من عنوان البريد الإلكتروني هذا",
"identity_not_found": "الهوية غير موجودة",
- "vacation_saved": "تم حفظ إعدادات الرد التلقائي أثناء الغياب",
- "vacation_save_failed": "فشل حفظ إعدادات الرد التلقائي أثناء الغياب",
+ "vacation_saved": "تم حفظ إعدادات الغياب",
+ "vacation_save_failed": "فشل حفظ إعدادات الغياب",
"filters_saved": "تم حفظ المرشحات بنجاح",
"filters_save_failed": "فشل حفظ المرشحات",
"filters_deleted": "تم حذف قاعدة المرشح",
@@ -858,7 +858,8 @@
"rule_undone": "تم التراجع عن تغيير القاعدة",
"rule_undo_failed": "تعذّر التراجع عن تغيير القاعدة",
"rule_undo_conflict": "تغيّرت مرشحاتك منذ ذلك الحين. تراجع عن التغيير من إعدادات المرشحات.",
- "rule_folder_failed": "تعذّر إنشاء المجلد"
+ "rule_folder_failed": "تعذّر إنشاء المجلد",
+ "vacation_filters_save_failed": "تم حفظ إعدادات الغياب، لكن تعذّر حفظ إعادة التوجيه ومستلمي الرد"
},
"date": {
"today": "اليوم",
@@ -946,7 +947,7 @@
"privacy": "الخصوصية والأمان",
"account": "الحساب",
"identities": "الهويات",
- "vacation": "الرد التلقائي أثناء الغياب",
+ "vacation": "الغياب",
"advanced": "متقدم",
"calendar": "التقويم",
"filters": "المرشحات",
@@ -1825,28 +1826,24 @@
}
},
"vacation": {
- "title": "الرد التلقائي أثناء الغياب",
- "description": "الرد تلقائيًا على الرسائل الواردة أثناء غيابك",
+ "title": "الغياب",
+ "description": "التعامل تلقائيًا مع الرسائل الواردة أثناء غيابك",
"loading": "جارٍ تحميل إعدادات الغياب...",
"not_supported": "خادم بريدك لا يدعم ردود الغياب.",
"fetch_error": "فشل تحميل إعدادات الغياب. يرجى المحاولة مرة أخرى.",
"status": {
- "label": "الرد التلقائي أثناء الغياب",
+ "label": "إرسال رد تلقائي",
"description": "إرسال رد تلقائي إلى من يرسل إليك بريدًا",
"active": "مفعّل",
"inactive": "معطّل"
},
"date_range": {
- "title": "النطاق الزمني",
- "description": "يمكنك اختياريًا تحديد الرد التلقائي بفترة معينة",
"start": "تاريخ البدء",
"start_description": "اتركه فارغًا لعدم وجود حد للبدء",
"end": "تاريخ الانتهاء",
"end_description": "اتركه فارغًا لعدم وجود حد للانتهاء"
},
"message": {
- "title": "رسالة الرد التلقائي",
- "description": "الرسالة التي ستُرسل كرد",
"subject_label": "الموضوع",
"subject_description": "سطر موضوع الرد التلقائي",
"subject_placeholder": "خارج المكتب",
@@ -1866,10 +1863,32 @@
"saving": "جارٍ الحفظ...",
"warnings": {
"end_before_start": "يجب أن يكون تاريخ الانتهاء بعد تاريخ البدء",
+ "invalid_date": "أدخل تاريخ بدء وانتهاء صالحين مع الوقت، أو اترك الحقل فارغًا",
"start_in_past": "تاريخ البدء في الماضي",
"empty_body": "نص الرسالة فارغ - سيتلقى المستلمون ردًا فارغًا",
"subject_too_long": "الموضوع طويل جدًا بالنسبة للخادم (بحد أقصى {max} بايت).",
- "body_too_long": "الرسالة طويلة جدًا بالنسبة للخادم (بحد أقصى {max} بايت بما في ذلك التنسيق)."
+ "body_too_long": "الرسالة طويلة جدًا بالنسبة للخادم (بحد أقصى {max} بايت بما في ذلك التنسيق).",
+ "forward_address": "أدخل عنوانًا صالحًا لإعادة التوجيه إليه",
+ "not_running": "إعادة التوجيه أو مستلمو الرد محفوظون لكنهم غير مفعّلين الآن. احفظ لإعادة تفعيلهم."
+ },
+ "forward": {
+ "title": "إعادة التوجيه",
+ "description": "تُعيد توجيه الرسائل الواردة إلى عنوان آخر، مع الرد التلقائي أو بدونه، خلال الفترة أعلاه (بدون تاريخ انتهاء، حتى يتم إيقافها). لا تتم إعادة توجيه الرسائل غير المرغوب فيها.",
+ "enabled_label": "إعادة توجيه الرسائل",
+ "to_label": "إعادة التوجيه إلى",
+ "to_placeholder": "email@example.com",
+ "keep_label": "الإبقاء في الوارد",
+ "keep_description": "وإلا فستتم إعادة توجيه الرسالة فقط: لن تبقى في صندوق البريد هذا، ولن تُطبَّق عليها أي قاعدة تصفية."
+ },
+ "audience": {
+ "label": "الرد على",
+ "description": "المرسلون الداخليون: {domains}",
+ "all": "جميع المرسلين",
+ "internal": "المرسلون الداخليون فقط",
+ "external": "المرسلون الخارجيون فقط"
+ },
+ "auto_reply": {
+ "title": "الرد التلقائي"
}
},
"folders": {
@@ -2238,7 +2257,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "طبّقها أيضًا على الرسائل الموجودة في هذا المجلد",
- "apply_existing_unsupported": "لا يمكن تطبيق القواعد على الرسائل الموجودة إلا إذا كانت تفحص المرسل أو المستلمين أو الموضوع أو الترويسات، وتنقل أو تنسخ أو تميّز كمقروءة أو بنجمة أو بوسم."
+ "apply_existing_unsupported": "لا يمكن تطبيق القواعد على الرسائل الموجودة إلا إذا كانت تفحص المرسل أو المستلمين أو الموضوع أو الترويسات، وتنقل أو تنسخ أو تميّز كمقروءة أو بنجمة أو بوسم.",
+ "period_title": "النطاق الزمني",
+ "period_start": "تاريخ البدء",
+ "period_end": "تاريخ الانتهاء",
+ "validation_period_order": "يجب أن يكون تاريخ الانتهاء بعد تاريخ البدء",
+ "period_toggle": "نشطة فقط خلال فترة زمنية",
+ "period_hint": "تنطبق على الرسائل التي تصل خلال هذه الفترة. اترك حقلًا فارغًا لإبقاء ذلك الطرف مفتوحًا.",
+ "validation_period_empty": "أدخل تاريخ بدء أو انتهاء للفترة",
+ "validation_period_invalid": "أدخل تاريخًا صالحًا مع الوقت للفترة، أو اترك الحقل فارغًا",
+ "apply_existing_period": "القاعدة ذات الفترة الزمنية تعمل فقط على الرسائل التي تصل خلالها، لذا لا يمكن تطبيقها على الرسائل الموجودة.",
+ "period_status_scheduled": "مجدولة",
+ "period_status_active": "نشطة",
+ "period_status_expired": "منتهية"
},
"templates": {
"title": "قوالب البريد",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 7c0058a89..be1237e83 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "No s'ha pogut suprimir la identitat: {error}",
"identity_unauthorized": "No teniu autorització per enviar des d'aquesta adreça electrònica",
"identity_not_found": "No s'ha trobat la identitat",
- "vacation_saved": "S'ha desat la configuració de la resposta automàtica",
- "vacation_save_failed": "No s'ha pogut desar la configuració de la resposta automàtica",
+ "vacation_saved": "S'ha desat la configuració d'absència",
+ "vacation_save_failed": "No s'ha pogut desar la configuració d'absència",
"filters_saved": "Filtres desats correctament",
"filters_save_failed": "No s'han pogut desar els filtres",
"filters_deleted": "Regla de filtre suprimida",
@@ -858,7 +858,8 @@
"rule_undone": "S'ha desfet el canvi de la regla",
"rule_undo_failed": "No s'ha pogut desfer el canvi de la regla",
"rule_undo_conflict": "Els teus filtres han canviat des d'aleshores. Desfés el canvi a la configuració dels filtres.",
- "rule_folder_failed": "No s'ha pogut crear la carpeta"
+ "rule_folder_failed": "No s'ha pogut crear la carpeta",
+ "vacation_filters_save_failed": "S'ha desat la configuració d'absència, però no s'han pogut desar el reenviament ni els destinataris de la resposta"
},
"date": {
"today": "Avui",
@@ -946,7 +947,7 @@
"privacy": "Privadesa i seguretat",
"account": "Compte",
"identities": "Identitats",
- "vacation": "Resposta automàtica",
+ "vacation": "Absència",
"advanced": "Avançat",
"calendar": "Calendari",
"filters": "Filtres",
@@ -1825,28 +1826,24 @@
}
},
"vacation": {
- "title": "Resposta automàtica",
- "description": "Respon automàticament als correus entrants mentre esteu fora",
+ "title": "Absència",
+ "description": "Gestiona automàticament els correus entrants mentre esteu fora",
"loading": "Carregant la configuració de la resposta automàtica...",
"not_supported": "El servidor de correu no admet respostes automàtiques.",
"fetch_error": "No s'ha pogut carregar la configuració de la resposta automàtica. Torneu-ho a provar.",
"status": {
- "label": "Resposta automàtica",
+ "label": "Respon automàticament",
"description": "Envia una resposta automàtica a les persones que us envien correu",
"active": "Activa",
"inactive": "Inactiva"
},
"date_range": {
- "title": "Interval de dates",
- "description": "Opcionalment, limiteu la resposta automàtica a un període específic",
"start": "Data d'inici",
"start_description": "Deixeu-ho buit per no establir cap límit inicial",
"end": "Data de finalització",
"end_description": "Deixeu-ho buit per no establir cap límit final"
},
"message": {
- "title": "Missatge de resposta automàtica",
- "description": "El missatge que s'enviarà com a resposta",
"subject_label": "Assumpte",
"subject_description": "Línia d'assumpte de la resposta automàtica",
"subject_placeholder": "Fora de l'oficina",
@@ -1866,10 +1863,32 @@
"saving": "Desant...",
"warnings": {
"end_before_start": "La data de finalització ha de ser posterior a la data d'inici",
+ "invalid_date": "Introdueix una data d'inici i de finalització vàlides amb l'hora, o deixa el camp buit",
"start_in_past": "La data d'inici és al passat",
"empty_body": "El cos del missatge està buit - els destinataris rebran una resposta en blanc",
"subject_too_long": "L'assumpte és massa llarg per al servidor (com a màxim {max} bytes).",
- "body_too_long": "El missatge és massa llarg per al servidor (com a màxim {max} bytes, amb el format inclòs)."
+ "body_too_long": "El missatge és massa llarg per al servidor (com a màxim {max} bytes, amb el format inclòs).",
+ "forward_address": "Introdueix una adreça vàlida per al reenviament",
+ "not_running": "El reenviament o els destinataris de la resposta estan desats però ara no estan actius. Desa per tornar-los a activar."
+ },
+ "forward": {
+ "title": "Reenviament",
+ "description": "Reenvia els missatges entrants a una altra adreça, amb resposta automàtica o sense, dins del període indicat a dalt (sense data de finalització, fins que es desactivi). El correu brossa no es reenvia.",
+ "enabled_label": "Reenvia els missatges",
+ "to_label": "Reenvia a",
+ "to_placeholder": "correu@exemple.com",
+ "keep_label": "Mantén a la safata d'entrada",
+ "keep_description": "Si no, el missatge només es reenvia: no es conserva en aquesta bústia i no s'hi aplica cap regla de filtre."
+ },
+ "audience": {
+ "label": "Respon a",
+ "description": "Remitents interns: {domains}",
+ "all": "Tots els remitents",
+ "internal": "Només remitents interns",
+ "external": "Només remitents externs"
+ },
+ "auto_reply": {
+ "title": "Resposta automàtica"
}
},
"folders": {
@@ -2238,7 +2257,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Aplica-ho també als missatges que ja hi ha en aquesta carpeta",
- "apply_existing_unsupported": "Només les regles que comproven el remitent, els destinataris, l'assumpte o les capçaleres, i que mouen, copien, marquen com a llegit, destaquen o etiqueten, es poden aplicar a missatges existents."
+ "apply_existing_unsupported": "Només les regles que comproven el remitent, els destinataris, l'assumpte o les capçaleres, i que mouen, copien, marquen com a llegit, destaquen o etiqueten, es poden aplicar a missatges existents.",
+ "period_title": "Interval de dates",
+ "period_start": "Data d'inici",
+ "period_end": "Data de finalització",
+ "validation_period_order": "La data de finalització ha de ser posterior a la data d'inici",
+ "period_toggle": "Activa només durant un període",
+ "period_hint": "S'aplica als missatges que arriben durant aquest període. Deixa un camp buit per no limitar aquell extrem.",
+ "validation_period_empty": "Introdueix una data d'inici o de finalització per al període",
+ "validation_period_invalid": "Introdueix una data vàlida amb l'hora per al període, o deixa el camp buit",
+ "apply_existing_period": "Una regla amb període només actua sobre els missatges que arriben dins d'aquest període, de manera que no es pot aplicar als missatges existents.",
+ "period_status_scheduled": "programada",
+ "period_status_active": "activa",
+ "period_status_expired": "caducada"
},
"templates": {
"title": "Plantilles de correu",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 1d2db2a9b..aaf4d5755 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Odstranění identity selhalo: {error}",
"identity_unauthorized": "Nemáte oprávnění odesílat z této e-mailové adresy",
"identity_not_found": "Identita nenalezena",
- "vacation_saved": "Nastavení automatické odpovědi bylo uloženo",
- "vacation_save_failed": "Uložení nastavení automatické odpovědi selhalo",
+ "vacation_saved": "Nastavení nepřítomnosti bylo uloženo",
+ "vacation_save_failed": "Uložení nastavení nepřítomnosti selhalo",
"filters_saved": "Filtry úspěšně uloženy",
"filters_save_failed": "Uložení filtrů selhalo",
"filters_deleted": "Pravidlo filtru bylo odstraněno",
@@ -858,7 +858,8 @@
"rule_undone": "Změna pravidla vrácena",
"rule_undo_failed": "Změnu pravidla se nepodařilo vrátit",
"rule_undo_conflict": "Vaše filtry se mezitím změnily. Vraťte změnu v nastavení filtrů.",
- "rule_folder_failed": "Složku se nepodařilo vytvořit"
+ "rule_folder_failed": "Složku se nepodařilo vytvořit",
+ "vacation_filters_save_failed": "Nastavení nepřítomnosti bylo uloženo, ale přeposílání a příjemce odpovědi se uložit nepodařilo"
},
"date": {
"today": "Dnes",
@@ -943,7 +944,7 @@
"privacy": "Soukromí a bezpečnost",
"account": "Účet",
"identities": "Identity",
- "vacation": "Automatická odpověď",
+ "vacation": "Nepřítomnost",
"advanced": "Pokročilé",
"calendar": "Kalendář",
"filters": "Filtry",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "Automatická odpověď",
- "description": "Během vaší nepřítomnosti bude automaticky odpovídáno na příchozí e-maily",
+ "title": "Nepřítomnost",
+ "description": "Během vaší nepřítomnosti se na příchozí e-maily bude reagovat automaticky",
"loading": "Načítání nastavení automatické odpovědi...",
"not_supported": "Váš poštovní server nepodporuje automatické odpovědi v nepřítomnosti.",
"fetch_error": "Nepodařilo se načíst nastavení automatické odpovědi. Zkuste to znovu.",
"status": {
- "label": "Automatická odpověď",
+ "label": "Odpovídat automaticky",
"description": "Odesílat automatickou odpověď lidem, kteří vám napíší",
"active": "Aktivní",
"inactive": "Neaktivní"
},
"date_range": {
- "title": "Časové období",
- "description": "Volitelně omezte automatickou odpověď na určité období",
"start": "Datum zahájení",
"start_description": "Nechte prázdné pro spuštění ihned",
"end": "Datum ukončení",
"end_description": "Nechte prázdné pro běh bez ukončení"
},
"message": {
- "title": "Zpráva automatické odpovědi",
- "description": "Zpráva, která bude odeslána jako odpověď",
"subject_label": "Předmět",
"subject_description": "Předmět automatické odpovědi",
"subject_placeholder": "Mimo kancelář",
@@ -1859,10 +1856,32 @@
"saving": "Ukládání...",
"warnings": {
"end_before_start": "Datum ukončení musí být po datu zahájení",
+ "invalid_date": "Zadejte platné datum zahájení a ukončení včetně času, nebo pole nechte prázdné",
"start_in_past": "Datum zahájení je v minulosti",
"empty_body": "Tělo zprávy je prázdné - příjemci obdrží prázdnou odpověď",
"subject_too_long": "Předmět je pro server příliš dlouhý (nejvýše {max} bajtů).",
- "body_too_long": "Zpráva je pro server příliš dlouhá (nejvýše {max} bajtů včetně formátování)."
+ "body_too_long": "Zpráva je pro server příliš dlouhá (nejvýše {max} bajtů včetně formátování).",
+ "forward_address": "Zadejte platnou adresu pro přeposílání",
+ "not_running": "Přeposílání nebo příjemci odpovědi jsou uloženi, ale právě nejsou aktivní. Uložením je znovu zapnete."
+ },
+ "forward": {
+ "title": "Přeposílání",
+ "description": "Přeposílá příchozí zprávy na jinou adresu, s automatickou odpovědí i bez ní, v období uvedeném výše (bez data ukončení, dokud ho nevypnete). Spam se nepřeposílá.",
+ "enabled_label": "Přeposílat zprávy",
+ "to_label": "Přeposlat na",
+ "to_placeholder": "email@example.com",
+ "keep_label": "Ponechat v Doručených",
+ "keep_description": "Jinak se zpráva pouze přepošle: nezůstane v této schránce a nepoužije se na ni žádné pravidlo filtru."
+ },
+ "audience": {
+ "label": "Odpovídat",
+ "description": "Interní odesílatelé: {domains}",
+ "all": "Všem odesílatelům",
+ "internal": "Jen interním odesílatelům",
+ "external": "Jen externím odesílatelům"
+ },
+ "auto_reply": {
+ "title": "Automatická odpověď"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Použít i na zprávy, které už v této složce jsou",
- "apply_existing_unsupported": "Na existující zprávy lze použít jen pravidla, která kontrolují odesílatele, příjemce, předmět nebo hlavičky a která přesouvají, kopírují, označují jako přečtené, hvězdičkou nebo štítkem."
+ "apply_existing_unsupported": "Na existující zprávy lze použít jen pravidla, která kontrolují odesílatele, příjemce, předmět nebo hlavičky a která přesouvají, kopírují, označují jako přečtené, hvězdičkou nebo štítkem.",
+ "period_title": "Časové období",
+ "period_start": "Datum zahájení",
+ "period_end": "Datum ukončení",
+ "validation_period_order": "Datum ukončení musí být po datu zahájení",
+ "period_toggle": "Aktivní jen v určitém období",
+ "period_hint": "Platí pro zprávy, které přijdou v tomto období. Prázdné pole ponechá daný konec otevřený.",
+ "validation_period_empty": "Zadejte datum zahájení nebo ukončení období",
+ "validation_period_invalid": "Zadejte pro období platné datum včetně času, nebo pole nechte prázdné",
+ "apply_existing_period": "Pravidlo s obdobím působí jen na zprávy, které v něm přijdou, a proto ho nelze použít na existující zprávy.",
+ "period_status_scheduled": "naplánováno",
+ "period_status_active": "aktivní",
+ "period_status_expired": "vypršelo"
},
"templates": {
"title": "E-mailové šablony",
diff --git a/locales/da/common.json b/locales/da/common.json
index 4787b5624..bc05de3aa 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Kunne ikke slette identitet: {error}",
"identity_unauthorized": "Du er ikke autoriseret til at sende fra denne e-mailadresse",
"identity_not_found": "Identitet ikke fundet",
- "vacation_saved": "Fraværsbesked-indstillinger gemt",
- "vacation_save_failed": "Kunne ikke gemme fraværsbesked-indstillinger",
+ "vacation_saved": "Fraværsindstillinger gemt",
+ "vacation_save_failed": "Kunne ikke gemme fraværsindstillinger",
"filters_saved": "Filtre gemt succesfuldt",
"filters_save_failed": "Kunne ikke gemme filtre",
"filters_deleted": "Filterregel slettet",
@@ -858,7 +858,8 @@
"rule_undone": "Regelændring fortrudt",
"rule_undo_failed": "Regelændringen kunne ikke fortrydes",
"rule_undo_conflict": "Dine filtre er ændret siden. Fortryd ændringen i filterindstillingerne.",
- "rule_folder_failed": "Mappen kunne ikke oprettes"
+ "rule_folder_failed": "Mappen kunne ikke oprettes",
+ "vacation_filters_save_failed": "Fraværsindstillinger gemt, men videresendelse og svarmodtagere kunne ikke gemmes"
},
"date": {
"today": "I dag",
@@ -946,7 +947,7 @@
"privacy": "Privatliv & sikkerhed",
"account": "Konto",
"identities": "Identiteter",
- "vacation": "Fraværsbeskeder",
+ "vacation": "Fravær",
"advanced": "Avanceret",
"calendar": "Kalender",
"filters": "Filtre",
@@ -1821,28 +1822,24 @@
}
},
"vacation": {
- "title": "Fraværsbeskeder",
- "description": "Svar automatisk på indkommende e-mails, mens du er væk",
+ "title": "Fravær",
+ "description": "Håndter indkommende e-mails automatisk, mens du er væk",
"loading": "Indlæser ferieindstillinger...",
"not_supported": "Din mailserver understøtter ikke fraværsbeskeder.",
"fetch_error": "Kunne ikke indlæse ferieindstillinger. Prøv igen.",
"status": {
- "label": "Fraværsbeskeder",
+ "label": "Svar automatisk",
"description": "Send et automatisk svar til personer, der skriver til dig",
"active": "Aktiv",
"inactive": "Inaktiv"
},
"date_range": {
- "title": "Datoperiode",
- "description": "Begræns eventuelt auto-svaret til en bestemt periode",
"start": "Startdato",
"start_description": "Lad være tom for ingen startbegrænsning",
"end": "Slutdato",
"end_description": "Lad være tom for ingen slutbegrænsning"
},
"message": {
- "title": "Auto-svar-besked",
- "description": "Beskeden der sendes som svar",
"subject_label": "Emne",
"subject_description": "Emnelinje for auto-svaret",
"subject_placeholder": "Fraværende",
@@ -1862,10 +1859,32 @@
"saving": "Gemmer...",
"warnings": {
"end_before_start": "Slutdato skal være efter startdato",
+ "invalid_date": "Angiv en gyldig start- og slutdato med klokkeslæt, eller lad feltet stå tomt",
"start_in_past": "Startdato er tidligere end i dag",
"empty_body": "Beskedteksten er tom - modtagere vil modtage et tomt svar",
"subject_too_long": "Emnet er for langt til serveren (højst {max} byte).",
- "body_too_long": "Beskeden er for lang til serveren (højst {max} byte inkl. formatering)."
+ "body_too_long": "Beskeden er for lang til serveren (højst {max} byte inkl. formatering).",
+ "forward_address": "Angiv en gyldig adresse at videresende til",
+ "not_running": "Videresendelse eller svarmodtagere er gemt, men er ikke aktive lige nu. Gem for at slå dem til igen."
+ },
+ "forward": {
+ "title": "Videresendelse",
+ "description": "Videresender indgående beskeder til en anden adresse, med eller uden fraværsbesked, i perioden ovenfor (uden slutdato, indtil den slås fra). Spam videresendes ikke.",
+ "enabled_label": "Videresend beskeder",
+ "to_label": "Videresend til",
+ "to_placeholder": "email@eksempel.dk",
+ "keep_label": "Behold i indbakke",
+ "keep_description": "Ellers bliver beskeden kun videresendt: Den bliver ikke i denne postkasse, og ingen filterregel køres på den."
+ },
+ "audience": {
+ "label": "Svar til",
+ "description": "Interne afsendere: {domains}",
+ "all": "Alle afsendere",
+ "internal": "Kun interne afsendere",
+ "external": "Kun eksterne afsendere"
+ },
+ "auto_reply": {
+ "title": "Automatisk svar"
}
},
"folders": {
@@ -2234,7 +2253,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Anvend også på beskeder, der allerede ligger i denne mappe",
- "apply_existing_unsupported": "Kun regler, der tjekker afsender, modtagere, emne eller headere, og som flytter, kopierer, markerer som læst, stjernemarkerer eller tagger, kan anvendes på eksisterende beskeder."
+ "apply_existing_unsupported": "Kun regler, der tjekker afsender, modtagere, emne eller headere, og som flytter, kopierer, markerer som læst, stjernemarkerer eller tagger, kan anvendes på eksisterende beskeder.",
+ "period_title": "Datoperiode",
+ "period_start": "Startdato",
+ "period_end": "Slutdato",
+ "validation_period_order": "Slutdato skal være efter startdato",
+ "period_toggle": "Kun aktiv i en periode",
+ "period_hint": "Gælder for beskeder, der ankommer i denne periode. Lad et felt stå tomt for at holde den ende åben.",
+ "validation_period_empty": "Angiv en start- eller slutdato for perioden",
+ "validation_period_invalid": "Angiv en gyldig dato med klokkeslæt for perioden, eller lad feltet stå tomt",
+ "apply_existing_period": "En regel med en periode virker kun på beskeder, der ankommer i perioden, og kan derfor ikke køres på eksisterende beskeder.",
+ "period_status_scheduled": "planlagt",
+ "period_status_active": "aktiv",
+ "period_status_expired": "udløbet"
},
"templates": {
"title": "E-mail-skabeloner",
diff --git a/locales/de/common.json b/locales/de/common.json
index a2be020bc..d94bd9bd4 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -841,8 +841,8 @@
"identity_delete_failed": "Identität löschen fehlgeschlagen: {error}",
"identity_unauthorized": "Sie sind nicht autorisiert, von dieser E-Mail-Adresse zu senden",
"identity_not_found": "Identität nicht gefunden",
- "vacation_saved": "Abwesenheitsnotiz-Einstellungen gespeichert",
- "vacation_save_failed": "Fehler beim Speichern der Abwesenheitsnotiz-Einstellungen",
+ "vacation_saved": "Abwesenheits-Einstellungen gespeichert",
+ "vacation_save_failed": "Fehler beim Speichern der Abwesenheits-Einstellungen",
"filters_saved": "Filter erfolgreich gespeichert",
"filters_save_failed": "Fehler beim Speichern der Filter",
"filters_deleted": "Filterregel gelöscht",
@@ -864,7 +864,8 @@
"rule_undone": "Regeländerung rückgängig gemacht",
"rule_undo_failed": "Die Regeländerung konnte nicht rückgängig gemacht werden",
"rule_undo_conflict": "Ihre Filter wurden inzwischen geändert. Machen Sie die Änderung in den Filter-Einstellungen rückgängig.",
- "rule_folder_failed": "Der Ordner konnte nicht erstellt werden"
+ "rule_folder_failed": "Der Ordner konnte nicht erstellt werden",
+ "vacation_filters_save_failed": "Abwesenheits-Einstellungen gespeichert, Weiterleitung und Antwortempfänger konnten aber nicht gespeichert werden"
},
"date": {
"today": "Heute",
@@ -949,7 +950,7 @@
"privacy": "Datenschutz & Sicherheit",
"account": "Konto",
"identities": "Identitäten",
- "vacation": "Abwesenheitsnotiz",
+ "vacation": "Abwesenheit",
"advanced": "Erweitert",
"calendar": "Kalender",
"filters": "Filter",
@@ -1824,28 +1825,24 @@
}
},
"vacation": {
- "title": "Abwesenheitsnotiz",
- "description": "Automatisch auf eingehende E-Mails antworten, während Sie abwesend sind",
+ "title": "Abwesenheit",
+ "description": "Automatisch auf eingehende E-Mails reagieren, während Sie abwesend sind",
"loading": "Abwesenheitseinstellungen werden geladen...",
"not_supported": "Ihr Mailserver unterstützt keine Abwesenheitsantworten.",
"fetch_error": "Fehler beim Laden der Abwesenheitseinstellungen. Bitte versuchen Sie es erneut.",
"status": {
- "label": "Abwesenheitsnotiz",
+ "label": "Automatisch antworten",
"description": "Senden Sie eine automatische Antwort an Personen, die Ihnen eine E-Mail senden",
"active": "Aktiv",
"inactive": "Inaktiv"
},
"date_range": {
- "title": "Zeitraum",
- "description": "Optional die automatische Antwort auf einen bestimmten Zeitraum beschränken",
"start": "Startdatum",
"start_description": "Leer lassen für kein Startlimit",
"end": "Enddatum",
"end_description": "Leer lassen für kein Endlimit"
},
"message": {
- "title": "Automatische Antwortnachricht",
- "description": "Die Nachricht, die als Antwort gesendet wird",
"subject_label": "Betreff",
"subject_description": "Betreffzeile der automatischen Antwort",
"subject_placeholder": "Abwesenheitsnotiz",
@@ -1865,10 +1862,32 @@
"saving": "Speichern...",
"warnings": {
"end_before_start": "Das Enddatum muss nach dem Startdatum liegen",
+ "invalid_date": "Bitte ein gültiges Start- und Enddatum mit Uhrzeit angeben oder das Feld leer lassen",
"start_in_past": "Das Startdatum liegt in der Vergangenheit",
"empty_body": "Der Nachrichtentext ist leer - Empfänger erhalten eine leere Antwort",
"subject_too_long": "Der Betreff ist für den Server zu lang (höchstens {max} Byte).",
- "body_too_long": "Die Nachricht ist für den Server zu lang (höchstens {max} Byte, inklusive Formatierung)."
+ "body_too_long": "Die Nachricht ist für den Server zu lang (höchstens {max} Byte, inklusive Formatierung).",
+ "forward_address": "Bitte eine gültige Adresse für die Weiterleitung angeben",
+ "not_running": "Weiterleitung oder Antwortempfänger sind gespeichert, aber gerade nicht aktiv. Speichern aktiviert sie wieder."
+ },
+ "forward": {
+ "title": "Weiterleitung",
+ "description": "Leitet eingehende Nachrichten an eine andere Adresse weiter, mit oder ohne automatische Antwort, im Zeitraum oben (ohne Enddatum, bis sie ausgeschaltet wird). Spam wird nicht weitergeleitet.",
+ "enabled_label": "Nachrichten weiterleiten",
+ "to_label": "Weiterleiten an",
+ "to_placeholder": "email@beispiel.de",
+ "keep_label": "Im Posteingang behalten",
+ "keep_description": "Sonst wird die Nachricht nur weitergeleitet: Sie bleibt nicht in diesem Postfach, und keine Filterregel läuft für sie."
+ },
+ "audience": {
+ "label": "Antworten an",
+ "description": "Interne Absender: {domains}",
+ "all": "Alle Absender",
+ "internal": "Nur interne Absender",
+ "external": "Nur externe Absender"
+ },
+ "auto_reply": {
+ "title": "Automatische Antwort"
}
},
"folders": {
@@ -2246,7 +2265,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Auch auf vorhandene Nachrichten in diesem Ordner anwenden",
- "apply_existing_unsupported": "Nur Regeln, die Absender, Empfänger, Betreff oder Kopfzeilen prüfen und verschieben, kopieren, als gelesen markieren, mit Stern markieren oder mit Label versehen, lassen sich auf vorhandene Nachrichten anwenden."
+ "apply_existing_unsupported": "Nur Regeln, die Absender, Empfänger, Betreff oder Kopfzeilen prüfen und verschieben, kopieren, als gelesen markieren, mit Stern markieren oder mit Label versehen, lassen sich auf vorhandene Nachrichten anwenden.",
+ "period_title": "Zeitraum",
+ "period_start": "Startdatum",
+ "period_end": "Enddatum",
+ "validation_period_order": "Das Enddatum muss nach dem Startdatum liegen",
+ "period_toggle": "Nur in einem Zeitraum aktiv",
+ "period_hint": "Gilt für Nachrichten, die in diesem Zeitraum eintreffen. Ein leeres Feld lässt den Zeitraum an dieser Seite offen.",
+ "validation_period_empty": "Bitte ein Start- oder Enddatum für den Zeitraum angeben",
+ "validation_period_invalid": "Bitte für den Zeitraum ein gültiges Datum mit Uhrzeit angeben oder das Feld leer lassen",
+ "apply_existing_period": "Eine Regel mit Zeitraum wirkt nur auf Nachrichten, die in diesem Zeitraum eintreffen, und lässt sich daher nicht auf vorhandene Nachrichten anwenden.",
+ "period_status_scheduled": "geplant",
+ "period_status_active": "aktiv",
+ "period_status_expired": "abgelaufen"
},
"templates": {
"title": "E-Mail-Vorlagen",
diff --git a/locales/en/common.json b/locales/en/common.json
index b27f4cecb..8ed9e0441 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -841,8 +841,8 @@
"identity_delete_failed": "Failed to delete identity: {error}",
"identity_unauthorized": "You are not authorized to send from this email address",
"identity_not_found": "Identity not found",
- "vacation_saved": "Vacation responder settings saved",
- "vacation_save_failed": "Failed to save vacation responder settings",
+ "vacation_saved": "Out of office settings saved",
+ "vacation_save_failed": "Failed to save out of office settings",
"filters_saved": "Filters saved successfully",
"filters_save_failed": "Failed to save filters",
"filters_deleted": "Filter rule deleted",
@@ -864,7 +864,8 @@
"rule_undone": "Rule change undone",
"rule_undo_failed": "Could not undo the rule change",
"rule_undo_conflict": "Your filters have changed since. Undo the change in Filters settings.",
- "rule_folder_failed": "Could not create the folder"
+ "rule_folder_failed": "Could not create the folder",
+ "vacation_filters_save_failed": "Out of office settings saved, but the forwarding and reply recipients could not be saved"
},
"date": {
"today": "Today",
@@ -952,7 +953,7 @@
"privacy": "Privacy & Security",
"account": "Account",
"identities": "Identities",
- "vacation": "Vacation Responder",
+ "vacation": "Out of Office",
"advanced": "Advanced",
"calendar": "Calendar",
"filters": "Filters",
@@ -1831,28 +1832,24 @@
}
},
"vacation": {
- "title": "Vacation Responder",
- "description": "Automatically reply to incoming emails while you're away",
+ "title": "Out of Office",
+ "description": "Automatically handle incoming emails while you're away",
"loading": "Loading vacation settings...",
"not_supported": "Your mail server does not support vacation responses.",
"fetch_error": "Failed to load vacation settings. Please try again.",
"status": {
- "label": "Vacation Responder",
+ "label": "Reply automatically",
"description": "Send an automatic reply to people who email you",
"active": "Active",
"inactive": "Inactive"
},
"date_range": {
- "title": "Date Range",
- "description": "Optionally limit the auto-reply to a specific period",
"start": "Start Date",
"start_description": "Leave empty for no start limit",
"end": "End Date",
"end_description": "Leave empty for no end limit"
},
"message": {
- "title": "Auto-Reply Message",
- "description": "The message that will be sent as a reply",
"subject_label": "Subject",
"subject_description": "Subject line of the auto-reply",
"subject_placeholder": "Out of Office",
@@ -1872,10 +1869,32 @@
"saving": "Saving...",
"warnings": {
"end_before_start": "End date must be after start date",
+ "invalid_date": "Enter a valid start and end date with a time, or leave the field empty",
"start_in_past": "Start date is in the past",
"empty_body": "Message body is empty - recipients will receive a blank reply",
"subject_too_long": "The subject is too long for the server (at most {max} bytes).",
- "body_too_long": "The message is too long for the server (at most {max} bytes, formatting included)."
+ "body_too_long": "The message is too long for the server (at most {max} bytes, formatting included).",
+ "forward_address": "Enter a valid address to forward to",
+ "not_running": "Forwarding or the reply recipients are saved but not active right now. Save to turn them back on."
+ },
+ "forward": {
+ "title": "Forwarding",
+ "description": "Passes incoming messages on to another address, with or without the auto-reply, within the period above (without an end date, until switched off). Spam is not forwarded.",
+ "enabled_label": "Forward messages",
+ "to_label": "Forward to",
+ "to_placeholder": "email@example.com",
+ "keep_label": "Keep in inbox",
+ "keep_description": "Otherwise the message is only forwarded: it is not kept in this mailbox, and no filter rule runs on it."
+ },
+ "audience": {
+ "label": "Reply to",
+ "description": "Internal senders: {domains}",
+ "all": "All senders",
+ "internal": "Internal senders only",
+ "external": "External senders only"
+ },
+ "auto_reply": {
+ "title": "Automatic Reply"
}
},
"folders": {
@@ -2253,7 +2272,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Also apply to existing messages in this folder",
- "apply_existing_unsupported": "Only rules that check the sender, recipients, subject or headers, and that move, copy, mark as read, star or tag, can run on existing messages."
+ "apply_existing_unsupported": "Only rules that check the sender, recipients, subject or headers, and that move, copy, mark as read, star or tag, can run on existing messages.",
+ "period_title": "Date Range",
+ "period_start": "Start Date",
+ "period_end": "End Date",
+ "validation_period_order": "End date must be after start date",
+ "period_toggle": "Only active during a period",
+ "period_hint": "Applies to messages that arrive during this period. Leave a field empty to keep that end open.",
+ "validation_period_empty": "Enter a start or end date for the period",
+ "validation_period_invalid": "Enter a valid date with a time for the period, or leave the field empty",
+ "apply_existing_period": "A rule with a period acts only on messages that arrive within it, so it cannot run on existing messages.",
+ "period_status_scheduled": "scheduled",
+ "period_status_active": "active",
+ "period_status_expired": "expired"
},
"templates": {
"title": "Email Templates",
diff --git a/locales/es/common.json b/locales/es/common.json
index 111980e64..583e1cd32 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Error al eliminar identidad: {error}",
"identity_unauthorized": "No está autorizado para enviar desde esta dirección de correo",
"identity_not_found": "Identidad no encontrada",
- "vacation_saved": "Configuración de respuesta automática guardada",
- "vacation_save_failed": "Error al guardar la configuración de respuesta automática",
+ "vacation_saved": "Configuración de ausencia guardada",
+ "vacation_save_failed": "Error al guardar la configuración de ausencia",
"filters_saved": "Filtros guardados correctamente",
"filters_save_failed": "Error al guardar los filtros",
"filters_deleted": "Regla de filtrado eliminada",
@@ -858,7 +858,8 @@
"rule_undone": "Cambio de regla deshecho",
"rule_undo_failed": "No se pudo deshacer el cambio de la regla",
"rule_undo_conflict": "Tus filtros han cambiado desde entonces. Deshaz el cambio en la configuración de filtros.",
- "rule_folder_failed": "No se pudo crear la carpeta"
+ "rule_folder_failed": "No se pudo crear la carpeta",
+ "vacation_filters_save_failed": "Configuración de ausencia guardada, pero no se pudieron guardar el reenvío ni los destinatarios de la respuesta"
},
"date": {
"today": "Hoy",
@@ -943,7 +944,7 @@
"privacy": "Privacidad y Seguridad",
"account": "Cuenta",
"identities": "Identidades",
- "vacation": "Respuesta automática",
+ "vacation": "Ausencia",
"advanced": "Avanzado",
"calendar": "Calendario",
"filters": "Filtros",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "Respuesta automática",
- "description": "Responder automáticamente a los correos entrantes mientras está ausente",
+ "title": "Ausencia",
+ "description": "Gestionar automáticamente los correos entrantes mientras está ausente",
"loading": "Cargando configuración de respuesta automática...",
"not_supported": "Su servidor de correo no admite respuestas automáticas de ausencia.",
"fetch_error": "Error al cargar la configuración de respuesta automática. Inténtelo de nuevo.",
"status": {
- "label": "Respuesta automática",
+ "label": "Responder automáticamente",
"description": "Enviar una respuesta automática a quienes le envíen un correo",
"active": "Activo",
"inactive": "Inactivo"
},
"date_range": {
- "title": "Período",
- "description": "Opcionalmente limitar la respuesta automática a un período específico",
"start": "Fecha de inicio",
"start_description": "Dejar vacío para sin límite de inicio",
"end": "Fecha de fin",
"end_description": "Dejar vacío para sin límite de fin"
},
"message": {
- "title": "Mensaje de respuesta automática",
- "description": "El mensaje que se enviará como respuesta",
"subject_label": "Asunto",
"subject_description": "Línea de asunto de la respuesta automática",
"subject_placeholder": "Fuera de la oficina",
@@ -1859,10 +1856,32 @@
"saving": "Guardando...",
"warnings": {
"end_before_start": "La fecha de fin debe ser posterior a la fecha de inicio",
+ "invalid_date": "Introduce una fecha de inicio y de fin válidas con hora o deja el campo vacío",
"start_in_past": "La fecha de inicio está en el pasado",
"empty_body": "El cuerpo del mensaje está vacío - los destinatarios recibirán una respuesta en blanco",
"subject_too_long": "El asunto es demasiado largo para el servidor (máximo {max} bytes).",
- "body_too_long": "El mensaje es demasiado largo para el servidor (máximo {max} bytes, formato incluido)."
+ "body_too_long": "El mensaje es demasiado largo para el servidor (máximo {max} bytes, formato incluido).",
+ "forward_address": "Introduce una dirección válida para el reenvío",
+ "not_running": "El reenvío o los destinatarios de la respuesta están guardados, pero ahora no están activos. Guarda para volver a activarlos."
+ },
+ "forward": {
+ "title": "Reenvío",
+ "description": "Reenvía los mensajes entrantes a otra dirección, con o sin respuesta automática, dentro del período indicado arriba (sin fecha de fin, hasta que se desactive). El correo no deseado no se reenvía.",
+ "enabled_label": "Reenviar mensajes",
+ "to_label": "Reenviar a",
+ "to_placeholder": "correo@ejemplo.com",
+ "keep_label": "Mantener en la bandeja de entrada",
+ "keep_description": "Si no, el mensaje solo se reenvía: no se conserva en este buzón y no se le aplica ninguna regla de filtro."
+ },
+ "audience": {
+ "label": "Responder a",
+ "description": "Remitentes internos: {domains}",
+ "all": "Todos los remitentes",
+ "internal": "Solo remitentes internos",
+ "external": "Solo remitentes externos"
+ },
+ "auto_reply": {
+ "title": "Respuesta automática"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Aplicar también a los mensajes que ya hay en esta carpeta",
- "apply_existing_unsupported": "Solo las reglas que comprueban el remitente, los destinatarios, el asunto o los encabezados, y que mueven, copian, marcan como leído, destacan o etiquetan, se pueden aplicar a mensajes existentes."
+ "apply_existing_unsupported": "Solo las reglas que comprueban el remitente, los destinatarios, el asunto o los encabezados, y que mueven, copian, marcan como leído, destacan o etiquetan, se pueden aplicar a mensajes existentes.",
+ "period_title": "Período",
+ "period_start": "Fecha de inicio",
+ "period_end": "Fecha de fin",
+ "validation_period_order": "La fecha de fin debe ser posterior a la fecha de inicio",
+ "period_toggle": "Activa solo durante un período",
+ "period_hint": "Se aplica a los mensajes que llegan durante este período. Deja un campo vacío para no limitar ese extremo.",
+ "validation_period_empty": "Introduce una fecha de inicio o de fin para el período",
+ "validation_period_invalid": "Introduce una fecha válida con hora para el período o deja el campo vacío",
+ "apply_existing_period": "Una regla con período solo actúa sobre los mensajes que llegan dentro de él, por lo que no se puede aplicar a los mensajes existentes.",
+ "period_status_scheduled": "programada",
+ "period_status_active": "activa",
+ "period_status_expired": "caducada"
},
"templates": {
"title": "Plantillas de correo",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 48aab407c..ef358355d 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "حذف هویت ناموفق بود: {error}",
"identity_unauthorized": "شما مجاز به ارسال از این آدرس ایمیل نیستید",
"identity_not_found": "هویت یافت نشد",
- "vacation_saved": "تنظیمات پاسخگوی خودکار ذخیره شد",
- "vacation_save_failed": "ذخیره تنظیمات پاسخگوی خودکار ناموفق بود",
+ "vacation_saved": "تنظیمات غیبت ذخیره شد",
+ "vacation_save_failed": "ذخیره تنظیمات غیبت ناموفق بود",
"filters_saved": "فیلترها با موفقیت ذخیره شدند",
"filters_save_failed": "ذخیره فیلترها ناموفق بود",
"filters_deleted": "قانون فیلتر حذف شد",
@@ -858,7 +858,8 @@
"rule_undone": "تغییر قانون بازگردانده شد",
"rule_undo_failed": "بازگردانی تغییر قانون ممکن نشد",
"rule_undo_conflict": "فیلترهای شما از آن زمان تغییر کردهاند. تغییر را در تنظیمات فیلترها بازگردانید.",
- "rule_folder_failed": "ایجاد پوشه ممکن نشد"
+ "rule_folder_failed": "ایجاد پوشه ممکن نشد",
+ "vacation_filters_save_failed": "تنظیمات غیبت ذخیره شد، اما ذخیره بازارسال و گیرندگان پاسخ ناموفق بود"
},
"date": {
"today": "امروز",
@@ -946,7 +947,7 @@
"privacy": "حریم خصوصی و امنیت",
"account": "حساب",
"identities": "هویتها",
- "vacation": "پاسخگوی خودکار",
+ "vacation": "غیبت",
"advanced": "پیشرفته",
"calendar": "تقویم",
"filters": "فیلترها",
@@ -1825,28 +1826,24 @@
}
},
"vacation": {
- "title": "پاسخگوی خودکار",
- "description": "پاسخ خودکار به ایمیلهای ورودی در زمان غیبت",
+ "title": "غیبت",
+ "description": "رسیدگی خودکار به ایمیلهای ورودی در زمان غیبت",
"loading": "در حال بارگذاری تنظیمات پاسخگوی خودکار...",
"not_supported": "سرور ایمیل شما از پاسخ خودکار پشتیبانی نمیکند.",
"fetch_error": "بارگذاری تنظیمات پاسخگوی خودکار ناموفق بود. لطفاً دوباره تلاش کنید.",
"status": {
- "label": "پاسخگوی خودکار",
+ "label": "ارسال پاسخ خودکار",
"description": "ارسال پاسخ خودکار به افرادی که به شما ایمیل میزنند",
"active": "فعال",
"inactive": "غیرفعال"
},
"date_range": {
- "title": "بازه زمانی",
- "description": "محدود کردن پاسخ خودکار به یک بازه زمانی خاص",
"start": "تاریخ شروع",
"start_description": "برای بدون محدودیت شروع خالی بگذارید",
"end": "تاریخ پایان",
"end_description": "برای بدون محدودیت پایان خالی بگذارید"
},
"message": {
- "title": "پیام پاسخ خودکار",
- "description": "پیامی که به عنوان پاسخ ارسال خواهد شد",
"subject_label": "موضوع",
"subject_description": "خط موضوع پاسخ خودکار",
"subject_placeholder": "خارج از دفتر",
@@ -1866,10 +1863,32 @@
"saving": "در حال ذخیره...",
"warnings": {
"end_before_start": "تاریخ پایان باید بعد از تاریخ شروع باشد",
+ "invalid_date": "تاریخ شروع و پایان معتبر را همراه با ساعت وارد کنید یا فیلد را خالی بگذارید",
"start_in_past": "تاریخ شروع در گذشته است",
"empty_body": "بدنه پیام خالی است - گیرندگان پاسخ خالی دریافت میکنند",
"subject_too_long": "موضوع برای سرور بیش از حد طولانی است (حداکثر {max} بایت).",
- "body_too_long": "پیام برای سرور بیش از حد طولانی است (حداکثر {max} بایت، همراه با قالببندی)."
+ "body_too_long": "پیام برای سرور بیش از حد طولانی است (حداکثر {max} بایت، همراه با قالببندی).",
+ "forward_address": "یک نشانی معتبر برای بازارسال وارد کنید",
+ "not_running": "بازارسال یا گیرندگان پاسخ ذخیره شدهاند اما اکنون فعال نیستند. برای فعالسازی دوباره ذخیره کنید."
+ },
+ "forward": {
+ "title": "بازارسال",
+ "description": "پیامهای ورودی را با یا بدون پاسخ خودکار، در بازه زمانی بالا به نشانی دیگری بازارسال میکند (بدون تاریخ پایان، تا زمانی که خاموش شود). هرزنامه بازارسال نمیشود.",
+ "enabled_label": "بازارسال پیامها",
+ "to_label": "ارسال به",
+ "to_placeholder": "email@example.com",
+ "keep_label": "نگه داشتن در صندوق ورودی",
+ "keep_description": "در غیر این صورت پیام فقط بازارسال میشود: در این صندوق پستی نمیماند و هیچ قانون فیلتری روی آن اجرا نمیشود."
+ },
+ "audience": {
+ "label": "پاسخ به",
+ "description": "فرستندگان داخلی: {domains}",
+ "all": "همه فرستندگان",
+ "internal": "فقط فرستندگان داخلی",
+ "external": "فقط فرستندگان خارجی"
+ },
+ "auto_reply": {
+ "title": "پاسخ خودکار"
}
},
"folders": {
@@ -2238,7 +2257,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "روی پیامهایی که از قبل در این پوشه هستند هم اعمال شود",
- "apply_existing_unsupported": "فقط قانونهایی که فرستنده، گیرندگان، موضوع یا سرایندها را بررسی میکنند و پیام را منتقل، کپی، خواندهشده، ستارهدار یا برچسبدار میکنند، روی پیامهای موجود اعمال میشوند."
+ "apply_existing_unsupported": "فقط قانونهایی که فرستنده، گیرندگان، موضوع یا سرایندها را بررسی میکنند و پیام را منتقل، کپی، خواندهشده، ستارهدار یا برچسبدار میکنند، روی پیامهای موجود اعمال میشوند.",
+ "period_title": "بازه زمانی",
+ "period_start": "تاریخ شروع",
+ "period_end": "تاریخ پایان",
+ "validation_period_order": "تاریخ پایان باید بعد از تاریخ شروع باشد",
+ "period_toggle": "فقط در یک بازه زمانی فعال باشد",
+ "period_hint": "برای پیامهایی اعمال میشود که در این بازه میرسند. برای باز گذاشتن هر طرف، فیلد آن را خالی بگذارید.",
+ "validation_period_empty": "تاریخ شروع یا پایان بازه را وارد کنید",
+ "validation_period_invalid": "برای بازه یک تاریخ معتبر همراه با ساعت وارد کنید یا فیلد را خالی بگذارید",
+ "apply_existing_period": "قانونی که بازه زمانی دارد فقط روی پیامهایی عمل میکند که در آن بازه میرسند، بنابراین روی پیامهای موجود اجرا نمیشود.",
+ "period_status_scheduled": "زمانبندیشده",
+ "period_status_active": "فعال",
+ "period_status_expired": "منقضیشده"
},
"templates": {
"title": "قالبهای ایمیل",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 6c3d64b57..10155148c 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Échec de la suppression de l'identité: {error}",
"identity_unauthorized": "Vous n'êtes pas autorisé à envoyer depuis cette adresse email",
"identity_not_found": "Identité introuvable",
- "vacation_saved": "Paramètres du répondeur d'absence enregistrés",
- "vacation_save_failed": "Échec de l'enregistrement des paramètres du répondeur d'absence",
+ "vacation_saved": "Paramètres d'absence enregistrés",
+ "vacation_save_failed": "Échec de l'enregistrement des paramètres d'absence",
"filters_saved": "Filtres enregistrés avec succès",
"filters_save_failed": "Échec de l'enregistrement des filtres",
"filters_deleted": "Règle de filtrage supprimée",
@@ -858,7 +858,8 @@
"rule_undone": "Modification de la règle annulée",
"rule_undo_failed": "Impossible d'annuler la modification de la règle",
"rule_undo_conflict": "Vos filtres ont changé entre-temps. Annulez la modification dans les réglages des filtres.",
- "rule_folder_failed": "Impossible de créer le dossier"
+ "rule_folder_failed": "Impossible de créer le dossier",
+ "vacation_filters_save_failed": "Paramètres d'absence enregistrés, mais le transfert et les destinataires de la réponse n'ont pas pu être enregistrés"
},
"date": {
"today": "Aujourd'hui",
@@ -943,7 +944,7 @@
"privacy": "Confidentialité et sécurité",
"account": "Compte",
"identities": "Identités",
- "vacation": "Répondeur d'absence",
+ "vacation": "Absence",
"advanced": "Avancé",
"calendar": "Calendrier",
"filters": "Filtres",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "Répondeur d'absence",
- "description": "Répondre automatiquement aux emails entrants pendant votre absence",
+ "title": "Absence",
+ "description": "Traiter automatiquement les emails entrants pendant votre absence",
"loading": "Chargement des paramètres d'absence...",
"not_supported": "Votre serveur de messagerie ne prend pas en charge les réponses d'absence.",
"fetch_error": "Échec du chargement des paramètres d'absence. Veuillez réessayer.",
"status": {
- "label": "Répondeur d'absence",
+ "label": "Répondre automatiquement",
"description": "Envoyer une réponse automatique aux personnes qui vous envoient un email",
"active": "Actif",
"inactive": "Inactif"
},
"date_range": {
- "title": "Période",
- "description": "Limiter optionnellement la réponse automatique à une période spécifique",
"start": "Date de début",
"start_description": "Laisser vide pour aucune limite de début",
"end": "Date de fin",
"end_description": "Laisser vide pour aucune limite de fin"
},
"message": {
- "title": "Message de réponse automatique",
- "description": "Le message qui sera envoyé en réponse",
"subject_label": "Objet",
"subject_description": "Ligne d'objet de la réponse automatique",
"subject_placeholder": "Absence du bureau",
@@ -1859,10 +1856,32 @@
"saving": "Enregistrement...",
"warnings": {
"end_before_start": "La date de fin doit être postérieure à la date de début",
+ "invalid_date": "Indiquez une date de début et de fin valides avec l'heure, ou laissez le champ vide",
"start_in_past": "La date de début est dans le passé",
"empty_body": "Le corps du message est vide - les destinataires recevront une réponse vide",
"subject_too_long": "L'objet est trop long pour le serveur ({max} octets au maximum).",
- "body_too_long": "Le message est trop long pour le serveur ({max} octets au maximum, mise en forme comprise)."
+ "body_too_long": "Le message est trop long pour le serveur ({max} octets au maximum, mise en forme comprise).",
+ "forward_address": "Indiquez une adresse valide pour le transfert",
+ "not_running": "Le transfert ou les destinataires de la réponse sont enregistrés mais pas actifs pour le moment. Enregistrez pour les réactiver."
+ },
+ "forward": {
+ "title": "Transfert",
+ "description": "Transfère les messages entrants à une autre adresse, avec ou sans réponse automatique, pendant la période ci-dessus (sans date de fin, jusqu'à sa désactivation). Les spams ne sont pas transférés.",
+ "enabled_label": "Transférer les messages",
+ "to_label": "Transférer à",
+ "to_placeholder": "email@exemple.com",
+ "keep_label": "Conserver dans la boîte de réception",
+ "keep_description": "Sinon, le message est seulement transféré : il n'est pas conservé dans cette boîte aux lettres et aucune règle de filtre ne s'y applique."
+ },
+ "audience": {
+ "label": "Répondre à",
+ "description": "Expéditeurs internes : {domains}",
+ "all": "Tous les expéditeurs",
+ "internal": "Expéditeurs internes uniquement",
+ "external": "Expéditeurs externes uniquement"
+ },
+ "auto_reply": {
+ "title": "Réponse automatique"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Appliquer aussi aux messages déjà présents dans ce dossier",
- "apply_existing_unsupported": "Seules les règles qui vérifient l'expéditeur, les destinataires, l'objet ou les en-têtes, et qui déplacent, copient, marquent comme lu, suivent ou étiquettent, peuvent s'appliquer aux messages existants."
+ "apply_existing_unsupported": "Seules les règles qui vérifient l'expéditeur, les destinataires, l'objet ou les en-têtes, et qui déplacent, copient, marquent comme lu, suivent ou étiquettent, peuvent s'appliquer aux messages existants.",
+ "period_title": "Période",
+ "period_start": "Date de début",
+ "period_end": "Date de fin",
+ "validation_period_order": "La date de fin doit être postérieure à la date de début",
+ "period_toggle": "Active uniquement pendant une période",
+ "period_hint": "S'applique aux messages qui arrivent pendant cette période. Laissez un champ vide pour ne pas limiter ce côté.",
+ "validation_period_empty": "Indiquez une date de début ou de fin pour la période",
+ "validation_period_invalid": "Indiquez une date valide avec l'heure pour la période, ou laissez le champ vide",
+ "apply_existing_period": "Une règle avec une période n'agit que sur les messages qui arrivent pendant celle-ci ; elle ne peut donc pas s'appliquer aux messages existants.",
+ "period_status_scheduled": "planifiée",
+ "period_status_active": "active",
+ "period_status_expired": "expirée"
},
"templates": {
"title": "Modèles d'e-mails",
diff --git a/locales/he/common.json b/locales/he/common.json
index a59156144..ca0d0b248 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -800,8 +800,8 @@
"identity_delete_failed": "מחיקת הזהות נכשלה:{error}",
"identity_unauthorized": "אינך מורשה לשלוח מכתובת דוא\"ל זו",
"identity_not_found": "זהות לא נמצאה",
- "vacation_saved": "הגדרות משיב חופשה נשמרו",
- "vacation_save_failed": "שמירת הגדרות משיב חופשה נכשלה",
+ "vacation_saved": "הגדרות ההיעדרות נשמרו",
+ "vacation_save_failed": "שמירת הגדרות ההיעדרות נכשלה",
"filters_saved": "המסננים נשמרו בהצלחה",
"filters_save_failed": "שמירת המסננים נכשלה",
"filters_deleted": "כלל הסינון נמחק",
@@ -823,7 +823,8 @@
"rule_undone": "שינוי הכלל בוטל",
"rule_undo_failed": "לא ניתן היה לבטל את שינוי הכלל",
"rule_undo_conflict": "המסננים שלך השתנו מאז. בטל את השינוי בהגדרות המסננים.",
- "rule_folder_failed": "לא ניתן היה ליצור את התיקייה"
+ "rule_folder_failed": "לא ניתן היה ליצור את התיקייה",
+ "vacation_filters_save_failed": "הגדרות ההיעדרות נשמרו, אך שמירת ההעברה ונמעני המענה נכשלה"
},
"date": {
"today": "היום",
@@ -908,7 +909,7 @@
"privacy": "פרטיות ואבטחה",
"account": "חשבון",
"identities": "זהויות",
- "vacation": "משיב נופש",
+ "vacation": "היעדרות",
"advanced": "מתקדם",
"calendar": "לוּחַ שָׁנָה",
"filters": "מסננים",
@@ -1783,28 +1784,24 @@
}
},
"vacation": {
- "title": "משיב נופש",
- "description": "הגב באופן אוטומטי להודעות דוא״ל נכנסות בזמן שאתה רחוק",
+ "title": "היעדרות",
+ "description": "טפל באופן אוטומטי בהודעות דוא״ל נכנסות בזמן שאתה רחוק",
"loading": "טוען הגדרות חופשה...",
"not_supported": "שרת הדואר שלך אינו תומך בתגובות חופשה.",
"fetch_error": "טעינת הגדרות החופשה נכשלה. אנא נסה שוב.",
"status": {
- "label": "משיב נופש",
+ "label": "שליחת מענה אוטומטי",
"description": "שלח תשובה אוטומטית לאנשים ששלחו לך דוא\"ל",
"active": "פעיל",
"inactive": "לא פעיל"
},
"date_range": {
- "title": "טווח תאריכים",
- "description": "אפשר להגביל את המענה האוטומטי לתקופה מסוימת",
"start": "תאריך התחלה",
"start_description": "השאר ריק ללא מגבלת התחלה",
"end": "תאריך סיום",
"end_description": "השאר ריק ללא הגבלה"
},
"message": {
- "title": "הודעת תשובה אוטומטית",
- "description": "ההודעה שתישלח כתשובה",
"subject_label": "נושא",
"subject_description": "שורת הנושא של התשובה האוטומטית",
"subject_placeholder": "מחוץ למשרד",
@@ -1824,10 +1821,32 @@
"saving": "שומר...",
"warnings": {
"end_before_start": "תאריך הסיום חייב להיות אחרי תאריך ההתחלה",
+ "invalid_date": "יש להזין תאריך התחלה וסיום תקינים עם שעה, או להשאיר את השדה ריק",
"start_in_past": "תאריך ההתחלה הוא בעבר",
"empty_body": "גוף ההודעה ריק - הנמענים יקבלו תשובה ריקה",
"subject_too_long": "הנושא ארוך מדי עבור השרת (לכל היותר {max} בתים).",
- "body_too_long": "ההודעה ארוכה מדי עבור השרת (לכל היותר {max} בתים, כולל עיצוב)."
+ "body_too_long": "ההודעה ארוכה מדי עבור השרת (לכל היותר {max} בתים, כולל עיצוב).",
+ "forward_address": "יש להזין כתובת תקינה להעברה",
+ "not_running": "ההעברה או נמעני המענה שמורים אך אינם פעילים כרגע. יש לשמור כדי להפעיל אותם מחדש."
+ },
+ "forward": {
+ "title": "העברה",
+ "description": "מעביר הודעות נכנסות לכתובת אחרת, עם מענה אוטומטי או בלעדיו, בטווח הזמן שלמעלה (ללא תאריך סיום, עד שיכובה). דואר זבל אינו מועבר.",
+ "enabled_label": "העברת הודעות",
+ "to_label": "העבר אל",
+ "to_placeholder": "email@example.com",
+ "keep_label": "שמור בתיבת הדואר הנכנס",
+ "keep_description": "אחרת ההודעה רק מועברת: היא לא נשמרת בתיבת דואר זו, ואף כלל סינון לא חל עליה."
+ },
+ "audience": {
+ "label": "להשיב אל",
+ "description": "שולחים פנימיים: {domains}",
+ "all": "כל השולחים",
+ "internal": "שולחים פנימיים בלבד",
+ "external": "שולחים חיצוניים בלבד"
+ },
+ "auto_reply": {
+ "title": "מענה אוטומטי"
}
},
"folders": {
@@ -2153,7 +2172,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "החל גם על הודעות שכבר נמצאות בתיקייה זו",
- "apply_existing_unsupported": "אפשר להחיל על הודעות קיימות רק כללים שבודקים שולח, נמענים, נושא או כותרות, ושמעבירים, מעתיקים, מסמנים כנקרא, בכוכב או בתווית."
+ "apply_existing_unsupported": "אפשר להחיל על הודעות קיימות רק כללים שבודקים שולח, נמענים, נושא או כותרות, ושמעבירים, מעתיקים, מסמנים כנקרא, בכוכב או בתווית.",
+ "period_title": "טווח תאריכים",
+ "period_start": "תאריך התחלה",
+ "period_end": "תאריך סיום",
+ "validation_period_order": "תאריך הסיום חייב להיות אחרי תאריך ההתחלה",
+ "period_toggle": "פעיל רק בטווח זמן מסוים",
+ "period_hint": "חל על הודעות שמגיעות בטווח זה. השאירו שדה ריק כדי להשאיר את הקצה הזה פתוח.",
+ "validation_period_empty": "יש להזין תאריך התחלה או סיום לטווח",
+ "validation_period_invalid": "יש להזין תאריך תקין עם שעה לטווח, או להשאיר את השדה ריק",
+ "apply_existing_period": "כלל עם טווח זמן פועל רק על הודעות שמגיעות בתוכו, ולכן לא ניתן להחיל אותו על הודעות קיימות.",
+ "period_status_scheduled": "מתוזמן",
+ "period_status_active": "פעיל",
+ "period_status_expired": "פג תוקף"
},
"templates": {
"title": "תבניות דואר אלקטרוני",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index e248e4262..061cb41de 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Nem sikerült törölni az identitást: {error}",
"identity_unauthorized": "Nem vagy jogosult erről az e-mail címről küldeni",
"identity_not_found": "Identitás nem található",
- "vacation_saved": "Automatikus válasz beállítások elmentve",
- "vacation_save_failed": "Nem sikerült menteni az automatikus válasz beállításokat",
+ "vacation_saved": "Távolléti beállítások elmentve",
+ "vacation_save_failed": "Nem sikerült menteni a távolléti beállításokat",
"filters_saved": "Szűrők sikeresen elmentve",
"filters_save_failed": "Nem sikerült menteni a szűrőket",
"filters_deleted": "Szűrő szabály törölve",
@@ -858,7 +858,8 @@
"rule_undone": "A szabálymódosítás visszavonva",
"rule_undo_failed": "Nem sikerült visszavonni a szabálymódosítást",
"rule_undo_conflict": "A szűrőid azóta megváltoztak. A módosítást a szűrőbeállításokban vond vissza.",
- "rule_folder_failed": "Nem sikerült létrehozni a mappát"
+ "rule_folder_failed": "Nem sikerült létrehozni a mappát",
+ "vacation_filters_save_failed": "Távolléti beállítások elmentve, de a továbbítást és a válasz címzettjeit nem sikerült menteni"
},
"date": {
"today": "Ma",
@@ -946,7 +947,7 @@
"privacy": "Adatvédelem és biztonság",
"account": "Fiók",
"identities": "Azonosságok",
- "vacation": "Automatikus válasz",
+ "vacation": "Távollét",
"advanced": "Speciális",
"calendar": "Naptár",
"filters": "Szűrők",
@@ -1821,28 +1822,24 @@
}
},
"vacation": {
- "title": "Automatikus válasz",
- "description": "Automatikus válasz a beérkező e-mailekre távolléted alatt",
+ "title": "Távollét",
+ "description": "A beérkező e-mailek automatikus kezelése távolléted alatt",
"loading": "Automatikus válasz beállítások betöltése...",
"not_supported": "A levelezőszervered nem támogatja az automatikus válaszokat.",
"fetch_error": "Nem sikerült betölteni az automatikus válasz beállításokat. Kérjük, próbáld újra.",
"status": {
- "label": "Automatikus válasz",
+ "label": "Automatikus válasz küldése",
"description": "Automatikus válasz küldése azoknak, akik e-mailt írnak neked",
"active": "Aktív",
"inactive": "Inaktív"
},
"date_range": {
- "title": "Dátumtartomány",
- "description": "Opcionálisan korlátozd az automatikus választ egy adott időszakra",
"start": "Kezdő dátum",
"start_description": "Hagyd üresen, ha nincs kezdő korlát",
"end": "Befejező dátum",
"end_description": "Hagyd üresen, ha nincs befejező korlát"
},
"message": {
- "title": "Automatikus válasz üzenet",
- "description": "Az üzenet, amely válaszként lesz elküldve",
"subject_label": "Tárgy",
"subject_description": "Az automatikus válasz tárgysora",
"subject_placeholder": "Távol vagyok",
@@ -1862,10 +1859,32 @@
"saving": "Mentés...",
"warnings": {
"end_before_start": "A befejező dátumnak a kezdő dátum után kell lennie",
+ "invalid_date": "Adjon meg érvényes kezdő és befejező dátumot időponttal, vagy hagyja üresen a mezőt",
"start_in_past": "A kezdő dátum a múltban van",
"empty_body": "Az üzenet szövege üres - a címzettek üres választ kapnak",
"subject_too_long": "A tárgy túl hosszú a kiszolgálónak (legfeljebb {max} bájt).",
- "body_too_long": "Az üzenet túl hosszú a kiszolgálónak (legfeljebb {max} bájt a formázással együtt)."
+ "body_too_long": "Az üzenet túl hosszú a kiszolgálónak (legfeljebb {max} bájt a formázással együtt).",
+ "forward_address": "Adjon meg egy érvényes címet a továbbításhoz",
+ "not_running": "A továbbítás vagy a válasz címzettjei el vannak mentve, de most nem aktívak. Mentéssel újra bekapcsolja őket."
+ },
+ "forward": {
+ "title": "Továbbítás",
+ "description": "A beérkező üzeneteket egy másik címre továbbítja, automatikus válasszal vagy anélkül, a fenti időszakban (befejező dátum nélkül a kikapcsolásig). A levélszemét nem lesz továbbítva.",
+ "enabled_label": "Üzenetek továbbítása",
+ "to_label": "Továbbítás",
+ "to_placeholder": "email@example.com",
+ "keep_label": "Megtartás a beérkezőben",
+ "keep_description": "Ellenkező esetben az üzenet csak továbbítva lesz: nem marad ebben a postafiókban, és egyetlen szűrőszabály sem fut rá."
+ },
+ "audience": {
+ "label": "Válasz küldése",
+ "description": "Belső feladók: {domains}",
+ "all": "Minden feladónak",
+ "internal": "Csak belső feladóknak",
+ "external": "Csak külső feladóknak"
+ },
+ "auto_reply": {
+ "title": "Automatikus válasz"
}
},
"folders": {
@@ -2234,7 +2253,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Alkalmazás a mappában már meglévő üzenetekre is",
- "apply_existing_unsupported": "Meglévő üzenetekre csak olyan szabály alkalmazható, amely a feladót, a címzetteket, a tárgyat vagy a fejléceket vizsgálja, és áthelyez, másol, olvasottnak jelöl, csillagoz vagy címkéz."
+ "apply_existing_unsupported": "Meglévő üzenetekre csak olyan szabály alkalmazható, amely a feladót, a címzetteket, a tárgyat vagy a fejléceket vizsgálja, és áthelyez, másol, olvasottnak jelöl, csillagoz vagy címkéz.",
+ "period_title": "Dátumtartomány",
+ "period_start": "Kezdő dátum",
+ "period_end": "Befejező dátum",
+ "validation_period_order": "A befejező dátumnak a kezdő dátum után kell lennie",
+ "period_toggle": "Csak egy időszakban aktív",
+ "period_hint": "Az ebben az időszakban érkező üzenetekre vonatkozik. Hagyjon egy mezőt üresen, ha azt a végét nem szeretné korlátozni.",
+ "validation_period_empty": "Adjon meg kezdő vagy befejező dátumot az időszakhoz",
+ "validation_period_invalid": "Adjon meg érvényes dátumot időponttal az időszakhoz, vagy hagyja üresen a mezőt",
+ "apply_existing_period": "Az időszakhoz kötött szabály csak az abban érkező üzenetekre hat, ezért meglévő üzenetekre nem alkalmazható.",
+ "period_status_scheduled": "ütemezett",
+ "period_status_active": "aktív",
+ "period_status_expired": "lejárt"
},
"templates": {
"title": "E-mail sablonok",
diff --git a/locales/it/common.json b/locales/it/common.json
index 639257deb..273e75056 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Impossibile eliminare l'identità: {error}",
"identity_unauthorized": "Non sei autorizzato a inviare da questo indirizzo email",
"identity_not_found": "Identità non trovata",
- "vacation_saved": "Impostazioni del risponditore automatico salvate",
- "vacation_save_failed": "Impossibile salvare le impostazioni del risponditore automatico",
+ "vacation_saved": "Impostazioni di assenza salvate",
+ "vacation_save_failed": "Impossibile salvare le impostazioni di assenza",
"filters_saved": "Filtri salvati con successo",
"filters_save_failed": "Impossibile salvare i filtri",
"filters_deleted": "Regola di filtraggio eliminata",
@@ -858,7 +858,8 @@
"rule_undone": "Modifica della regola annullata",
"rule_undo_failed": "Impossibile annullare la modifica della regola",
"rule_undo_conflict": "I tuoi filtri sono cambiati nel frattempo. Annulla la modifica nelle impostazioni dei filtri.",
- "rule_folder_failed": "Impossibile creare la cartella"
+ "rule_folder_failed": "Impossibile creare la cartella",
+ "vacation_filters_save_failed": "Impostazioni di assenza salvate, ma non è stato possibile salvare l'inoltro e i destinatari della risposta"
},
"date": {
"today": "Oggi",
@@ -943,7 +944,7 @@
"privacy": "Privacy e sicurezza",
"account": "Account",
"identities": "Identità",
- "vacation": "Risponditore automatico",
+ "vacation": "Assenza",
"advanced": "Avanzate",
"calendar": "Calendario",
"filters": "Filtri",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "Risponditore automatico",
- "description": "Rispondi automaticamente alle email in arrivo durante la tua assenza",
+ "title": "Assenza",
+ "description": "Gestisci automaticamente le email in arrivo durante la tua assenza",
"loading": "Caricamento impostazioni di risposta automatica...",
"not_supported": "Il server di posta non supporta le risposte automatiche di assenza.",
"fetch_error": "Impossibile caricare le impostazioni di risposta automatica. Riprova.",
"status": {
- "label": "Risponditore automatico",
+ "label": "Rispondi automaticamente",
"description": "Invia una risposta automatica a chi ti scrive",
"active": "Attivo",
"inactive": "Inattivo"
},
"date_range": {
- "title": "Periodo",
- "description": "Limita opzionalmente la risposta automatica a un periodo specifico",
"start": "Data di inizio",
"start_description": "Lascia vuoto per nessun limite di inizio",
"end": "Data di fine",
"end_description": "Lascia vuoto per nessun limite di fine"
},
"message": {
- "title": "Messaggio di risposta automatica",
- "description": "Il messaggio che verrà inviato come risposta",
"subject_label": "Oggetto",
"subject_description": "Oggetto della risposta automatica",
"subject_placeholder": "Fuori ufficio",
@@ -1859,10 +1856,32 @@
"saving": "Salvataggio...",
"warnings": {
"end_before_start": "La data di fine deve essere successiva alla data di inizio",
+ "invalid_date": "Inserisci una data di inizio e di fine valide con l'ora oppure lascia vuoto il campo",
"start_in_past": "La data di inizio è nel passato",
"empty_body": "Il corpo del messaggio è vuoto - i destinatari riceveranno una risposta vuota",
"subject_too_long": "L'oggetto è troppo lungo per il server (al massimo {max} byte).",
- "body_too_long": "Il messaggio è troppo lungo per il server (al massimo {max} byte, formattazione inclusa)."
+ "body_too_long": "Il messaggio è troppo lungo per il server (al massimo {max} byte, formattazione inclusa).",
+ "forward_address": "Inserisci un indirizzo valido per l'inoltro",
+ "not_running": "L'inoltro o i destinatari della risposta sono salvati ma al momento non sono attivi. Salva per riattivarli."
+ },
+ "forward": {
+ "title": "Inoltro",
+ "description": "Inoltra i messaggi in arrivo a un altro indirizzo, con o senza risposta automatica, nel periodo indicato sopra (senza data di fine, finché non viene disattivato). Lo spam non viene inoltrato.",
+ "enabled_label": "Inoltra i messaggi",
+ "to_label": "Inoltra a",
+ "to_placeholder": "email@esempio.com",
+ "keep_label": "Mantieni nella posta in arrivo",
+ "keep_description": "Altrimenti il messaggio viene solo inoltrato: non resta in questa casella e non gli si applica nessuna regola di filtro."
+ },
+ "audience": {
+ "label": "Rispondi a",
+ "description": "Mittenti interni: {domains}",
+ "all": "Tutti i mittenti",
+ "internal": "Solo mittenti interni",
+ "external": "Solo mittenti esterni"
+ },
+ "auto_reply": {
+ "title": "Risposta automatica"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Applica anche ai messaggi già presenti in questa cartella",
- "apply_existing_unsupported": "Solo le regole che controllano mittente, destinatari, oggetto o intestazioni, e che spostano, copiano, segnano come letto, contrassegnano con stella o etichettano, si possono applicare ai messaggi esistenti."
+ "apply_existing_unsupported": "Solo le regole che controllano mittente, destinatari, oggetto o intestazioni, e che spostano, copiano, segnano come letto, contrassegnano con stella o etichettano, si possono applicare ai messaggi esistenti.",
+ "period_title": "Periodo",
+ "period_start": "Data di inizio",
+ "period_end": "Data di fine",
+ "validation_period_order": "La data di fine deve essere successiva alla data di inizio",
+ "period_toggle": "Attiva solo in un periodo",
+ "period_hint": "Si applica ai messaggi che arrivano in questo periodo. Lascia un campo vuoto per non limitare quell'estremo.",
+ "validation_period_empty": "Inserisci una data di inizio o di fine per il periodo",
+ "validation_period_invalid": "Inserisci una data valida con l'ora per il periodo oppure lascia vuoto il campo",
+ "apply_existing_period": "Una regola con periodo agisce solo sui messaggi che arrivano al suo interno, quindi non si può applicare ai messaggi esistenti.",
+ "period_status_scheduled": "programmata",
+ "period_status_active": "attiva",
+ "period_status_expired": "scaduta"
},
"templates": {
"title": "Modelli email",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 434eead72..cced07d42 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "送信者情報の削除に失敗しました: {error}",
"identity_unauthorized": "このメールアドレスからの送信は許可されていません",
"identity_not_found": "送信者情報が見つかりません",
- "vacation_saved": "不在応答の設定を保存しました",
- "vacation_save_failed": "不在応答の設定の保存に失敗しました",
+ "vacation_saved": "不在の設定を保存しました",
+ "vacation_save_failed": "不在の設定の保存に失敗しました",
"filters_saved": "フィルターを保存しました",
"filters_save_failed": "フィルターの保存に失敗しました",
"filters_deleted": "フィルタールールが削除されました",
@@ -858,7 +858,8 @@
"rule_undone": "ルールの変更を元に戻しました",
"rule_undo_failed": "ルールの変更を元に戻せませんでした",
"rule_undo_conflict": "その後フィルターが変更されています。フィルター設定で変更を元に戻してください。",
- "rule_folder_failed": "フォルダを作成できませんでした"
+ "rule_folder_failed": "フォルダを作成できませんでした",
+ "vacation_filters_save_failed": "不在の設定を保存しましたが、転送と返信する相手の保存に失敗しました"
},
"date": {
"today": "今日",
@@ -943,7 +944,7 @@
"privacy": "プライバシーとセキュリティ",
"account": "アカウント",
"identities": "送信者情報",
- "vacation": "不在応答",
+ "vacation": "不在",
"advanced": "詳細設定",
"calendar": "カレンダー",
"filters": "フィルター",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "不在応答",
- "description": "不在時に受信メールへ自動返信します",
+ "title": "不在",
+ "description": "不在時に受信メールを自動で処理します",
"loading": "不在応答設定を読み込み中...",
"not_supported": "メールサーバーは不在応答をサポートしていません。",
"fetch_error": "不在応答設定の読み込みに失敗しました。もう一度お試しください。",
"status": {
- "label": "不在応答",
+ "label": "自動で返信する",
"description": "メールを送信してきた方に自動返信を送信します",
"active": "有効",
"inactive": "無効"
},
"date_range": {
- "title": "期間",
- "description": "オプションで自動返信を特定の期間に限定します",
"start": "開始日",
"start_description": "空白のままにすると開始制限なし",
"end": "終了日",
"end_description": "空白のままにすると終了制限なし"
},
"message": {
- "title": "自動返信メッセージ",
- "description": "返信として送信されるメッセージ",
"subject_label": "件名",
"subject_description": "自動返信の件名",
"subject_placeholder": "不在通知",
@@ -1859,10 +1856,32 @@
"saving": "保存中...",
"warnings": {
"end_before_start": "終了日は開始日より後に設定してください",
+ "invalid_date": "開始日と終了日は時刻付きの有効な日付を入力するか、欄を空のままにしてください",
"start_in_past": "開始日が過去の日付です",
"empty_body": "メッセージ本文が空です - 受信者には空の返信が届きます",
"subject_too_long": "件名がサーバーの上限を超えています(最大 {max} バイト)。",
- "body_too_long": "メッセージがサーバーの上限を超えています(書式を含めて最大 {max} バイト)。"
+ "body_too_long": "メッセージがサーバーの上限を超えています(書式を含めて最大 {max} バイト)。",
+ "forward_address": "転送先に有効なアドレスを入力してください",
+ "not_running": "転送または返信する相手は保存されていますが、現在は有効になっていません。保存すると再び有効になります。"
+ },
+ "forward": {
+ "title": "転送",
+ "description": "受信したメッセージを、不在応答の有無にかかわらず、上の期間内に別のアドレスへ転送します(終了日がない場合はオフにするまで)。迷惑メールは転送されません。",
+ "enabled_label": "メッセージを転送する",
+ "to_label": "転送先",
+ "to_placeholder": "email@example.com",
+ "keep_label": "受信トレイに保持",
+ "keep_description": "オフの場合、メッセージは転送されるだけで、このメールボックスには残らず、フィルタールールも適用されません。"
+ },
+ "audience": {
+ "label": "返信する相手",
+ "description": "内部の送信者: {domains}",
+ "all": "すべての送信者",
+ "internal": "内部の送信者のみ",
+ "external": "外部の送信者のみ"
+ },
+ "auto_reply": {
+ "title": "自動応答"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "このフォルダにある既存のメールにも適用",
- "apply_existing_unsupported": "既存のメールに適用できるのは、差出人・宛先・件名・ヘッダーを条件とし、移動・コピー・既読・スター・ラベルを実行するルールだけです。"
+ "apply_existing_unsupported": "既存のメールに適用できるのは、差出人・宛先・件名・ヘッダーを条件とし、移動・コピー・既読・スター・ラベルを実行するルールだけです。",
+ "period_title": "期間",
+ "period_start": "開始日",
+ "period_end": "終了日",
+ "validation_period_order": "終了日は開始日より後に設定してください",
+ "period_toggle": "期間を限定して有効にする",
+ "period_hint": "この期間に届いたメッセージに適用されます。空欄にすると、その側は制限なしになります。",
+ "validation_period_empty": "期間の開始日または終了日を入力してください",
+ "validation_period_invalid": "期間には時刻付きの有効な日付を入力するか、欄を空のままにしてください",
+ "apply_existing_period": "期間付きのルールは期間内に届いたメッセージにのみ適用されるため、既存のメッセージには適用できません。",
+ "period_status_scheduled": "予定",
+ "period_status_active": "有効",
+ "period_status_expired": "期限切れ"
},
"templates": {
"title": "メールテンプレート",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 748bd553a..b234c2573 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "보내는 사람 삭제 실패: {error}",
"identity_unauthorized": "이 이메일 주소로 메일을 보낼 권한이 없어요",
"identity_not_found": "해당 계정을 찾을 수 없어요",
- "vacation_saved": "부재중 자동 응답 설정이 저장되었어요",
- "vacation_save_failed": "부재중 자동 응답 설정을 저장하지 못했어요",
+ "vacation_saved": "부재중 설정이 저장되었어요",
+ "vacation_save_failed": "부재중 설정을 저장하지 못했어요",
"filters_saved": "필터가 저장되었어요",
"filters_save_failed": "필터 저장에 실패했어요",
"filters_deleted": "필터 규칙이 삭제되었어요",
@@ -858,7 +858,8 @@
"rule_undone": "규칙 변경을 취소했어요",
"rule_undo_failed": "규칙 변경을 취소하지 못했어요",
"rule_undo_conflict": "그 사이에 필터가 바뀌었어요. 필터 설정에서 변경을 취소하세요.",
- "rule_folder_failed": "폴더를 만들지 못했어요"
+ "rule_folder_failed": "폴더를 만들지 못했어요",
+ "vacation_filters_save_failed": "부재중 설정은 저장되었지만 전달 및 응답 대상 설정을 저장하지 못했어요"
},
"date": {
"today": "오늘",
@@ -943,7 +944,7 @@
"privacy": "개인정보 및 보안",
"account": "계정",
"identities": "보내는 사람",
- "vacation": "부재중 자동 응답",
+ "vacation": "부재중",
"advanced": "고급 설정",
"calendar": "캘린더",
"filters": "필터",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "부재중 자동 응답",
- "description": "자리를 비웠을 때 도착한 메일에 자동으로 답장을 보내요",
+ "title": "부재중",
+ "description": "자리를 비웠을 때 도착한 메일을 자동으로 처리해요",
"loading": "자동 응답 설정을 불러오는 중...",
"not_supported": "사용 중인 메일 서버는 자동 응답 기능을 지원하지 않아요.",
"fetch_error": "자동 응답 설정을 불러오지 못했어요. 다시 시도해 주세요.",
"status": {
- "label": "자동 응답",
+ "label": "자동으로 응답하기",
"description": "나에게 메일을 보낸 사람에게 자동으로 답장을 보내요",
"active": "사용 중",
"inactive": "사용 안 함"
},
"date_range": {
- "title": "기간 설정",
- "description": "자동 응답을 보낼 특정 기간을 설정할 수 있어요",
"start": "시작일",
"start_description": "비워두면 지금부터 바로 시작해요",
"end": "종료일",
"end_description": "비워두면 계속해서 켜져 있어요"
},
"message": {
- "title": "자동 응답 메시지",
- "description": "답장으로 보낼 메시지 내용을 작성해 주세요",
"subject_label": "제목",
"subject_description": "자동 응답 메일의 제목이에요",
"subject_placeholder": "부재중 알림",
@@ -1859,10 +1856,32 @@
"saving": "저장 중...",
"warnings": {
"end_before_start": "종료일은 시작일보다 나중이어야 해요",
+ "invalid_date": "시작일과 종료일에 시간을 포함한 올바른 날짜를 입력하거나 칸을 비워 두세요",
"start_in_past": "시작일이 이미 지났어요",
"empty_body": "메시지 내용이 비어있어요. 상대방에게 빈 메일이 가게 돼요",
"subject_too_long": "제목이 서버에 비해 너무 깁니다(최대 {max}바이트).",
- "body_too_long": "메시지가 서버에 비해 너무 깁니다(서식 포함 최대 {max}바이트)."
+ "body_too_long": "메시지가 서버에 비해 너무 깁니다(서식 포함 최대 {max}바이트).",
+ "forward_address": "전달받을 올바른 주소를 입력해 주세요",
+ "not_running": "전달 또는 응답 대상이 저장되어 있지만 지금은 작동하지 않아요. 저장하면 다시 켜져요."
+ },
+ "forward": {
+ "title": "전달",
+ "description": "받은 메시지를 자동 응답 여부와 관계없이 위 기간 동안 다른 주소로 전달해요(종료일이 없으면 끌 때까지). 스팸은 전달하지 않아요.",
+ "enabled_label": "메시지 전달",
+ "to_label": "다음으로 전달",
+ "to_placeholder": "email@example.com",
+ "keep_label": "받은편지함에 유지",
+ "keep_description": "끄면 메시지는 전달만 되고, 이 메일함에 남지 않으며 필터 규칙도 적용되지 않아요."
+ },
+ "audience": {
+ "label": "응답 대상",
+ "description": "내부 보낸 사람: {domains}",
+ "all": "모든 보낸 사람",
+ "internal": "내부 보낸 사람만",
+ "external": "외부 보낸 사람만"
+ },
+ "auto_reply": {
+ "title": "자동 응답"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "이 폴더에 이미 있는 메일에도 적용",
- "apply_existing_unsupported": "보낸 사람, 받는 사람, 제목 또는 헤더를 확인하고 이동, 복사, 읽음 표시, 별표 또는 태그를 하는 규칙만 기존 메일에 적용할 수 있어요."
+ "apply_existing_unsupported": "보낸 사람, 받는 사람, 제목 또는 헤더를 확인하고 이동, 복사, 읽음 표시, 별표 또는 태그를 하는 규칙만 기존 메일에 적용할 수 있어요.",
+ "period_title": "기간 설정",
+ "period_start": "시작일",
+ "period_end": "종료일",
+ "validation_period_order": "종료일은 시작일보다 나중이어야 해요",
+ "period_toggle": "특정 기간에만 활성화",
+ "period_hint": "이 기간에 도착하는 메시지에 적용돼요. 한쪽을 비워 두면 그쪽은 제한이 없어요.",
+ "validation_period_empty": "기간의 시작일 또는 종료일을 입력해 주세요",
+ "validation_period_invalid": "기간에 시간을 포함한 올바른 날짜를 입력하거나 칸을 비워 두세요",
+ "apply_existing_period": "기간이 설정된 규칙은 그 기간에 도착한 메시지에만 적용되므로 기존 메시지에는 적용할 수 없어요.",
+ "period_status_scheduled": "예정",
+ "period_status_active": "활성",
+ "period_status_expired": "만료"
},
"templates": {
"title": "이메일 템플릿",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index e0d64b230..98619458a 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Neizdevās izdzēst identitāti: {error}",
"identity_unauthorized": "Jums nav tiesību sūtīt no šīs e-pasta adreses",
"identity_not_found": "Identitāte netika atrasta",
- "vacation_saved": "Atvaļinājuma atbildes iestatījumi saglabāti",
- "vacation_save_failed": "Neizdevās saglabāt atvaļinājuma atbildes iestatījumus",
+ "vacation_saved": "Prombūtnes iestatījumi saglabāti",
+ "vacation_save_failed": "Neizdevās saglabāt prombūtnes iestatījumus",
"filters_saved": "Filtri veiksmīgi saglabāti",
"filters_save_failed": "Neizdevās saglabāt filtrus",
"filters_deleted": "Filtra nosacījums izdzēsts",
@@ -858,7 +858,8 @@
"rule_undone": "Noteikuma izmaiņa atsaukta",
"rule_undo_failed": "Neizdevās atsaukt noteikuma izmaiņu",
"rule_undo_conflict": "Jūsu filtri kopš tā laika ir mainījušies. Atsauciet izmaiņu filtru iestatījumos.",
- "rule_folder_failed": "Neizdevās izveidot mapi"
+ "rule_folder_failed": "Neizdevās izveidot mapi",
+ "vacation_filters_save_failed": "Prombūtnes iestatījumi saglabāti, taču pārsūtīšanu un atbildes saņēmējus neizdevās saglabāt"
},
"date": {
"today": "Šodien",
@@ -943,7 +944,7 @@
"privacy": "Privātums un drošība",
"account": "Konts",
"identities": "Identitātes",
- "vacation": "Atvaļinājuma atbilde",
+ "vacation": "Prombūtne",
"advanced": "Papildu",
"calendar": "Kalendārs",
"filters": "Filtri",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "Atvaļinājuma atbilde",
- "description": "Automātiski atbildēt uz ienākošajām vēstulēm prombūtnes laikā",
+ "title": "Prombūtne",
+ "description": "Automātiski apstrādāt ienākošās vēstules prombūtnes laikā",
"loading": "Ielādē atvaļinājuma iestatījumus...",
"not_supported": "Jūsu pasta serveris neatbalsta atvaļinājuma atbildes.",
"fetch_error": "Neizdevās ielādēt atvaļinājuma iestatījumus.",
"status": {
- "label": "Atvaļinājuma atbilde",
+ "label": "Atbildēt automātiski",
"description": "Sūtīt automātisku atbildi uz ienākošajām vēstulēm",
"active": "Aktīva",
"inactive": "Neaktīva"
},
"date_range": {
- "title": "Datumu diapazons",
- "description": "Ierobežojiet atbildi noteiktā periodā (pēc izvēles)",
"start": "Sākuma datums",
"start_description": "Atstājiet tukšu, lai nebūtu ierobežojuma",
"end": "Beigu datums",
"end_description": "Atstājiet tukšu, lai nebūtu ierobežojuma"
},
"message": {
- "title": "Atbildes ziņojums",
- "description": "Ziņojums, kas tiks nosūtīts kā atbilde",
"subject_label": "Temats",
"subject_description": "Atvaļinājuma atbildes temats",
"subject_placeholder": "Atvaļinājumā / Esmu prom",
@@ -1859,10 +1856,32 @@
"saving": "Saglabā...",
"warnings": {
"end_before_start": "Beigu datumam jābūt vēlāk par sākuma datumu",
+ "invalid_date": "Norādiet derīgu sākuma un beigu datumu ar laiku vai atstājiet lauku tukšu",
"start_in_past": "Sākuma datums ir pagātnē",
"empty_body": "Ziņojuma teksts ir tukšs - saņēmēji saņems tukšu atbildi",
"subject_too_long": "Temats serverim ir pārāk garš (ne vairāk kā {max} baiti).",
- "body_too_long": "Ziņa serverim ir pārāk gara (ne vairāk kā {max} baiti, ieskaitot formatējumu)."
+ "body_too_long": "Ziņa serverim ir pārāk gara (ne vairāk kā {max} baiti, ieskaitot formatējumu).",
+ "forward_address": "Norādiet derīgu adresi pārsūtīšanai",
+ "not_running": "Pārsūtīšana vai atbildes saņēmēji ir saglabāti, taču pašlaik nav aktīvi. Saglabājiet, lai tos atkal ieslēgtu."
+ },
+ "forward": {
+ "title": "Pārsūtīšana",
+ "description": "Pārsūta ienākošos ziņojumus uz citu adresi, ar automātisko atbildi vai bez tās, iepriekš norādītajā laika posmā (bez beigu datuma, līdz to izslēdz). Mēstules netiek pārsūtītas.",
+ "enabled_label": "Pārsūtīt ziņojumus",
+ "to_label": "Pārsūtīt uz",
+ "to_placeholder": "lietotajs@piemers.lv",
+ "keep_label": "Atstāt iesūtnē",
+ "keep_description": "Pretējā gadījumā ziņojums tiek tikai pārsūtīts: tas nepaliek šajā pastkastē, un tam netiek piemērots neviens filtra noteikums."
+ },
+ "audience": {
+ "label": "Atbildēt",
+ "description": "Iekšējie sūtītāji: {domains}",
+ "all": "Visiem sūtītājiem",
+ "internal": "Tikai iekšējiem sūtītājiem",
+ "external": "Tikai ārējiem sūtītājiem"
+ },
+ "auto_reply": {
+ "title": "Automātiskā atbilde"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Piemērot arī vēstulēm, kas jau ir šajā mapē",
- "apply_existing_unsupported": "Esošajām vēstulēm var piemērot tikai noteikumus, kas pārbauda sūtītāju, adresātus, tematu vai galvenes un kas pārvieto, kopē, atzīmē kā izlasītu, ar zvaigznīti vai tagu."
+ "apply_existing_unsupported": "Esošajām vēstulēm var piemērot tikai noteikumus, kas pārbauda sūtītāju, adresātus, tematu vai galvenes un kas pārvieto, kopē, atzīmē kā izlasītu, ar zvaigznīti vai tagu.",
+ "period_title": "Datumu diapazons",
+ "period_start": "Sākuma datums",
+ "period_end": "Beigu datums",
+ "validation_period_order": "Beigu datumam jābūt vēlāk par sākuma datumu",
+ "period_toggle": "Aktīvs tikai noteiktā laika posmā",
+ "period_hint": "Attiecas uz ziņojumiem, kas pienāk šajā laika posmā. Atstājiet lauku tukšu, lai šo galu neierobežotu.",
+ "validation_period_empty": "Norādiet laika posma sākuma vai beigu datumu",
+ "validation_period_invalid": "Norādiet laika posmam derīgu datumu ar laiku vai atstājiet lauku tukšu",
+ "apply_existing_period": "Noteikums ar laika posmu darbojas tikai ar ziņojumiem, kas pienāk šajā posmā, tāpēc to nevar lietot esošajiem ziņojumiem.",
+ "period_status_scheduled": "ieplānots",
+ "period_status_active": "aktīvs",
+ "period_status_expired": "beidzies"
},
"templates": {
"title": "Vēstuļu veidnes",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index dd8599051..63163a41c 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Таниулбарыг устгаж чадсангүй: {error}",
"identity_unauthorized": "Та энэ имэйл хаягаас илгээх эрхгүй",
"identity_not_found": "Таниулбар олдсонгүй",
- "vacation_saved": "Амралтын хариулагчийн тохиргоог хадгалсан",
- "vacation_save_failed": "Амралтын хариулагчийн тохиргоог хадгалж чадсангүй",
+ "vacation_saved": "Эзгүй байх тохиргоог хадгалсан",
+ "vacation_save_failed": "Эзгүй байх тохиргоог хадгалж чадсангүй",
"filters_saved": "Шүүлтүүрийг амжилттай хадгаллаа",
"filters_save_failed": "Шүүлтүүрийг хадгалж чадсангүй",
"filters_deleted": "Шүүлтүүрийн дүрмийг устгасан",
@@ -858,7 +858,8 @@
"rule_undone": "Дүрмийн өөрчлөлтийг буцаалаа",
"rule_undo_failed": "Дүрмийн өөрчлөлтийг буцааж чадсангүй",
"rule_undo_conflict": "Үүнээс хойш таны шүүлтүүр өөрчлөгдсөн. Өөрчлөлтийг шүүлтүүрийн тохиргооноос буцаана уу.",
- "rule_folder_failed": "Хавтас үүсгэж чадсангүй"
+ "rule_folder_failed": "Хавтас үүсгэж чадсангүй",
+ "vacation_filters_save_failed": "Эзгүй байх тохиргоог хадгалсан боловч дамжуулалт болон хариу хүлээн авагчдыг хадгалж чадсангүй"
},
"date": {
"today": "Өнөөдөр",
@@ -946,7 +947,7 @@
"privacy": "Нууцлал ба аюулгүй байдал",
"account": " Данс",
"identities": "Тэмдэгтүүд",
- "vacation": "Амралтын хариулагч",
+ "vacation": "Эзгүй байх",
"advanced": "Дэвшилтэт",
"calendar": "Хуанли",
"filters": "Шүүлтүүр",
@@ -1825,28 +1826,24 @@
}
},
"vacation": {
- "title": "Амралтын хариулагч",
- "description": "Таныг эзгүй байхад ирж буй имэйлд автоматаар хариу бичих",
+ "title": "Эзгүй байх",
+ "description": "Таныг эзгүй байхад ирж буй имэйлийг автоматаар боловсруулах",
"loading": "Амралтын тохиргоог ачаалж байна...",
"not_supported": "Таны мэйл сервер амралтын хариуг дэмждэггүй.",
"fetch_error": "Амралтын тохиргоог ачаалж чадсангүй. Дахин оролдоно уу.",
"status": {
- "label": "Амралтын хариулагч",
+ "label": "Автоматаар хариулах",
"description": "Тан руу имэйл илгээсэн хүмүүст автоматаар хариу илгээх",
"active": "Идэвхтэй",
"inactive": "Идэвхгүй"
},
"date_range": {
- "title": "Огнооны муж",
- "description": "Заавал автомат хариуг тодорхой хугацаанд хязгаарлах",
"start": "Эхлэх огноо",
"start_description": "Эхлэх хязгааргүй хоосон орхино",
"end": "Төгсгөлийн огноо",
"end_description": "Төгсгөлгүй хоосон орхино"
},
"message": {
- "title": "Автоматаар хариулах мессеж",
- "description": "Хариулт хэлбэрээр илгээх мессеж",
"subject_label": "Сэдэв",
"subject_description": "Автомат хариултын сэдвийн мөр",
"subject_placeholder": "Оффисоос гадуур",
@@ -1866,10 +1863,32 @@
"saving": "Хадгалж байна...",
"warnings": {
"end_before_start": "Төгсгөлийн огноо нь эхлэх огнооноос хойш байх ёстой",
+ "invalid_date": "Цагтай хүчинтэй эхлэх болон дуусах огноо оруулах эсвэл талбарыг хоосон үлдээнэ үү",
"start_in_past": "Эхлэх огноо өнгөрсөн байна",
"empty_body": "Зурвасны хэсэг хоосон байна - хүлээн авагчид хоосон хариу ирэх болно",
"subject_too_long": "Гарчиг сервертэй харьцуулахад хэт урт байна (дээд тал нь {max} байт).",
- "body_too_long": "Захидал сервертэй харьцуулахад хэт урт байна (форматыг оруулаад дээд тал нь {max} байт)."
+ "body_too_long": "Захидал сервертэй харьцуулахад хэт урт байна (форматыг оруулаад дээд тал нь {max} байт).",
+ "forward_address": "Дамжуулах хүчинтэй хаяг оруулна уу",
+ "not_running": "Дамжуулалт эсвэл хариу хүлээн авагчид хадгалагдсан ч одоогоор идэвхгүй байна. Дахин идэвхжүүлэхийн тулд хадгална уу."
+ },
+ "forward": {
+ "title": "Дамжуулалт",
+ "description": "Ирсэн мессежийг автомат хариутай эсвэл хариугүйгээр дээрх хугацаанд өөр хаяг руу дамжуулна (дуусах огноогүй бол унтраах хүртэл). Спамыг дамжуулахгүй.",
+ "enabled_label": "Мессеж дамжуулах",
+ "to_label": "Дамжуулах хаяг",
+ "to_placeholder": "email@example.com",
+ "keep_label": "Ирсэн имэйлд хадгалах",
+ "keep_description": "Эс бөгөөс мессежийг зөвхөн дамжуулна: энэ шуудангийн хайрцагт үлдэхгүй бөгөөд ямар ч шүүлтүүрийн дүрэм ажиллахгүй."
+ },
+ "audience": {
+ "label": "Хариу илгээх",
+ "description": "Дотоод илгээгчид: {domains}",
+ "all": "Бүх илгээгчид",
+ "internal": "Зөвхөн дотоод илгээгчид",
+ "external": "Зөвхөн гадаад илгээгчид"
+ },
+ "auto_reply": {
+ "title": "Автомат хариу"
}
},
"folders": {
@@ -2238,7 +2257,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Энэ хавтсанд байгаа захиануудад ч мөн хэрэглэх",
- "apply_existing_unsupported": "Илгээгч, хүлээн авагч, гарчиг эсвэл толгойг шалгаж, зөөх, хуулах, уншсан болгох, од тавих эсвэл таг нэмэх дүрмүүдийг л байгаа захиануудад хэрэглэж болно."
+ "apply_existing_unsupported": "Илгээгч, хүлээн авагч, гарчиг эсвэл толгойг шалгаж, зөөх, хуулах, уншсан болгох, од тавих эсвэл таг нэмэх дүрмүүдийг л байгаа захиануудад хэрэглэж болно.",
+ "period_title": "Огнооны муж",
+ "period_start": "Эхлэх огноо",
+ "period_end": "Төгсгөлийн огноо",
+ "validation_period_order": "Төгсгөлийн огноо нь эхлэх огнооноос хойш байх ёстой",
+ "period_toggle": "Зөвхөн тодорхой хугацаанд идэвхтэй",
+ "period_hint": "Энэ хугацаанд ирсэн мессежид хамаарна. Аль нэг талыг хязгаарлахгүй бол талбарыг хоосон үлдээнэ үү.",
+ "validation_period_empty": "Хугацааны эхлэх эсвэл төгсгөлийн огноог оруулна уу",
+ "validation_period_invalid": "Хугацаанд цагтай хүчинтэй огноо оруулах эсвэл талбарыг хоосон үлдээнэ үү",
+ "apply_existing_period": "Хугацаатай дүрэм зөвхөн тэр хугацаанд ирсэн мессежид үйлчилдэг тул одоо байгаа мессежид хэрэглэх боломжгүй.",
+ "period_status_scheduled": "төлөвлөсөн",
+ "period_status_active": "идэвхтэй",
+ "period_status_expired": "хугацаа дууссан"
},
"templates": {
"title": "И-мэйл загварууд",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 71041689f..47bee195b 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Kunne ikke slette identitet: {error}",
"identity_unauthorized": "Du er ikke autorisert til å sende fra denne e-postadressen",
"identity_not_found": "Finner ikke identiteten",
- "vacation_saved": "Innstillinger for feriesvar er lagret",
- "vacation_save_failed": "Kunne ikke lagre innstillingene for feriesvar",
+ "vacation_saved": "Fraværsinnstillinger er lagret",
+ "vacation_save_failed": "Kunne ikke lagre fraværsinnstillingene",
"filters_saved": "Filtrene er lagret",
"filters_save_failed": "Kunne ikke lagre filtrene",
"filters_deleted": "Filterregelen er slettet",
@@ -858,7 +858,8 @@
"rule_undone": "Regelendringen er angret",
"rule_undo_failed": "Kunne ikke angre regelendringen",
"rule_undo_conflict": "Filtrene dine er endret siden. Angre endringen i filterinnstillingene.",
- "rule_folder_failed": "Kunne ikke opprette mappen"
+ "rule_folder_failed": "Kunne ikke opprette mappen",
+ "vacation_filters_save_failed": "Fraværsinnstillinger er lagret, men videresending og svarmottakere kunne ikke lagres"
},
"date": {
"today": "I dag",
@@ -946,7 +947,7 @@
"privacy": "Personvern og sikkerhet",
"account": "Konto",
"identities": "Identiteter",
- "vacation": "Feriesvar",
+ "vacation": "Fravær",
"advanced": "Avansert",
"calendar": "Kalender",
"filters": "Filtre",
@@ -1825,28 +1826,24 @@
}
},
"vacation": {
- "title": "Feriesvar",
- "description": "Svar automatisk på innkommende e-poster mens du er borte",
+ "title": "Fravær",
+ "description": "Håndter innkommende e-poster automatisk mens du er borte",
"loading": "Laster inn innstillinger for feriesvar...",
"not_supported": "E-postserveren din støtter ikke feriesvar.",
"fetch_error": "Kunne ikke laste inn innstillingene for feriesvar. Prøv igjen.",
"status": {
- "label": "Feriesvar",
+ "label": "Svar automatisk",
"description": "Send et automatisk svar til folk som sender deg e-post",
"active": "Aktiv",
"inactive": "Inaktiv"
},
"date_range": {
- "title": "Datoperiode",
- "description": "Begrens om ønskelig autosvaret til en bestemt periode",
"start": "Startdato",
"start_description": "La feltet stå tomt hvis det ikke skal være noen startdato",
"end": "Sluttdato",
"end_description": "La feltet stå tomt hvis det ikke skal være noen sluttdato"
},
"message": {
- "title": "Melding for automatisk svar",
- "description": "Meldingen som sendes som svar",
"subject_label": "Emne",
"subject_description": "Emnelinje for autosvaret",
"subject_placeholder": "Ikke på kontoret",
@@ -1866,10 +1863,32 @@
"saving": "Lagrer...",
"warnings": {
"end_before_start": "Sluttdatoen må være etter startdatoen",
+ "invalid_date": "Angi en gyldig start- og sluttdato med klokkeslett, eller la feltet stå tomt",
"start_in_past": "Startdatoen er i fortiden",
"empty_body": "Meldingsteksten er tom – mottakerne får et tomt svar",
"subject_too_long": "Emnet er for langt for serveren (maks. {max} byte).",
- "body_too_long": "Meldingen er for lang for serveren (maks. {max} byte inkludert formatering)."
+ "body_too_long": "Meldingen er for lang for serveren (maks. {max} byte inkludert formatering).",
+ "forward_address": "Angi en gyldig adresse å videresende til",
+ "not_running": "Videresending eller svarmottakere er lagret, men er ikke aktive akkurat nå. Lagre for å slå dem på igjen."
+ },
+ "forward": {
+ "title": "Videresending",
+ "description": "Videresender innkommende meldinger til en annen adresse, med eller uten feriesvar, i perioden ovenfor (uten sluttdato, til den slås av). Søppelpost videresendes ikke.",
+ "enabled_label": "Videresend meldinger",
+ "to_label": "Videresend til",
+ "to_placeholder": "email@example.com",
+ "keep_label": "Behold i innboksen",
+ "keep_description": "Ellers blir meldingen bare videresendt: Den blir ikke liggende i denne postkassen, og ingen filterregel kjøres på den."
+ },
+ "audience": {
+ "label": "Svar til",
+ "description": "Interne avsendere: {domains}",
+ "all": "Alle avsendere",
+ "internal": "Bare interne avsendere",
+ "external": "Bare eksterne avsendere"
+ },
+ "auto_reply": {
+ "title": "Automatisk svar"
}
},
"folders": {
@@ -2238,7 +2257,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Bruk også på meldinger som allerede ligger i denne mappen",
- "apply_existing_unsupported": "Bare regler som sjekker avsender, mottakere, emne eller hoder, og som flytter, kopierer, merker som lest, stjernemerker eller setter etikett, kan brukes på eksisterende meldinger."
+ "apply_existing_unsupported": "Bare regler som sjekker avsender, mottakere, emne eller hoder, og som flytter, kopierer, merker som lest, stjernemerker eller setter etikett, kan brukes på eksisterende meldinger.",
+ "period_title": "Datoperiode",
+ "period_start": "Startdato",
+ "period_end": "Sluttdato",
+ "validation_period_order": "Sluttdatoen må være etter startdatoen",
+ "period_toggle": "Bare aktiv i en periode",
+ "period_hint": "Gjelder meldinger som kommer i denne perioden. La et felt stå tomt for å holde den enden åpen.",
+ "validation_period_empty": "Angi en start- eller sluttdato for perioden",
+ "validation_period_invalid": "Angi en gyldig dato med klokkeslett for perioden, eller la feltet stå tomt",
+ "apply_existing_period": "En regel med periode virker bare på meldinger som kommer i perioden, og kan derfor ikke brukes på eksisterende meldinger.",
+ "period_status_scheduled": "planlagt",
+ "period_status_active": "aktiv",
+ "period_status_expired": "utløpt"
},
"templates": {
"title": "E-postmaler",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index f2b8b35f2..2b29558f6 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -858,7 +858,8 @@
"rule_undone": "Regelwijziging ongedaan gemaakt",
"rule_undo_failed": "De regelwijziging kon niet ongedaan worden gemaakt",
"rule_undo_conflict": "Je filters zijn intussen gewijzigd. Maak de wijziging ongedaan in de filterinstellingen.",
- "rule_folder_failed": "De map kon niet worden gemaakt"
+ "rule_folder_failed": "De map kon niet worden gemaakt",
+ "vacation_filters_save_failed": "Afwezigheidsinstellingen opgeslagen, maar het doorsturen en de ontvangers van het antwoord konden niet worden opgeslagen"
},
"date": {
"today": "Vandaag",
@@ -943,7 +944,7 @@
"privacy": "Privacy & Beveiliging",
"account": "Account",
"identities": "Identiteiten",
- "vacation": "Afwezigheidsmelder",
+ "vacation": "Afwezigheid",
"advanced": "Geavanceerd",
"calendar": "Agenda",
"filters": "Filters",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "Afwezigheidsmelder",
- "description": "Automatisch antwoorden op inkomende e-mails terwijl je afwezig bent",
+ "title": "Afwezigheid",
+ "description": "Inkomende e-mails automatisch afhandelen terwijl je afwezig bent",
"loading": "Afwezigheidsinstellingen laden...",
"not_supported": "Je mailserver ondersteunt geen afwezigheidsantwoorden.",
"fetch_error": "Kan afwezigheidsinstellingen niet laden. Probeer het opnieuw.",
"status": {
- "label": "Afwezigheidsmelder",
+ "label": "Automatisch antwoorden",
"description": "Stuur een automatisch antwoord naar mensen die je mailen",
"active": "Actief",
"inactive": "Inactief"
},
"date_range": {
- "title": "Periode",
- "description": "Beperk optioneel het automatische antwoord tot een specifieke periode",
"start": "Startdatum",
"start_description": "Leeg laten voor geen startlimiet",
"end": "Einddatum",
"end_description": "Leeg laten voor geen eindlimiet"
},
"message": {
- "title": "Automatisch antwoordbericht",
- "description": "Het bericht dat als antwoord wordt verzonden",
"subject_label": "Onderwerp",
"subject_description": "Onderwerpregel van het automatische antwoord",
"subject_placeholder": "Afwezigheidsbericht",
@@ -1859,10 +1856,32 @@
"saving": "Opslaan...",
"warnings": {
"end_before_start": "De einddatum moet na de startdatum liggen",
+ "invalid_date": "Vul een geldige start- en einddatum met tijd in, of laat het veld leeg",
"start_in_past": "De startdatum ligt in het verleden",
"empty_body": "De berichttekst is leeg - ontvangers krijgen een leeg antwoord",
"subject_too_long": "Het onderwerp is te lang voor de server (maximaal {max} bytes).",
- "body_too_long": "Het bericht is te lang voor de server (maximaal {max} bytes, inclusief opmaak)."
+ "body_too_long": "Het bericht is te lang voor de server (maximaal {max} bytes, inclusief opmaak).",
+ "forward_address": "Vul een geldig adres in om naar door te sturen",
+ "not_running": "Doorsturen of de ontvangers van het antwoord zijn opgeslagen, maar nu niet actief. Sla op om ze weer in te schakelen."
+ },
+ "forward": {
+ "title": "Doorsturen",
+ "description": "Stuurt binnenkomende berichten door naar een ander adres, met of zonder automatisch antwoord, binnen de periode hierboven (zonder einddatum, tot het wordt uitgeschakeld). Spam wordt niet doorgestuurd.",
+ "enabled_label": "Berichten doorsturen",
+ "to_label": "Doorsturen naar",
+ "to_placeholder": "email@voorbeeld.nl",
+ "keep_label": "In postvak IN houden",
+ "keep_description": "Anders wordt het bericht alleen doorgestuurd: het blijft niet in deze mailbox en er wordt geen filterregel op toegepast."
+ },
+ "audience": {
+ "label": "Antwoorden aan",
+ "description": "Interne afzenders: {domains}",
+ "all": "Alle afzenders",
+ "internal": "Alleen interne afzenders",
+ "external": "Alleen externe afzenders"
+ },
+ "auto_reply": {
+ "title": "Automatisch antwoord"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Ook toepassen op berichten die al in deze map staan",
- "apply_existing_unsupported": "Alleen regels die de afzender, ontvangers, het onderwerp of headers controleren, en die verplaatsen, kopiëren, als gelezen markeren, een ster geven of labelen, kunnen op bestaande berichten worden toegepast."
+ "apply_existing_unsupported": "Alleen regels die de afzender, ontvangers, het onderwerp of headers controleren, en die verplaatsen, kopiëren, als gelezen markeren, een ster geven of labelen, kunnen op bestaande berichten worden toegepast.",
+ "period_title": "Periode",
+ "period_start": "Startdatum",
+ "period_end": "Einddatum",
+ "validation_period_order": "De einddatum moet na de startdatum liggen",
+ "period_toggle": "Alleen actief in een periode",
+ "period_hint": "Geldt voor berichten die in deze periode binnenkomen. Laat een veld leeg om die kant open te laten.",
+ "validation_period_empty": "Vul een start- of einddatum voor de periode in",
+ "validation_period_invalid": "Vul voor de periode een geldige datum met tijd in, of laat het veld leeg",
+ "apply_existing_period": "Een regel met een periode werkt alleen op berichten die binnen die periode binnenkomen en kan dus niet op bestaande berichten worden toegepast.",
+ "period_status_scheduled": "gepland",
+ "period_status_active": "actief",
+ "period_status_expired": "verlopen"
},
"templates": {
"title": "E-mailsjablonen",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 5947f1aa4..3ad5f491c 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Nie udało się usunąć tożsamości: {error}",
"identity_unauthorized": "Nie masz uprawnień do wysyłania z tego adresu e-mail",
"identity_not_found": "Nie znaleziono tożsamości",
- "vacation_saved": "Ustawienia autorespondera wakacyjnego zapisane",
- "vacation_save_failed": "Nie udało się zapisać ustawień autorespondera wakacyjnego",
+ "vacation_saved": "Ustawienia nieobecności zapisane",
+ "vacation_save_failed": "Nie udało się zapisać ustawień nieobecności",
"filters_saved": "Filtry zapisane pomyślnie",
"filters_save_failed": "Nie udało się zapisać filtrów",
"filters_deleted": "Reguła filtra usunięta",
@@ -858,7 +858,8 @@
"rule_undone": "Cofnięto zmianę reguły",
"rule_undo_failed": "Nie udało się cofnąć zmiany reguły",
"rule_undo_conflict": "Twoje filtry zmieniły się w międzyczasie. Cofnij zmianę w ustawieniach filtrów.",
- "rule_folder_failed": "Nie udało się utworzyć folderu"
+ "rule_folder_failed": "Nie udało się utworzyć folderu",
+ "vacation_filters_save_failed": "Ustawienia nieobecności zapisane, ale nie udało się zapisać przekazywania ani odbiorców odpowiedzi"
},
"date": {
"today": "Dzisiaj",
@@ -943,7 +944,7 @@
"privacy": "Prywatność i bezpieczeństwo",
"account": "Konto",
"identities": "Tożsamości",
- "vacation": "Autoresponder urlopowy",
+ "vacation": "Nieobecność",
"advanced": "Zaawansowane",
"calendar": "Kalendarz",
"filters": "Filtry",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "Autoresponder urlopowy",
- "description": "Automatycznie odpowiadaj na przychodzące wiadomości e-mail podczas nieobecności",
+ "title": "Nieobecność",
+ "description": "Automatycznie obsługuj przychodzące wiadomości e-mail podczas nieobecności",
"loading": "Ładowanie ustawień autorespondera urlopowego...",
"not_supported": "Twój serwer pocztowy nie obsługuje odpowiedzi urlopowych.",
"fetch_error": "Nie udało się załadować ustawień urlopowych. Spróbuj ponownie.",
"status": {
- "label": "Autoresponder urlopowy",
+ "label": "Odpowiadaj automatycznie",
"description": "Wysyłaj automatyczną odpowiedź do osób, które do Ciebie piszą",
"active": "Aktywny",
"inactive": "Nieaktywny"
},
"date_range": {
- "title": "Zakres dat",
- "description": "Opcjonalnie ogranicz automatyczną odpowiedź do określonego okresu",
"start": "Data rozpoczęcia",
"start_description": "Pozostaw puste, aby nie ustawiać daty początkowej",
"end": "Data zakończenia",
"end_description": "Pozostaw puste, aby nie ustawiać daty końcowej"
},
"message": {
- "title": "Wiadomość automatycznej odpowiedzi",
- "description": "Wiadomość, która zostanie wysłana jako odpowiedź",
"subject_label": "Temat",
"subject_description": "Wiersz tematu automatycznej odpowiedzi",
"subject_placeholder": "Poza biurem",
@@ -1859,10 +1856,32 @@
"saving": "Zapisywanie...",
"warnings": {
"end_before_start": "Data zakończenia musi być późniejsza niż data rozpoczęcia",
+ "invalid_date": "Podaj prawidłową datę rozpoczęcia i zakończenia z godziną albo pozostaw pole puste",
"start_in_past": "Data rozpoczęcia jest w przeszłości",
"empty_body": "Treść wiadomości jest pusta - odbiorcy otrzymają pustą odpowiedź",
"subject_too_long": "Temat jest za długi dla serwera (maksymalnie {max} bajtów).",
- "body_too_long": "Wiadomość jest za długa dla serwera (maksymalnie {max} bajtów wraz z formatowaniem)."
+ "body_too_long": "Wiadomość jest za długa dla serwera (maksymalnie {max} bajtów wraz z formatowaniem).",
+ "forward_address": "Podaj prawidłowy adres do przekazywania",
+ "not_running": "Przekazywanie lub odbiorcy odpowiedzi są zapisani, ale obecnie nieaktywni. Zapisz, aby ponownie je włączyć."
+ },
+ "forward": {
+ "title": "Przekazywanie",
+ "description": "Przekazuje wiadomości przychodzące na inny adres, z automatyczną odpowiedzią lub bez niej, w okresie podanym powyżej (bez daty zakończenia, dopóki nie zostanie wyłączone). Spam nie jest przekazywany.",
+ "enabled_label": "Przekazuj wiadomości",
+ "to_label": "Prześlij dalej do",
+ "to_placeholder": "email@example.com",
+ "keep_label": "Pozostaw w Odebranych",
+ "keep_description": "W przeciwnym razie wiadomość jest tylko przekazywana: nie zostaje w tej skrzynce i nie działa na nią żadna reguła filtrowania."
+ },
+ "audience": {
+ "label": "Odpowiadaj",
+ "description": "Nadawcy wewnętrzni: {domains}",
+ "all": "Wszystkim nadawcom",
+ "internal": "Tylko nadawcom wewnętrznym",
+ "external": "Tylko nadawcom zewnętrznym"
+ },
+ "auto_reply": {
+ "title": "Automatyczna odpowiedź"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Zastosuj też do wiadomości, które już są w tym folderze",
- "apply_existing_unsupported": "Do istniejących wiadomości można zastosować tylko reguły, które sprawdzają nadawcę, odbiorców, temat lub nagłówki i które przenoszą, kopiują, oznaczają jako przeczytane, oznaczają gwiazdką lub etykietą."
+ "apply_existing_unsupported": "Do istniejących wiadomości można zastosować tylko reguły, które sprawdzają nadawcę, odbiorców, temat lub nagłówki i które przenoszą, kopiują, oznaczają jako przeczytane, oznaczają gwiazdką lub etykietą.",
+ "period_title": "Zakres dat",
+ "period_start": "Data rozpoczęcia",
+ "period_end": "Data zakończenia",
+ "validation_period_order": "Data zakończenia musi być późniejsza niż data rozpoczęcia",
+ "period_toggle": "Aktywna tylko w wybranym okresie",
+ "period_hint": "Dotyczy wiadomości, które przychodzą w tym okresie. Pozostaw pole puste, aby nie ograniczać tej strony.",
+ "validation_period_empty": "Podaj datę rozpoczęcia lub zakończenia okresu",
+ "validation_period_invalid": "Podaj prawidłową datę z godziną dla okresu albo pozostaw pole puste",
+ "apply_existing_period": "Reguła z okresem działa tylko na wiadomości, które przychodzą w tym okresie, dlatego nie można jej zastosować do istniejących wiadomości.",
+ "period_status_scheduled": "zaplanowana",
+ "period_status_active": "aktywna",
+ "period_status_expired": "wygasła"
},
"templates": {
"title": "Szablony wiadomości e-mail",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 5951f05e4..2b99037f9 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Falha ao excluir identidade: {error}",
"identity_unauthorized": "Você não está autorizado a enviar deste endereço de e-mail",
"identity_not_found": "Identidade não encontrada",
- "vacation_saved": "Configurações de resposta automática salvas",
- "vacation_save_failed": "Falha ao salvar configurações de resposta automática",
+ "vacation_saved": "Configurações de ausência salvas",
+ "vacation_save_failed": "Falha ao salvar configurações de ausência",
"filters_saved": "Filtros salvos com sucesso",
"filters_save_failed": "Falha ao salvar os filtros",
"filters_deleted": "Regra de filtragem eliminada",
@@ -858,7 +858,8 @@
"rule_undone": "Alteração da regra desfeita",
"rule_undo_failed": "Não foi possível desfazer a alteração da regra",
"rule_undo_conflict": "Seus filtros mudaram desde então. Desfaça a alteração nas configurações de filtros.",
- "rule_folder_failed": "Não foi possível criar a pasta"
+ "rule_folder_failed": "Não foi possível criar a pasta",
+ "vacation_filters_save_failed": "Configurações de ausência salvas, mas não foi possível salvar o encaminhamento e os destinatários da resposta"
},
"date": {
"today": "Hoje",
@@ -943,7 +944,7 @@
"privacy": "Privacidade e Segurança",
"account": "Conta",
"identities": "Identidades",
- "vacation": "Resposta automática",
+ "vacation": "Ausência",
"advanced": "Avançado",
"calendar": "Calendário",
"filters": "Filtros",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "Resposta automática",
- "description": "Responder automaticamente a e-mails recebidos enquanto estiver ausente",
+ "title": "Ausência",
+ "description": "Tratar automaticamente os e-mails recebidos enquanto estiver ausente",
"loading": "Carregando configurações de resposta automática...",
"not_supported": "Seu servidor de e-mail não suporta respostas automáticas de ausência.",
"fetch_error": "Falha ao carregar as configurações de resposta automática. Tente novamente.",
"status": {
- "label": "Resposta automática",
+ "label": "Responder automaticamente",
"description": "Enviar uma resposta automática para quem lhe enviar um e-mail",
"active": "Ativo",
"inactive": "Inativo"
},
"date_range": {
- "title": "Período",
- "description": "Opcionalmente limitar a resposta automática a um período específico",
"start": "Data de início",
"start_description": "Deixe vazio para sem limite de início",
"end": "Data de fim",
"end_description": "Deixe vazio para sem limite de fim"
},
"message": {
- "title": "Mensagem de resposta automática",
- "description": "A mensagem que será enviada como resposta",
"subject_label": "Assunto",
"subject_description": "Linha de assunto da resposta automática",
"subject_placeholder": "Fora do escritório",
@@ -1859,10 +1856,32 @@
"saving": "Salvando...",
"warnings": {
"end_before_start": "A data de fim deve ser posterior à data de início",
+ "invalid_date": "Indique uma data de início e de fim válidas com hora ou deixe o campo vazio",
"start_in_past": "A data de início está no passado",
"empty_body": "O corpo da mensagem está vazio - os destinatários receberão uma resposta em branco",
"subject_too_long": "O assunto é demasiado longo para o servidor (no máximo {max} bytes).",
- "body_too_long": "A mensagem é demasiado longa para o servidor (no máximo {max} bytes, incluindo a formatação)."
+ "body_too_long": "A mensagem é demasiado longa para o servidor (no máximo {max} bytes, incluindo a formatação).",
+ "forward_address": "Informe um endereço válido para o encaminhamento",
+ "not_running": "O encaminhamento ou os destinatários da resposta estão salvos, mas não estão ativos agora. Salve para reativá-los."
+ },
+ "forward": {
+ "title": "Encaminhamento",
+ "description": "Encaminha as mensagens recebidas para outro endereço, com ou sem resposta automática, dentro do período acima (sem data de término, até ser desativado). Spam não é encaminhado.",
+ "enabled_label": "Encaminhar mensagens",
+ "to_label": "Encaminhar para",
+ "to_placeholder": "email@exemplo.com",
+ "keep_label": "Manter na caixa de entrada",
+ "keep_description": "Caso contrário, a mensagem é apenas encaminhada: ela não fica nesta caixa de correio e nenhuma regra de filtro é aplicada a ela."
+ },
+ "audience": {
+ "label": "Responder a",
+ "description": "Remetentes internos: {domains}",
+ "all": "Todos os remetentes",
+ "internal": "Somente remetentes internos",
+ "external": "Somente remetentes externos"
+ },
+ "auto_reply": {
+ "title": "Resposta automática"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Aplicar também às mensagens que já estão nesta pasta",
- "apply_existing_unsupported": "Só regras que verificam o remetente, os destinatários, o assunto ou os cabeçalhos, e que movem, copiam, marcam como lida, marcam com estrela ou etiquetam, podem ser aplicadas a mensagens existentes."
+ "apply_existing_unsupported": "Só regras que verificam o remetente, os destinatários, o assunto ou os cabeçalhos, e que movem, copiam, marcam como lida, marcam com estrela ou etiquetam, podem ser aplicadas a mensagens existentes.",
+ "period_title": "Período",
+ "period_start": "Data de início",
+ "period_end": "Data de fim",
+ "validation_period_order": "A data de fim deve ser posterior à data de início",
+ "period_toggle": "Ativa apenas durante um período",
+ "period_hint": "Aplica-se às mensagens que chegam durante este período. Deixe um campo vazio para não limitar esse extremo.",
+ "validation_period_empty": "Indique uma data de início ou de fim para o período",
+ "validation_period_invalid": "Indique uma data válida com hora para o período ou deixe o campo vazio",
+ "apply_existing_period": "Uma regra com período só atua sobre as mensagens que chegam dentro dele, por isso não pode ser aplicada às mensagens existentes.",
+ "period_status_scheduled": "agendada",
+ "period_status_active": "ativa",
+ "period_status_expired": "expirada"
},
"templates": {
"title": "Modelos de e-mail",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index cb025d88e..96d449068 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Nu s-a putut șterge identitatea: {error}",
"identity_unauthorized": "Nu aveți autorizație să trimiteți mesaje de la această adresă de e-mail",
"identity_not_found": "Identitate neidentificată",
- "vacation_saved": "Setările răspunsului automat de vacanță au fost salvate",
- "vacation_save_failed": "Nu s-au putut salva setările răspunsului automat de concediu",
+ "vacation_saved": "Setările de absență au fost salvate",
+ "vacation_save_failed": "Nu s-au putut salva setările de absență",
"filters_saved": "Filtrele au fost salvate cu succes",
"filters_save_failed": "Nu s-au putut salva filtrele",
"filters_deleted": "Regula de filtrare a fost ștersă",
@@ -858,7 +858,8 @@
"rule_undone": "Modificarea regulii a fost anulată",
"rule_undo_failed": "Modificarea regulii nu a putut fi anulată",
"rule_undo_conflict": "Filtrele dvs. s-au schimbat între timp. Anulați modificarea din setările filtrelor.",
- "rule_folder_failed": "Dosarul nu a putut fi creat"
+ "rule_folder_failed": "Dosarul nu a putut fi creat",
+ "vacation_filters_save_failed": "Setările de absență au fost salvate, dar redirecționarea și destinatarii răspunsului nu au putut fi salvați"
},
"date": {
"today": "Astăzi",
@@ -946,7 +947,7 @@
"privacy": "Confidențialitate și securitate",
"account": "Cont",
"identities": "Identități",
- "vacation": "Răspuns automat de vacanță",
+ "vacation": "Absență",
"advanced": "Nivel avansat",
"calendar": "Calendar",
"filters": "Filtre",
@@ -1825,28 +1826,24 @@
}
},
"vacation": {
- "title": "Răspuns automat de vacanță",
- "description": "Răspundeți automat la e-mailurile primite în timp ce sunteți plecat",
+ "title": "Absență",
+ "description": "Gestionați automat e-mailurile primite în timp ce sunteți plecat",
"loading": "Se încarcă setările de concediu...",
"not_supported": "Serverul dvs. de e-mail nu acceptă răspunsurile de vacanță.",
"fetch_error": "Nu s-au putut încărca setările de concediu. Vă rugăm să încercați din nou.",
"status": {
- "label": "Răspuns automat de vacanță",
+ "label": "Răspunde automat",
"description": "Trimiteți un răspuns automat persoanelor care vă trimit e-mailuri",
"active": "Activ",
"inactive": "Inactiv"
},
"date_range": {
- "title": "Interval de date",
- "description": "Opțional, limitați răspunsul automat la o anumită perioadă",
"start": "Data de începere",
"start_description": "Lăsați necompletat dacă nu există o limită inițială",
"end": "Data de încheiere",
"end_description": "Lăsați necompletat dacă nu există o limită de sfârșit"
},
"message": {
- "title": "Mesaj de răspuns automat",
- "description": "Mesajul care va fi trimis ca răspuns",
"subject_label": "Subiect",
"subject_description": "Subiectul răspunsului automat",
"subject_placeholder": "Absent de la birou",
@@ -1866,10 +1863,32 @@
"saving": "Se salvează...",
"warnings": {
"end_before_start": "Data de încheiere trebuie să fie ulterioară datei de începere",
+ "invalid_date": "Introduceți o dată de început și de sfârșit validă, cu oră, sau lăsați câmpul gol",
"start_in_past": "Data de începere este în trecut",
"empty_body": "Corpul mesajului este gol – destinatarii vor primi un răspuns gol",
"subject_too_long": "Subiectul este prea lung pentru server (cel mult {max} octeți).",
- "body_too_long": "Mesajul este prea lung pentru server (cel mult {max} octeți, inclusiv formatarea)."
+ "body_too_long": "Mesajul este prea lung pentru server (cel mult {max} octeți, inclusiv formatarea).",
+ "forward_address": "Introduceți o adresă validă pentru redirecționare",
+ "not_running": "Redirecționarea sau destinatarii răspunsului sunt salvați, dar momentan nu sunt activi. Salvați pentru a-i reactiva."
+ },
+ "forward": {
+ "title": "Redirecționare",
+ "description": "Redirecționează mesajele primite către o altă adresă, cu sau fără răspuns automat, în perioada de mai sus (fără dată de încheiere, până când este dezactivată). Spamul nu este redirecționat.",
+ "enabled_label": "Redirecționează mesajele",
+ "to_label": "Redirecționează către",
+ "to_placeholder": "email@example.com",
+ "keep_label": "Păstrează în căsuța de primire",
+ "keep_description": "Altfel, mesajul este doar redirecționat: nu rămâne în această căsuță poștală și nu i se aplică nicio regulă de filtrare."
+ },
+ "audience": {
+ "label": "Răspunde la",
+ "description": "Expeditori interni: {domains}",
+ "all": "Toți expeditorii",
+ "internal": "Doar expeditorii interni",
+ "external": "Doar expeditorii externi"
+ },
+ "auto_reply": {
+ "title": "Răspuns automat"
}
},
"folders": {
@@ -2238,7 +2257,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Aplicați și mesajelor care sunt deja în acest dosar",
- "apply_existing_unsupported": "Doar regulile care verifică expeditorul, destinatarii, subiectul sau antetele și care mută, copiază, marchează ca citit, marchează cu stea sau etichetează pot fi aplicate mesajelor existente."
+ "apply_existing_unsupported": "Doar regulile care verifică expeditorul, destinatarii, subiectul sau antetele și care mută, copiază, marchează ca citit, marchează cu stea sau etichetează pot fi aplicate mesajelor existente.",
+ "period_title": "Interval de date",
+ "period_start": "Data de începere",
+ "period_end": "Data de încheiere",
+ "validation_period_order": "Data de încheiere trebuie să fie ulterioară datei de începere",
+ "period_toggle": "Activă doar într-o anumită perioadă",
+ "period_hint": "Se aplică mesajelor care sosesc în această perioadă. Lăsați un câmp gol pentru a nu limita acel capăt.",
+ "validation_period_empty": "Introduceți o dată de începere sau de încheiere pentru perioadă",
+ "validation_period_invalid": "Introduceți o dată validă cu oră pentru perioadă sau lăsați câmpul gol",
+ "apply_existing_period": "O regulă cu perioadă acționează doar asupra mesajelor care sosesc în acea perioadă, deci nu poate fi aplicată mesajelor existente.",
+ "period_status_scheduled": "programată",
+ "period_status_active": "activă",
+ "period_status_expired": "expirată"
},
"templates": {
"title": "Șabloane de e-mail",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 7c03e8485..a8cb598f4 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Не удалось удалить удостоверение: {error}",
"identity_unauthorized": "У вас нет прав для отправки с этого email-адреса",
"identity_not_found": "Удостоверение не найдено",
- "vacation_saved": "Настройки автоответа на отпуск сохранены",
- "vacation_save_failed": "Не удалось сохранить настройки автоответа на отпуск",
+ "vacation_saved": "Настройки отсутствия сохранены",
+ "vacation_save_failed": "Не удалось сохранить настройки отсутствия",
"filters_saved": "Фильтры успешно сохранены",
"filters_save_failed": "Не удалось сохранить фильтры",
"filters_deleted": "Правило фильтра удалено",
@@ -858,7 +858,8 @@
"rule_undone": "Изменение правила отменено",
"rule_undo_failed": "Не удалось отменить изменение правила",
"rule_undo_conflict": "Ваши фильтры с тех пор изменились. Отмените изменение в настройках фильтров.",
- "rule_folder_failed": "Не удалось создать папку"
+ "rule_folder_failed": "Не удалось создать папку",
+ "vacation_filters_save_failed": "Настройки отсутствия сохранены, но пересылку и получателей ответа сохранить не удалось"
},
"date": {
"today": "Сегодня",
@@ -943,7 +944,7 @@
"privacy": "Конфиденциальность и безопасность",
"account": "Аккаунт",
"identities": "Удостоверения",
- "vacation": "Автоответ на отпуск",
+ "vacation": "Отсутствие",
"advanced": "Дополнительно",
"calendar": "Календарь",
"filters": "Фильтры",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "Автоответ на отпуск",
- "description": "Автоматически отвечать на входящие письма в ваше отсутствие",
+ "title": "Отсутствие",
+ "description": "Автоматически обрабатывать входящие письма в ваше отсутствие",
"loading": "Загрузка настроек автоответа...",
"not_supported": "Ваш почтовый сервер не поддерживает автоответы на отпуск.",
"fetch_error": "Не удалось загрузить настройки автоответа. Повторите попытку.",
"status": {
- "label": "Автоответ на отпуск",
+ "label": "Отвечать автоматически",
"description": "Отправлять автоматический ответ на входящие письма",
"active": "Активен",
"inactive": "Неактивен"
},
"date_range": {
- "title": "Диапазон дат",
- "description": "Ограничьте автоответ определённым периодом (необязательно)",
"start": "Дата начала",
"start_description": "Оставьте пустым для без ограничения",
"end": "Дата окончания",
"end_description": "Оставьте пустым для без ограничения"
},
"message": {
- "title": "Сообщение автоответа",
- "description": "Сообщение, которое будет отправляться в качестве ответа",
"subject_label": "Тема",
"subject_description": "Тема автоответа",
"subject_placeholder": "Вне офиса",
@@ -1859,10 +1856,32 @@
"saving": "Сохранение...",
"warnings": {
"end_before_start": "Дата окончания должна быть позже даты начала",
+ "invalid_date": "Укажите корректные даты начала и окончания со временем или оставьте поле пустым",
"start_in_past": "Дата начала в прошлом",
"empty_body": "Текст сообщения пуст - получатели получат пустой ответ",
"subject_too_long": "Тема слишком длинная для сервера (не более {max} байт).",
- "body_too_long": "Сообщение слишком длинное для сервера (не более {max} байт с учётом форматирования)."
+ "body_too_long": "Сообщение слишком длинное для сервера (не более {max} байт с учётом форматирования).",
+ "forward_address": "Укажите действительный адрес для пересылки",
+ "not_running": "Пересылка или получатели ответа сохранены, но сейчас не действуют. Сохраните, чтобы снова их включить."
+ },
+ "forward": {
+ "title": "Пересылка",
+ "description": "Пересылает входящие письма на другой адрес, с автоответом или без него, в указанный выше период (без даты окончания, пока пересылку не выключат). Спам не пересылается.",
+ "enabled_label": "Пересылать письма",
+ "to_label": "Переслать на",
+ "to_placeholder": "user@пример.рф",
+ "keep_label": "Оставить во входящих",
+ "keep_description": "Иначе письмо только пересылается: оно не остаётся в этом почтовом ящике, и к нему не применяется ни одно правило фильтрации."
+ },
+ "audience": {
+ "label": "Отвечать",
+ "description": "Внутренние отправители: {domains}",
+ "all": "Всем отправителям",
+ "internal": "Только внутренним отправителям",
+ "external": "Только внешним отправителям"
+ },
+ "auto_reply": {
+ "title": "Автоответ"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Применить и к письмам, которые уже есть в этой папке",
- "apply_existing_unsupported": "К имеющимся письмам можно применить только правила, которые проверяют отправителя, получателей, тему или заголовки и которые перемещают, копируют, отмечают прочитанными, помечают звёздочкой или тегом."
+ "apply_existing_unsupported": "К имеющимся письмам можно применить только правила, которые проверяют отправителя, получателей, тему или заголовки и которые перемещают, копируют, отмечают прочитанными, помечают звёздочкой или тегом.",
+ "period_title": "Диапазон дат",
+ "period_start": "Дата начала",
+ "period_end": "Дата окончания",
+ "validation_period_order": "Дата окончания должна быть позже даты начала",
+ "period_toggle": "Активно только в указанный период",
+ "period_hint": "Действует для писем, которые приходят в этот период. Оставьте поле пустым, чтобы не ограничивать эту границу.",
+ "validation_period_empty": "Укажите дату начала или окончания периода",
+ "validation_period_invalid": "Укажите для периода корректную дату со временем или оставьте поле пустым",
+ "apply_existing_period": "Правило с периодом действует только на письма, пришедшие в этот период, поэтому его нельзя применить к уже имеющимся письмам.",
+ "period_status_scheduled": "запланировано",
+ "period_status_active": "активно",
+ "period_status_expired": "истекло"
},
"templates": {
"title": "Шаблоны писем",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index ae5f6dd87..36f5418b6 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Odstránenie identity zlyhalo: {error}",
"identity_unauthorized": "Nemáte oprávnenie odosielať z tejto e-mailovej adresy",
"identity_not_found": "Identita sa nenašla",
- "vacation_saved": "Nastavenia automatickej odpovede boli uložené",
- "vacation_save_failed": "Uloženie nastavenia automatickej odpovede zlyhalo",
+ "vacation_saved": "Nastavenia neprítomnosti boli uložené",
+ "vacation_save_failed": "Uloženie nastavení neprítomnosti zlyhalo",
"filters_saved": "Filtre boli úspešne uložené",
"filters_save_failed": "Uloženie filtrov zlyhalo",
"filters_deleted": "Pravidlo filtra bolo odstránené",
@@ -858,7 +858,8 @@
"rule_undone": "Zmena pravidla bola vrátená",
"rule_undo_failed": "Zmenu pravidla sa nepodarilo vrátiť",
"rule_undo_conflict": "Vaše filtre sa medzitým zmenili. Vráťte zmenu v nastaveniach filtrov.",
- "rule_folder_failed": "Priečinok sa nepodarilo vytvoriť"
+ "rule_folder_failed": "Priečinok sa nepodarilo vytvoriť",
+ "vacation_filters_save_failed": "Nastavenia neprítomnosti boli uložené, ale preposielanie a príjemcov odpovede sa nepodarilo uložiť"
},
"date": {
"today": "Dnes",
@@ -946,7 +947,7 @@
"privacy": "Súkromie a zabezpečenie",
"account": "Účet",
"identities": "Identity",
- "vacation": "Automatická odpoveď",
+ "vacation": "Neprítomnosť",
"advanced": "Pokročilé",
"calendar": "Kalendár",
"filters": "Filtre",
@@ -1825,28 +1826,24 @@
}
},
"vacation": {
- "title": "Automatická odpoveď",
- "description": "Automaticky odpovedať na prichádzajúce e-maily počas vašej neprítomnosti",
+ "title": "Neprítomnosť",
+ "description": "Automaticky spracovať prichádzajúce e-maily počas vašej neprítomnosti",
"loading": "Načítavanie nastavení automatickej odpovede...",
"not_supported": "Váš poštový server nepodporuje automatické odpovede v neprítomnosti.",
"fetch_error": "Nepodarilo sa načítať nastavenia automatickej odpovede. Skúste to znova.",
"status": {
- "label": "Automatická odpoveď",
+ "label": "Odpovedať automaticky",
"description": "Odosielať automatickú odpoveď ľuďom, ktorí vám napíšu",
"active": "Aktívna",
"inactive": "Neaktívna"
},
"date_range": {
- "title": "Časové obdobie",
- "description": "Voliteľne obmedzte automatickú odpoveď na určité obdobie",
"start": "Dátum začiatku",
"start_description": "Nechajte prázdne pre okamžité spustenie",
"end": "Dátum ukončenia",
"end_description": "Nechajte prázdne pre beh bez ukončenia"
},
"message": {
- "title": "Správa automatickej odpovede",
- "description": "Správa, ktorá bude odoslaná ako odpoveď",
"subject_label": "Predmet",
"subject_description": "Predmet automatickej odpovede",
"subject_placeholder": "Mimo kancelárie",
@@ -1866,10 +1863,32 @@
"saving": "Ukladanie...",
"warnings": {
"end_before_start": "Dátum ukončenia musí byť po dátume začiatku",
+ "invalid_date": "Zadajte platný dátum začiatku a konca aj s časom alebo pole nechajte prázdne",
"start_in_past": "Dátum začiatku je v minulosti",
"empty_body": "Telo správy je prázdne – príjemcovia dostanú prázdnu odpoveď",
"subject_too_long": "Predmet je pre server príliš dlhý (najviac {max} bajtov).",
- "body_too_long": "Správa je pre server príliš dlhá (najviac {max} bajtov vrátane formátovania)."
+ "body_too_long": "Správa je pre server príliš dlhá (najviac {max} bajtov vrátane formátovania).",
+ "forward_address": "Zadajte platnú adresu na preposielanie",
+ "not_running": "Preposielanie alebo príjemcovia odpovede sú uložení, ale momentálne nie sú aktívni. Uložením ich znova zapnete."
+ },
+ "forward": {
+ "title": "Preposielanie",
+ "description": "Preposiela prichádzajúce správy na inú adresu, s automatickou odpoveďou alebo bez nej, v období uvedenom vyššie (bez dátumu ukončenia, kým ho nevypnete). Spam sa nepreposiela.",
+ "enabled_label": "Preposielať správy",
+ "to_label": "Preposlať na",
+ "to_placeholder": "email@example.com",
+ "keep_label": "Ponechať v Doručených",
+ "keep_description": "Inak sa správa iba prepošle: nezostane v tejto schránke a nepoužije sa na ňu žiadne pravidlo filtra."
+ },
+ "audience": {
+ "label": "Odpovedať",
+ "description": "Interní odosielatelia: {domains}",
+ "all": "Všetkým odosielateľom",
+ "internal": "Iba interným odosielateľom",
+ "external": "Iba externým odosielateľom"
+ },
+ "auto_reply": {
+ "title": "Automatická odpoveď"
}
},
"folders": {
@@ -2238,7 +2257,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Použiť aj na správy, ktoré už sú v tomto priečinku",
- "apply_existing_unsupported": "Na existujúce správy možno použiť len pravidlá, ktoré kontrolujú odosielateľa, príjemcov, predmet alebo hlavičky a ktoré presúvajú, kopírujú, označujú ako prečítané, hviezdičkou alebo štítkom."
+ "apply_existing_unsupported": "Na existujúce správy možno použiť len pravidlá, ktoré kontrolujú odosielateľa, príjemcov, predmet alebo hlavičky a ktoré presúvajú, kopírujú, označujú ako prečítané, hviezdičkou alebo štítkom.",
+ "period_title": "Časové obdobie",
+ "period_start": "Dátum začiatku",
+ "period_end": "Dátum ukončenia",
+ "validation_period_order": "Dátum ukončenia musí byť po dátume začiatku",
+ "period_toggle": "Aktívne iba v určitom období",
+ "period_hint": "Platí pre správy, ktoré prídu v tomto období. Prázdne pole ponechá daný koniec otvorený.",
+ "validation_period_empty": "Zadajte dátum začiatku alebo ukončenia obdobia",
+ "validation_period_invalid": "Zadajte pre obdobie platný dátum aj s časom alebo pole nechajte prázdne",
+ "apply_existing_period": "Pravidlo s obdobím pôsobí len na správy, ktoré v ňom prídu, preto ho nemožno použiť na existujúce správy.",
+ "period_status_scheduled": "naplánované",
+ "period_status_active": "aktívne",
+ "period_status_expired": "uplynulo"
},
"templates": {
"title": "E-mailové šablóny",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 6aaefc6b9..41ce7a8d7 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Kimlik silinemedi: {error}",
"identity_unauthorized": "Bu e-posta adresinden gönderme yetkiniz yok",
"identity_not_found": "Kimlik bulunamadı",
- "vacation_saved": "Tatil otomatik yanıtı ayarları kaydedildi",
- "vacation_save_failed": "Tatil otomatik yanıtı ayarları kaydedilemedi",
+ "vacation_saved": "Ofis dışında ayarları kaydedildi",
+ "vacation_save_failed": "Ofis dışında ayarları kaydedilemedi",
"filters_saved": "Filtreler başarıyla kaydedildi",
"filters_save_failed": "Filtreler kaydedilemedi",
"filters_deleted": "Filtre kuralı silindi",
@@ -858,7 +858,8 @@
"rule_undone": "Kural değişikliği geri alındı",
"rule_undo_failed": "Kural değişikliği geri alınamadı",
"rule_undo_conflict": "Filtreleriniz o zamandan beri değişti. Değişikliği filtre ayarlarından geri alın.",
- "rule_folder_failed": "Klasör oluşturulamadı"
+ "rule_folder_failed": "Klasör oluşturulamadı",
+ "vacation_filters_save_failed": "Ofis dışında ayarları kaydedildi, ancak iletme ve yanıt alıcıları kaydedilemedi"
},
"date": {
"today": "Bugün",
@@ -943,7 +944,7 @@
"privacy": "Gizlilik ve Güvenlik",
"account": "Hesap",
"identities": "Kimlikler",
- "vacation": "Tatil Yanıtlayıcı",
+ "vacation": "Ofis Dışında",
"advanced": "Gelişmiş",
"calendar": "Takvim",
"filters": "Filtreler",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "Tatil Yanıtlayıcı",
- "description": "Yokken gelen e-postalara otomatik yanıt gönderin",
+ "title": "Ofis Dışında",
+ "description": "Yokken gelen e-postaları otomatik olarak yönetin",
"loading": "Tatil ayarları yükleniyor...",
"not_supported": "Posta sunucunuz tatil yanıtlarını desteklemiyor.",
"fetch_error": "Tatil ayarları yüklenemedi. Lütfen tekrar deneyin.",
"status": {
- "label": "Tatil Yanıtlayıcı",
+ "label": "Otomatik yanıt gönder",
"description": "Size e-posta gönderenlere otomatik yanıt gönderin",
"active": "Etkin",
"inactive": "Devre dışı"
},
"date_range": {
- "title": "Tarih Aralığı",
- "description": "Otomatik yanıtı isteğe bağlı olarak belirli bir dönemle sınırlayın",
"start": "Başlangıç Tarihi",
"start_description": "Başlangıç sınırı olmaması için boş bırakın",
"end": "Bitiş Tarihi",
"end_description": "Bitiş sınırı olmaması için boş bırakın"
},
"message": {
- "title": "Otomatik Yanıt İletisi",
- "description": "Yanıt olarak gönderilecek ileti",
"subject_label": "Konu",
"subject_description": "Otomatik yanıtın konu satırı",
"subject_placeholder": "Ofis Dışındayım",
@@ -1859,10 +1856,32 @@
"saving": "Kaydediliyor...",
"warnings": {
"end_before_start": "Bitiş tarihi başlangıç tarihinden sonra olmalıdır",
+ "invalid_date": "Saatiyle birlikte geçerli bir başlangıç ve bitiş tarihi girin veya alanı boş bırakın",
"start_in_past": "Başlangıç tarihi geçmişte",
"empty_body": "İleti gövdesi boş - alıcılar boş bir yanıt alacak",
"subject_too_long": "Konu sunucu için çok uzun (en fazla {max} bayt).",
- "body_too_long": "İleti sunucu için çok uzun (biçimlendirme dahil en fazla {max} bayt)."
+ "body_too_long": "İleti sunucu için çok uzun (biçimlendirme dahil en fazla {max} bayt).",
+ "forward_address": "İletilecek geçerli bir adres girin",
+ "not_running": "İletme veya yanıt alıcıları kaydedildi, ancak şu anda etkin değil. Yeniden etkinleştirmek için kaydedin."
+ },
+ "forward": {
+ "title": "İletme",
+ "description": "Gelen iletileri, otomatik yanıtla ya da yanıtsız, yukarıdaki dönem içinde başka bir adrese iletir (bitiş tarihi yoksa kapatılana kadar). İstenmeyen e-postalar iletilmez.",
+ "enabled_label": "İletileri ilet",
+ "to_label": "İlet",
+ "to_placeholder": "email@example.com",
+ "keep_label": "Gelen kutusunda tut",
+ "keep_description": "Aksi halde ileti yalnızca iletilir: bu posta kutusunda kalmaz ve ona hiçbir filtre kuralı uygulanmaz."
+ },
+ "audience": {
+ "label": "Yanıt gönderilecek kişiler",
+ "description": "Dahili gönderenler: {domains}",
+ "all": "Tüm gönderenler",
+ "internal": "Yalnızca dahili gönderenler",
+ "external": "Yalnızca harici gönderenler"
+ },
+ "auto_reply": {
+ "title": "Otomatik Yanıt"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Bu klasörde zaten bulunan iletilere de uygula",
- "apply_existing_unsupported": "Mevcut iletilere yalnızca göndereni, alıcıları, konuyu veya üstbilgileri denetleyen ve taşıyan, kopyalayan, okundu işaretleyen, yıldızlayan veya etiketleyen kurallar uygulanabilir."
+ "apply_existing_unsupported": "Mevcut iletilere yalnızca göndereni, alıcıları, konuyu veya üstbilgileri denetleyen ve taşıyan, kopyalayan, okundu işaretleyen, yıldızlayan veya etiketleyen kurallar uygulanabilir.",
+ "period_title": "Tarih Aralığı",
+ "period_start": "Başlangıç Tarihi",
+ "period_end": "Bitiş Tarihi",
+ "validation_period_order": "Bitiş tarihi başlangıç tarihinden sonra olmalıdır",
+ "period_toggle": "Yalnızca belirli bir dönemde etkin",
+ "period_hint": "Bu dönemde gelen iletilere uygulanır. O ucu sınırlamamak için alanı boş bırakın.",
+ "validation_period_empty": "Dönem için bir başlangıç veya bitiş tarihi girin",
+ "validation_period_invalid": "Dönem için saatiyle birlikte geçerli bir tarih girin veya alanı boş bırakın",
+ "apply_existing_period": "Dönemi olan bir kural yalnızca o dönemde gelen iletilere uygulanır, bu nedenle mevcut iletilere uygulanamaz.",
+ "period_status_scheduled": "planlandı",
+ "period_status_active": "etkin",
+ "period_status_expired": "süresi doldu"
},
"templates": {
"title": "E-posta Şablonları",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 7859ac4b1..86bb82f86 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "Не вдалося видалити особу: {error}",
"identity_unauthorized": "Ви не авторизовані надсилати з цієї електронної адреси",
"identity_not_found": "Особа не знайдена",
- "vacation_saved": "Налаштування автовідповідача збережено",
- "vacation_save_failed": "Не вдалося зберегти налаштування автовідповідача",
+ "vacation_saved": "Налаштування відсутності збережено",
+ "vacation_save_failed": "Не вдалося зберегти налаштування відсутності",
"filters_saved": "Фільтри успішно збережено",
"filters_save_failed": "Не вдалося зберегти фільтри",
"filters_deleted": "Правило фільтра видалено",
@@ -858,7 +858,8 @@
"rule_undone": "Зміну правила скасовано",
"rule_undo_failed": "Не вдалося скасувати зміну правила",
"rule_undo_conflict": "Ваші фільтри відтоді змінилися. Скасуйте зміну в налаштуваннях фільтрів.",
- "rule_folder_failed": "Не вдалося створити папку"
+ "rule_folder_failed": "Не вдалося створити папку",
+ "vacation_filters_save_failed": "Налаштування відсутності збережено, але пересилання та отримувачів відповіді зберегти не вдалося"
},
"date": {
"today": "Сьогодні",
@@ -943,7 +944,7 @@
"privacy": "Конфіденційність і безпека",
"account": "Обліковий запис",
"identities": "Ідентичності",
- "vacation": "Відповідач у відпустці",
+ "vacation": "Відсутність",
"advanced": "Просунутий",
"calendar": "Календар",
"filters": "Фільтри",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "Відповідач у відпустці",
- "description": "Автоматично відповідайте на вхідні листи, поки вас немає",
+ "title": "Відсутність",
+ "description": "Автоматично обробляйте вхідні листи, поки вас немає",
"loading": "Завантаження налаштувань відпустки...",
"not_supported": "Ваш поштовий сервер не підтримує відповіді про відпустку.",
"fetch_error": "Не вдалося завантажити налаштування відпустки. Спробуйте ще раз.",
"status": {
- "label": "Відповідач у відпустці",
+ "label": "Відповідати автоматично",
"description": "Надсилайте автоматичну відповідь людям, які надсилають вам електронні листи",
"active": "Активний",
"inactive": "Неактивний"
},
"date_range": {
- "title": "Діапазон дат",
- "description": "За бажанням можна обмежити автоматичну відповідь певним періодом",
"start": "Дата початку",
"start_description": "Залиште пустим, щоб не було обмеження на початок",
"end": "Дата закінчення",
"end_description": "Залиште пустим, щоб не було кінцевого обмеження"
},
"message": {
- "title": "Повідомлення автоматичної відповіді",
- "description": "Повідомлення, яке буде надіслано у відповідь",
"subject_label": "Тема",
"subject_description": "Тема автоматичної відповіді",
"subject_placeholder": "Поза офісом",
@@ -1859,10 +1856,32 @@
"saving": "Збереження...",
"warnings": {
"end_before_start": "Дата завершення має бути після дати початку",
+ "invalid_date": "Вкажіть дійсні дати початку й закінчення з часом або залиште поле порожнім",
"start_in_past": "Дата початку в минулому",
"empty_body": "Тіло повідомлення порожнє - одержувачі отримають порожню відповідь",
"subject_too_long": "Тема задовга для сервера (не більше {max} байтів).",
- "body_too_long": "Повідомлення задовге для сервера (не більше {max} байтів разом із форматуванням)."
+ "body_too_long": "Повідомлення задовге для сервера (не більше {max} байтів разом із форматуванням).",
+ "forward_address": "Вкажіть дійсну адресу для пересилання",
+ "not_running": "Пересилання або отримувачів відповіді збережено, але зараз вони не діють. Збережіть, щоб знову їх увімкнути."
+ },
+ "forward": {
+ "title": "Пересилання",
+ "description": "Пересилає вхідні листи на іншу адресу, з автовідповіддю або без неї, у зазначений вище період (без дати завершення, доки пересилання не вимкнуть). Спам не пересилається.",
+ "enabled_label": "Пересилати листи",
+ "to_label": "Переслати до",
+ "to_placeholder": "email@example.com",
+ "keep_label": "Зберігати у вхідних",
+ "keep_description": "Інакше лист лише пересилається: він не залишається в цій поштовій скриньці, і до нього не застосовується жодне правило фільтра."
+ },
+ "audience": {
+ "label": "Відповідати",
+ "description": "Внутрішні відправники: {domains}",
+ "all": "Усім відправникам",
+ "internal": "Лише внутрішнім відправникам",
+ "external": "Лише зовнішнім відправникам"
+ },
+ "auto_reply": {
+ "title": "Автовідповідь"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "Застосувати й до листів, які вже є в цій папці",
- "apply_existing_unsupported": "До наявних листів можна застосувати лише правила, що перевіряють відправника, одержувачів, тему або заголовки й переміщують, копіюють, позначають прочитаними, зірочкою чи міткою."
+ "apply_existing_unsupported": "До наявних листів можна застосувати лише правила, що перевіряють відправника, одержувачів, тему або заголовки й переміщують, копіюють, позначають прочитаними, зірочкою чи міткою.",
+ "period_title": "Діапазон дат",
+ "period_start": "Дата початку",
+ "period_end": "Дата закінчення",
+ "validation_period_order": "Дата завершення має бути після дати початку",
+ "period_toggle": "Активне лише в певний період",
+ "period_hint": "Діє для листів, що надходять у цей період. Залиште поле порожнім, щоб не обмежувати цю межу.",
+ "validation_period_empty": "Вкажіть дату початку або закінчення періоду",
+ "validation_period_invalid": "Вкажіть для періоду дійсну дату з часом або залиште поле порожнім",
+ "apply_existing_period": "Правило з періодом діє лише на листи, що надходять у цей період, тому його не можна застосувати до наявних листів.",
+ "period_status_scheduled": "заплановано",
+ "period_status_active": "активне",
+ "period_status_expired": "завершено"
},
"templates": {
"title": "Шаблони електронної пошти",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 0e9d6dc1c..e95e40717 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "無法刪除寄件身分:{error}",
"identity_unauthorized": "您沒有使用此電子郵件地址寄信的權限",
"identity_not_found": "找不到寄件身分",
- "vacation_saved": "休假自動回覆設定已儲存",
- "vacation_save_failed": "無法儲存休假自動回覆設定",
+ "vacation_saved": "外出設定已儲存",
+ "vacation_save_failed": "無法儲存外出設定",
"filters_saved": "篩選規則已成功儲存",
"filters_save_failed": "無法儲存篩選規則",
"filters_deleted": "篩選規則已刪除",
@@ -858,7 +858,8 @@
"rule_undone": "已復原規則變更",
"rule_undo_failed": "無法復原規則變更",
"rule_undo_conflict": "篩選規則在那之後已變更。請在篩選規則設定中復原該變更。",
- "rule_folder_failed": "無法建立資料夾"
+ "rule_folder_failed": "無法建立資料夾",
+ "vacation_filters_save_failed": "外出設定已儲存,但轉寄和回覆對象設定未能儲存"
},
"date": {
"today": "今天",
@@ -946,7 +947,7 @@
"privacy": "隱私權與安全",
"account": "帳號",
"identities": "寄件身分",
- "vacation": "休假自動回覆",
+ "vacation": "外出",
"advanced": "進階",
"calendar": "行事曆",
"filters": "篩選規則",
@@ -1825,28 +1826,24 @@
}
},
"vacation": {
- "title": "休假自動回覆",
- "description": "您不在時,自動回覆收到的郵件",
+ "title": "外出",
+ "description": "您不在時,自動處理收到的郵件",
"loading": "正在載入自動回覆設定…",
"not_supported": "您的郵件伺服器不支援自動回覆功能。",
"fetch_error": "無法載入自動回覆設定。請再試一次。",
"status": {
- "label": "休假自動回覆",
+ "label": "傳送自動回覆",
"description": "自動回覆寄信給您的人",
"active": "已啟用",
"inactive": "已停用"
},
"date_range": {
- "title": "日期範圍",
- "description": "可選擇將自動回覆限制在特定期間",
"start": "開始日期",
"start_description": "留空表示不限制開始時間",
"end": "結束日期",
"end_description": "留空表示不限制結束時間"
},
"message": {
- "title": "自動回覆郵件",
- "description": "作為回覆傳送的郵件內容",
"subject_label": "主題",
"subject_description": "自動回覆郵件的主題",
"subject_placeholder": "不在辦公室",
@@ -1866,10 +1863,32 @@
"saving": "正在儲存…",
"warnings": {
"end_before_start": "結束日期必須晚於開始日期",
+ "invalid_date": "請輸入含時間的有效開始與結束日期,或將該欄位留空",
"start_in_past": "開始日期已在過去",
"empty_body": "郵件內文為空 — 收件者將收到空白回覆",
"subject_too_long": "主旨對伺服器來說太長(最多 {max} 位元組)。",
- "body_too_long": "訊息對伺服器來說太長(含格式最多 {max} 位元組)。"
+ "body_too_long": "訊息對伺服器來說太長(含格式最多 {max} 位元組)。",
+ "forward_address": "請輸入有效的轉寄地址",
+ "not_running": "轉寄或回覆對象已儲存,但目前未生效。儲存即可重新啟用。"
+ },
+ "forward": {
+ "title": "轉寄",
+ "description": "在上方期間內將收到的郵件轉寄到另一個地址,無論是否啟用自動回覆(未設結束日期時,直到關閉為止)。垃圾郵件不會被轉寄。",
+ "enabled_label": "轉寄郵件",
+ "to_label": "轉寄至",
+ "to_placeholder": "email@example.com",
+ "keep_label": "保留在收件匣中",
+ "keep_description": "否則郵件只會被轉寄:不會保留在此信箱中,也不會對其執行任何篩選規則。"
+ },
+ "audience": {
+ "label": "回覆對象",
+ "description": "內部寄件者:{domains}",
+ "all": "所有寄件者",
+ "internal": "僅內部寄件者",
+ "external": "僅外部寄件者"
+ },
+ "auto_reply": {
+ "title": "自動回覆"
}
},
"folders": {
@@ -2238,7 +2257,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "也套用到此資料夾中既有的郵件",
- "apply_existing_unsupported": "只有檢查寄件者、收件者、主旨或標頭,並執行移動、複製、標示為已讀、加上星號或標籤的規則,才能套用到既有郵件。"
+ "apply_existing_unsupported": "只有檢查寄件者、收件者、主旨或標頭,並執行移動、複製、標示為已讀、加上星號或標籤的規則,才能套用到既有郵件。",
+ "period_title": "日期範圍",
+ "period_start": "開始日期",
+ "period_end": "結束日期",
+ "validation_period_order": "結束日期必須晚於開始日期",
+ "period_toggle": "僅在指定期間內生效",
+ "period_hint": "適用於在此期間內收到的郵件。留空某一欄即表示該端不設限制。",
+ "validation_period_empty": "請輸入期間的開始日期或結束日期",
+ "validation_period_invalid": "請為期間輸入含時間的有效日期,或將該欄位留空",
+ "apply_existing_period": "設定了期間的規則只作用於在該期間內收到的郵件,因此無法套用到既有郵件。",
+ "period_status_scheduled": "尚未開始",
+ "period_status_active": "生效中",
+ "period_status_expired": "已過期"
},
"templates": {
"title": "郵件範本",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index f43ec5e65..e4f31b5fa 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -835,8 +835,8 @@
"identity_delete_failed": "无法删除身份:{error}",
"identity_unauthorized": "您无权从此邮箱地址发送",
"identity_not_found": "未找到身份",
- "vacation_saved": "假期自动回复设置已保存",
- "vacation_save_failed": "无法保存假期自动回复设置",
+ "vacation_saved": "外出设置已保存",
+ "vacation_save_failed": "无法保存外出设置",
"filters_saved": "过滤规则保存成功",
"filters_save_failed": "无法保存过滤器",
"filters_deleted": "过滤规则已删除",
@@ -858,7 +858,8 @@
"rule_undone": "已撤销规则更改",
"rule_undo_failed": "无法撤销规则更改",
"rule_undo_conflict": "过滤器在此之后已更改。请在过滤器设置中撤销该更改。",
- "rule_folder_failed": "无法创建文件夹"
+ "rule_folder_failed": "无法创建文件夹",
+ "vacation_filters_save_failed": "外出设置已保存,但转发和回复对象设置未能保存"
},
"date": {
"today": "今天",
@@ -943,7 +944,7 @@
"privacy": "隐私与安全",
"account": "账户",
"identities": "身份",
- "vacation": "假期自动回复",
+ "vacation": "外出",
"advanced": "高级",
"calendar": "日历",
"filters": "过滤器",
@@ -1818,28 +1819,24 @@
}
},
"vacation": {
- "title": "假期自动回复",
- "description": "在您不在时自动回复收到的邮件",
+ "title": "外出",
+ "description": "在您不在时自动处理收到的邮件",
"loading": "正在加载自动回复设置…",
"not_supported": "您的邮件服务器不支持自动回复功能。",
"fetch_error": "加载自动回复设置失败,请重试。",
"status": {
- "label": "自动回复",
+ "label": "发送自动回复",
"description": "向给您发送邮件的人自动发送回复",
"active": "已开启",
"inactive": "已关闭"
},
"date_range": {
- "title": "日期范围",
- "description": "可选择仅在指定时间段内启用自动回复",
"start": "开始日期",
"start_description": "留空表示不限制开始时间",
"end": "结束日期",
"end_description": "留空表示不限制结束时间"
},
"message": {
- "title": "自动回复正文",
- "description": "作为回复发送的邮件内容",
"subject_label": "主题",
"subject_description": "自动回复邮件的主题",
"subject_placeholder": "外出办公",
@@ -1859,10 +1856,32 @@
"saving": "正在保存…",
"warnings": {
"end_before_start": "结束日期必须晚于开始日期",
+ "invalid_date": "请输入带时间的有效开始和结束日期,或将该字段留空",
"start_in_past": "开始日期已在过去",
"empty_body": "邮件正文为空--收件人将收到空白回复",
"subject_too_long": "主题对服务器来说太长(最多 {max} 字节)。",
- "body_too_long": "消息对服务器来说太长(含格式最多 {max} 字节)。"
+ "body_too_long": "消息对服务器来说太长(含格式最多 {max} 字节)。",
+ "forward_address": "请输入有效的转发地址",
+ "not_running": "转发或回复对象已保存,但当前未生效。保存即可重新启用。"
+ },
+ "forward": {
+ "title": "转发",
+ "description": "在上方时间段内将收到的邮件转发到另一个地址,无论是否开启自动回复(未设结束日期时,直到关闭为止)。垃圾邮件不会被转发。",
+ "enabled_label": "转发邮件",
+ "to_label": "转发至",
+ "to_placeholder": "email@example.com",
+ "keep_label": "保留在收件箱中",
+ "keep_description": "否则邮件只会被转发:不会保留在此邮箱中,也不会对其执行任何过滤规则。"
+ },
+ "audience": {
+ "label": "回复对象",
+ "description": "内部发件人:{domains}",
+ "all": "所有发件人",
+ "internal": "仅内部发件人",
+ "external": "仅外部发件人"
+ },
+ "auto_reply": {
+ "title": "自动回复"
}
},
"folders": {
@@ -2231,7 +2250,19 @@
"suggest_list": "List-Id",
"suggest_domain": "@{domain}",
"apply_existing": "同时应用于此文件夹中已有的邮件",
- "apply_existing_unsupported": "只有检查发件人、收件人、主题或邮件头,并执行移动、复制、标记为已读、加星标或添加标签的规则,才能应用于已有邮件。"
+ "apply_existing_unsupported": "只有检查发件人、收件人、主题或邮件头,并执行移动、复制、标记为已读、加星标或添加标签的规则,才能应用于已有邮件。",
+ "period_title": "日期范围",
+ "period_start": "开始日期",
+ "period_end": "结束日期",
+ "validation_period_order": "结束日期必须晚于开始日期",
+ "period_toggle": "仅在指定时间段内生效",
+ "period_hint": "适用于在此时间段内收到的邮件。留空某一项即表示该端不设限制。",
+ "validation_period_empty": "请输入时间段的开始日期或结束日期",
+ "validation_period_invalid": "请为时间段输入带时间的有效日期,或将该字段留空",
+ "apply_existing_period": "设置了时间段的规则只作用于在该时间段内收到的邮件,因此无法应用于已有邮件。",
+ "period_status_scheduled": "未开始",
+ "period_status_active": "生效中",
+ "period_status_expired": "已过期"
},
"templates": {
"title": "邮件模板",
diff --git a/stores/__tests__/filter-store.test.ts b/stores/__tests__/filter-store.test.ts
index de3a84e36..05f8658a0 100644
--- a/stores/__tests__/filter-store.test.ts
+++ b/stores/__tests__/filter-store.test.ts
@@ -531,9 +531,9 @@ describe('filter-store', () => {
});
it('reports the auto-reply as on while the filters include it', async () => {
- const { isVacationIncludedInFilters } = await import('../filter-store');
- expect(await isVacationIncludedInFilters((await makeClient(false, true, true)).client)).toBe(true);
- expect(await isVacationIncludedInFilters((await makeClient(false, true)).client)).toBe(false);
+ const { readVacationFilters } = await import('../filter-store');
+ expect((await readVacationFilters((await makeClient(false, true, true)).client)).includesVacation).toBe(true);
+ expect((await readVacationFilters((await makeClient(false, true)).client)).includesVacation).toBe(false);
});
});
});
diff --git a/stores/__tests__/vacation-audience.test.ts b/stores/__tests__/vacation-audience.test.ts
new file mode 100644
index 000000000..7f3129505
--- /dev/null
+++ b/stores/__tests__/vacation-audience.test.ts
@@ -0,0 +1,168 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import type { FilterRule, VacationAudience } from '@/lib/jmap/sieve-types';
+import { generateScript } from '@/lib/sieve/generator';
+import { parseScript } from '@/lib/sieve/parser';
+import { updateAccountFilters } from '@/lib/filters/account-filters';
+import { insertRuleAtTop } from '@/lib/filters/quick-rules';
+import { STALWART_VACATION_SCRIPT, mockStalwartAccount } from '@/lib/filters/__tests__/sieve-mock';
+import { readVacationFilters, syncVacationWithFilters, useFilterStore } from '../filter-store';
+import { VacationFiltersError, useVacationStore } from '../vacation-store';
+
+const EXTENSIONS = ['fileinto', 'imap4flags', 'include', 'envelope'];
+const INCLUDE = 'include :personal :optional "vacation";';
+const external: VacationAudience = { only: 'external', domains: ['example.com'] };
+const NARROWED = 'if not envelope :domain :is "from" ["example.com"] {';
+
+const rule = (id: string): FilterRule => ({
+ id,
+ name: `Rule ${id}`,
+ enabled: true,
+ matchType: 'all',
+ conditions: [{ field: 'from', comparator: 'contains', value: `${id}@example.net` }],
+ actions: [{ type: 'mark_read' }],
+ stopProcessing: false,
+});
+
+const filters = (rules: FilterRule[], options: Parameters[2] = {}) =>
+ generateScript(rules, undefined, { extensions: EXTENSIONS, ...options });
+const stalwart = (initial: Parameters[1], extensions = EXTENSIONS) =>
+ mockStalwartAccount('b', initial, extensions);
+const vacationScript = (isActive: boolean) => ({ name: 'vacation', content: STALWART_VACATION_SCRIPT, isActive });
+
+const initialVacation = useVacationStore.getState();
+const initialFilters = useFilterStore.getState();
+afterEach(() => {
+ useVacationStore.setState(initialVacation, true);
+ useFilterStore.setState(initialFilters, true);
+});
+
+describe('syncVacationWithFilters with a narrowed auto-reply', () => {
+ it('runs it from the filters script even without rules: on its own it would answer everyone', async () => {
+ const server = stalwart([vacationScript(true)]);
+ await syncVacationWithFilters(server.client, true, 'b', undefined, external);
+ expect(server.active()).toBe('filters');
+ expect(server.content('filters')).toContain(`${NARROWED}\n ${INCLUDE}\n}`);
+ });
+
+ it('takes over again when Stalwart switched its own script back on', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([], { includeVacation: true, vacationAudience: external }), isActive: false },
+ vacationScript(true),
+ ]);
+ await syncVacationWithFilters(server.client, true, 'b');
+ expect(server.active()).toBe('filters');
+ expect(server.content('filters')).toContain(NARROWED);
+ });
+
+ it('answers everyone again once the choice is cleared', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationAudience: external }), isActive: true },
+ vacationScript(false),
+ ]);
+ await syncVacationWithFilters(server.client, true, 'b', undefined, null);
+ const written = server.content('filters');
+ expect(written).not.toContain('envelope :domain');
+ expect(written).toContain(`# Vacation auto-reply\n${INCLUDE}`);
+ expect(parseScript(written).vacationAudience).toBeUndefined();
+ });
+
+ it('keeps the choice while the auto-reply is off', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationAudience: external }), isActive: true },
+ vacationScript(false),
+ ]);
+ await syncVacationWithFilters(server.client, false, 'b');
+ const written = server.content('filters');
+ expect(written).not.toContain('include');
+ expect(parseScript(written).vacationAudience).toEqual(external);
+ });
+
+ it('refuses a choice it could not read back, rather than answer everyone', async () => {
+ const server = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }]);
+ await expect(syncVacationWithFilters(server.client, true, 'b', undefined, { only: 'internal', domains: [] }))
+ .rejects.toThrow(/Unusable/);
+ expect(server.writes()).toBe(0);
+ });
+});
+
+describe('readVacationFilters and the other writes', () => {
+ it('reports the choice and that it can be made', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationAudience: external }), isActive: true },
+ ]);
+ expect(await readVacationFilters(server.client, 'b')).toMatchObject({ audience: external, audienceAvailable: true });
+ const handEdited = stalwart([{ name: 'filters', content: '/* @metadata:begin\n{\n@metadata:end */\n', isActive: true }]);
+ expect((await readVacationFilters(handEdited.client, 'b')).audienceAvailable).toBe(false);
+ const noInclude = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }], ['fileinto']);
+ expect((await readVacationFilters(noInclude.client, 'b')).audienceAvailable).toBe(false);
+ // The sender is judged by the envelope, which the reply goes to.
+ const noEnvelope = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }], ['fileinto', 'include']);
+ expect((await readVacationFilters(noEnvelope.client, 'b')).audienceAvailable).toBe(false);
+ });
+
+ it('says when Stalwart answers everyone although the auto-reply is for some senders only', async () => {
+ const read = async (filtersActive: boolean, vacationActive: boolean) => (await readVacationFilters(stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationAudience: external }), isActive: filtersActive },
+ vacationScript(vacationActive),
+ ]).client, 'b')).notRunning;
+ expect(await read(false, true)).toBe(true);
+ expect(await read(true, false)).toBe(false);
+ // The auto-reply is off: whom it would answer does not matter.
+ expect(await read(false, false)).toBe(false);
+ });
+
+ it('keeps the choice through a rule made from a message and a save on the filter settings page', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationAudience: external }), isActive: true },
+ vacationScript(false),
+ ]);
+ await updateAccountFilters(server.client, 'b', (rules) => insertRuleAtTop(rules, rule('quick')));
+ expect(server.content('filters')).toContain(NARROWED);
+
+ await useFilterStore.getState().fetchFilters(server.client, 'b');
+ useFilterStore.getState().addRule(rule('settings'));
+ await useFilterStore.getState().saveFilters(server.client);
+ const written = server.content('filters');
+ expect(written).toContain('# Rule: Rule settings');
+ expect(written).toContain(NARROWED);
+ expect(written.split(INCLUDE).length).toBe(2);
+ });
+});
+
+describe('vacation store with a narrowed auto-reply', () => {
+ it('loads the choice and saves a new one', async () => {
+ const server = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }]);
+ await useVacationStore.getState().fetchVacationResponse(server.client);
+ expect(useVacationStore.getState()).toMatchObject({ audience: null, audienceAvailable: true });
+
+ await useVacationStore.getState().updateVacationResponse(server.client, { isEnabled: true, audience: external });
+ expect(server.active()).toBe('filters');
+ expect(server.content('filters')).toContain(NARROWED);
+ expect(server.client.setVacationResponse.mock.calls[0][0]).not.toHaveProperty('audience');
+ expect(useVacationStore.getState().audience).toEqual(external);
+ });
+
+ it('says when the auto-reply was saved but the choice of who gets it was not', async () => {
+ const server = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }]);
+ await useVacationStore.getState().fetchVacationResponse(server.client);
+ server.client.updateSieveScript.mockRejectedValueOnce(new Error('server said no'));
+ await expect(
+ useVacationStore.getState().updateVacationResponse(server.client, { isEnabled: true, audience: external }),
+ ).rejects.toBeInstanceOf(VacationFiltersError);
+ expect(useVacationStore.getState().audience).toBeNull();
+ });
+
+ it('says when a stored choice could not be cleared', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationAudience: external }), isActive: true },
+ vacationScript(false),
+ ]);
+ await useVacationStore.getState().fetchVacationResponse(server.client);
+ server.client.updateSieveScript.mockRejectedValueOnce(new Error('server said no'));
+ // Only the stored choice tells that the server may now answer everyone.
+ await expect(
+ useVacationStore.getState().updateVacationResponse(server.client, { isEnabled: true, audience: null }),
+ ).rejects.toBeInstanceOf(VacationFiltersError);
+ expect(useVacationStore.getState().audience).toEqual(external);
+ });
+});
diff --git a/stores/__tests__/vacation-forward.test.ts b/stores/__tests__/vacation-forward.test.ts
new file mode 100644
index 000000000..367c87f05
--- /dev/null
+++ b/stores/__tests__/vacation-forward.test.ts
@@ -0,0 +1,465 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import type { FilterRule, VacationForward } from '@/lib/jmap/sieve-types';
+import { generateScript } from '@/lib/sieve/generator';
+import { parseScript } from '@/lib/sieve/parser';
+import { OpaqueFiltersError, updateAccountFilters } from '@/lib/filters/account-filters';
+import { insertRuleAtTop } from '@/lib/filters/quick-rules';
+import { STALWART_VACATION_SCRIPT, mockStalwartAccount } from '@/lib/filters/__tests__/sieve-mock';
+import { readVacationFilters, syncVacationWithFilters, useFilterStore } from '../filter-store';
+import { VacationFiltersError, useVacationStore } from '../vacation-store';
+
+const EXTENSIONS = ['fileinto', 'mailbox', 'mailboxid', 'imap4flags', 'include', 'envelope', 'copy', 'date', 'relational', 'spamtestplus', 'comparator-i;ascii-numeric'];
+const FORWARD_MARKER = '# Vacation forwarding';
+const INCLUDE = 'include :personal :optional "vacation";';
+
+const forward = (extra: Partial = {}): VacationForward => ({
+ enabled: true,
+ to: 'kollege@example.com',
+ keepCopy: false,
+ activeFrom: '2026-10-05T06:00:00.000Z',
+ activeUntil: '2026-10-16T16:00:00.000Z',
+ ...extra,
+});
+
+const rule = (id: string, extra: Partial = {}): FilterRule => ({
+ id,
+ name: `Rule ${id}`,
+ enabled: true,
+ matchType: 'all',
+ conditions: [{ field: 'from', comparator: 'contains', value: `${id}@example.net` }],
+ actions: [{ type: 'mark_read' }],
+ stopProcessing: false,
+ ...extra,
+});
+
+const filters = (rules: FilterRule[], options: Parameters[2] = {}) =>
+ generateScript(rules, undefined, { extensions: EXTENSIONS, ...options });
+
+const VACATION_SCRIPT = STALWART_VACATION_SCRIPT;
+
+const stalwart = (
+ initial: Parameters[1],
+ options: { extensions?: string[]; maxNumberRedirects?: number } = {},
+) => mockStalwartAccount('b', initial, options.extensions ?? EXTENSIONS, options);
+
+const initialVacation = useVacationStore.getState();
+const initialFilters = useFilterStore.getState();
+afterEach(() => {
+ useVacationStore.setState(initialVacation, true);
+ useFilterStore.setState(initialFilters, true);
+});
+
+describe('syncVacationWithFilters with forwarding', () => {
+ it('runs forwarding next to the auto-reply even when there is no filter rule', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([]), isActive: false },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: true },
+ ]);
+ await syncVacationWithFilters(server.client, true, 'b', forward());
+ expect(server.active()).toBe('filters');
+ const written = server.content('filters');
+ expect(written).toContain(INCLUDE);
+ expect(written).toContain(FORWARD_MARKER);
+ expect(parseScript(written).vacationForward).toEqual(forward());
+ });
+
+ it('creates the filters script for forwarding when there is none', async () => {
+ const server = stalwart([{ name: 'vacation', content: VACATION_SCRIPT, isActive: true }]);
+ await syncVacationWithFilters(server.client, true, 'b', forward());
+ expect(server.active()).toBe('filters');
+ expect(server.content('filters')).toContain(INCLUDE);
+ expect(server.content('filters')).toContain(FORWARD_MARKER);
+ });
+
+ it('brings the stored forwarding back when the auto-reply took over again', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: false },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: true },
+ ]);
+ await syncVacationWithFilters(server.client, true, 'b');
+ expect(server.active()).toBe('filters');
+ expect(server.content('filters')).toContain(FORWARD_MARKER);
+ });
+
+ it('brings the stored forwarding back without any rule when the auto-reply took over again', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([], { includeVacation: true, vacationForward: forward() }), isActive: false },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: true },
+ ]);
+ await syncVacationWithFilters(server.client, true, 'b');
+ expect(server.active()).toBe('filters');
+ expect(server.content('filters')).toContain(FORWARD_MARKER);
+ });
+
+ it('moves the forwarding to new vacation dates', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: false },
+ ]);
+ const later = forward({ activeFrom: '2026-11-02T07:00:00.000Z', activeUntil: '2026-11-13T17:00:00.000Z' });
+ await syncVacationWithFilters(server.client, true, 'b', later);
+ const written = server.content('filters');
+ expect(parseScript(written).vacationForward).toEqual(later);
+ expect(written).toContain('"date" "2026-11-02"');
+ expect(written).not.toContain('"date" "2026-10-05"');
+ });
+
+ it('keeps forwarding when the auto-reply is turned off', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: false },
+ ]);
+ await syncVacationWithFilters(server.client, false, 'b', forward());
+ const written = server.content('filters');
+ expect(written).not.toContain(INCLUDE);
+ expect(written).toContain(FORWARD_MARKER);
+ expect(parseScript(written).vacationForward).toEqual(forward());
+ expect(server.active()).toBe('filters');
+ });
+
+ it('forwards without any auto-reply, creating the filters script for it', async () => {
+ const server = stalwart([]);
+ await syncVacationWithFilters(server.client, false, 'b', forward());
+ expect(server.active()).toBe('filters');
+ const written = server.content('filters');
+ expect(written).not.toContain(INCLUDE);
+ expect(written).toContain(FORWARD_MARKER);
+ });
+
+ it('switches the filters back on for forwarding when the auto-reply was the only active script', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([], { vacationForward: forward() }), isActive: false },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: false },
+ ]);
+ await syncVacationWithFilters(server.client, false, 'b');
+ expect(server.active()).toBe('filters');
+ expect(server.content('filters')).toContain(FORWARD_MARKER);
+ });
+
+ it('leaves switched-off forwarding where nothing else needs the filters script', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([], { vacationForward: forward({ enabled: false }) }), isActive: false },
+ ]);
+ await syncVacationWithFilters(server.client, false, 'b');
+ expect(server.writes()).toBe(0);
+ expect(server.active()).toBeNull();
+ });
+
+ it('removes forwarding that was cleared', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: false },
+ ]);
+ await syncVacationWithFilters(server.client, true, 'b', null);
+ const written = server.content('filters');
+ expect(written).toContain(INCLUDE);
+ expect(written).not.toContain(FORWARD_MARKER);
+ expect(parseScript(written).vacationForward).toBeUndefined();
+ });
+
+ it('leaves the auto-reply on its own without rules or forwarding, as before', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([]), isActive: false },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: true },
+ ]);
+ await syncVacationWithFilters(server.client, true, 'b');
+ await syncVacationWithFilters(server.client, true, 'b', null);
+ expect(server.writes()).toBe(0);
+ expect(server.active()).toBe('vacation');
+ });
+
+ it('refuses forwarding on a hand-edited script, and writes nothing', async () => {
+ const handEdited = '/* @metadata:begin\n{not json\n@metadata:end */\nkeep;\n';
+ const server = stalwart([
+ { name: 'filters', content: handEdited, isActive: false },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: true },
+ ]);
+ await expect(syncVacationWithFilters(server.client, true, 'b', forward())).rejects.toBeInstanceOf(OpaqueFiltersError);
+ expect(server.writes()).toBe(0);
+ });
+
+ it('refuses forwarding where the server cannot include the auto-reply', async () => {
+ const server = stalwart([{ name: 'vacation', content: VACATION_SCRIPT, isActive: true }], { extensions: ['fileinto'] });
+ await expect(syncVacationWithFilters(server.client, true, 'b', forward())).rejects.toThrow(/"include"/);
+ expect(server.writes()).toBe(0);
+ });
+
+ it('moves the stored forwarding to the vacation dates without it being sent again', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: false },
+ ]);
+ await syncVacationWithFilters(server.client, true, 'b', undefined, undefined, {
+ from: '2026-11-02T07:00:00.000Z', until: null,
+ });
+ // Without an end date it forwards until switched off.
+ expect(parseScript(server.content('filters')).vacationForward).toEqual({
+ enabled: true, to: 'kollege@example.com', keepCopy: false, activeFrom: '2026-11-02T07:00:00.000Z',
+ });
+ });
+
+ it('takes the same moment, written another way, as no change', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: false },
+ ]);
+ // The server hands the vacation's dates back without milliseconds.
+ await syncVacationWithFilters(server.client, true, 'b', undefined, undefined, {
+ from: '2026-10-05T06:00:00Z', until: '2026-10-16T18:00:00+02:00',
+ });
+ expect(server.writes()).toBe(0);
+ });
+
+ it('refuses forwarding it could not read back, and writes nothing', async () => {
+ const server = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }]);
+ // The generator would leave it out, and the save would look done.
+ await expect(syncVacationWithFilters(server.client, false, 'b', forward(), undefined, {
+ from: null, until: '+010000-01-01T00:59:00.000Z',
+ })).rejects.toThrow(/Unusable/);
+ await expect(syncVacationWithFilters(server.client, false, 'b', forward({ to: 'kollege' }))).rejects.toThrow(/Unusable/);
+ expect(server.writes()).toBe(0);
+ });
+});
+
+describe('readVacationFilters', () => {
+ it('reports the forwarding, that it can be set up, and the forwards in the rules', async () => {
+ const forwarding = rule('f', { actions: [{ type: 'forward', value: 'chef@example.com' }] });
+ const server = stalwart([
+ { name: 'filters', content: filters([forwarding, rule('off', { ...forwarding, enabled: false })], { includeVacation: true, vacationForward: forward() }), isActive: true },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: false },
+ ]);
+ expect(await readVacationFilters(server.client, 'b')).toEqual({
+ includesVacation: true,
+ forward: forward(),
+ forwardAvailable: true,
+ audience: null,
+ audienceAvailable: true,
+ notRunning: false,
+ otherForwards: 1,
+ });
+ });
+
+ it('reports the most forwards one message can collect in the rules, not all of them', async () => {
+ const forwarding = (id: string, stopProcessing: boolean) =>
+ rule(id, { actions: [{ type: 'forward', value: 'chef@example.com' }], stopProcessing });
+ const read = async (rules: FilterRule[]) =>
+ (await readVacationFilters(stalwart([{ name: 'filters', content: filters(rules), isActive: true }]).client, 'b')).otherForwards;
+ // A message the first rule matches stops there.
+ expect(await read([forwarding('a', true), forwarding('b', false)])).toBe(1);
+ expect(await read([forwarding('a', false), forwarding('b', false)])).toBe(2);
+ });
+
+ it('offers no forwarding without include, on a hand-edited script, or without redirects', async () => {
+ const plain = (options = {}, content = filters([])) =>
+ stalwart([{ name: 'filters', content, isActive: true }], options);
+ expect((await readVacationFilters(plain({ extensions: ['fileinto'] }).client, 'b')).forwardAvailable).toBe(false);
+ expect((await readVacationFilters(plain({}, '/* @metadata:begin\n{\n@metadata:end */\n').client, 'b')).forwardAvailable).toBe(false);
+ expect((await readVacationFilters(plain({ maxNumberRedirects: 0 }).client, 'b')).forwardAvailable).toBe(false);
+ expect((await readVacationFilters(plain().client, 'b')).forwardAvailable).toBe(true);
+ });
+
+ it('offers no forwarding where the server lacks what the block uses', async () => {
+ // Its period, its spam check, and the copy kept here.
+ for (const missing of ['date', 'relational', 'spamtestplus', 'copy']) {
+ const server = stalwart([{ name: 'filters', content: filters([]), isActive: true }], {
+ extensions: EXTENSIONS.filter((e) => e !== missing),
+ });
+ expect((await readVacationFilters(server.client, 'b')).forwardAvailable, missing).toBe(false);
+ }
+ });
+
+ it('still offers forwarding that is on, so that it can be switched off', async () => {
+ const server = stalwart(
+ [{ name: 'filters', content: filters([], { vacationForward: forward() }), isActive: true }],
+ { maxNumberRedirects: 0 },
+ );
+ expect((await readVacationFilters(server.client, 'b')).forwardAvailable).toBe(true);
+ });
+
+ it('says when forwarding that is on does not run, and when it does', async () => {
+ const read = async (filtersActive: boolean, vacationActive: boolean, f = forward()) => (await readVacationFilters(stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: f }), isActive: filtersActive },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: vacationActive },
+ ]).client, 'b')).notRunning;
+ // Stalwart's own script took over: it answers, nothing forwards.
+ expect(await read(false, true)).toBe(true);
+ // No script runs at all.
+ expect(await read(false, false)).toBe(true);
+ expect(await read(true, false)).toBe(false);
+ // Switched off, there is nothing that should run.
+ expect(await read(false, true, forward({ enabled: false }))).toBe(false);
+ });
+});
+
+describe('every other write keeps the forwarding', () => {
+ const withForwarding = () => stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: false },
+ ]);
+
+ it('a rule made from a message', async () => {
+ const server = withForwarding();
+ await updateAccountFilters(server.client, 'b', (rules) => insertRuleAtTop(rules, rule('new')));
+ const written = server.content('filters');
+ expect(written).toContain('# Rule: Rule new');
+ expect(written.split(FORWARD_MARKER).length).toBe(2);
+ expect(parseScript(written).vacationForward).toEqual(forward());
+ });
+
+ it('a save on the filter settings page', async () => {
+ const server = withForwarding();
+ await useFilterStore.getState().fetchFilters(server.client, 'b');
+ expect(useFilterStore.getState().vacationForward).toEqual(forward());
+ useFilterStore.getState().addRule(rule('new'));
+ await useFilterStore.getState().saveFilters(server.client);
+ const written = server.content('filters');
+ expect(written).toContain('# Rule: Rule new');
+ expect(written.split(FORWARD_MARKER).length).toBe(2);
+ expect(parseScript(written).vacationForward).toEqual(forward());
+ });
+});
+
+describe('vacation store', () => {
+ it('loads the forwarding along with the auto-reply', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: false },
+ ]);
+ await useVacationStore.getState().fetchVacationResponse(server.client);
+ expect(useVacationStore.getState()).toMatchObject({
+ isEnabled: true,
+ forward: forward(),
+ forwardAvailable: true,
+ otherForwards: 0,
+ });
+ });
+
+ it('forwards in the vacation period it saves, with the auto-reply and after it', async () => {
+ const server = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }]);
+ await useVacationStore.getState().fetchVacationResponse(server.client);
+ await useVacationStore.getState().updateVacationResponse(server.client, {
+ isEnabled: true,
+ fromDate: '2026-10-05T06:00:00.000Z',
+ toDate: '2026-10-16T16:00:00.000Z',
+ forward: { enabled: true, to: 'kollege@example.com', keepCopy: false },
+ });
+ // The auto-reply runs from the filters script, which forwards in its period.
+ expect(server.active()).toBe('filters');
+ const written = server.content('filters');
+ expect(written).toContain(INCLUDE);
+ expect(parseScript(written).vacationForward).toEqual(forward());
+ // VacationResponse itself never sees the forwarding.
+ expect(server.client.setVacationResponse.mock.calls[0][0]).not.toHaveProperty('forward');
+ expect(useVacationStore.getState().forward).toEqual(forward());
+
+ // Turning the auto-reply off leaves the forwarding running on its own.
+ await useVacationStore.getState().updateVacationResponse(server.client, { isEnabled: false });
+ expect(server.active()).toBe('filters');
+ expect(server.content('filters')).not.toContain(INCLUDE);
+ expect(server.content('filters')).toContain(FORWARD_MARKER);
+ });
+
+ it('turns the auto-reply off and forwarding on in one save, even when the auto-reply ran alone', async () => {
+ const server = stalwart([{ name: 'vacation', content: VACATION_SCRIPT, isActive: true }]);
+ await useVacationStore.getState().fetchVacationResponse(server.client);
+ expect(useVacationStore.getState().isEnabled).toBe(true);
+ await useVacationStore.getState().updateVacationResponse(server.client, {
+ isEnabled: false,
+ fromDate: null,
+ toDate: null,
+ forward: { enabled: true, to: 'kollege@example.com', keepCopy: false },
+ });
+ // No auto-reply any more; the filters script forwards on its own.
+ expect(server.active()).toBe('filters');
+ expect(server.content('filters')).not.toContain(INCLUDE);
+ expect(server.content('filters')).toContain('redirect "kollege@example.com";');
+ });
+
+ it('says when the auto-reply was saved but its forwarding was not', async () => {
+ const server = stalwart([{ name: 'filters', content: filters([rule('a')]), isActive: true }]);
+ await useVacationStore.getState().fetchVacationResponse(server.client);
+ server.client.updateSieveScript.mockRejectedValueOnce(new Error('server said no'));
+ await expect(useVacationStore.getState().updateVacationResponse(server.client, {
+ isEnabled: true,
+ forward: { enabled: true, to: 'kollege@example.com', keepCopy: false },
+ })).rejects.toBeInstanceOf(VacationFiltersError);
+ expect(server.client.setVacationResponse).toHaveBeenCalledTimes(1);
+ expect(useVacationStore.getState()).toMatchObject({ isEnabled: true, forward: null, isSaving: false, error: null });
+ });
+
+ it('says when forwarding that is on could not be switched off', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { vacationForward: forward() }), isActive: true },
+ ]);
+ await useVacationStore.getState().fetchVacationResponse(server.client);
+ server.client.updateSieveScript.mockRejectedValueOnce(new Error('server said no'));
+ // Only the stored forwarding tells that mail is still forwarded.
+ await expect(useVacationStore.getState().updateVacationResponse(server.client, {
+ isEnabled: false,
+ forward: { enabled: false, to: 'kollege@example.com', keepCopy: false },
+ })).rejects.toBeInstanceOf(VacationFiltersError);
+ expect(server.content('filters')).toContain(FORWARD_MARKER);
+ expect(useVacationStore.getState().forward).toEqual(forward());
+ });
+
+ it('says when a save cut short leaves the forwarding idle, and the next save sets it right', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: false },
+ ]);
+ await useVacationStore.getState().fetchVacationResponse(server.client);
+ expect(useVacationStore.getState()).toMatchObject({ isEnabled: true, notRunning: false });
+
+ // Only the text changes. Stalwart's own script takes over with the
+ // response, and putting the filters back fails.
+ server.client.updateSieveScript.mockRejectedValueOnce(new Error('connection lost'));
+ await expect(useVacationStore.getState().updateVacationResponse(server.client, {
+ isEnabled: true, subject: 'Weg',
+ })).rejects.toBeInstanceOf(VacationFiltersError);
+ expect(server.active()).toBe('vacation');
+ expect(useVacationStore.getState()).toMatchObject({ forward: forward(), notRunning: true });
+
+ await useVacationStore.getState().updateVacationResponse(server.client, { isEnabled: true });
+ expect(server.active()).toBe('filters');
+ expect(server.content('filters')).toContain(FORWARD_MARKER);
+ expect(useVacationStore.getState().notRunning).toBe(false);
+ });
+
+ it('leaves forwarding it was not asked to change as the server has it, in the vacation period', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward({ enabled: false }) }), isActive: true },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: false },
+ ]);
+ await useVacationStore.getState().fetchVacationResponse(server.client);
+ // Another tab switched it on since this one loaded.
+ await syncVacationWithFilters(server.client, true, 'b', forward());
+ await useVacationStore.getState().updateVacationResponse(server.client, {
+ isEnabled: true, fromDate: '2026-11-02T07:00:00.000Z', toDate: '2026-11-13T17:00:00.000Z',
+ });
+ const stored = parseScript(server.content('filters')).vacationForward;
+ expect(stored).toEqual(forward({ activeFrom: '2026-11-02T07:00:00.000Z', activeUntil: '2026-11-13T17:00:00.000Z' }));
+ expect(useVacationStore.getState().forward).toEqual(stored);
+ });
+
+ it('drops what a load finds once a later load or an account switch outdated it', async () => {
+ const server = stalwart([
+ { name: 'filters', content: filters([rule('a')], { includeVacation: true, vacationForward: forward() }), isActive: true },
+ { name: 'vacation', content: VACATION_SCRIPT, isActive: false },
+ ]);
+ const other = { fromDate: null, toDate: null, subject: 'Other account', textBody: 'x', htmlBody: null, isEnabled: false };
+ let answer: (value: typeof other) => void = () => {};
+ server.client.getVacationResponse.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve; }));
+ const slow = useVacationStore.getState().fetchVacationResponse(server.client);
+ await useVacationStore.getState().fetchVacationResponse(server.client);
+ answer(other);
+ await slow;
+ expect(useVacationStore.getState()).toMatchObject({ subject: '', forward: forward(), isLoading: false });
+
+ server.client.getVacationResponse.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve; }));
+ const beforeSwitch = useVacationStore.getState().fetchVacationResponse(server.client);
+ useVacationStore.getState().clearState();
+ answer(other);
+ await beforeSwitch;
+ expect(useVacationStore.getState()).toMatchObject({ subject: '', forward: null, isLoading: false });
+ });
+});
diff --git a/stores/filter-store.ts b/stores/filter-store.ts
index 4248fd274..35f113e88 100644
--- a/stores/filter-store.ts
+++ b/stores/filter-store.ts
@@ -1,9 +1,13 @@
import { create } from 'zustand';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
-import type { FilterRule, SieveCapabilities, VacationSieveConfig } from '@/lib/jmap/sieve-types';
+import type { FilterRule, SieveCapabilities, VacationAudience, VacationForward, VacationSieveConfig } from '@/lib/jmap/sieve-types';
import { parseScript } from '@/lib/sieve/parser';
-import { generateScript, VACATION_SCRIPT_NAME } from '@/lib/sieve/generator';
-import { applyScriptTransforms, supportsInclude, writeFiltersScript } from '@/lib/filters/account-filters';
+import { generateScript, supportsSpamGuard, VACATION_SCRIPT_NAME } from '@/lib/sieve/generator';
+import { OpaqueFiltersError, applyScriptTransforms, supportsInclude, writeFiltersScript } from '@/lib/filters/account-filters';
+import { worstCaseForwards } from '@/lib/filters/forward-limit';
+import { supportsPeriods } from '@/lib/sieve/period';
+import { isValidVacationForward, withVacationPeriod } from '@/lib/sieve/vacation-forward';
+import { isValidVacationAudience, normalizeVacationAudience } from '@/lib/sieve/vacation-audience';
import { debug } from '@/lib/debug';
interface SieveAccount {
@@ -25,6 +29,10 @@ interface FilterStore {
vacationSettings: VacationSieveConfig | null;
externalRequires: string[];
includeVacation: boolean;
+ /** Forwarding from the vacation card; carried through every save. */
+ vacationForward: VacationForward | null;
+ /** Who gets the auto-reply, when not everyone; carried through every save. */
+ vacationAudience: VacationAudience | null;
availableAccounts: SieveAccount[];
selectedAccountId: string | null;
@@ -64,6 +72,8 @@ export const useFilterStore = create()((set, get) => ({
vacationSettings: null,
externalRequires: [],
includeVacation: false,
+ vacationForward: null,
+ vacationAudience: null,
availableAccounts: [],
selectedAccountId: null,
@@ -105,6 +115,8 @@ export const useFilterStore = create()((set, get) => ({
rawScript: '',
isOpaque: false,
includeVacation: vacationActive,
+ vacationForward: null,
+ vacationAudience: null,
});
return;
}
@@ -126,6 +138,8 @@ export const useFilterStore = create()((set, get) => ({
vacationSettings: result.vacation || null,
externalRequires: result.externalRequires,
includeVacation: false,
+ vacationForward: null,
+ vacationAudience: null,
});
} else {
debug.log('filters', 'Parsed', result.rules.length, 'filter rules');
@@ -136,6 +150,8 @@ export const useFilterStore = create()((set, get) => ({
vacationSettings: result.vacation || null,
externalRequires: result.externalRequires,
includeVacation: !!result.includeVacation || vacationActive,
+ vacationForward: result.vacationForward ?? null,
+ vacationAudience: result.vacationAudience ?? null,
});
}
} catch (error) {
@@ -160,6 +176,8 @@ export const useFilterStore = create()((set, get) => ({
vacationSettings: null,
externalRequires: [],
includeVacation: false,
+ vacationForward: null,
+ vacationAudience: null,
});
await get().fetchFilters(client, accountId);
},
@@ -169,7 +187,7 @@ export const useFilterStore = create()((set, get) => ({
try {
const {
isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires, includeVacation,
- selectedAccountId, sieveCapabilities,
+ vacationForward, vacationAudience, selectedAccountId, sieveCapabilities,
} = get();
let content: string;
@@ -179,6 +197,8 @@ export const useFilterStore = create()((set, get) => ({
content = generateScript(rules, vacationSettings || undefined, {
externalRequires,
includeVacation,
+ vacationForward,
+ vacationAudience,
extensions: sieveCapabilities?.sieveExtensions,
});
}
@@ -272,6 +292,8 @@ export const useFilterStore = create()((set, get) => ({
vacationSettings: null,
externalRequires: [],
includeVacation: false,
+ vacationForward: null,
+ vacationAudience: null,
availableAccounts: [],
selectedAccountId: null,
});
@@ -288,56 +310,187 @@ async function loadManagedScript(client: IJMAPClient, accountId: string) {
return { vacationScript, target, parsed: parsed.isOpaque ? undefined : parsed };
}
-/**
- * Whether the account's filters script runs the server's vacation script.
- * VacationResponse.isEnabled reads false in that case, because the vacation
- * script itself is not the active one.
- */
-export async function isVacationIncludedInFilters(
- client: IJMAPClient,
- accountId?: string,
-): Promise {
+/** What the vacation card needs from the account's filters script. */
+export interface VacationFilters {
+ /**
+ * The filters script runs the server's vacation script. VacationResponse
+ * .isEnabled reads false in that case, because the vacation script itself
+ * is not the active one.
+ */
+ includesVacation: boolean;
+ /** The forwarding as stored, on or off; null when none is set up. */
+ forward: VacationForward | null;
+ /**
+ * Forwarding can be set up: it runs from the filters script next to the
+ * included vacation script, so it needs `include`, a script Bulwark can
+ * read, a server that allows a redirect and what the block uses besides
+ * (its period, its spam check, the copy). Forwarding that is on is offered
+ * all the same, so that it can be switched off.
+ */
+ forwardAvailable: boolean;
+ /** Who gets the auto-reply as stored; null when everyone does. */
+ audience: VacationAudience | null;
+ /**
+ * The auto-reply can be narrowed to some senders: it then runs from the
+ * filters script, so this needs `include`, `envelope` and a script Bulwark
+ * can read.
+ */
+ audienceAvailable: boolean;
+ /**
+ * Forwarding that is on, or an auto-reply for some senders only, is stored
+ * but does not run: both run only from the active filters script, and
+ * Stalwart's own vacation script took over (a save cut short, another
+ * client), or no script runs at all. Saving the card sets it right.
+ */
+ notRunning: boolean;
+ /**
+ * The most forwards the filter rules let one message collect (see
+ * worstCaseForwards). Forwarding that keeps a copy runs ahead of them, so
+ * it shares the server's redirect limit with these.
+ */
+ otherForwards: number;
+}
+
+/** See VacationFilters.notRunning. */
+function storedButIdle(
+ filtersActive: boolean,
+ vacationActive: boolean,
+ forward: VacationForward | null,
+ audience: VacationAudience | null,
+): boolean {
+ if (filtersActive) return false;
+ return !!forward?.enabled || (!!audience && vacationActive);
+}
+
+/** The server runs the forwarding block: a redirect, its period, its spam check and the copy. */
+function canForward(capabilities: SieveCapabilities | null | undefined): boolean {
+ const extensions = capabilities?.sieveExtensions;
+ return capabilities?.maxNumberRedirects !== 0 &&
+ supportsPeriods(extensions) &&
+ supportsSpamGuard(extensions) &&
+ !!extensions?.includes('copy');
+}
+
+export async function readVacationFilters(client: IJMAPClient, accountId?: string): Promise {
const sieveAccountId = accountId || client.getSieveAccountId();
+ const capabilities = client.getSieveCapabilities(sieveAccountId);
const { vacationScript, target, parsed } = await loadManagedScript(client, sieveAccountId);
- return !!(vacationScript && target?.isActive && parsed?.includeVacation);
+ const filtersUsable = supportsInclude(capabilities) && !(target && !parsed);
+ const forward = parsed?.vacationForward ?? null;
+ const audience = parsed?.vacationAudience ?? null;
+ return {
+ includesVacation: !!(vacationScript && target?.isActive && parsed?.includeVacation),
+ forward,
+ forwardAvailable: filtersUsable && (canForward(capabilities) || !!forward?.enabled),
+ audience,
+ // Told apart by the envelope sender, which the auto-reply goes to.
+ audienceAvailable: filtersUsable && !!capabilities?.sieveExtensions?.includes('envelope'),
+ notRunning: storedButIdle(!!target?.isActive, !!vacationScript?.isActive, forward, audience),
+ otherForwards: worstCaseForwards(parsed?.rules ?? []),
+ };
+}
+
+function same(a: T | null, b: T | null, normalize: (value: T) => T): boolean {
+ if (!a || !b) return a === b;
+ return JSON.stringify(normalize(a)) === JSON.stringify(normalize(b));
+}
+
+// The same moment, however it is written: the server hands the vacation's
+// dates back in its own form.
+function sameMoment(a: string | undefined, b: string | undefined): boolean {
+ if (a === undefined || b === undefined) return a === b;
+ return a === b || Date.parse(a) === Date.parse(b);
+}
+
+function sameForward(a: VacationForward | null, b: VacationForward | null): boolean {
+ if (!a || !b) return a === b;
+ return a.enabled === b.enabled && a.to === b.to && a.keepCopy === b.keepCopy &&
+ sameMoment(a.activeFrom, b.activeFrom) && sameMoment(a.activeUntil, b.activeUntil);
}
/**
- * Keep the filters and the auto-reply both running after VacationResponse/set.
+ * Keep the filters, the auto-reply and its forwarding running after
+ * VacationResponse/set.
*
* Stalwart allows one active Sieve script and turns the auto-reply on by
* activating its own "vacation" script, which switches every filter off.
* When that happened, re-activate the filters script with an `include` of
* the vacation script. When the auto-reply is turned off, drop the include.
+ *
+ * `forward` replaces the stored forwarding (null removes it, undefined keeps
+ * it). Forwarding runs with or without the auto-reply, but only from an
+ * active filters script, so for it the script is activated even without
+ * rules, and created when there is none. `audience` likewise replaces who
+ * gets the auto-reply (null: everyone). `period` is the vacation's, which
+ * whatever forwarding there is keeps to.
*/
export async function syncVacationWithFilters(
client: IJMAPClient,
enabled: boolean,
accountId?: string,
+ forward?: VacationForward | null,
+ audience?: VacationAudience | null,
+ period?: { from: string | null; until: string | null },
): Promise {
const sieveAccountId = accountId || client.getSieveAccountId();
const capabilities = client.getSieveCapabilities(sieveAccountId);
- if (enabled && !supportsInclude(capabilities)) return;
+ const canInclude = supportsInclude(capabilities);
+ if (enabled && !canInclude && forward === undefined && audience === undefined) return;
const { vacationScript, target, parsed } = await loadManagedScript(client, sieveAccountId);
- if (!target || !parsed) return;
+ const opaque = !!target && !parsed;
+ const storedForward = parsed?.vacationForward ?? null;
+ const storedAudience = parsed?.vacationAudience ?? null;
+ const requestedForward = forward === undefined ? storedForward : forward;
+ const nextForward = requestedForward && period ? withVacationPeriod(requestedForward, period) : requestedForward;
+ const nextAudience = audience === undefined ? storedAudience : audience;
+ const changed =
+ !sameForward(storedForward, nextForward) ||
+ !same(storedAudience, nextAudience, normalizeVacationAudience);
+ if (changed) {
+ // The card only offers these where they can be stored and run.
+ if (opaque) throw new OpaqueFiltersError();
+ if (!canInclude) throw new Error('Forwarding and reply recipients need the Sieve "include" extension');
+ // The generator leaves out what it could not read back, and the save
+ // would look done while nothing forwards, or everyone gets the reply.
+ if (nextForward && !isValidVacationForward(nextForward)) throw new Error('Unusable vacation forwarding');
+ if (nextAudience && !isValidVacationAudience(nextAudience)) throw new Error('Unusable vacation reply recipients');
+ }
+ if ((enabled && !canInclude) || opaque) return;
+
+ const rules = parsed?.rules ?? [];
+ const forwarding = !!nextForward?.enabled;
if (enabled) {
- // Only act when the vacation script took over from existing filters.
- if (!vacationScript?.isActive || target.isActive || parsed.rules.length === 0) return;
- } else if (!parsed.includeVacation) {
- return;
+ // Act when the vacation script took over from filters that must keep
+ // running, or when something changed. An auto-reply for some senders
+ // only must run from the filters script: on its own it answers everyone.
+ const tookOver = !!vacationScript?.isActive && !target?.isActive;
+ const needsFilters = rules.length > 0 || forwarding || !!nextAudience;
+ if (!changed && !(tookOver && needsFilters)) return;
+ } else {
+ // Forwarding also runs without the auto-reply, from an active filters
+ // script; turning the auto-reply off can leave no script active.
+ const forwardingIdle = forwarding && !target?.isActive;
+ if (!changed && !parsed?.includeVacation && !forwardingIdle) return;
}
const content = await applyScriptTransforms(
- generateScript(parsed.rules, parsed.vacation, {
- externalRequires: parsed.externalRequires,
+ generateScript(rules, parsed?.vacation, {
+ externalRequires: parsed?.externalRequires,
includeVacation: enabled,
+ vacationForward: nextForward,
+ vacationAudience: nextAudience,
extensions: capabilities?.sieveExtensions,
}),
sieveAccountId,
);
- await client.updateSieveScript(target.id, content, enabled || target.isActive, sieveAccountId);
+ const activate = enabled || forwarding || !!target?.isActive;
+ if (target) {
+ await client.updateSieveScript(target.id, content, activate, sieveAccountId);
+ } else {
+ await client.createSieveScript('filters', content, activate, sieveAccountId);
+ }
debug.log('filters', enabled ? 'Filters now include the vacation script' : 'Removed the vacation include');
const store = useFilterStore.getState();
diff --git a/stores/vacation-store.ts b/stores/vacation-store.ts
index 4057f93ab..cacebd65b 100644
--- a/stores/vacation-store.ts
+++ b/stores/vacation-store.ts
@@ -1,8 +1,26 @@
import { create } from 'zustand';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
-import { isVacationIncludedInFilters, syncVacationWithFilters } from '@/stores/filter-store';
+import type { VacationAudience, VacationForward } from '@/lib/jmap/sieve-types';
+import { readVacationFilters, syncVacationWithFilters, type VacationFilters } from '@/stores/filter-store';
+import { withVacationPeriod } from '@/lib/sieve/vacation-forward';
+import { currentStoreEpoch } from '@/lib/store-epoch';
import { debug } from '@/lib/debug';
+/** What the vacation card sets for forwarding; the period comes from the vacation. */
+export type VacationForwardSettings = Pick;
+
+/**
+ * The vacation response was saved, but what the filters script carries for
+ * it (forwarding, who gets the auto-reply) was not: forwarding may not run
+ * or still run, and the auto-reply may go to the wrong senders.
+ */
+export class VacationFiltersError extends Error {
+ constructor(cause: unknown) {
+ super(cause instanceof Error ? cause.message : 'filters_save_error');
+ this.name = 'VacationFiltersError';
+ }
+}
+
interface VacationStore {
isEnabled: boolean;
fromDate: string | null;
@@ -10,12 +28,25 @@ interface VacationStore {
subject: string;
textBody: string;
htmlBody: string | null;
+ /** Forwarding as stored in the filters script; null when none is set up. */
+ forward: VacationForward | null;
+ /** The account's filters script can carry forwarding (see readVacationFilters). */
+ forwardAvailable: boolean;
+ /** Who gets the auto-reply as stored; null when everyone does. */
+ audience: VacationAudience | null;
+ /** The auto-reply can be narrowed to some senders (see readVacationFilters). */
+ audienceAvailable: boolean;
+ /** Stored forwarding or recipients do not run until the card is saved (see readVacationFilters). */
+ notRunning: boolean;
+ /** The most forwards the filter rules let one message collect (see readVacationFilters). */
+ otherForwards: number;
isLoading: boolean;
isSaving: boolean;
error: string | null;
isSupported: boolean;
fetchVacationResponse: (client: IJMAPClient, accountId?: string) => Promise;
+ /** Throws VacationFiltersError when only the filters script part could not be saved. */
updateVacationResponse: (client: IJMAPClient, updates: {
isEnabled?: boolean;
fromDate?: string | null;
@@ -23,33 +54,66 @@ interface VacationStore {
subject?: string;
textBody?: string;
htmlBody?: string | null;
+ /** null removes the forwarding, leaving it out keeps it as it is. */
+ forward?: VacationForwardSettings | null;
+ /** null lets everyone have the auto-reply, leaving it out keeps it as it is. */
+ audience?: VacationAudience | null;
}, accountId?: string) => Promise;
setSupported: (supported: boolean) => void;
clearState: () => void;
}
-export const useVacationStore = create()((set) => ({
+/** The card's part of what the filters script holds. */
+function fromFilters(filters: VacationFilters) {
+ return {
+ forward: filters.forward,
+ forwardAvailable: filters.forwardAvailable,
+ audience: filters.audience,
+ audienceAvailable: filters.audienceAvailable,
+ notRunning: filters.notRunning,
+ otherForwards: filters.otherForwards,
+ };
+}
+
+// Each load, and each account switch (the store epoch), outdates the answers
+// of the loads before it: one for another account must not land in the card
+// on screen, where saving would write it to this account.
+let loadGeneration = 0;
+
+export const useVacationStore = create()((set, get) => ({
isEnabled: false,
fromDate: null,
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
+ forward: null,
+ forwardAvailable: false,
+ audience: null,
+ audienceAvailable: false,
+ notRunning: false,
+ otherForwards: 0,
isLoading: false,
isSaving: false,
error: null,
isSupported: false,
fetchVacationResponse: async (client, accountId) => {
+ const generation = ++loadGeneration;
+ const epoch = currentStoreEpoch();
+ const stale = () => generation !== loadGeneration || currentStoreEpoch() !== epoch;
set({ isLoading: true, error: null });
try {
const vacation = await client.getVacationResponse(accountId);
let isEnabled = vacation.isEnabled;
- if (!isEnabled && client.supportsSieve()) {
+ let filters: VacationFilters | null = null;
+ if (client.supportsSieve()) {
+ filters = await readVacationFilters(client, accountId).catch(() => null);
// Running from the filters script leaves the vacation script itself
// inactive, so VacationResponse reports it as off.
- isEnabled = await isVacationIncludedInFilters(client, accountId).catch(() => false);
+ if (filters) isEnabled = isEnabled || filters.includesVacation;
}
+ if (stale()) return;
set({
isEnabled,
fromDate: vacation.fromDate,
@@ -57,9 +121,17 @@ export const useVacationStore = create()((set) => ({
subject: vacation.subject || '',
textBody: vacation.textBody || '',
htmlBody: vacation.htmlBody,
+ forward: null,
+ forwardAvailable: false,
+ audience: null,
+ audienceAvailable: false,
+ notRunning: false,
+ otherForwards: 0,
+ ...(filters ? fromFilters(filters) : {}),
isLoading: false,
});
} catch (error) {
+ if (stale()) return;
set({
isLoading: false,
error: error instanceof Error ? error.message : 'fetch_error',
@@ -68,42 +140,90 @@ export const useVacationStore = create()((set) => ({
},
updateVacationResponse: async (client, updates, accountId) => {
+ // The save itself goes to the account it was made for; only what it
+ // leaves in the store is dropped once another load or account took over.
+ const generation = loadGeneration;
+ const epoch = currentStoreEpoch();
+ const superseded = () => generation !== loadGeneration || currentStoreEpoch() !== epoch;
set({ isSaving: true, error: null });
+ const { forward: forwardSettings, audience, ...vacationUpdates } = updates;
try {
- await client.setVacationResponse(updates, accountId);
- if (updates.isEnabled !== undefined && client.supportsSieve()) {
- try {
- await syncVacationWithFilters(client, updates.isEnabled, accountId);
- } catch (error) {
- debug.error('Failed to keep filters active next to the vacation response:', error);
- }
- }
- set((state) => ({
- ...state,
- ...updates,
- isSaving: false,
- }));
+ await client.setVacationResponse(vacationUpdates, accountId);
} catch (error) {
- set({
- isSaving: false,
- error: error instanceof Error ? error.message : 'save_error',
- });
+ set(superseded()
+ ? { isSaving: false }
+ : { isSaving: false, error: error instanceof Error ? error.message : 'save_error' });
throw error;
}
+
+ // Forwarding runs in the vacation's period, so it takes the dates along.
+ const state = get();
+ const fromDate = vacationUpdates.fromDate !== undefined ? vacationUpdates.fromDate : state.fromDate;
+ const toDate = vacationUpdates.toDate !== undefined ? vacationUpdates.toDate : state.toDate;
+ const period = { from: fromDate, until: toDate };
+
+ let filtersError: VacationFiltersError | null = null;
+ let filtersState: Partial = {};
+ const needsSync = vacationUpdates.isEnabled !== undefined || forwardSettings !== undefined || audience !== undefined;
+ if (needsSync && client.supportsSieve()) {
+ let synced = false;
+ try {
+ await syncVacationWithFilters(
+ client, vacationUpdates.isEnabled ?? state.isEnabled, accountId, forwardSettings, audience, period,
+ );
+ synced = true;
+ } catch (error) {
+ debug.error('Failed to keep filters active next to the vacation response:', error);
+ // Forwarding that is or was on may now not run, or still run; an
+ // auto-reply meant for some senders may go to everyone, or the other
+ // way round.
+ if (forwardSettings?.enabled || state.forward?.enabled || audience || state.audience) {
+ filtersError = new VacationFiltersError(error);
+ }
+ }
+ // What the filters script holds now, and whether it runs. After a
+ // failed save the card keeps what the user set, to save it again.
+ const filters = await readVacationFilters(client, accountId).catch(() => null);
+ if (filters) {
+ filtersState = synced ? fromFilters(filters) : { notRunning: filters.notRunning };
+ } else if (synced) {
+ filtersState = {
+ ...(forwardSettings !== undefined
+ ? { forward: forwardSettings && withVacationPeriod(forwardSettings, period) }
+ : {}),
+ ...(audience !== undefined ? { audience } : {}),
+ notRunning: false,
+ };
+ }
+ }
+
+ set((current) => (superseded()
+ ? { ...current, isSaving: false }
+ : { ...current, ...vacationUpdates, ...filtersState, isSaving: false }));
+ if (filtersError) throw filtersError;
},
setSupported: (supported) => set({ isSupported: supported }),
- clearState: () => set({
- isEnabled: false,
- fromDate: null,
- toDate: null,
- subject: '',
- textBody: '',
- htmlBody: null,
- isLoading: false,
- isSaving: false,
- error: null,
- isSupported: false,
- }),
+ clearState: () => {
+ loadGeneration++;
+ set({
+ isEnabled: false,
+ fromDate: null,
+ toDate: null,
+ subject: '',
+ textBody: '',
+ htmlBody: null,
+ forward: null,
+ forwardAvailable: false,
+ audience: null,
+ audienceAvailable: false,
+ notRunning: false,
+ otherForwards: 0,
+ isLoading: false,
+ isSaving: false,
+ error: null,
+ isSupported: false,
+ });
+ },
}));