Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions components/settings/keyword-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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 });
Expand Down
156 changes: 156 additions & 0 deletions lib/sieve/__tests__/keyword-rename.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 100 additions & 0 deletions lib/sieve/keyword-rename.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions locales/ar/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,7 @@
"cancel": "إلغاء",
"migrating": "جارٍ تحديث الوسم على الرسائل الحالية…",
"migration_error": "فشل تحديث الوسم على الرسائل الحالية",
"filters_migration_error": "لا تزال بعض قواعد التصفية تستخدم الوسم القديم — حدّثها يدويًا",
"nesting": {
"label": "وسوم متداخلة",
"description": "ضع الوسوم داخل وسوم أخرى واعرضها كشجرة في الشريط الجانبي."
Expand Down
1 change: 1 addition & 0 deletions locales/ca/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions locales/cs/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions locales/da/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions locales/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions locales/fa/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,7 @@
"cancel": "انصراف",
"migrating": "در حال به‌روزرسانی برچسب روی ایمیل‌های موجود…",
"migration_error": "به‌روزرسانی برچسب ناموفق بود",
"filters_migration_error": "برخی قوانین فیلتر هنوز از برچسب قدیمی استفاده می‌کنند — آن‌ها را دستی به‌روزرسانی کنید",
"nesting": {
"label": "برچسب‌های تودرتو",
"description": "برچسب‌ها را زیر برچسب‌های دیگر قرار دهید و آن‌ها را به‌صورت درختی در نوار کناری نمایش دهید."
Expand Down
1 change: 1 addition & 0 deletions locales/fr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Loading