mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
Merge pull request #854 from dev-hive-kazniisa/fix/keyword-rename-sieve-sync
fix: renaming a tag leaves filter rules tagging under the old id
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFilterStore } from "@/stores/filter-store";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section";
|
||||
import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2, Search } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -523,6 +524,7 @@ export function KeywordSettings() {
|
||||
useSettingsStore();
|
||||
const { client } = useAuthStore();
|
||||
const { fetchTagCounts } = useEmailStore();
|
||||
const renameKeywordInFilters = useFilterStore((state) => state.renameKeywordInFilters);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [isMigrating, setIsMigrating] = useState(false);
|
||||
@@ -572,9 +574,9 @@ export function KeywordSettings() {
|
||||
|
||||
if (idChanged && client) {
|
||||
setIsMigrating(true);
|
||||
const oldJmapKeyword = `$label:${oldId}`;
|
||||
const newJmapKeyword = `$label:${keyword.id}`;
|
||||
try {
|
||||
const oldJmapKeyword = `$label:${oldId}`;
|
||||
const newJmapKeyword = `$label:${keyword.id}`;
|
||||
const migration = await client.migrateKeyword(oldJmapKeyword, newJmapKeyword);
|
||||
renameKeyword(oldId, keyword);
|
||||
fetchTagCounts(client);
|
||||
@@ -589,6 +591,41 @@ export function KeywordSettings() {
|
||||
setIsMigrating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// The messages now carry the new keyword, so the definition is right
|
||||
// whatever happens next. Filter rules name the same id and would go on
|
||||
// tagging new mail under the old one, so follow the rename into them -
|
||||
// and say so when a rule is left behind, since only the user can fix a
|
||||
// hand-edited script.
|
||||
let rewroteFilters = false;
|
||||
try {
|
||||
const outcome = await renameKeywordInFilters(client, oldId, keyword.id);
|
||||
rewroteFilters = outcome.changed > 0;
|
||||
if (outcome.unhandled > 0) {
|
||||
const toastModule = await import('sonner');
|
||||
toastModule.toast.warning(t("filters_migration_error"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to migrate keyword in filters:", error);
|
||||
const toastModule = await import('sonner');
|
||||
toastModule.toast.warning(t("filters_migration_error"));
|
||||
}
|
||||
|
||||
if (rewroteFilters) {
|
||||
// Mail delivered between the migration above and the rewrite was still
|
||||
// tagged by the old rule, and the migration has already passed it by.
|
||||
// Sweep once more now that no rule can write the old keyword. A failure
|
||||
// here is about the messages, not the filters, so it says so.
|
||||
try {
|
||||
await client.migrateKeyword(oldJmapKeyword, newJmapKeyword);
|
||||
fetchTagCounts(client);
|
||||
} catch (error) {
|
||||
console.error("Failed to sweep up mail tagged during the rename:", error);
|
||||
const toastModule = await import('sonner');
|
||||
toastModule.toast.error(t("migration_error"));
|
||||
}
|
||||
}
|
||||
|
||||
setIsMigrating(false);
|
||||
} else {
|
||||
updateKeyword(oldId, { label: keyword.label, color: keyword.color });
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { countKeywordUses, renameKeywordInRules } from '../keyword-rename';
|
||||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||
|
||||
const makeRule = (overrides: Partial<FilterRule> = {}): FilterRule => ({
|
||||
id: 'rule-1',
|
||||
name: 'ISO',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'iso.org' }],
|
||||
actions: [{ type: 'add_label', value: 'red' }],
|
||||
stopProcessing: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('renameKeywordInRules', () => {
|
||||
it('rewrites an add_label action that names the old tag', () => {
|
||||
const { rules, changed } = renameKeywordInRules([makeRule()], 'red', 'iso');
|
||||
expect(changed).toBe(1);
|
||||
expect(rules[0].actions[0]).toEqual({ type: 'add_label', value: 'iso' });
|
||||
});
|
||||
|
||||
it('leaves actions naming a different tag alone', () => {
|
||||
const rules = [makeRule({ actions: [{ type: 'add_label', value: 'blue' }] })];
|
||||
const result = renameKeywordInRules(rules, 'red', 'iso');
|
||||
expect(result.changed).toBe(0);
|
||||
expect(result.rules[0].actions[0].value).toBe('blue');
|
||||
});
|
||||
|
||||
it('leaves actions of other types alone even when the value collides', () => {
|
||||
const rules = [makeRule({ actions: [{ type: 'move', value: 'red' }] })];
|
||||
const result = renameKeywordInRules(rules, 'red', 'iso');
|
||||
expect(result.changed).toBe(0);
|
||||
expect(result.rules[0].actions[0].value).toBe('red');
|
||||
});
|
||||
|
||||
it('counts every action it rewrites, across rules', () => {
|
||||
const rules = [
|
||||
makeRule({ id: 'r1' }),
|
||||
makeRule({ id: 'r2', actions: [{ type: 'star' }, { type: 'add_label', value: 'red' }] }),
|
||||
];
|
||||
expect(renameKeywordInRules(rules, 'red', 'iso').changed).toBe(2);
|
||||
});
|
||||
|
||||
it('rewrites a disabled rule too, so enabling it later still tags correctly', () => {
|
||||
const { changed, rules } = renameKeywordInRules([makeRule({ enabled: false })], 'red', 'iso');
|
||||
expect(changed).toBe(1);
|
||||
expect(rules[0].actions[0].value).toBe('iso');
|
||||
});
|
||||
|
||||
it('does nothing when the id is unchanged', () => {
|
||||
const rules = [makeRule()];
|
||||
const result = renameKeywordInRules(rules, 'red', 'red');
|
||||
expect(result).toEqual({ rules, changed: 0 });
|
||||
expect(result.rules).toBe(rules);
|
||||
});
|
||||
|
||||
it('does not mutate the rules it was given', () => {
|
||||
const rules = [makeRule()];
|
||||
renameKeywordInRules(rules, 'red', 'iso');
|
||||
expect(rules[0].actions[0].value).toBe('red');
|
||||
});
|
||||
|
||||
it('rewrites the raw block of an external rule, which is emitted verbatim', () => {
|
||||
const rules = [
|
||||
makeRule({
|
||||
origin: 'external',
|
||||
rawBlock: '# hand written\nif header :contains "From" "iso.org" {\n addflag "$label:red";\n}',
|
||||
}),
|
||||
];
|
||||
const { rules: renamed, changed } = renameKeywordInRules(rules, 'red', 'iso');
|
||||
expect(changed).toBe(1);
|
||||
expect(renamed[0].rawBlock).toContain('addflag "$label:iso";');
|
||||
expect(renamed[0].rawBlock).not.toContain('$label:red');
|
||||
});
|
||||
|
||||
it('rewrites only the addflag operand of a raw block, not a matching test string', () => {
|
||||
const rules = [
|
||||
makeRule({
|
||||
origin: 'external',
|
||||
rawBlock: 'if header :contains "X-Tag" "$label:red" {\n addflag "$label:red";\n}',
|
||||
}),
|
||||
];
|
||||
const { rules: renamed, changed } = renameKeywordInRules(rules, 'red', 'iso');
|
||||
expect(changed).toBe(1);
|
||||
expect(renamed[0].rawBlock).toContain('addflag "$label:iso";');
|
||||
expect(renamed[0].rawBlock).toContain('header :contains "X-Tag" "$label:red"');
|
||||
});
|
||||
|
||||
it('leaves an opaque rule, which has no parsed actions, untouched', () => {
|
||||
const rules = [
|
||||
makeRule({
|
||||
origin: 'opaque',
|
||||
actions: [],
|
||||
conditions: [],
|
||||
rawBlock: '# unparseable\nif anyof (true) {\n addflag "$label:red";\n}',
|
||||
}),
|
||||
];
|
||||
const result = renameKeywordInRules(rules, 'red', 'iso');
|
||||
expect(result.changed).toBe(0);
|
||||
expect(result.rules[0].rawBlock).toContain('$label:red');
|
||||
});
|
||||
|
||||
it('does not touch a raw block whose keyword only shares a prefix with the old id', () => {
|
||||
const rules = [
|
||||
makeRule({
|
||||
origin: 'external',
|
||||
actions: [{ type: 'add_label', value: 'red-alert' }],
|
||||
rawBlock: 'if true {\n addflag "$label:red-alert";\n}',
|
||||
}),
|
||||
];
|
||||
const { rules: renamed, changed } = renameKeywordInRules(rules, 'red', 'iso');
|
||||
expect(changed).toBe(0);
|
||||
expect(renamed[0].rawBlock).toContain('$label:red-alert');
|
||||
});
|
||||
|
||||
it('rewrites a nested id without treating its separator as a pattern', () => {
|
||||
const rules = [
|
||||
makeRule({
|
||||
actions: [{ type: 'add_label', value: 'work/clients' }],
|
||||
origin: 'external',
|
||||
rawBlock: 'if true {\n addflag "$label:work/clients";\n}',
|
||||
}),
|
||||
];
|
||||
const { rules: renamed, changed } = renameKeywordInRules(rules, 'work/clients', 'work/acme');
|
||||
expect(changed).toBe(1);
|
||||
expect(renamed[0].actions[0].value).toBe('work/acme');
|
||||
expect(renamed[0].rawBlock).toContain('addflag "$label:work/acme";');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countKeywordUses', () => {
|
||||
it('counts the keyword as it is written in a script', () => {
|
||||
const script = 'if true {\n addflag "$label:red";\n}\nif false {\n addflag "$label:red";\n}';
|
||||
expect(countKeywordUses(script, 'red')).toBe(2);
|
||||
});
|
||||
|
||||
it('does not count a keyword that merely starts with the id', () => {
|
||||
expect(countKeywordUses('addflag "$label:red-alert";', 'red')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns zero for a script that never names the tag', () => {
|
||||
expect(countKeywordUses('addflag "\\\\Seen";', 'red')).toBe(0);
|
||||
});
|
||||
|
||||
it('counts the tag in a multi-flag argument, which is one Sieve string', () => {
|
||||
// RFC 5232 section 3.2: the argument is a space-separated flag list, so the
|
||||
// keyword is not always followed by the closing quote.
|
||||
expect(countKeywordUses('addflag "$label:red \\\\Seen";', 'red')).toBe(1);
|
||||
expect(countKeywordUses('addflag "\\\\Seen $label:red";', 'red')).toBe(1);
|
||||
});
|
||||
|
||||
it('still does not count a longer tag that starts with the id', () => {
|
||||
expect(countKeywordUses('addflag "$label:red-alert \\\\Seen";', 'red')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Following a renamed tag into the filters that name it.
|
||||
*
|
||||
* A tag is stored on messages as the keyword `$label:<id>`, and `id` is a slug
|
||||
* of the display name - so renaming a tag changes its id, and every message
|
||||
* carrying it has to be migrated (`IJMAPClient.migrateKeyword`). A Sieve rule
|
||||
* with an `add_label` action names that same id, in the generated `addflag`
|
||||
* line and in the metadata block the visual editor reads back. Migrating only
|
||||
* the messages leaves the rule writing the old keyword: new mail keeps arriving
|
||||
* under a tag no definition names any more, which reads as "the filter stopped
|
||||
* working" while the rule is in fact running exactly as written.
|
||||
*
|
||||
* This module is the rewrite half. It is pure - the caller fetches the script,
|
||||
* parses it, applies this, and writes the result back.
|
||||
*/
|
||||
import type { FilterAction, FilterRule } from '@/lib/jmap/sieve-types';
|
||||
import { KEYWORD_PREFIX } from '@/lib/thread-utils';
|
||||
|
||||
/** The result of following a rename through a set of rules. */
|
||||
export interface KeywordRenameResult {
|
||||
/** The rules, with every reference to the old id rewritten. */
|
||||
rules: FilterRule[];
|
||||
/** How many actions were rewritten. Zero means nothing referenced the tag. */
|
||||
changed: number;
|
||||
}
|
||||
|
||||
/** The keyword an `add_label` action writes, as it appears in a script. */
|
||||
function quotedKeyword(id: string): string {
|
||||
return `"${KEYWORD_PREFIX}${id}"`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* How many times `script` writes the keyword of the tag `id`.
|
||||
*
|
||||
* Whole-token only: `$label:red-alert` is a different tag from `$label:red`
|
||||
* and must not be counted, which a plain substring search would get wrong. The
|
||||
* token ends at the closing quote or at a space, since the argument of an
|
||||
* `addflag` is a space-separated flag list (RFC 5232 section 3.2) and the tag
|
||||
* need not be the last flag in it.
|
||||
*
|
||||
* Used to tell whether a script this module cannot rewrite - a hand-edited one,
|
||||
* kept verbatim - still refers to a tag that has since been renamed.
|
||||
*/
|
||||
export function countKeywordUses(script: string, id: string): number {
|
||||
const pattern = new RegExp(`["\\s]${escapeRegExp(KEYWORD_PREFIX + id)}(?=["\\s])`, 'g');
|
||||
return script.match(pattern)?.length ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* `rules` with every `add_label` action naming `oldId` rewritten to `newId`.
|
||||
*
|
||||
* Disabled rules are rewritten too: leaving one behind would have it tag under
|
||||
* the old id the moment it is switched back on. An external rule - one Bulwark
|
||||
* did not write, re-emitted from its `rawBlock` verbatim - has that block
|
||||
* rewritten as well, since its parsed actions never reach the generated script.
|
||||
* Only the operand of an `addflag` is touched there: a test comparing some
|
||||
* header against the same literal is the rule's own logic, and rewriting it
|
||||
* would change what the rule matches rather than what it tags.
|
||||
*
|
||||
* What is left naming the old id afterwards - that test, or an opaque rule,
|
||||
* which parses to no actions at all - is for the caller to count in the script
|
||||
* it ends up writing, with `countKeywordUses`. The input is left untouched, and
|
||||
* an id that did not actually change returns the very same array, so a caller
|
||||
* can skip writing anything.
|
||||
*/
|
||||
export function renameKeywordInRules(
|
||||
rules: FilterRule[],
|
||||
oldId: string,
|
||||
newId: string,
|
||||
): KeywordRenameResult {
|
||||
if (!oldId || !newId || oldId === newId) return { rules, changed: 0 };
|
||||
|
||||
let changed = 0;
|
||||
const rewritten = rules.map((rule) => {
|
||||
let ruleChanged = 0;
|
||||
const actions: FilterAction[] = rule.actions.map((action) => {
|
||||
if (action.type !== 'add_label' || action.value !== oldId) return action;
|
||||
ruleChanged++;
|
||||
return { ...action, value: newId };
|
||||
});
|
||||
if (ruleChanged === 0) return rule;
|
||||
|
||||
changed += ruleChanged;
|
||||
const next: FilterRule = { ...rule, actions };
|
||||
if (next.rawBlock) next.rawBlock = renameInRawBlock(next.rawBlock, oldId, newId);
|
||||
return next;
|
||||
});
|
||||
|
||||
return changed === 0 ? { rules, changed: 0 } : { rules: rewritten, changed };
|
||||
}
|
||||
|
||||
/** Retags the `addflag` lines of a block that is otherwise kept verbatim. */
|
||||
function renameInRawBlock(block: string, oldId: string, newId: string): string {
|
||||
const pattern = new RegExp(`(addflag\\s+)${escapeRegExp(quotedKeyword(oldId))}`, 'g');
|
||||
return block.replace(pattern, `$1${quotedKeyword(newId)}`);
|
||||
}
|
||||
@@ -1246,6 +1246,7 @@
|
||||
"cancel": "إلغاء",
|
||||
"migrating": "جارٍ تحديث الوسم على الرسائل الحالية…",
|
||||
"migration_error": "فشل تحديث الوسم على الرسائل الحالية",
|
||||
"filters_migration_error": "لا تزال بعض قواعد التصفية تستخدم الوسم القديم — حدّثها يدويًا",
|
||||
"nesting": {
|
||||
"label": "وسوم متداخلة",
|
||||
"description": "ضع الوسوم داخل وسوم أخرى واعرضها كشجرة في الشريط الجانبي."
|
||||
|
||||
@@ -1246,6 +1246,7 @@
|
||||
"cancel": "Cancel·la",
|
||||
"migrating": "Actualitzant l'etiqueta als correus existents…",
|
||||
"migration_error": "No s'ha pogut actualitzar l'etiqueta als correus existents",
|
||||
"filters_migration_error": "Algunes regles de filtre encara fan servir l'etiqueta antiga: actualitza-les manualment",
|
||||
"nesting": {
|
||||
"label": "Etiquetes imbricades",
|
||||
"description": "Imbrica etiquetes sota altres etiquetes i mostra-les com un arbre a la barra lateral."
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "Zrušit",
|
||||
"migrating": "Aktualizace štítku v existujících e-mailech…",
|
||||
"migration_error": "Nepodařilo se aktualizovat štítek v existujících e-mailech",
|
||||
"filters_migration_error": "Některá pravidla filtrů stále používají starý štítek – upravte je ručně",
|
||||
"nesting": {
|
||||
"label": "Vnořené štítky",
|
||||
"description": "Vnořujte štítky pod jiné štítky a zobrazujte je v postranním panelu jako strom."
|
||||
|
||||
@@ -1246,6 +1246,7 @@
|
||||
"cancel": "Annuller",
|
||||
"migrating": "Opdaterer tag på eksisterende e-mails…",
|
||||
"migration_error": "Kunne ikke opdatere tag på eksisterende e-mails",
|
||||
"filters_migration_error": "Nogle filterregler bruger stadig det gamle tag – opdatér dem manuelt",
|
||||
"nesting": {
|
||||
"label": "Indlejrede tags",
|
||||
"description": "Indlejr tags under andre tags og vis dem som et træ i sidepanelet."
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "Abbrechen",
|
||||
"migrating": "Label auf vorhandenen E-Mails aktualisieren…",
|
||||
"migration_error": "Label auf vorhandenen E-Mails konnte nicht aktualisiert werden",
|
||||
"filters_migration_error": "Einige Filterregeln verwenden noch das alte Tag – aktualisieren Sie sie manuell",
|
||||
"nesting": {
|
||||
"label": "Verschachtelte Labels",
|
||||
"description": "Labels unter anderen Labels verschachteln und als Baum in der Seitenleiste anzeigen."
|
||||
|
||||
@@ -1246,6 +1246,7 @@
|
||||
"cancel": "Cancel",
|
||||
"migrating": "Updating tag on existing emails…",
|
||||
"migration_error": "Failed to update tag on existing emails",
|
||||
"filters_migration_error": "Some filter rules still use the old tag — update them manually",
|
||||
"nesting": {
|
||||
"label": "Nested Tags",
|
||||
"description": "Nest tags underneath other tags and show them as a tree in the sidebar."
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "Cancelar",
|
||||
"migrating": "Actualizando etiqueta en correos existentes…",
|
||||
"migration_error": "Error al actualizar la etiqueta en correos existentes",
|
||||
"filters_migration_error": "Algunas reglas de filtro siguen usando la etiqueta anterior: actualízalas manualmente",
|
||||
"nesting": {
|
||||
"label": "Etiquetas anidadas",
|
||||
"description": "Anida etiquetas debajo de otras etiquetas y muéstralas como un árbol en la barra lateral."
|
||||
|
||||
@@ -1246,6 +1246,7 @@
|
||||
"cancel": "انصراف",
|
||||
"migrating": "در حال بهروزرسانی برچسب روی ایمیلهای موجود…",
|
||||
"migration_error": "بهروزرسانی برچسب ناموفق بود",
|
||||
"filters_migration_error": "برخی قوانین فیلتر هنوز از برچسب قدیمی استفاده میکنند — آنها را دستی بهروزرسانی کنید",
|
||||
"nesting": {
|
||||
"label": "برچسبهای تودرتو",
|
||||
"description": "برچسبها را زیر برچسبهای دیگر قرار دهید و آنها را بهصورت درختی در نوار کناری نمایش دهید."
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "Annuler",
|
||||
"migrating": "Mise à jour de l'étiquette sur les e-mails existants…",
|
||||
"migration_error": "Impossible de mettre à jour l'étiquette sur les e-mails existants",
|
||||
"filters_migration_error": "Certaines règles de filtrage utilisent encore l'ancienne étiquette — mettez-les à jour manuellement",
|
||||
"nesting": {
|
||||
"label": "Étiquettes imbriquées",
|
||||
"description": "Imbriquez des étiquettes sous d'autres étiquettes et affichez-les sous forme d'arborescence dans la barre latérale."
|
||||
|
||||
@@ -1208,6 +1208,7 @@
|
||||
"cancel": "לְבַטֵל",
|
||||
"migrating": "מעדכן מילת מפתח באימיילים קיימים...",
|
||||
"migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות",
|
||||
"filters_migration_error": "חלק מכללי הסינון עדיין משתמשים בתגית הישנה — עדכנו אותם ידנית",
|
||||
"nesting": {
|
||||
"label": "תגים מקוננים",
|
||||
"description": "קנן תגים תחת תגים אחרים והצג אותם כעץ בסרגל הצד."
|
||||
|
||||
@@ -1246,6 +1246,7 @@
|
||||
"cancel": "Mégse",
|
||||
"migrating": "Címke frissítése a meglévő e-maileken...",
|
||||
"migration_error": "Nem sikerült frissíteni a címkét a meglévő e-maileken",
|
||||
"filters_migration_error": "Néhány szűrőszabály még a régi címkét használja – frissítse őket kézzel",
|
||||
"nesting": {
|
||||
"label": "Beágyazott címkék",
|
||||
"description": "Ágyazzon címkéket más címkék alá, és jelenítse meg őket fastruktúraként az oldalsávon."
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "Annulla",
|
||||
"migrating": "Aggiornamento dell'etichetta nelle e-mail esistenti…",
|
||||
"migration_error": "Impossibile aggiornare l'etichetta nelle e-mail esistenti",
|
||||
"filters_migration_error": "Alcune regole dei filtri usano ancora il vecchio tag: aggiornale manualmente",
|
||||
"nesting": {
|
||||
"label": "Etichette nidificate",
|
||||
"description": "Nidifica le etichette sotto altre etichette e mostrale come un albero nella barra laterale."
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "キャンセル",
|
||||
"migrating": "既存のメールのラベルを更新中…",
|
||||
"migration_error": "既存のメールのラベルの更新に失敗しました",
|
||||
"filters_migration_error": "一部のフィルタールールが古いタグを使用しています。手動で更新してください",
|
||||
"nesting": {
|
||||
"label": "ネストされたラベル",
|
||||
"description": "ラベルを他のラベルの下にネストし、サイドバーにツリーとして表示します。"
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "취소",
|
||||
"migrating": "기존 이메일의 태그 업데이트 중…",
|
||||
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다",
|
||||
"filters_migration_error": "일부 필터 규칙이 여전히 이전 태그를 사용합니다. 수동으로 업데이트하세요",
|
||||
"nesting": {
|
||||
"label": "중첩 태그",
|
||||
"description": "태그를 다른 태그 아래에 중첩하고 사이드바에 트리로 표시합니다."
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "Atcelt",
|
||||
"migrating": "Taga atjaunināšana esošajos e-pastos…",
|
||||
"migration_error": "Neizdevās atjaunināt tagu esošajos e-pastos",
|
||||
"filters_migration_error": "Dažas filtru kārtulas joprojām izmanto veco tagu — atjauniniet tās manuāli",
|
||||
"nesting": {
|
||||
"label": "Ligzdoti tagi",
|
||||
"description": "Ligzdojiet tagus zem citiem tagiem un rādiet tos sānjoslā kā koku."
|
||||
|
||||
@@ -1246,6 +1246,7 @@
|
||||
"cancel": "Цуцлах",
|
||||
"migrating": "Одоо байгаа имэйлүүд дээрх шошгыг шинэчилж байна...",
|
||||
"migration_error": "Одоо байгаа имэйлийн шошгыг шинэчилж чадсангүй",
|
||||
"filters_migration_error": "Зарим шүүлтүүрийн дүрэм хуучин шошгыг ашигласаар байна — гараар шинэчилнэ үү",
|
||||
"nesting": {
|
||||
"label": "Оруулсан шошго",
|
||||
"description": "Тагуудыг бусад шошгоны доор байрлуулж, хажуугийн самбарт мод хэлбэрээр харуул."
|
||||
|
||||
@@ -1246,6 +1246,7 @@
|
||||
"cancel": "Avbryt",
|
||||
"migrating": "Oppdaterer etiketten på eksisterende e-poster...",
|
||||
"migration_error": "Kunne ikke oppdatere etiketten på eksisterende e-poster",
|
||||
"filters_migration_error": "Noen filterregler bruker fortsatt den gamle etiketten – oppdater dem manuelt",
|
||||
"nesting": {
|
||||
"label": "Nestede etiketter",
|
||||
"description": "Plasser etiketter under hverandre og vis dem som et hierarki i sidefeltet."
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "Annuleren",
|
||||
"migrating": "Label bijwerken op bestaande e-mails…",
|
||||
"migration_error": "Label bijwerken op bestaande e-mails mislukt",
|
||||
"filters_migration_error": "Sommige filterregels gebruiken nog het oude label — werk ze handmatig bij",
|
||||
"nesting": {
|
||||
"label": "Geneste labels",
|
||||
"description": "Nest labels onder andere labels en toon ze als een boomstructuur in de zijbalk."
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "Anuluj",
|
||||
"migrating": "Aktualizowanie etykiety w istniejących e-mailach…",
|
||||
"migration_error": "Nie udało się zaktualizować etykiety w istniejących e-mailach",
|
||||
"filters_migration_error": "Niektóre reguły filtrów nadal używają starego tagu — zaktualizuj je ręcznie",
|
||||
"nesting": {
|
||||
"label": "Zagnieżdżone etykiety",
|
||||
"description": "Zagnieżdżaj etykiety pod innymi etykietami i wyświetlaj je w panelu bocznym jako drzewo."
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "Cancelar",
|
||||
"migrating": "A atualizar etiqueta nos e-mails existentes…",
|
||||
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes",
|
||||
"filters_migration_error": "Algumas regras de filtro ainda usam a etiqueta antiga — atualize-as manualmente",
|
||||
"nesting": {
|
||||
"label": "Etiquetas aninhadas",
|
||||
"description": "Aninhe etiquetas sob outras etiquetas e mostre-as como uma árvore na barra lateral."
|
||||
|
||||
@@ -1246,6 +1246,7 @@
|
||||
"cancel": "Anulează",
|
||||
"migrating": "Actualizarea etichetei pentru e-mailurile existente…",
|
||||
"migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente",
|
||||
"filters_migration_error": "Unele reguli de filtrare folosesc încă eticheta veche — actualizează-le manual",
|
||||
"nesting": {
|
||||
"label": "Etichete imbricate",
|
||||
"description": "Imbricați etichete sub alte etichete și afișați-le ca un arbore în bara laterală."
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "Отмена",
|
||||
"migrating": "Обновление тега в существующих письмах…",
|
||||
"migration_error": "Не удалось обновить тег в существующих письмах",
|
||||
"filters_migration_error": "Некоторые правила фильтров всё ещё используют старый тег — обновите их вручную",
|
||||
"nesting": {
|
||||
"label": "Вложенные теги",
|
||||
"description": "Вкладывайте теги в другие теги и показывайте их в боковой панели в виде дерева."
|
||||
|
||||
@@ -1246,6 +1246,7 @@
|
||||
"cancel": "Zrušiť",
|
||||
"migrating": "Aktualizácia štítku v existujúcich e-mailoch…",
|
||||
"migration_error": "Nepodarilo sa aktualizovať štítok v existujúcich e-mailoch",
|
||||
"filters_migration_error": "Niektoré pravidlá filtrov stále používajú starý štítok – upravte ich ručne",
|
||||
"nesting": {
|
||||
"label": "Vnorené štítky",
|
||||
"description": "Vnorujte štítky pod iné štítky a zobrazujte ich v bočnom paneli ako strom."
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "İptal",
|
||||
"migrating": "Mevcut e-postalardaki etiket güncelleniyor…",
|
||||
"migration_error": "Mevcut e-postalardaki etiket güncellenemedi",
|
||||
"filters_migration_error": "Bazı filtre kuralları hâlâ eski etiketi kullanıyor — bunları elle güncelleyin",
|
||||
"nesting": {
|
||||
"label": "İç içe etiketler",
|
||||
"description": "Etiketleri başka etiketlerin altına yerleştirin ve kenar çubuğunda ağaç olarak gösterin."
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "Скасувати",
|
||||
"migrating": "Оновлення ключового слова в наявних електронних листах…",
|
||||
"migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах",
|
||||
"filters_migration_error": "Деякі правила фільтрів досі використовують старий тег — оновіть їх вручну",
|
||||
"nesting": {
|
||||
"label": "Вкладені теги",
|
||||
"description": "Вкладайте теги в інші теги та показуйте їх на бічній панелі у вигляді дерева."
|
||||
|
||||
@@ -1246,6 +1246,7 @@
|
||||
"cancel": "取消",
|
||||
"migrating": "正在更新現有郵件的標籤…",
|
||||
"migration_error": "更新現有郵件的標籤失敗",
|
||||
"filters_migration_error": "部分篩選規則仍在使用舊標籤,請手動更新",
|
||||
"nesting": {
|
||||
"label": "巢狀標籤",
|
||||
"description": "將標籤巢狀放在其他標籤下,並在側邊欄以樹狀結構顯示。"
|
||||
|
||||
@@ -1243,6 +1243,7 @@
|
||||
"cancel": "取消",
|
||||
"migrating": "正在更新现有邮件的标签…",
|
||||
"migration_error": "更新现有邮件的标签失败",
|
||||
"filters_migration_error": "部分过滤规则仍在使用旧标签,请手动更新",
|
||||
"nesting": {
|
||||
"label": "嵌套标签",
|
||||
"description": "将标签嵌套在其他标签之下,并在侧边栏中以树形显示。"
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useFilterStore } from '../filter-store';
|
||||
import { generateScript } from '@/lib/sieve/generator';
|
||||
import { filterHooks } from '@/lib/plugin-hooks';
|
||||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
const makeRule = (overrides: Partial<FilterRule> = {}): FilterRule => ({
|
||||
id: 'rule-1',
|
||||
name: 'ISO',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'iso.org' }],
|
||||
actions: [{ type: 'add_label', value: 'red' }],
|
||||
stopProcessing: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** A client serving one active script, recording what gets written back. */
|
||||
function makeClient(content: string, scriptsOverride?: { id: string; name: string; blobId: string; isActive: boolean }[]) {
|
||||
const updateSieveScript = vi.fn(
|
||||
async (_scriptId: string, _content: string, _activate?: boolean, _accountId?: string) => {},
|
||||
);
|
||||
const client = {
|
||||
supportsSieve: () => true,
|
||||
getSieveAccountId: () => 'primary',
|
||||
getSieveAccounts: () => [{ id: 'primary', name: 'Me', isPrimary: true }],
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => scriptsOverride ?? [
|
||||
{ id: 'vac', name: 'vacation', blobId: 'bv', isActive: false },
|
||||
{ id: 's1', name: 'filters', blobId: 'b1', isActive: true },
|
||||
],
|
||||
getSieveScriptContent: async () => content,
|
||||
updateSieveScript,
|
||||
};
|
||||
return { client: client as unknown as IJMAPClient, updateSieveScript };
|
||||
}
|
||||
|
||||
describe('filter-store renameKeywordInFilters', () => {
|
||||
beforeEach(() => {
|
||||
useFilterStore.getState().clearState();
|
||||
});
|
||||
|
||||
it('rewrites the tag in the active script and uploads it', async () => {
|
||||
const { client, updateSieveScript } = makeClient(generateScript([makeRule()]));
|
||||
|
||||
const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
|
||||
|
||||
expect(outcome).toEqual({ changed: 1, unhandled: 0 });
|
||||
expect(updateSieveScript).toHaveBeenCalledTimes(1);
|
||||
const [scriptId, uploaded, activate, accountId] = updateSieveScript.mock.calls[0];
|
||||
expect(scriptId).toBe('s1');
|
||||
expect(activate).toBe(true);
|
||||
expect(accountId).toBe('primary');
|
||||
expect(uploaded).toContain('addflag "$label:iso";');
|
||||
expect(uploaded).not.toContain('$label:red');
|
||||
});
|
||||
|
||||
it('rewrites the metadata block the visual editor reads back', async () => {
|
||||
const { client, updateSieveScript } = makeClient(generateScript([makeRule()]));
|
||||
|
||||
await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
|
||||
|
||||
const uploaded = updateSieveScript.mock.calls[0][1];
|
||||
const metadata = uploaded.match(/@metadata:begin\n([\s\S]*)\n@metadata:end/)?.[1];
|
||||
expect(metadata).toBeDefined();
|
||||
const parsed = JSON.parse(metadata as string) as { rules: FilterRule[] };
|
||||
expect(parsed.rules[0].actions[0]).toEqual({ type: 'add_label', value: 'iso' });
|
||||
});
|
||||
|
||||
it('writes nothing when no rule names the tag', async () => {
|
||||
const script = generateScript([makeRule({ actions: [{ type: 'add_label', value: 'blue' }] })]);
|
||||
const { client, updateSieveScript } = makeClient(script);
|
||||
|
||||
const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
|
||||
|
||||
expect(outcome).toEqual({ changed: 0, unhandled: 0 });
|
||||
expect(updateSieveScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves a hand-edited script alone and reports what it could not rewrite', async () => {
|
||||
const { client, updateSieveScript } = makeClient(
|
||||
'/* @metadata:begin\n{corrupt\n@metadata:end */\naddflag "$label:red";',
|
||||
);
|
||||
|
||||
const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
|
||||
|
||||
expect(outcome).toEqual({ changed: 0, unhandled: 1 });
|
||||
expect(updateSieveScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an open filter view in step when it shows the same account', async () => {
|
||||
const { client } = makeClient(generateScript([makeRule()]));
|
||||
await useFilterStore.getState().fetchFilters(client);
|
||||
expect(useFilterStore.getState().rules[0].actions[0].value).toBe('red');
|
||||
|
||||
await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
|
||||
|
||||
expect(useFilterStore.getState().rules[0].actions[0].value).toBe('iso');
|
||||
expect(useFilterStore.getState().rawScript).toContain('$label:iso');
|
||||
});
|
||||
|
||||
it('does not touch loaded state belonging to another account', async () => {
|
||||
const { client } = makeClient(generateScript([makeRule()]));
|
||||
useFilterStore.setState({ selectedAccountId: 'shared', rules: [makeRule({ id: 'other' })] });
|
||||
|
||||
await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
|
||||
|
||||
expect(useFilterStore.getState().rules[0].id).toBe('other');
|
||||
expect(useFilterStore.getState().rules[0].actions[0].value).toBe('red');
|
||||
});
|
||||
|
||||
it('does nothing when the account has no script at all', async () => {
|
||||
const client = {
|
||||
supportsSieve: () => true,
|
||||
getSieveAccountId: () => 'primary',
|
||||
getSieveScripts: async () => [],
|
||||
} as unknown as IJMAPClient;
|
||||
|
||||
const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
|
||||
|
||||
expect(outcome).toEqual({ changed: 0, unhandled: 0 });
|
||||
});
|
||||
|
||||
it('leaves an inactive script alone rather than activating it by writing to it', async () => {
|
||||
const { client, updateSieveScript } = makeClient(generateScript([makeRule()]), [
|
||||
{ id: 's1', name: 'filters', blobId: 'b1', isActive: false },
|
||||
]);
|
||||
|
||||
const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
|
||||
|
||||
expect(outcome).toEqual({ changed: 0, unhandled: 0 });
|
||||
expect(updateSieveScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing on a server without Sieve', async () => {
|
||||
const client = {
|
||||
supportsSieve: () => false,
|
||||
getSieveAccountId: () => 'primary',
|
||||
getSieveScripts: async () => {
|
||||
throw new Error('should not be called');
|
||||
},
|
||||
} as unknown as IJMAPClient;
|
||||
|
||||
await expect(
|
||||
useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso'),
|
||||
).resolves.toEqual({ changed: 0, unhandled: 0 });
|
||||
});
|
||||
|
||||
it('reports a reference it could not rewrite alongside the ones it did', async () => {
|
||||
const script = `${generateScript([makeRule()])}\n# unmanaged\nif header :contains "X-Tag" "$label:red" {\n keep;\n}\n`;
|
||||
const { client, updateSieveScript } = makeClient(script);
|
||||
|
||||
const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
|
||||
|
||||
expect(outcome.changed).toBe(1);
|
||||
expect(outcome.unhandled).toBe(1);
|
||||
expect(updateSieveScript).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('counts what a plugin grafted back on as unhandled, since that is what gets written', async () => {
|
||||
const { client } = makeClient(generateScript([makeRule()]));
|
||||
const registration = filterHooks.onSieveScriptGenerate.register(
|
||||
'test-plugin',
|
||||
(script: string) => `# plugin section\nif true {\n addflag "$label:red";\n}\n${script}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const outcome = await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
|
||||
expect(outcome).toEqual({ changed: 1, unhandled: 1 });
|
||||
} finally {
|
||||
registration.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('runs the uploaded script through the plugin generate hook', async () => {
|
||||
const { client, updateSieveScript } = makeClient(generateScript([makeRule()]));
|
||||
const registration = filterHooks.onSieveScriptGenerate.register(
|
||||
'test-plugin',
|
||||
(script: string) => `# plugin section\n${script}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso');
|
||||
} finally {
|
||||
registration.dispose();
|
||||
}
|
||||
|
||||
expect(updateSieveScript.mock.calls[0][1]).toContain('# plugin section');
|
||||
});
|
||||
|
||||
it('propagates a failed upload so the caller can warn', async () => {
|
||||
const { client } = makeClient(generateScript([makeRule()]));
|
||||
(client as unknown as { updateSieveScript: unknown }).updateSieveScript = vi.fn(async () => {
|
||||
throw new Error('over quota');
|
||||
});
|
||||
|
||||
await expect(
|
||||
useFilterStore.getState().renameKeywordInFilters(client, 'red', 'iso'),
|
||||
).rejects.toThrow('over quota');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { generateScript, supportsSpamGuard, VACATION_SCRIPT_NAME } from '@/lib/s
|
||||
import {
|
||||
OpaqueFiltersError, applyScriptTransforms, assertFiltersSaveAllowed, filterRuleToView, supportsInclude, writeFiltersScript,
|
||||
} from '@/lib/filters/account-filters';
|
||||
import { countKeywordUses, renameKeywordInRules } from '@/lib/sieve/keyword-rename';
|
||||
import { filterHooks } from '@/lib/plugin-hooks';
|
||||
import { worstCaseForwards } from '@/lib/filters/forward-limit';
|
||||
import { supportsPeriods } from '@/lib/sieve/period';
|
||||
@@ -13,6 +14,18 @@ import { isValidVacationForward, withVacationPeriod } from '@/lib/sieve/vacation
|
||||
import { isValidVacationAudience, normalizeVacationAudience } from '@/lib/sieve/vacation-audience';
|
||||
import { debug } from '@/lib/debug';
|
||||
|
||||
/** What a tag rename managed to do to the filters that name the tag. */
|
||||
export interface KeywordRenameOutcome {
|
||||
/** Filter actions rewritten to the new tag id. */
|
||||
changed: number;
|
||||
/**
|
||||
* References to the old tag left in place because the script is hand-edited
|
||||
* and is kept verbatim. The caller should tell the user, who has to fix those
|
||||
* by hand - nothing else will.
|
||||
*/
|
||||
unhandled: number;
|
||||
}
|
||||
|
||||
interface SieveAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -59,6 +72,7 @@ interface FilterStore {
|
||||
fetchFilters: (client: IJMAPClient, accountId?: string) => Promise<void>;
|
||||
selectAccount: (client: IJMAPClient, accountId: string) => Promise<void>;
|
||||
saveFilters: (client: IJMAPClient) => Promise<void>;
|
||||
renameKeywordInFilters: (client: IJMAPClient, oldId: string, newId: string) => Promise<KeywordRenameOutcome>;
|
||||
validateScript: (client: IJMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
|
||||
addRule: (rule: FilterRule) => void;
|
||||
updateRule: (ruleId: string, updates: Partial<FilterRule>) => void;
|
||||
@@ -242,6 +256,85 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Follows a renamed tag into this account's filters.
|
||||
*
|
||||
* Renaming a tag changes its id, and therefore the `$label:` keyword a rule
|
||||
* writes. Migrating only the messages (`migrateKeyword`) would leave the rule
|
||||
* tagging new mail under the old id, which nothing names any more - the rule
|
||||
* looks broken while running exactly as written.
|
||||
*
|
||||
* The script is fetched and written back on its own rather than through
|
||||
* `fetchFilters`/`saveFilters`, so a rename from the tag settings never
|
||||
* disturbs which account an open filter view is showing. That view is updated
|
||||
* in place when it happens to be showing this same account.
|
||||
*
|
||||
* A hand-edited (opaque) script is left alone - it has no parsed rules to
|
||||
* rewrite and re-generating it would discard the user's own Sieve - and its
|
||||
* remaining references are reported instead. Upload failures propagate.
|
||||
*/
|
||||
renameKeywordInFilters: async (client, oldId, newId) => {
|
||||
const nothing: KeywordRenameOutcome = { changed: 0, unhandled: 0 };
|
||||
if (!oldId || !newId || oldId === newId) return nothing;
|
||||
if (!client.supportsSieve()) return nothing;
|
||||
|
||||
// The account that runs the rules. A rule can only tag mail arriving in
|
||||
// its own account, so this is the one to rewrite even in a session where
|
||||
// the Sieve and mail primaries are different accounts - which RFC 8620
|
||||
// permits, and which the caller's message migration, bound to the mail
|
||||
// primary, would not have covered anyway.
|
||||
const accountId = client.getSieveAccountId();
|
||||
const scripts = (await client.getSieveScripts(accountId)).filter(s => s.name !== 'vacation');
|
||||
const activeScript = scripts.find(s => s.isActive);
|
||||
if (!activeScript) {
|
||||
// Nothing is filtering mail right now. Rewriting an inactive script would
|
||||
// mean activating it - updateSieveScript activates on write - and a tag
|
||||
// rename must not switch someone's filters back on.
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const content = await client.getSieveScriptContent(activeScript.blobId, accountId);
|
||||
const parsed = parseScript(content);
|
||||
if (parsed.isOpaque) {
|
||||
return { changed: 0, unhandled: countKeywordUses(content, oldId) };
|
||||
}
|
||||
|
||||
const { rules, changed } = renameKeywordInRules(parsed.rules, oldId, newId);
|
||||
if (changed === 0) return { changed: 0, unhandled: countKeywordUses(content, oldId) };
|
||||
|
||||
let script = generateScript(rules, parsed.vacation || undefined, {
|
||||
externalRequires: parsed.externalRequires,
|
||||
});
|
||||
// Same plugin pipeline a normal save runs through: a plugin's managed
|
||||
// section is grafted on at generate time, so skipping the transform here
|
||||
// would drop it from the script this write makes active.
|
||||
const transformed = await filterHooks.onSieveScriptGenerate.transform(script, { accountId });
|
||||
if (typeof transformed === 'string' && transformed.trim().length > 0) {
|
||||
script = transformed;
|
||||
}
|
||||
|
||||
// Count what still names the old tag in the script actually being written,
|
||||
// after the plugin transform: a rule kept verbatim, a test comparing
|
||||
// against the keyword, or a section a plugin just grafted back on.
|
||||
const unhandled = countKeywordUses(script, oldId);
|
||||
|
||||
await client.updateSieveScript(activeScript.id, script, true, accountId);
|
||||
debug.log('filters', 'Renamed tag in', changed, 'filter action(s)');
|
||||
|
||||
const state = get();
|
||||
if (state.selectedAccountId === accountId && !state.isOpaque) {
|
||||
set({
|
||||
rules,
|
||||
rawScript: script,
|
||||
activeScriptId: activeScript.id,
|
||||
vacationSettings: parsed.vacation || null,
|
||||
externalRequires: parsed.externalRequires,
|
||||
});
|
||||
}
|
||||
void filterHooks.onSieveScriptChange.emit({ accountId, script });
|
||||
return { changed, unhandled };
|
||||
},
|
||||
|
||||
validateScript: async (client, content) => {
|
||||
return client.validateSieveScript(content, get().selectedAccountId || undefined);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user