diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index d55317caa..ed2b35d56 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -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(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}`; await client.migrateKeyword(oldJmapKeyword, newJmapKeyword); renameKeyword(oldId, keyword); fetchTagCounts(client); @@ -584,6 +586,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 }); diff --git a/lib/sieve/__tests__/keyword-rename.test.ts b/lib/sieve/__tests__/keyword-rename.test.ts new file mode 100644 index 000000000..eb89bd348 --- /dev/null +++ b/lib/sieve/__tests__/keyword-rename.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/lib/sieve/keyword-rename.ts b/lib/sieve/keyword-rename.ts new file mode 100644 index 000000000..834acc00a --- /dev/null +++ b/lib/sieve/keyword-rename.ts @@ -0,0 +1,100 @@ +/** + * Following a renamed tag into the filters that name it. + * + * A tag is stored on messages as the keyword `$label:`, 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)}`); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index b39180bb8..4cc80837a 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1036,6 +1036,7 @@ "cancel": "إلغاء", "migrating": "جارٍ تحديث الوسم على الرسائل الحالية…", "migration_error": "فشل تحديث الوسم على الرسائل الحالية", + "filters_migration_error": "لا تزال بعض قواعد التصفية تستخدم الوسم القديم — حدّثها يدويًا", "nesting": { "label": "وسوم متداخلة", "description": "ضع الوسوم داخل وسوم أخرى واعرضها كشجرة في الشريط الجانبي." diff --git a/locales/ca/common.json b/locales/ca/common.json index 22f503834..8cb96b534 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1036,6 +1036,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." diff --git a/locales/cs/common.json b/locales/cs/common.json index 2570b45f2..6c469184d 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1033,6 +1033,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." diff --git a/locales/da/common.json b/locales/da/common.json index 3cb27e398..8c8e5911f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1036,6 +1036,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." diff --git a/locales/de/common.json b/locales/de/common.json index d4d7045a6..e9edc7ce0 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1033,6 +1033,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." diff --git a/locales/en/common.json b/locales/en/common.json index 9280dc65f..af82d5c12 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1039,6 +1039,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." diff --git a/locales/es/common.json b/locales/es/common.json index 25e20438e..c3a7bd147 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1033,6 +1033,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." diff --git a/locales/fa/common.json b/locales/fa/common.json index 629d514fc..f86e70041 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1036,6 +1036,7 @@ "cancel": "انصراف", "migrating": "در حال به‌روزرسانی برچسب روی ایمیل‌های موجود…", "migration_error": "به‌روزرسانی برچسب ناموفق بود", + "filters_migration_error": "برخی قوانین فیلتر هنوز از برچسب قدیمی استفاده می‌کنند — آن‌ها را دستی به‌روزرسانی کنید", "nesting": { "label": "برچسب‌های تودرتو", "description": "برچسب‌ها را زیر برچسب‌های دیگر قرار دهید و آن‌ها را به‌صورت درختی در نوار کناری نمایش دهید." diff --git a/locales/fr/common.json b/locales/fr/common.json index 366374a3e..ece99103e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1033,6 +1033,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." diff --git a/locales/he/common.json b/locales/he/common.json index bc6c7f5d1..4d87be04b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -998,6 +998,7 @@ "cancel": "לְבַטֵל", "migrating": "מעדכן מילת מפתח באימיילים קיימים...", "migration_error": "נכשל עדכון מילת המפתח בהודעות דוא\"ל קיימות", + "filters_migration_error": "חלק מכללי הסינון עדיין משתמשים בתגית הישנה — עדכנו אותם ידנית", "nesting": { "label": "תגים מקוננים", "description": "קנן תגים תחת תגים אחרים והצג אותם כעץ בסרגל הצד." diff --git a/locales/hu/common.json b/locales/hu/common.json index c1d7170f8..b2838d7c2 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1036,6 +1036,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." diff --git a/locales/it/common.json b/locales/it/common.json index 45a2bc615..8949e984b 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1033,6 +1033,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." diff --git a/locales/ja/common.json b/locales/ja/common.json index d4b7d4df4..2a09694ab 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1033,6 +1033,7 @@ "cancel": "キャンセル", "migrating": "既存のメールのラベルを更新中…", "migration_error": "既存のメールのラベルの更新に失敗しました", + "filters_migration_error": "一部のフィルタールールが古いタグを使用しています。手動で更新してください", "nesting": { "label": "ネストされたラベル", "description": "ラベルを他のラベルの下にネストし、サイドバーにツリーとして表示します。" diff --git a/locales/ko/common.json b/locales/ko/common.json index 56ed12e9d..cf2136d3b 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1033,6 +1033,7 @@ "cancel": "취소", "migrating": "기존 이메일의 태그 업데이트 중…", "migration_error": "기존 이메일의 태그 업데이트에 실패했습니다", + "filters_migration_error": "일부 필터 규칙이 여전히 이전 태그를 사용합니다. 수동으로 업데이트하세요", "nesting": { "label": "중첩 태그", "description": "태그를 다른 태그 아래에 중첩하고 사이드바에 트리로 표시합니다." diff --git a/locales/lv/common.json b/locales/lv/common.json index 0c4f15193..404d6b8e5 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1033,6 +1033,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." diff --git a/locales/mn/common.json b/locales/mn/common.json index 59424b3d0..ebd47de5a 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1036,6 +1036,7 @@ "cancel": "Цуцлах", "migrating": "Одоо байгаа имэйлүүд дээрх шошгыг шинэчилж байна...", "migration_error": "Одоо байгаа имэйлийн шошгыг шинэчилж чадсангүй", + "filters_migration_error": "Зарим шүүлтүүрийн дүрэм хуучин шошгыг ашигласаар байна — гараар шинэчилнэ үү", "nesting": { "label": "Оруулсан шошго", "description": "Тагуудыг бусад шошгоны доор байрлуулж, хажуугийн самбарт мод хэлбэрээр харуул." diff --git a/locales/nl/common.json b/locales/nl/common.json index c8e460521..ee072b7a2 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1033,6 +1033,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." diff --git a/locales/pl/common.json b/locales/pl/common.json index f4d1fa706..7425ed449 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1033,6 +1033,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." diff --git a/locales/pt/common.json b/locales/pt/common.json index d234128a5..8d8d8614e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1033,6 +1033,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." diff --git a/locales/ro/common.json b/locales/ro/common.json index 1a0d06a7d..31b87bbd0 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1036,6 +1036,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ă." diff --git a/locales/ru/common.json b/locales/ru/common.json index 4725a400c..498b3f1f2 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1033,6 +1033,7 @@ "cancel": "Отмена", "migrating": "Обновление тега в существующих письмах…", "migration_error": "Не удалось обновить тег в существующих письмах", + "filters_migration_error": "Некоторые правила фильтров всё ещё используют старый тег — обновите их вручную", "nesting": { "label": "Вложенные теги", "description": "Вкладывайте теги в другие теги и показывайте их в боковой панели в виде дерева." diff --git a/locales/sk/common.json b/locales/sk/common.json index 6cded9d01..d0a2ffd77 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1036,6 +1036,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." diff --git a/locales/tr/common.json b/locales/tr/common.json index 65d2da05a..3f9ab7354 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1033,6 +1033,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." diff --git a/locales/uk/common.json b/locales/uk/common.json index f21e1ebb3..61b311614 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1033,6 +1033,7 @@ "cancel": "Скасувати", "migrating": "Оновлення ключового слова в наявних електронних листах…", "migration_error": "Не вдалося оновити ключове слово в існуючих електронних листах", + "filters_migration_error": "Деякі правила фільтрів досі використовують старий тег — оновіть їх вручну", "nesting": { "label": "Вкладені теги", "description": "Вкладайте теги в інші теги та показуйте їх на бічній панелі у вигляді дерева." diff --git a/locales/zh/common.json b/locales/zh/common.json index 7bd5deab1..2b92cd38c 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1033,6 +1033,7 @@ "cancel": "取消", "migrating": "正在更新现有邮件的标签…", "migration_error": "更新现有邮件的标签失败", + "filters_migration_error": "部分过滤规则仍在使用旧标签,请手动更新", "nesting": { "label": "嵌套标签", "description": "将标签嵌套在其他标签之下,并在侧边栏中以树形显示。" diff --git a/stores/__tests__/filter-store-keyword-rename.test.ts b/stores/__tests__/filter-store-keyword-rename.test.ts new file mode 100644 index 000000000..ff936c301 --- /dev/null +++ b/stores/__tests__/filter-store-keyword-rename.test.ts @@ -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 => ({ + 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'); + }); +}); diff --git a/stores/filter-store.ts b/stores/filter-store.ts index 0964aefc1..7142a4b5c 100644 --- a/stores/filter-store.ts +++ b/stores/filter-store.ts @@ -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; selectAccount: (client: IJMAPClient, accountId: string) => Promise; saveFilters: (client: IJMAPClient) => Promise; + renameKeywordInFilters: (client: IJMAPClient, oldId: string, newId: string) => Promise; validateScript: (client: IJMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>; addRule: (rule: FilterRule) => void; updateRule: (ruleId: string, updates: Partial) => void; @@ -176,6 +190,85 @@ export const useFilterStore = create()((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); },