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:
Linus Rath
2026-10-18 15:02:15 +02:00
32 changed files with 617 additions and 2 deletions
+39 -2
View File
@@ -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 });
+156
View File
@@ -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);
});
});
+100
View File
@@ -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)}`);
}
+1
View File
@@ -1246,6 +1246,7 @@
"cancel": "إلغاء",
"migrating": "جارٍ تحديث الوسم على الرسائل الحالية…",
"migration_error": "فشل تحديث الوسم على الرسائل الحالية",
"filters_migration_error": "لا تزال بعض قواعد التصفية تستخدم الوسم القديم — حدّثها يدويًا",
"nesting": {
"label": "وسوم متداخلة",
"description": "ضع الوسوم داخل وسوم أخرى واعرضها كشجرة في الشريط الجانبي."
+1
View File
@@ -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."
+1
View File
@@ -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."
+1
View File
@@ -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."
+1
View File
@@ -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."
+1
View File
@@ -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."
+1
View File
@@ -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."
+1
View File
@@ -1246,6 +1246,7 @@
"cancel": "انصراف",
"migrating": "در حال به‌روزرسانی برچسب روی ایمیل‌های موجود…",
"migration_error": "به‌روزرسانی برچسب ناموفق بود",
"filters_migration_error": "برخی قوانین فیلتر هنوز از برچسب قدیمی استفاده می‌کنند — آن‌ها را دستی به‌روزرسانی کنید",
"nesting": {
"label": "برچسب‌های تودرتو",
"description": "برچسب‌ها را زیر برچسب‌های دیگر قرار دهید و آن‌ها را به‌صورت درختی در نوار کناری نمایش دهید."
+1
View File
@@ -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."
+1
View File
@@ -1208,6 +1208,7 @@
"cancel": "לְבַטֵל",
"migrating": "מעדכן מילת מפתח באימיילים קיימים...",
"migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות",
"filters_migration_error": "חלק מכללי הסינון עדיין משתמשים בתגית הישנה — עדכנו אותם ידנית",
"nesting": {
"label": "תגים מקוננים",
"description": "קנן תגים תחת תגים אחרים והצג אותם כעץ בסרגל הצד."
+1
View File
@@ -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."
+1
View File
@@ -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."
+1
View File
@@ -1243,6 +1243,7 @@
"cancel": "キャンセル",
"migrating": "既存のメールのラベルを更新中…",
"migration_error": "既存のメールのラベルの更新に失敗しました",
"filters_migration_error": "一部のフィルタールールが古いタグを使用しています。手動で更新してください",
"nesting": {
"label": "ネストされたラベル",
"description": "ラベルを他のラベルの下にネストし、サイドバーにツリーとして表示します。"
+1
View File
@@ -1243,6 +1243,7 @@
"cancel": "취소",
"migrating": "기존 이메일의 태그 업데이트 중…",
"migration_error": "기존 이메일의 태그 업데이트에 실패했습니다",
"filters_migration_error": "일부 필터 규칙이 여전히 이전 태그를 사용합니다. 수동으로 업데이트하세요",
"nesting": {
"label": "중첩 태그",
"description": "태그를 다른 태그 아래에 중첩하고 사이드바에 트리로 표시합니다."
+1
View File
@@ -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."
+1
View File
@@ -1246,6 +1246,7 @@
"cancel": "Цуцлах",
"migrating": "Одоо байгаа имэйлүүд дээрх шошгыг шинэчилж байна...",
"migration_error": "Одоо байгаа имэйлийн шошгыг шинэчилж чадсангүй",
"filters_migration_error": "Зарим шүүлтүүрийн дүрэм хуучин шошгыг ашигласаар байна — гараар шинэчилнэ үү",
"nesting": {
"label": "Оруулсан шошго",
"description": "Тагуудыг бусад шошгоны доор байрлуулж, хажуугийн самбарт мод хэлбэрээр харуул."
+1
View File
@@ -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."
+1
View File
@@ -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."
+1
View File
@@ -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."
+1
View File
@@ -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."
+1
View File
@@ -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ă."
+1
View File
@@ -1243,6 +1243,7 @@
"cancel": "Отмена",
"migrating": "Обновление тега в существующих письмах…",
"migration_error": "Не удалось обновить тег в существующих письмах",
"filters_migration_error": "Некоторые правила фильтров всё ещё используют старый тег — обновите их вручную",
"nesting": {
"label": "Вложенные теги",
"description": "Вкладывайте теги в другие теги и показывайте их в боковой панели в виде дерева."
+1
View File
@@ -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."
+1
View File
@@ -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."
+1
View File
@@ -1243,6 +1243,7 @@
"cancel": "Скасувати",
"migrating": "Оновлення ключового слова в наявних електронних листах…",
"migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах",
"filters_migration_error": "Деякі правила фільтрів досі використовують старий тег — оновіть їх вручну",
"nesting": {
"label": "Вкладені теги",
"description": "Вкладайте теги в інші теги та показуйте їх на бічній панелі у вигляді дерева."
+1
View File
@@ -1246,6 +1246,7 @@
"cancel": "取消",
"migrating": "正在更新現有郵件的標籤…",
"migration_error": "更新現有郵件的標籤失敗",
"filters_migration_error": "部分篩選規則仍在使用舊標籤,請手動更新",
"nesting": {
"label": "巢狀標籤",
"description": "將標籤巢狀放在其他標籤下,並在側邊欄以樹狀結構顯示。"
+1
View File
@@ -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');
});
});
+93
View File
@@ -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);
},