mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix(keywords): follow a renamed tag into the filters that name it
Renaming a tag changes its id - the id is a slug of the display name - and with it the `$label:` keyword the tag is stored under. The rename migrated the keyword on every message but left Sieve rules naming the old id, in both the generated `addflag` line and the metadata block the visual editor reads back. The rule then went on tagging new mail under an id no definition names any more: the tag renders as its raw id in grey and a search for the renamed tag misses everything that arrived since, which reads as the filter having quietly stopped working. `renameKeywordInRules` rewrites every `add_label` action naming the old id, disabled rules included. An external rule reaches the script through its verbatim `rawBlock`, so that is rewritten too - but only the operand of an `addflag`, since a test comparing a header against the same literal is the rule's own logic rather than the tag it applies. The store fetches, rewrites and uploads the account's active script on its own rather than through fetchFilters/saveFilters, so a rename from the tag settings cannot disturb which account an open filter view is showing; that view is updated in place when it shows the same account. The write still runs through the same plugin pipeline a save does, or a plugin's managed section would be dropped from the script this activates. A script that is not the active one is left alone entirely: writing to it would activate it, and a tag rename must not switch someone's filters back on. Whatever still names the old tag in the script that ends up being written - a rule kept verbatim, a test comparing against the keyword, a section a plugin just grafted back on - is counted, and the tag settings warn that some rules still use the old tag, because only the user can fix those. Mail delivered between the message migration and the rewrite was still tagged by the old rule, so a successful rewrite is followed by one more migration sweep, which no rewritten rule can race any more.
This commit is contained in:
@@ -12,6 +12,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 "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -522,6 +523,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);
|
||||
@@ -571,9 +573,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);
|
||||
@@ -590,6 +592,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)}`);
|
||||
}
|
||||
@@ -1099,6 +1099,7 @@
|
||||
"cancel": "إلغاء",
|
||||
"migrating": "جارٍ تحديث الوسم على الرسائل الحالية…",
|
||||
"migration_error": "فشل تحديث الوسم على الرسائل الحالية",
|
||||
"filters_migration_error": "لا تزال بعض قواعد التصفية تستخدم الوسم القديم — حدّثها يدويًا",
|
||||
"nesting": {
|
||||
"label": "وسوم متداخلة",
|
||||
"description": "ضع الوسوم داخل وسوم أخرى واعرضها كشجرة في الشريط الجانبي."
|
||||
|
||||
@@ -1099,6 +1099,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."
|
||||
|
||||
@@ -1096,6 +1096,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."
|
||||
|
||||
@@ -1099,6 +1099,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."
|
||||
|
||||
@@ -1096,6 +1096,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."
|
||||
|
||||
@@ -1099,6 +1099,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."
|
||||
|
||||
@@ -1096,6 +1096,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."
|
||||
|
||||
@@ -1099,6 +1099,7 @@
|
||||
"cancel": "انصراف",
|
||||
"migrating": "در حال بهروزرسانی برچسب روی ایمیلهای موجود…",
|
||||
"migration_error": "بهروزرسانی برچسب ناموفق بود",
|
||||
"filters_migration_error": "برخی قوانین فیلتر هنوز از برچسب قدیمی استفاده میکنند — آنها را دستی بهروزرسانی کنید",
|
||||
"nesting": {
|
||||
"label": "برچسبهای تودرتو",
|
||||
"description": "برچسبها را زیر برچسبهای دیگر قرار دهید و آنها را بهصورت درختی در نوار کناری نمایش دهید."
|
||||
|
||||
@@ -1096,6 +1096,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."
|
||||
|
||||
@@ -1061,6 +1061,7 @@
|
||||
"cancel": "לְבַטֵל",
|
||||
"migrating": "מעדכן מילת מפתח באימיילים קיימים...",
|
||||
"migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות",
|
||||
"filters_migration_error": "חלק מכללי הסינון עדיין משתמשים בתגית הישנה — עדכנו אותם ידנית",
|
||||
"nesting": {
|
||||
"label": "תגים מקוננים",
|
||||
"description": "קנן תגים תחת תגים אחרים והצג אותם כעץ בסרגל הצד."
|
||||
|
||||
@@ -1099,6 +1099,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."
|
||||
|
||||
@@ -1096,6 +1096,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."
|
||||
|
||||
@@ -1096,6 +1096,7 @@
|
||||
"cancel": "キャンセル",
|
||||
"migrating": "既存のメールのラベルを更新中…",
|
||||
"migration_error": "既存のメールのラベルの更新に失敗しました",
|
||||
"filters_migration_error": "一部のフィルタールールが古いタグを使用しています。手動で更新してください",
|
||||
"nesting": {
|
||||
"label": "ネストされたラベル",
|
||||
"description": "ラベルを他のラベルの下にネストし、サイドバーにツリーとして表示します。"
|
||||
|
||||
@@ -1096,6 +1096,7 @@
|
||||
"cancel": "취소",
|
||||
"migrating": "기존 이메일의 태그 업데이트 중…",
|
||||
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다",
|
||||
"filters_migration_error": "일부 필터 규칙이 여전히 이전 태그를 사용합니다. 수동으로 업데이트하세요",
|
||||
"nesting": {
|
||||
"label": "중첩 태그",
|
||||
"description": "태그를 다른 태그 아래에 중첩하고 사이드바에 트리로 표시합니다."
|
||||
|
||||
@@ -1096,6 +1096,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."
|
||||
|
||||
@@ -1099,6 +1099,7 @@
|
||||
"cancel": "Цуцлах",
|
||||
"migrating": "Одоо байгаа имэйлүүд дээрх шошгыг шинэчилж байна...",
|
||||
"migration_error": "Одоо байгаа имэйлийн шошгыг шинэчилж чадсангүй",
|
||||
"filters_migration_error": "Зарим шүүлтүүрийн дүрэм хуучин шошгыг ашигласаар байна — гараар шинэчилнэ үү",
|
||||
"nesting": {
|
||||
"label": "Оруулсан шошго",
|
||||
"description": "Тагуудыг бусад шошгоны доор байрлуулж, хажуугийн самбарт мод хэлбэрээр харуул."
|
||||
|
||||
@@ -1099,6 +1099,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."
|
||||
|
||||
@@ -1096,6 +1096,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."
|
||||
|
||||
@@ -1096,6 +1096,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."
|
||||
|
||||
@@ -1096,6 +1096,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."
|
||||
|
||||
@@ -1099,6 +1099,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ă."
|
||||
|
||||
@@ -1096,6 +1096,7 @@
|
||||
"cancel": "Отмена",
|
||||
"migrating": "Обновление тега в существующих письмах…",
|
||||
"migration_error": "Не удалось обновить тег в существующих письмах",
|
||||
"filters_migration_error": "Некоторые правила фильтров всё ещё используют старый тег — обновите их вручную",
|
||||
"nesting": {
|
||||
"label": "Вложенные теги",
|
||||
"description": "Вкладывайте теги в другие теги и показывайте их в боковой панели в виде дерева."
|
||||
|
||||
@@ -1099,6 +1099,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."
|
||||
|
||||
@@ -1096,6 +1096,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."
|
||||
|
||||
@@ -1096,6 +1096,7 @@
|
||||
"cancel": "Скасувати",
|
||||
"migrating": "Оновлення ключового слова в наявних електронних листах…",
|
||||
"migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах",
|
||||
"filters_migration_error": "Деякі правила фільтрів досі використовують старий тег — оновіть їх вручну",
|
||||
"nesting": {
|
||||
"label": "Вкладені теги",
|
||||
"description": "Вкладайте теги в інші теги та показуйте їх на бічній панелі у вигляді дерева."
|
||||
|
||||
@@ -1096,6 +1096,7 @@
|
||||
"cancel": "取消",
|
||||
"migrating": "正在更新現有郵件的標籤…",
|
||||
"migration_error": "更新現有郵件的標籤失敗",
|
||||
"filters_migration_error": "部分篩選規則仍在使用舊標籤,請手動更新",
|
||||
"nesting": {
|
||||
"label": "巢狀標籤",
|
||||
"description": "將標籤巢狀放在其他標籤下,並在側邊欄以樹狀結構顯示。"
|
||||
|
||||
@@ -1096,6 +1096,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');
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,22 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { FilterRule, SieveCapabilities, VacationSieveConfig } from '@/lib/jmap/sieve-types';
|
||||
import { parseScript } from '@/lib/sieve/parser';
|
||||
import { generateScript } from '@/lib/sieve/generator';
|
||||
import { countKeywordUses, renameKeywordInRules } from '@/lib/sieve/keyword-rename';
|
||||
import { filterHooks } from '@/lib/plugin-hooks';
|
||||
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;
|
||||
@@ -31,6 +44,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;
|
||||
@@ -176,6 +190,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