feat: detect and import unrecognized JMAP keywords as tags #658

This commit is contained in:
Linus Rath
2026-08-06 18:44:14 +02:00
parent 1b10e027bd
commit 69c8c041f9
9 changed files with 1080 additions and 3 deletions
@@ -1,7 +1,9 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { KeywordSettings } from '../keyword-settings';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
// Mock SettingsSection to just render children, keeping the real controls
vi.mock('../settings-section', async (importOriginal) => ({
@@ -210,3 +212,152 @@ describe('KeywordSettings', () => {
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread');
});
});
// Tag names and colours live only in these settings, so losing them hides tags
// whose keywords are still intact on every message. Scanning finds those
// keywords and offers them back under a name the user never had to remember.
describe('KeywordSettings unrecognized tags', () => {
const discoverKeywords = vi.fn();
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
discoverKeywords.mockReset();
discoverKeywords.mockResolvedValue({ keywords: {}, scanned: 0, total: 0, complete: true });
useAuthStore.setState({ client: { discoverKeywords, getTagCounts: vi.fn().mockResolvedValue({}) } as never });
useEmailStore.setState({ tagCounts: {} });
});
const scan = async () => {
fireEvent.click(screen.getByText('unrecognized.scan'));
await waitFor(() => expect(discoverKeywords).toHaveBeenCalled());
};
it('offers no scan when there is no connected account', async () => {
useAuthStore.setState({ client: null });
render(<KeywordSettings />);
expect(screen.getByText('unrecognized.scan').closest('button')).toBeDisabled();
});
it('says so when every keyword on the server is already defined', async () => {
discoverKeywords.mockResolvedValue({
keywords: { $seen: 9, '$label:red': 4 },
scanned: 9,
total: 9,
complete: true,
});
render(<KeywordSettings />);
await scan();
expect(await screen.findByText('unrecognized.none')).toBeInTheDocument();
});
it('surfaces an unrecognized keyword with a proposed name and its raw id', async () => {
discoverKeywords.mockResolvedValue({
keywords: { '$label:q3-invoices': 7 },
scanned: 9,
total: 9,
complete: true,
});
render(<KeywordSettings />);
await scan();
expect(await screen.findByDisplayValue('Q3 Invoices')).toBeInTheDocument();
expect(screen.getByText('$label:q3-invoices')).toBeInTheDocument();
});
it('adds the tag under the keyword the messages already carry', async () => {
discoverKeywords.mockResolvedValue({
keywords: { '$label:q3-invoices': 7 },
scanned: 9,
total: 9,
complete: true,
});
render(<KeywordSettings />);
await scan();
await screen.findByDisplayValue('Q3 Invoices');
fireEvent.click(screen.getAllByText('add').slice(-1)[0]);
expect(useSettingsStore.getState().emailKeywords).toContainEqual(
expect.objectContaining({ id: 'q3-invoices', label: 'Q3 Invoices' }),
);
});
it('keeps the id when the proposed name is edited first', async () => {
discoverKeywords.mockResolvedValue({
keywords: { '$label:q3-invoices': 7 },
scanned: 9,
total: 9,
complete: true,
});
render(<KeywordSettings />);
await scan();
fireEvent.change(await screen.findByDisplayValue('Q3 Invoices'), { target: { value: 'Invoices' } });
fireEvent.click(screen.getAllByText('add').slice(-1)[0]);
expect(useSettingsStore.getState().emailKeywords).toContainEqual(
expect.objectContaining({ id: 'q3-invoices', label: 'Invoices' }),
);
});
it('drops a row from the list once its tag is defined', async () => {
discoverKeywords.mockResolvedValue({
keywords: { '$label:q3-invoices': 7 },
scanned: 9,
total: 9,
complete: true,
});
render(<KeywordSettings />);
await scan();
await screen.findByDisplayValue('Q3 Invoices');
fireEvent.click(screen.getAllByText('add').slice(-1)[0]);
// The name field belongs to the unrecognized row alone - the tag itself is
// now in the list above, where it is named by a badge rather than an input.
expect(screen.queryByDisplayValue('Q3 Invoices')).not.toBeInTheDocument();
expect(screen.getByText('unrecognized.none')).toBeInTheDocument();
});
it('adds every found tag at once', async () => {
discoverKeywords.mockResolvedValue({
keywords: { '$label:q3-invoices': 7, '$label:receipts': 3 },
scanned: 9,
total: 9,
complete: true,
});
render(<KeywordSettings />);
await scan();
fireEvent.click(await screen.findByText('unrecognized.add_all'));
const ids = useSettingsStore.getState().emailKeywords.map((k) => k.id);
expect(ids).toContain('q3-invoices');
expect(ids).toContain('receipts');
});
it('warns that a capped scan may have missed tags on older mail', async () => {
discoverKeywords.mockResolvedValue({
keywords: { '$label:q3-invoices': 7 },
scanned: 25000,
total: 90000,
complete: false,
});
render(<KeywordSettings />);
await scan();
expect(await screen.findByText('unrecognized.partial')).toBeInTheDocument();
});
it('reports a failed scan instead of claiming nothing was found', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
discoverKeywords.mockRejectedValue(new Error('network down'));
render(<KeywordSettings />);
await scan();
expect(await screen.findByText('unrecognized.error')).toBeInTheDocument();
expect(screen.queryByText('unrecognized.none')).not.toBeInTheDocument();
});
});
+235 -2
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import {
useSettingsStore,
@@ -13,9 +13,10 @@ import {
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section";
import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2 } from "lucide-react";
import { Plus, Pencil, Trash2, GripVertical, Check, X, Loader2, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { KEYWORD_PREFIX } from "@/lib/thread-utils";
import { findUnrecognizedKeywords, type UnrecognizedKeyword } from "@/lib/keyword-discovery";
import {
buildKeywordTree,
composeKeywordId,
@@ -285,6 +286,236 @@ function KeywordEditForm({
);
}
/**
* One found keyword, with the name and colour it would be adopted under.
*
* The id is shown but not editable. It is what the messages already say, and
* rewording it would create a tag pointing at nothing while leaving the real
* one just as invisible as before - which is the whole problem being fixed.
*/
function UnrecognizedKeywordRow({
entry,
draft,
onChange,
onAdd,
}: {
entry: UnrecognizedKeyword;
draft: { label: string; color: string };
onChange: (draft: { label: string; color: string }) => void;
onAdd: () => void;
}) {
const t = useTranslations("settings.keywords");
const [pickingColor, setPickingColor] = useState(false);
const canAdd = draft.label.trim().length > 0;
return (
<div className="space-y-2 py-2.5 px-3 rounded-md border border-border bg-background">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPickingColor((open) => !open)}
className={cn(
"w-5 h-5 shrink-0 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
KEYWORD_PALETTE[draft.color]?.dot ?? KEYWORD_PALETTE.gray.dot,
)}
aria-label={t("color_field")}
aria-expanded={pickingColor}
/>
<input
type="text"
value={draft.label}
onChange={(e) => onChange({ ...draft, label: e.target.value })}
className="min-w-0 flex-1 px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
placeholder={t("label_placeholder")}
maxLength={30}
aria-label={t("label_field")}
onKeyDown={(e) => e.key === "Enter" && canAdd && onAdd()}
/>
<button
type="button"
onClick={onAdd}
disabled={!canAdd}
className="flex shrink-0 items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" />
{t("add")}
</button>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="min-w-0 truncate font-mono" title={entry.keyword}>
{entry.keyword}
</span>
<span aria-hidden="true">·</span>
<span className="shrink-0">{t("unrecognized.messages", { count: entry.count })}</span>
</div>
{pickingColor && (
<KeywordColorPicker
value={draft.color}
onChange={(color) => {
onChange({ ...draft, color });
setPickingColor(false);
}}
/>
)}
</div>
);
}
/**
* Tags that exist on the server but not in these settings.
*
* A tag is a keyword on the messages plus a name and colour held only here, so
* losing this browser's settings hides tags that are still perfectly intact on
* the server: the mail keeps its `$label:q3-invoices`, and nothing left knows
* that keyword was ever called anything. Scanning finds those keywords and
* offers them back, named as well as an id can be read, so recovering a tag
* does not depend on the user remembering its exact spelling.
*
* The scan is on demand rather than automatic. It walks the message list a page
* at a time - the only way to find keywords in JMAP - which is far too much
* work to spend on merely opening a settings page.
*/
function UnrecognizedKeywords() {
const t = useTranslations("settings.keywords");
const { emailKeywords, nestedTags, addKeyword } = useSettingsStore();
const { client } = useAuthStore();
const { fetchTagCounts } = useEmailStore();
const [isScanning, setIsScanning] = useState(false);
const [progress, setProgress] = useState({ scanned: 0, total: 0 });
const [scan, setScan] = useState<{ found: UnrecognizedKeyword[]; complete: boolean; scanned: number; total: number } | null>(null);
const [error, setError] = useState(false);
// Edits keyed by tag id, so a name typed into one row survives the others
// being added and the list re-rendering without it.
const [drafts, setDrafts] = useState<Record<string, { label: string; color: string }>>({});
const abortRef = useRef<AbortController | null>(null);
// A scan in flight has no reason to keep paging once nobody is watching.
useEffect(() => () => abortRef.current?.abort(), []);
const handleScan = async () => {
if (!client) return;
const controller = new AbortController();
abortRef.current?.abort();
abortRef.current = controller;
setIsScanning(true);
setError(false);
setProgress({ scanned: 0, total: 0 });
try {
const result = await client.discoverKeywords({
signal: controller.signal,
onProgress: (scanned, total) => {
if (!controller.signal.aborted) setProgress({ scanned, total });
},
});
if (controller.signal.aborted) return;
setScan({
found: findUnrecognizedKeywords(result.keywords, emailKeywords, nestedTags),
complete: result.complete,
scanned: result.scanned,
total: result.total,
});
} catch (err) {
console.error("Failed to scan for unrecognized keywords:", err);
if (!controller.signal.aborted) setError(true);
} finally {
if (!controller.signal.aborted) setIsScanning(false);
}
};
const handleAdd = (entries: UnrecognizedKeyword[]) => {
for (const entry of entries) {
const draft = drafts[entry.id] ?? entry;
const label = draft.label.trim();
if (!label) continue;
addKeyword({ id: entry.id, label, color: draft.color });
}
if (client) fetchTagCounts(client);
};
// Anything added - here or by hand while the results sat on screen - stops
// being unrecognized, so the list is filtered against the definitions on every
// render rather than spliced when a row is used.
const known = new Set(emailKeywords.map((keyword) => keyword.id.toLowerCase()));
const pending = (scan?.found ?? []).filter((entry) => !known.has(entry.id.toLowerCase()));
const addable = pending.filter((entry) => (drafts[entry.id]?.label ?? entry.label).trim().length > 0);
return (
<div className="space-y-3 pt-4 mt-4 border-t border-border">
<div>
<h4 className="text-sm font-medium">{t("unrecognized.title")}</h4>
<p className="text-xs text-muted-foreground mt-0.5">{t("unrecognized.description")}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleScan}
disabled={isScanning || !client}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors disabled:opacity-50"
>
{isScanning ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
{scan && !isScanning ? t("unrecognized.rescan") : t("unrecognized.scan")}
</button>
{isScanning && (
<span className="text-xs text-muted-foreground" role="status">
{t("unrecognized.scanning", { scanned: progress.scanned, total: progress.total })}
</span>
)}
{isScanning && (
<button
type="button"
onClick={() => {
abortRef.current?.abort();
setIsScanning(false);
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors"
>
<X className="w-3.5 h-3.5" />
{t("cancel")}
</button>
)}
{pending.length > 1 && !isScanning && (
<button
type="button"
onClick={() => handleAdd(addable)}
disabled={addable.length === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" />
{t("unrecognized.add_all", { count: addable.length })}
</button>
)}
</div>
{error && <p className="text-xs text-destructive">{t("unrecognized.error")}</p>}
{scan && !isScanning && !scan.complete && (
<p className="text-xs text-muted-foreground">
{t("unrecognized.partial", { scanned: scan.scanned, total: scan.total })}
</p>
)}
{scan && !isScanning && pending.length === 0 && !error && (
<p className="text-xs text-muted-foreground">{t("unrecognized.none")}</p>
)}
{pending.length > 0 && (
<div className="space-y-2">
{pending.map((entry) => (
<UnrecognizedKeywordRow
key={entry.id}
entry={entry}
draft={drafts[entry.id] ?? { label: entry.label, color: entry.color }}
onChange={(draft) => setDrafts((current) => ({ ...current, [entry.id]: draft }))}
onAdd={() => handleAdd([entry])}
/>
))}
</div>
)}
</div>
);
}
export function KeywordSettings() {
const t = useTranslations("settings.keywords");
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
@@ -442,6 +673,8 @@ export function KeywordSettings() {
</div>
)}
</div>
<UnrecognizedKeywords />
</SettingsSection>
);
}
@@ -0,0 +1,209 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
// JMAP has no way to ask which keywords an account uses, so `discoverKeywords`
// finds them by walking the message list: an Email/query for the next slice of
// ids and an Email/get back-referencing it. What matters is that the walk pages
// correctly (a skipped page is a tag the user never gets offered) and that it
// admits when it stopped early rather than passing a partial scan off as the
// whole account.
function makeSession(core: Record<string, number> = {}) {
return {
capabilities: { 'urn:ietf:params:jmap:core': core },
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
apiUrl: 'https://mail.example.com/jmap/api',
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
};
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
/** One page of a walk: the ids the query returns and the keywords the get does. */
function page(ids: string[], keywords: Array<Record<string, boolean>>, total?: number) {
return {
methodResponses: [
['Email/query', { ids, ...(total === undefined ? {} : { total }) }, '0'],
['Email/get', { list: keywords.map((kw, i) => ({ id: ids[i], keywords: kw })) }, '1'],
],
};
}
describe('JMAPClient.discoverKeywords', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
fetchSpy.mockRestore();
vi.restoreAllMocks();
});
async function connectedClient(core?: Record<string, number>): Promise<JMAPClient> {
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(core)));
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
await client.connect();
fetchSpy.mockReset();
return client;
}
/** Records the method calls of every request the client makes. */
function recordRequests(reply: (methodCalls: Array<[string, Record<string, unknown>, string]>, index: number) => unknown) {
const sent: Array<Array<[string, Record<string, unknown>, string]>> = [];
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string);
const index = sent.length;
sent.push(body.methodCalls);
return jsonResponse(reply(body.methodCalls, index));
}) as never);
return sent;
}
it('unions the keywords on every message and counts them', async () => {
const client = await connectedClient({ maxObjectsInGet: 2 });
recordRequests((_calls, index) =>
index === 0
? page(['m1', 'm2'], [
{ $seen: true, '$label:work': true },
{ '$label:work': true, '$label:receipts': true },
], 3)
: page(['m3'], [{ '$label:receipts': true }]),
);
const result = await client.discoverKeywords();
expect(result.keywords).toEqual({ $seen: 1, '$label:work': 2, '$label:receipts': 2 });
expect(result.scanned).toBe(3);
expect(result.complete).toBe(true);
});
it('ignores keywords the message does not actually carry', async () => {
const client = await connectedClient();
recordRequests(() => page(['m1'], [{ '$label:work': true, '$label:stale': false }], 1));
const { keywords } = await client.discoverKeywords();
expect(keywords).toEqual({ '$label:work': 1 });
});
it('asks only for keywords, page by page, using the advertised object ceiling', async () => {
const client = await connectedClient({ maxObjectsInGet: 2 });
const sent = recordRequests((_calls, index) =>
index === 0
? page(['m1', 'm2'], [{ '$label:a': true }, { '$label:b': true }], 3)
: page(['m3'], [{ '$label:c': true }]),
);
await client.discoverKeywords();
expect(sent).toHaveLength(2);
expect(sent[0][0][1]).toMatchObject({ limit: 2, position: 0, calculateTotal: true });
expect(sent[0][1][0]).toBe('Email/get');
expect(sent[0][1][1]).toMatchObject({
properties: ['keywords'],
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
});
expect(sent[1][0][1]).toMatchObject({ limit: 2, position: 2 });
});
it('pages by the ids the query returned, so a message destroyed mid-walk skips nothing', async () => {
const client = await connectedClient({ maxObjectsInGet: 2 });
const sent = recordRequests((_calls, index) =>
index === 0
// Two ids queried, but one was destroyed before the get ran.
? { methodResponses: [
['Email/query', { ids: ['m1', 'm2'], total: 3 }, '0'],
['Email/get', { list: [{ id: 'm1', keywords: { '$label:a': true } }], notFound: ['m2'] }, '1'],
] }
: page(['m3'], [{ '$label:b': true }]),
);
const result = await client.discoverKeywords();
expect(sent[1][0][1]).toMatchObject({ position: 2 });
expect(result.scanned).toBe(3);
expect(result.keywords).toEqual({ '$label:a': 1, '$label:b': 1 });
});
it('stops at the cap and says the scan is incomplete', async () => {
const client = await connectedClient({ maxObjectsInGet: 2 });
const sent = recordRequests(() => page(['m1', 'm2'], [{ '$label:a': true }, { '$label:b': true }], 100));
const result = await client.discoverKeywords({ limit: 4 });
expect(sent).toHaveLength(2);
expect(result.scanned).toBe(4);
expect(result.total).toBe(100);
expect(result.complete).toBe(false);
});
it('never asks for more messages than the cap leaves', async () => {
const client = await connectedClient({ maxObjectsInGet: 500 });
const sent = recordRequests(() => page(['m1', 'm2', 'm3'], [{}, {}, {}], 100));
await client.discoverKeywords({ limit: 3 });
expect(sent[0][0][1]).toMatchObject({ limit: 3 });
});
it('reports progress as it walks', async () => {
const client = await connectedClient({ maxObjectsInGet: 2 });
recordRequests((_calls, index) =>
index === 0 ? page(['m1', 'm2'], [{}, {}], 3) : page(['m3'], [{}]),
);
const progress: Array<[number, number]> = [];
await client.discoverKeywords({ onProgress: (scanned, total) => progress.push([scanned, total]) });
expect(progress).toEqual([[2, 3], [3, 3]]);
});
it('stops when aborted and reports the scan as incomplete', async () => {
const client = await connectedClient({ maxObjectsInGet: 2 });
const controller = new AbortController();
const sent = recordRequests(() => {
controller.abort();
return page(['m1', 'm2'], [{ '$label:a': true }, {}], 100);
});
const result = await client.discoverKeywords({ signal: controller.signal });
expect(sent).toHaveLength(1);
expect(result.keywords).toEqual({ '$label:a': 1 });
expect(result.complete).toBe(false);
});
it('keeps what it found when a page fails, and calls the scan incomplete', async () => {
const client = await connectedClient({ maxObjectsInGet: 2 });
recordRequests((_calls, index) => {
if (index === 0) return page(['m1', 'm2'], [{ '$label:a': true }, {}], 100);
throw new Error('network down');
});
const result = await client.discoverKeywords();
expect(result.keywords).toEqual({ '$label:a': 1 });
expect(result.scanned).toBe(2);
expect(result.complete).toBe(false);
});
it('handles an account with no mail at all', async () => {
const client = await connectedClient();
recordRequests(() => page([], [], 0));
const result = await client.discoverKeywords();
expect(result).toMatchObject({ keywords: {}, scanned: 0, total: 0, complete: true });
});
});
+172
View File
@@ -0,0 +1,172 @@
import { describe, it, expect } from 'vitest';
import {
findUnrecognizedKeywords,
suggestKeywordColor,
suggestKeywordLabel,
tagIdFromKeyword,
} from '../keyword-discovery';
import { KEYWORD_PALETTE, type KeywordDefinition } from '@/stores/settings-store';
const defined = (...ids: string[]): KeywordDefinition[] =>
ids.map((id) => ({ id, label: id, color: 'gray' }));
describe('tagIdFromKeyword', () => {
it('reads the current prefix', () => {
expect(tagIdFromKeyword('$label:work')).toBe('work');
});
it('reads the legacy prefix', () => {
expect(tagIdFromKeyword('$color:work')).toBe('work');
});
it('keeps nesting separators in the id', () => {
expect(tagIdFromKeyword('$label:work/clients/acme')).toBe('work/clients/acme');
});
it('rejects system keywords', () => {
expect(tagIdFromKeyword('$seen')).toBeNull();
expect(tagIdFromKeyword('$flagged')).toBeNull();
expect(tagIdFromKeyword('nonsense')).toBeNull();
});
it('rejects a bare prefix with no id', () => {
expect(tagIdFromKeyword('$label:')).toBeNull();
});
});
describe('suggestKeywordLabel', () => {
it('turns separators into words and capitalises them', () => {
expect(suggestKeywordLabel('q3-invoices', false)).toBe('Q3 Invoices');
expect(suggestKeywordLabel('to_do', false)).toBe('To Do');
});
it('names only the innermost level when nesting is on', () => {
expect(suggestKeywordLabel('work/clients/acme-corp', true)).toBe('Acme Corp');
});
it('names the whole id when nesting is off, since it is one opaque token', () => {
expect(suggestKeywordLabel('work/clients/acme-corp', false)).toBe('Work/Clients/Acme Corp');
});
it('falls back to the id when there is nothing to humanise', () => {
expect(suggestKeywordLabel('/', true)).toBe('/');
});
});
describe('suggestKeywordColor', () => {
it('keeps the colour of a tag named after one', () => {
expect(suggestKeywordColor('blue')).toBe('blue');
expect(suggestKeywordColor('work/red-dark')).toBe('red-dark');
});
it('always proposes a real palette key', () => {
for (const id of ['q3-invoices', 'a', 'zzz', 'работа', 'x'.repeat(200)]) {
expect(suggestKeywordColor(id)).toBeTypeOf('string');
expect(KEYWORD_PALETTE[suggestKeywordColor(id)]).toBeDefined();
}
});
it('is stable for the same id', () => {
expect(suggestKeywordColor('q3-invoices')).toBe(suggestKeywordColor('q3-invoices'));
});
it('avoids colours already in use', () => {
const first = suggestKeywordColor('q3-invoices');
expect(suggestKeywordColor('q3-invoices', [first])).not.toBe(first);
});
it('still yields a colour when every hue is taken', () => {
const everything = Object.keys(KEYWORD_PALETTE);
expect(KEYWORD_PALETTE[suggestKeywordColor('q3-invoices', everything)]).toBeDefined();
});
});
describe('findUnrecognizedKeywords', () => {
it('reports only tag keywords with no definition', () => {
const found = findUnrecognizedKeywords(
{ $seen: 12, $flagged: 3, '$label:work': 5, '$label:q3-invoices': 2 },
defined('work'),
false,
);
expect(found.map((entry) => entry.id)).toEqual(['q3-invoices']);
});
it('proposes a name and the keyword as stored', () => {
const [entry] = findUnrecognizedKeywords({ '$label:q3-invoices': 2 }, [], false);
expect(entry).toMatchObject({
id: 'q3-invoices',
keyword: '$label:q3-invoices',
label: 'Q3 Invoices',
count: 2,
});
});
it('finds tags still written under the legacy prefix', () => {
const found = findUnrecognizedKeywords({ '$color:receipts': 4 }, [], false);
expect(found.map((entry) => entry.id)).toEqual(['receipts']);
});
it('treats both prefixes as one tag and adds their counts up', () => {
const found = findUnrecognizedKeywords(
{ '$label:receipts': 4, '$color:receipts': 3 },
[],
false,
);
expect(found).toHaveLength(1);
expect(found[0].count).toBe(7);
});
it('matches definitions case-insensitively', () => {
expect(findUnrecognizedKeywords({ '$label:Work': 5 }, defined('work'), false)).toEqual([]);
});
it('folds spellings that differ only in case into one entry', () => {
const found = findUnrecognizedKeywords({ '$label:Work': 5, '$label:work': 2 }, [], false);
expect(found).toHaveLength(1);
expect(found[0].count).toBe(7);
});
it('orders by how much mail carries the tag, then by id', () => {
const found = findUnrecognizedKeywords(
{ '$label:rare': 1, '$label:common': 90, '$label:also-rare': 1 },
[],
false,
);
expect(found.map((entry) => entry.id)).toEqual(['common', 'also-rare', 'rare']);
});
it('proposes a distinct colour per tag so a bulk import stays legible', () => {
const found = findUnrecognizedKeywords(
{ '$label:one': 5, '$label:two': 4, '$label:three': 3, '$label:four': 2 },
[],
false,
);
expect(new Set(found.map((entry) => entry.color)).size).toBe(found.length);
});
it('avoids colours existing tags already wear', () => {
const existing: KeywordDefinition[] = [{ id: 'work', label: 'Work', color: 'teal' }];
const found = findUnrecognizedKeywords(
{ '$label:one': 5, '$label:two': 4, '$label:three': 3 },
existing,
false,
);
expect(found.map((entry) => entry.color)).not.toContain('teal');
});
it('recovers a tag named after a colour in that colour, taken or not', () => {
const existing: KeywordDefinition[] = [{ id: 'work', label: 'Work', color: 'blue' }];
const found = findUnrecognizedKeywords({ '$label:blue': 5 }, existing, false);
expect(found[0].color).toBe('blue');
});
it('returns nothing when the account carries no tags at all', () => {
expect(findUnrecognizedKeywords({ $seen: 40, $draft: 2 }, defined('work'), false)).toEqual([]);
});
it('proposes ids that keep pointing at the same messages', () => {
const [entry] = findUnrecognizedKeywords({ '$label:work/clients/acme': 3 }, [], true);
expect(entry.id).toBe('work/clients/acme');
expect(entry.label).toBe('Acme');
});
});
+17
View File
@@ -232,6 +232,23 @@ export class DemoJMAPClient implements IJMAPClient {
return result;
}
async discoverKeywords(options?: {
limit?: number;
onProgress?: (scanned: number, total: number) => void;
signal?: AbortSignal;
}): Promise<{ keywords: Record<string, number>; scanned: number; total: number; complete: boolean }> {
const total = this.data.emails.length;
const scanned = Math.min(total, Math.max(0, options?.limit ?? total));
const keywords: Record<string, number> = {};
for (const email of this.data.emails.slice(0, scanned)) {
for (const [keyword, isSet] of Object.entries(email.keywords || {})) {
if (isSet) keywords[keyword] = (keywords[keyword] ?? 0) + 1;
}
}
options?.onProgress?.(scanned, total);
return { keywords, scanned, total, complete: scanned >= total };
}
async getCategoryUnreadCounts(mailboxId: string, tabs: Array<{ id: string; filter: Record<string, unknown> | null }>, _accountId?: string): Promise<Record<string, number>> {
const inBox = this.data.emails.filter(e => e.mailboxIds[mailboxId] && !e.keywords.$seen);
const result: Record<string, number> = {};
+11
View File
@@ -88,6 +88,17 @@ export interface IJMAPClient {
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]>
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
/**
* Every keyword currently set on the account's messages and how many of the
* walked messages carry it, found by walking the message list - JMAP offers no
* way to ask for the keywords in use. `complete` is false when `limit` (or an
* abort) ended the walk early, which also makes every count a floor.
*/
discoverKeywords(options?: {
limit?: number;
onProgress?: (scanned: number, total: number) => void;
signal?: AbortSignal;
}): Promise<{ keywords: Record<string, number>; scanned: number; total: number; complete: boolean }>;
/** Per-tab unread counts for message-list category tabs (filter = resolved tab fragment, null = unfiltered). */
getCategoryUnreadCounts(mailboxId: string, tabs: Array<{ id: string; filter: Record<string, unknown> | null }>, accountId?: string): Promise<Record<string, number>>;
searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
+96
View File
@@ -172,6 +172,15 @@ const EMAIL_LIST_PROPERTIES = [
"blobId",
] as const;
/**
* How many messages `discoverKeywords` walks before it gives up and reports an
* incomplete scan. A keyword only has to sit on one message to be worth
* recovering, so there is no cheaper place to stop than "all of them" - the cap
* exists so a very large account cannot turn a settings page into an unbounded
* page-by-page crawl, not because the tail is uninteresting.
*/
export const DEFAULT_KEYWORD_SCAN_LIMIT = 25000;
// Stalwart's default property list for Calendar/get omits shareWith, isVisible,
// includeInAvailability, and the default-alerts properties. Without an explicit
// `properties` list the share indicator and share dialog can't see existing
@@ -1404,6 +1413,93 @@ export class JMAPClient implements IJMAPClient {
return result;
}
/**
* Every keyword the account's messages actually carry, and how many messages
* carry each.
*
* JMAP has no "list the keywords in use" call - a keyword exists only as a
* property of the messages bearing it - so the only way to find them is to
* walk the message list and union what turns up. Each page is one request:
* an Email/query for the next slice of ids and an Email/get back-referencing
* it, asking for nothing but `keywords`.
*
* Counting here rather than following up with `getTagCounts` costs nothing
* extra - the messages are already in hand - and counts whatever spelling the
* keyword actually has, which a `$label:`-shaped count query cannot do for a
* keyword still written the legacy way. The trade is that a count is only
* over the messages walked, so it is a floor rather than a total whenever
* `complete` is false.
*
* The walk is capped at `limit` messages because an account can hold far more
* mail than is worth paging through for this; `complete` says whether the cap
* (or an abort) cut the scan short, so a caller can say so rather than
* present a partial answer as the whole truth. Reading is all this does, and
* it stops at the first failed page instead of retrying - a scan that ends
* early is reported as incomplete, which is exactly what it is.
*/
async discoverKeywords(options?: {
limit?: number;
onProgress?: (scanned: number, total: number) => void;
signal?: AbortSignal;
}): Promise<{ keywords: Record<string, number>; scanned: number; total: number; complete: boolean }> {
const cap = Math.max(0, options?.limit ?? DEFAULT_KEYWORD_SCAN_LIMIT);
const pageSize = Math.max(1, Math.min(500, this.getMaxObjectsInGet()));
const keywords: Record<string, number> = {};
let scanned = 0;
let total = 0;
let complete = false;
while (scanned < cap) {
if (options?.signal?.aborted) break;
try {
const response = await this.request([
["Email/query", {
accountId: this.accountId,
sort: [{ property: "receivedAt", isAscending: false }],
limit: Math.min(pageSize, cap - scanned),
position: scanned,
calculateTotal: scanned === 0,
}, "0"],
["Email/get", {
accountId: this.accountId,
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
properties: ["keywords"],
}, "1"],
]);
const queryResponse = response.methodResponses?.[0]?.[1];
const getResponse = response.methodResponses?.[1]?.[1];
const ids: string[] = queryResponse?.ids || [];
if (scanned === 0) total = queryResponse?.total ?? 0;
const list = (getResponse?.list || []) as Array<{ keywords?: Record<string, boolean> }>;
for (const email of list) {
for (const [keyword, isSet] of Object.entries(email.keywords || {})) {
if (isSet) keywords[keyword] = (keywords[keyword] ?? 0) + 1;
}
}
// Page by what the query returned, not by what the get did: a message
// destroyed between the two lands in `notFound` and would otherwise
// shift every later page by one and skip a message per gap.
scanned += ids.length;
options?.onProgress?.(scanned, Math.max(total, scanned));
// A short page is the end of the list, however much `total` claimed.
if (ids.length === 0 || ids.length < Math.min(pageSize, cap - (scanned - ids.length))) {
complete = true;
break;
}
} catch (error) {
console.error('Failed to scan keywords:', error);
break;
}
}
return { keywords, scanned, total: Math.max(total, scanned), complete };
}
/**
* Per-tab unread counts for message-list category tabs. One Email/query
* (limit 0, calculateTotal) per tab, batched into as few requests as the
+176
View File
@@ -0,0 +1,176 @@
/**
* Tags the server carries but this client cannot name.
*
* A tag lives in two places: the keyword `$label:<id>` on every message that
* bears it, which is the server's business, and the `{ id, label, color }`
* definition in local settings, which is only ever this client's. Lose the
* settings - a reinstall, a second browser, a cleared profile - and the
* keywords survive untouched while the tags they belong to vanish from the UI,
* because nothing left knows that `$label:q3-invoices` was ever called
* "Q3 Invoices".
*
* This module closes that gap from the other end: given the keywords actually
* found on messages (`IJMAPClient.discoverKeywords`), it works out which ones
* no definition explains and proposes a name and a colour for each, so the user
* can adopt them back into settings instead of having to guess the exact id.
*
* Everything here is a proposal. The id is the one part that is not negotiable:
* it is what the messages already say, so importing a tag must never reword it.
*/
import type { KeywordDefinition } from "@/stores/settings-store";
import { KEYWORD_PALETTE, KEYWORD_PALETTE_ROWS } from "@/stores/settings-store";
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "./thread-utils";
import { KEYWORD_SEPARATOR, keywordLevels } from "./keyword-nesting";
/** A keyword found on the server that no tag definition accounts for. */
export interface UnrecognizedKeyword {
/** The tag id: the keyword with its prefix removed. Fixed - never reworded. */
id: string;
/** The keyword as it is stored on messages, prefix included. */
keyword: string;
/** Proposed display name, derived from the id. */
label: string;
/** Proposed colour, a key of `KEYWORD_PALETTE`. */
color: string;
/**
* How many scanned messages carry the tag, under either prefix. A floor
* rather than a total when the scan that produced it was incomplete.
*/
count: number;
}
/**
* The tag id inside a keyword, or null when the keyword is not a tag.
*
* Both the current `$label:` prefix and the legacy `$color:` one are read, so a
* tag written by an older version is recognised rather than reported as an
* unrelated keyword. System keywords (`$seen`, `$flagged`, and anything else a
* server sets) carry neither prefix and are dropped here.
*/
export function tagIdFromKeyword(keyword: string): string | null {
for (const prefix of [KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY]) {
if (keyword.startsWith(prefix)) {
const id = keyword.slice(prefix.length);
return id.length > 0 ? id : null;
}
}
return null;
}
/** Turns one level of an id into words: `q3-invoices` -> `Q3 Invoices`. */
function humanizeLevel(level: string): string {
const words = level
.split(/[-_]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1));
return words.length > 0 ? words.join(" ") : level;
}
/**
* The name to propose for a tag whose definition is gone.
*
* With nesting on, a definition names one level and the levels above it supply
* the rest, so only the innermost level is named here - proposing
* "Work Clients Acme" for `work/clients/acme` would read as
* `Work/Clients/Work Clients Acme` once its ancestors are imported too. With
* nesting off the id is a single opaque token and the whole of it is the name.
*/
export function suggestKeywordLabel(id: string, nested: boolean): string {
const levels = keywordLevels(id);
if (levels.length === 0) return id;
if (!nested) return levels.map(humanizeLevel).join(KEYWORD_SEPARATOR);
return humanizeLevel(levels[levels.length - 1]);
}
/** A small stable spread over `range`, so the same id always starts at the same hue. */
function hashIndex(id: string, range: number): number {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) | 0;
}
return Math.abs(hash) % Math.max(1, range);
}
/**
* The colour to propose for a tag whose definition is gone.
*
* An id that is itself a palette key keeps that colour: the tags shipped by
* default are named after their colour (`$label:blue`), so recovering them
* should give back the blue tag rather than a blue-labelled green one.
*
* Otherwise a hue is picked from the base row by hashing the id, then walked
* forward past any colour in `taken` - a batch of recovered tags that all come
* out the same shade is far harder to tell apart in the sidebar than one whose
* hues merely differ from the originals, which are unknowable anyway. When
* every hue is taken the hashed pick stands.
*/
export function suggestKeywordColor(id: string, taken: Iterable<string> = []): string {
const levels = keywordLevels(id);
const own = levels.length > 0 ? levels[levels.length - 1] : id;
if (own in KEYWORD_PALETTE) return own;
const hues = KEYWORD_PALETTE_ROWS[1] ?? Object.keys(KEYWORD_PALETTE);
const used = new Set(taken);
const start = hashIndex(id, hues.length);
for (let step = 0; step < hues.length; step++) {
const candidate = hues[(start + step) % hues.length];
if (!used.has(candidate)) return candidate;
}
return hues[start];
}
/**
* The tags in `keywords` - a keyword-to-message-count map, as
* `IJMAPClient.discoverKeywords` returns - that no definition in `defined`
* explains, each with a name and colour to propose.
*
* Keywords are compared case-insensitively: RFC 8621 leaves the case of a
* keyword to whoever set it, so `$label:Work` and `$label:work` are one tag and
* a definition for either covers both. The spelling that reaches the result is
* the one the server actually uses, since that is what an id has to match to
* keep pointing at the same messages, and the counts of every spelling are
* added up - `$label:work` and the legacy `$color:work` are one tag whose
* messages happen to be split across two ways of writing it.
*
* Results are ordered by descending count, then by id: the tag on a thousand
* messages is the one whose name was worth knowing. Colours are proposed
* against the definitions *and* the earlier proposals together, so importing
* the whole list at once still yields tags that can be told apart.
*/
export function findUnrecognizedKeywords(
keywords: Record<string, number>,
defined: KeywordDefinition[],
nested: boolean,
): UnrecognizedKeyword[] {
const known = new Set(defined.map((keyword) => keyword.id.toLowerCase()));
const taken = new Set(defined.map((keyword) => keyword.color));
// One entry per tag, keyed by the id folded to lower case: `$label:work` and
// `$color:Work` are the same tag reached two ways, and offering it twice
// would let the user create a definition that cannot apply to both. The first
// spelling seen names the tag; the rest only add to its count.
const unrecognized = new Map<string, { id: string; count: number }>();
for (const [keyword, count] of Object.entries(keywords)) {
const id = tagIdFromKeyword(keyword);
if (!id) continue;
const folded = id.toLowerCase();
if (known.has(folded)) continue;
const existing = unrecognized.get(folded);
if (existing) existing.count += count;
else unrecognized.set(folded, { id, count });
}
return [...unrecognized.values()]
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id))
.map(({ id, count }) => {
const color = suggestKeywordColor(id, taken);
taken.add(color);
return {
id,
keyword: KEYWORD_PREFIX + id,
label: suggestKeywordLabel(id, nested),
color,
count,
};
});
}
+12
View File
@@ -1046,6 +1046,18 @@
"show": "Show",
"unread": "Show if unread",
"hide": "Hide"
},
"unrecognized": {
"title": "Recover tags from the server",
"description": "Tag names and colors are stored in this browser, but the tags themselves live on your messages as JMAP keywords. Scan your mail for keywords no tag here explains, then add them back.",
"scan": "Scan for tags",
"rescan": "Scan again",
"scanning": "Scanning… {scanned} of {total} messages",
"none": "Every tag on your messages is already defined here.",
"messages": "{count, plural, one {# message} other {# messages}}",
"add_all": "Add all {count}",
"partial": "Only the {scanned} most recent of {total} messages were scanned. Tags used solely on older mail may be missing.",
"error": "Could not scan your mail for tags."
}
},
"notifications": {