@remit/web-client 0.0.85 → 0.0.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/package.json +1 -1
  2. package/src/auth/BetterAuthShell.tsx +2 -2
  3. package/src/components/compose/AddressField.tsx +60 -77
  4. package/src/components/mail/AutoMovedIndicator.tsx +7 -9
  5. package/src/components/mail/BriefPane.tsx +4 -2
  6. package/src/components/mail/DailyBrief.selection.test.ts +49 -0
  7. package/src/components/mail/DailyBrief.tsx +484 -76
  8. package/src/components/mail/FlaggedList.tsx +3 -2
  9. package/src/components/mail/FlaggedPane.tsx +4 -2
  10. package/src/components/mail/MailListHeader.tsx +155 -34
  11. package/src/components/mail/MailViewChrome.tsx +18 -1
  12. package/src/components/mail/MailboxPane.tsx +55 -20
  13. package/src/components/mail/MessageCard.tsx +0 -1
  14. package/src/components/mail/MessageList.tsx +70 -15
  15. package/src/components/mail/MessageListItem.tsx +4 -21
  16. package/src/components/mail/MessageRow.tsx +13 -31
  17. package/src/components/mail/SwipeableMessageRow.tsx +13 -6
  18. package/src/components/mail/ThreadListInteraction.tsx +51 -6
  19. package/src/components/mail/organize/OrganizeRuleEditor.tsx +57 -2
  20. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +13 -2
  21. package/src/components/mail/organize/SearchFilterDialog.tsx +3 -1
  22. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
  23. package/src/components/mail/organize/SearchFilterEditor.tsx +8 -2
  24. package/src/components/mail/organize/smart-organize.stories.tsx +86 -5
  25. package/src/components/mail/useModifierSelect.render.test.ts +247 -0
  26. package/src/components/mail/useModifierSelect.ts +109 -0
  27. package/src/components/onboarding/OnboardingWizard.tsx +53 -9
  28. package/src/components/settings/AccountFormPanel.tsx +10 -5
  29. package/src/components/settings/TlsRootCaDownload.render.test.ts +37 -0
  30. package/src/components/settings/TlsRootCaDownload.tsx +33 -0
  31. package/src/hooks/bulk-chunking.render.test.ts +157 -0
  32. package/src/hooks/useApplyLabel.ts +6 -3
  33. package/src/hooks/useClauseSuggestions.ts +57 -0
  34. package/src/hooks/useDeleteMessages.ts +6 -3
  35. package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
  36. package/src/hooks/useFollowFocusOpen.ts +81 -0
  37. package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
  38. package/src/hooks/useInitialSyncProgress.ts +134 -0
  39. package/src/hooks/useListCursor.ts +36 -5
  40. package/src/hooks/useMarkAsRead.ts +6 -3
  41. package/src/hooks/useMoveMessages.ts +6 -3
  42. package/src/hooks/useRulePreview.ts +11 -1
  43. package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
  44. package/src/hooks/useSearchFilterSeed.ts +21 -3
  45. package/src/hooks/useSearchSuggestions.ts +126 -0
  46. package/src/hooks/useSelectedSubjects.ts +0 -0
  47. package/src/hooks/useSemanticSearch.ts +26 -7
  48. package/src/hooks/useThreadActions.ts +11 -2
  49. package/src/lib/brief.test.ts +67 -0
  50. package/src/lib/brief.ts +11 -1
  51. package/src/lib/bulk-actions.ts +29 -0
  52. package/src/lib/drafts.ts +1 -1
  53. package/src/lib/organize/clause-suggestions.test.ts +113 -0
  54. package/src/lib/organize/clause-suggestions.ts +90 -0
  55. package/src/lib/organize/property-prefill.test.ts +173 -0
  56. package/src/lib/organize/property-prefill.ts +151 -0
  57. package/src/lib/organize/rule-model.test.ts +57 -0
  58. package/src/lib/organize/rule-model.ts +110 -31
  59. package/src/lib/organize/search-to-rule.ts +12 -4
  60. package/src/lib/organize/sender-fallback.ts +11 -1
  61. package/src/lib/search-result.ts +2 -0
  62. package/src/lib/search-suggestions.test.ts +249 -0
  63. package/src/lib/search-suggestions.ts +296 -0
  64. package/src/lib/search-token-index.test.ts +99 -0
  65. package/src/lib/search-token-index.ts +67 -1
  66. package/src/lib/search-tokens.test.ts +236 -0
  67. package/src/lib/search-tokens.ts +303 -36
  68. package/src/lib/thread-cache.ts +1 -1
  69. package/src/lib/thread-search-tokens.test.ts +193 -0
  70. package/src/lib/thread-search-tokens.ts +148 -0
  71. package/src/routes/onboarding.tsx +9 -4
  72. package/src/routes/settings/advanced.tsx +2 -0
  73. package/src/runtime-config.test.ts +26 -0
  74. package/src/runtime-config.ts +15 -0
@@ -4,11 +4,34 @@
4
4
  * into removable chips. Only tokens the search APIs can actually honor are
5
5
  * recognized — everything else is left untouched as ordinary free-text words.
6
6
  *
7
+ * The vocabulary is the search parameters `threadOperationsSearchThreads` and
8
+ * `semanticSearchOperationsSemanticSearch` take, said in words:
9
+ *
10
+ * | token | thread search | semantic search |
11
+ * | ---------------------- | ------------------ | ---------------------- |
12
+ * | `from:` | `from` | — |
13
+ * | `subject:` | `subject` | — |
14
+ * | `category:` | `category[]` | `category` |
15
+ * | `is:unread` / `is:read`| `unread` | `isRead` |
16
+ * | `is:starred` | `starred` | `hasStars` |
17
+ * | `has:attachment` | `attachments` | `hasAttachment` |
18
+ * | `before:` / `after:` | — | `sentDateTo` / `From` |
19
+ * | `in:` | route scope | `mailboxId` |
20
+ * | `account:` | — | — |
21
+ *
22
+ * A token an engine has no parameter for is applied client-side over the rows
23
+ * that engine returned (`matchesSearchTokens` in `lib/brief.ts`), never dropped.
24
+ *
7
25
  * `in:<mailbox>` and `account:<name>` resolve against a name index the caller
8
26
  * supplies via `SearchTokenContext` (built from the loaded mailboxes/accounts —
9
27
  * see `lib/search-token-index.ts`). An unresolvable name (no index, or no
10
28
  * match in it) is left as free text rather than becoming a chip for a filter
11
- * that can't actually apply.
29
+ * that can't actually apply. `category:` resolves the same way against a fixed
30
+ * vocabulary, so `category:nonsense` stays free text too.
31
+ *
32
+ * Token names are case-insensitive. A value containing whitespace is written in
33
+ * double quotes — `in:"Sent Items"` — and an unterminated quote runs to the end
34
+ * of the input, so a value stays readable while it is still being typed.
12
35
  *
13
36
  * Pure functions only: no React, no fetch — the name index is injected, not
14
37
  * fetched here. `MailListHeader` renders the chips and the callers that issue
@@ -17,10 +40,22 @@
17
40
  * actually supports.
18
41
  */
19
42
 
43
+ import type { RemitImapMessageCategory } from "@remit/api-http-client/types.gen.ts";
44
+ import { MessageCategory } from "@remit/domain-enums";
45
+
20
46
  export type SearchToken =
21
47
  | { type: "from"; raw: string; value: string }
48
+ | { type: "subject"; raw: string; value: string }
49
+ | {
50
+ type: "category";
51
+ raw: string;
52
+ value: string;
53
+ category: RemitImapMessageCategory;
54
+ }
22
55
  | { type: "hasAttachment"; raw: string }
23
56
  | { type: "isUnread"; raw: string }
57
+ | { type: "isRead"; raw: string }
58
+ | { type: "isStarred"; raw: string }
24
59
  | { type: "before"; raw: string; value: string; epochSeconds: number }
25
60
  | { type: "after"; raw: string; value: string; epochSeconds: number }
26
61
  | { type: "in"; raw: string; value: string; mailboxId: string }
@@ -40,8 +75,200 @@ export interface SearchTokenContext {
40
75
  accountsByName?: ReadonlyMap<string, string>;
41
76
  }
42
77
 
78
+ /** The word before the colon. `is`/`has` carry their meaning in the value. */
79
+ export type SearchTokenName =
80
+ | "from"
81
+ | "subject"
82
+ | "category"
83
+ | "is"
84
+ | "has"
85
+ | "before"
86
+ | "after"
87
+ | "in"
88
+ | "account";
89
+
90
+ /** One option of a token's fixed value vocabulary. */
91
+ export interface SearchTokenValueOption {
92
+ value: string;
93
+ /** What the value reads as in prose. Defaults to the value. */
94
+ label?: string;
95
+ }
96
+
97
+ /**
98
+ * Where a token's values come from. `fixed` is the vocabulary carried here;
99
+ * the rest are looked up by the caller (mailboxes, accounts, the address
100
+ * search) or typed freely.
101
+ */
102
+ export type SearchTokenValueSource =
103
+ | "fixed"
104
+ | "contact"
105
+ | "mailbox"
106
+ | "account"
107
+ | "date"
108
+ | "text";
109
+
110
+ /** A token name, what it narrows by, and where its values come from. */
111
+ export interface SearchTokenSpec {
112
+ name: SearchTokenName;
113
+ /** What the token narrows by, in the user's words. */
114
+ label: string;
115
+ values: SearchTokenValueSource;
116
+ /** The vocabulary, when `values` is `fixed`. */
117
+ options?: readonly SearchTokenValueOption[];
118
+ }
119
+
120
+ /**
121
+ * Category labels, keyed by the API enum. `uncategorized` reads "Unclassified"
122
+ * — the same word the filter chips use (issue #45): mail the classifier has not
123
+ * reached is a pending state with a name, never the absence of one.
124
+ */
125
+ const CATEGORY_LABELS: Record<RemitImapMessageCategory, string> = {
126
+ personal: "Personal",
127
+ uncategorized: "Unclassified",
128
+ transactional: "Transactional",
129
+ newsletter: "Newsletter",
130
+ marketing: "Marketing",
131
+ social: "Social",
132
+ automated: "Automated",
133
+ };
134
+
135
+ const CATEGORY_OPTIONS: readonly SearchTokenValueOption[] = Object.values(
136
+ MessageCategory,
137
+ ).map((value) => ({ value, label: CATEGORY_LABELS[value] }));
138
+
139
+ /**
140
+ * Spellings that resolve to a category beyond the enum values themselves — the
141
+ * label the chips show, so what the user reads is also what the user can type.
142
+ */
143
+ const CATEGORY_ALIASES: Record<string, RemitImapMessageCategory> = {
144
+ unclassified: "uncategorized",
145
+ };
146
+
147
+ /** The whole vocabulary, in the order a suggestion list offers it. */
148
+ export const SEARCH_TOKEN_SPECS: readonly SearchTokenSpec[] = [
149
+ { name: "from", label: "Sender", values: "contact" },
150
+ { name: "subject", label: "Subject", values: "text" },
151
+ {
152
+ name: "category",
153
+ label: "Category",
154
+ values: "fixed",
155
+ options: CATEGORY_OPTIONS,
156
+ },
157
+ {
158
+ name: "is",
159
+ label: "State",
160
+ values: "fixed",
161
+ options: [
162
+ { value: "unread", label: "Unread" },
163
+ { value: "read", label: "Read" },
164
+ { value: "starred", label: "Starred" },
165
+ ],
166
+ },
167
+ {
168
+ name: "has",
169
+ label: "Contents",
170
+ values: "fixed",
171
+ options: [{ value: "attachment", label: "Has attachment" }],
172
+ },
173
+ { name: "in", label: "Folder", values: "mailbox" },
174
+ { name: "account", label: "Account", values: "account" },
175
+ { name: "after", label: "Sent on or after", values: "date" },
176
+ { name: "before", label: "Sent before", values: "date" },
177
+ ];
178
+
179
+ const SPECS_BY_NAME = new Map<string, SearchTokenSpec>(
180
+ SEARCH_TOKEN_SPECS.map((spec) => [spec.name, spec]),
181
+ );
182
+
183
+ /** The spec for a token name, or `undefined` when the name is not one of ours. */
184
+ export const searchTokenSpec = (name: string): SearchTokenSpec | undefined =>
185
+ SPECS_BY_NAME.get(name.toLowerCase());
186
+
43
187
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
44
188
 
189
+ const needsQuotes = (value: string): boolean => /[\s"]/.test(value);
190
+
191
+ /**
192
+ * A value as it is written in a query: quoted when it carries whitespace, bare
193
+ * otherwise. The inverse of the unquoting the parser does, so a suggestion the
194
+ * user picks parses back to the value it was built from.
195
+ */
196
+ export const quoteSearchTokenValue = (value: string): string =>
197
+ needsQuotes(value) ? `"${value.replace(/"/g, "")}"` : value;
198
+
199
+ /** `name:value`, quoted as needed — the text a query carries for one token. */
200
+ export const searchTokenTerm = (name: string, value: string): string =>
201
+ `${name}:${quoteSearchTokenValue(value)}`;
202
+
203
+ const unquote = (value: string): string => {
204
+ if (!value.startsWith('"')) return value;
205
+ const inner = value.slice(1);
206
+ return inner.endsWith('"') ? inner.slice(0, -1) : inner;
207
+ };
208
+
209
+ /** One whitespace-separated term of a query, with where it sits in the input. */
210
+ export interface SearchQueryWord {
211
+ /** The term exactly as typed, quotes included. */
212
+ raw: string;
213
+ /** Index of the term's first character in the query. */
214
+ start: number;
215
+ /** Index just past the term's last character. */
216
+ end: number;
217
+ }
218
+
219
+ /**
220
+ * Split a query into terms on whitespace, except inside double quotes, so
221
+ * `in:"Sent Items"` is one term. An unterminated quote runs to the end of the
222
+ * input — a half-typed value is still one term, not a broken one.
223
+ */
224
+ export function splitSearchWords(query: string): SearchQueryWord[] {
225
+ const words: SearchQueryWord[] = [];
226
+ let start = -1;
227
+ let quoted = false;
228
+ for (let i = 0; i < query.length; i++) {
229
+ const char = query[i] as string;
230
+ if (char === '"') {
231
+ quoted = !quoted;
232
+ if (start < 0) start = i;
233
+ continue;
234
+ }
235
+ if (!quoted && /\s/.test(char)) {
236
+ if (start >= 0) words.push({ raw: query.slice(start, i), start, end: i });
237
+ start = -1;
238
+ continue;
239
+ }
240
+ if (start < 0) start = i;
241
+ }
242
+ if (start >= 0) {
243
+ words.push({ raw: query.slice(start), start, end: query.length });
244
+ }
245
+ return words;
246
+ }
247
+
248
+ /** A term split at its first colon: the token name and the value as typed. */
249
+ export interface SearchTermParts {
250
+ name: string;
251
+ /** The value with its quotes removed. */
252
+ value: string;
253
+ /** The value exactly as typed, quotes included. */
254
+ rawValue: string;
255
+ }
256
+
257
+ /**
258
+ * Split `name:value` at the first colon. A term with no colon, or one starting
259
+ * with a colon, is not a token attempt and returns `undefined`.
260
+ */
261
+ export function splitSearchTerm(word: string): SearchTermParts | undefined {
262
+ const colon = word.indexOf(":");
263
+ if (colon <= 0) return undefined;
264
+ const rawValue = word.slice(colon + 1);
265
+ return {
266
+ name: word.slice(0, colon).toLowerCase(),
267
+ value: unquote(rawValue),
268
+ rawValue,
269
+ };
270
+ }
271
+
45
272
  function parseDateToken(
46
273
  type: "before" | "after",
47
274
  raw: string,
@@ -53,39 +280,72 @@ function parseDateToken(
53
280
  return { type, raw, value, epochSeconds: Math.floor(epochMs / 1000) };
54
281
  }
55
282
 
283
+ /** The category a `category:` value names, by enum value or by chip label. */
284
+ export function resolveCategory(
285
+ value: string,
286
+ ): RemitImapMessageCategory | undefined {
287
+ const lower = value.toLowerCase();
288
+ const alias = CATEGORY_ALIASES[lower];
289
+ if (alias) return alias;
290
+ const known = Object.values(MessageCategory).find((c) => c === lower);
291
+ return known;
292
+ }
293
+
294
+ function parseStateToken(raw: string, value: string): SearchToken | undefined {
295
+ switch (value.toLowerCase()) {
296
+ case "unread":
297
+ return { type: "isUnread", raw };
298
+ case "read":
299
+ return { type: "isRead", raw };
300
+ // `flagged` is the wire name for IMAP \Flagged; "starred" is the word the
301
+ // chips use. Both are typed, so both parse.
302
+ case "starred":
303
+ case "flagged":
304
+ return { type: "isStarred", raw };
305
+ default:
306
+ return undefined;
307
+ }
308
+ }
309
+
56
310
  function parseWord(
57
311
  word: string,
58
312
  context: SearchTokenContext,
59
313
  ): SearchToken | undefined {
60
- const lower = word.toLowerCase();
61
- if (lower.startsWith("from:") && word.length > 5) {
62
- return { type: "from", raw: word, value: word.slice(5) };
63
- }
64
- if (lower === "has:attachment") {
65
- return { type: "hasAttachment", raw: word };
66
- }
67
- if (lower === "is:unread") {
68
- return { type: "isUnread", raw: word };
69
- }
70
- if (lower.startsWith("before:") && word.length > 7) {
71
- return parseDateToken("before", word, word.slice(7));
72
- }
73
- if (lower.startsWith("after:") && word.length > 6) {
74
- return parseDateToken("after", word, word.slice(6));
75
- }
76
- if (lower.startsWith("in:") && word.length > 3) {
77
- const value = word.slice(3);
78
- const mailboxId = context.mailboxesByName?.get(value.toLowerCase());
79
- if (!mailboxId) return undefined;
80
- return { type: "in", raw: word, value, mailboxId };
81
- }
82
- if (lower.startsWith("account:") && word.length > 8) {
83
- const value = word.slice(8);
84
- const accountId = context.accountsByName?.get(value.toLowerCase());
85
- if (!accountId) return undefined;
86
- return { type: "account", raw: word, value, accountId };
314
+ const parts = splitSearchTerm(word);
315
+ if (!parts || parts.value.length === 0) return undefined;
316
+ const { name, value } = parts;
317
+ switch (name) {
318
+ case "from":
319
+ return { type: "from", raw: word, value };
320
+ case "subject":
321
+ return { type: "subject", raw: word, value };
322
+ case "category": {
323
+ const category = resolveCategory(value);
324
+ if (!category) return undefined;
325
+ return { type: "category", raw: word, value, category };
326
+ }
327
+ case "is":
328
+ return parseStateToken(word, value);
329
+ case "has":
330
+ return value.toLowerCase() === "attachment"
331
+ ? { type: "hasAttachment", raw: word }
332
+ : undefined;
333
+ case "before":
334
+ case "after":
335
+ return parseDateToken(name, word, value);
336
+ case "in": {
337
+ const mailboxId = context.mailboxesByName?.get(value.toLowerCase());
338
+ if (!mailboxId) return undefined;
339
+ return { type: "in", raw: word, value, mailboxId };
340
+ }
341
+ case "account": {
342
+ const accountId = context.accountsByName?.get(value.toLowerCase());
343
+ if (!accountId) return undefined;
344
+ return { type: "account", raw: word, value, accountId };
345
+ }
346
+ default:
347
+ return undefined;
87
348
  }
88
- return undefined;
89
349
  }
90
350
 
91
351
  /** Split a query into recognized filter tokens and the remaining free text. */
@@ -93,22 +353,21 @@ export function parseSearchTokens(
93
353
  query: string,
94
354
  context: SearchTokenContext = {},
95
355
  ): ParsedSearchQuery {
96
- const words = query.split(/\s+/).filter((w) => w.length > 0);
97
356
  const tokens: SearchToken[] = [];
98
357
  const freeWords: string[] = [];
99
- for (const word of words) {
100
- const token = parseWord(word, context);
358
+ for (const word of splitSearchWords(query)) {
359
+ const token = parseWord(word.raw, context);
101
360
  if (token) tokens.push(token);
102
- else freeWords.push(word);
361
+ else freeWords.push(word.raw);
103
362
  }
104
363
  return { freeText: freeWords.join(" "), tokens };
105
364
  }
106
365
 
107
366
  /** Remove one parsed token's raw text from the original query string. */
108
367
  export function removeSearchToken(query: string, token: SearchToken): string {
109
- return query
110
- .split(/\s+/)
111
- .filter((w) => w.length > 0 && w !== token.raw)
368
+ return splitSearchWords(query)
369
+ .filter((word) => word.raw !== token.raw)
370
+ .map((word) => word.raw)
112
371
  .join(" ");
113
372
  }
114
373
 
@@ -117,10 +376,18 @@ export function searchTokenLabel(token: SearchToken): string {
117
376
  switch (token.type) {
118
377
  case "from":
119
378
  return `From: ${token.value}`;
379
+ case "subject":
380
+ return `Subject: ${token.value}`;
381
+ case "category":
382
+ return `Category: ${CATEGORY_LABELS[token.category]}`;
120
383
  case "hasAttachment":
121
384
  return "Has attachment";
122
385
  case "isUnread":
123
386
  return "Unread";
387
+ case "isRead":
388
+ return "Read";
389
+ case "isStarred":
390
+ return "Starred";
124
391
  case "before":
125
392
  return `Before ${token.value}`;
126
393
  case "after":
@@ -37,7 +37,7 @@ export const isInfiniteThreadData = (
37
37
  data !== null &&
38
38
  Array.isArray((data as { pages?: unknown }).pages);
39
39
 
40
- const isThreadItemsPage = (data: unknown): data is ThreadItemsPage =>
40
+ export const isThreadItemsPage = (data: unknown): data is ThreadItemsPage =>
41
41
  typeof data === "object" &&
42
42
  data !== null &&
43
43
  Array.isArray((data as { items?: unknown }).items);
@@ -0,0 +1,193 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
+ import type { InboxFilterParams } from "./inbox-filters.js";
5
+ import { parseSearchTokens, type SearchToken } from "./search-tokens.js";
6
+ import {
7
+ applyResidualTokens,
8
+ threadSearchTokens,
9
+ } from "./thread-search-tokens.js";
10
+
11
+ const tokensOf = (query: string): SearchToken[] =>
12
+ parseSearchTokens(query, {
13
+ accountsByName: new Map([["alice", "acc_1"]]),
14
+ mailboxesByName: new Map([["sent", "mb2"]]),
15
+ }).tokens;
16
+
17
+ const split = (query: string, chips: InboxFilterParams = {}) =>
18
+ threadSearchTokens(tokensOf(query), chips);
19
+
20
+ const residualTypes = (query: string, chips: InboxFilterParams = {}) =>
21
+ split(query, chips).residual.map((token) => token.type);
22
+
23
+ const thread = (
24
+ overrides: Partial<RemitImapThreadMessageResponse> = {},
25
+ ): RemitImapThreadMessageResponse => ({
26
+ threadId: "t1",
27
+ threadMessageId: "tm1",
28
+ messageId: "m1",
29
+ accountConfigId: "cfg_1",
30
+ accountId: "acc_1",
31
+ mailboxId: "mb1",
32
+ fromName: "Sender",
33
+ fromEmail: "sender@example.com",
34
+ subject: "Subject",
35
+ snippet: "Snippet",
36
+ category: "uncategorized",
37
+ sentDate: Date.parse("2026-03-01T00:00:00Z"),
38
+ isRead: false,
39
+ isDeleted: false,
40
+ hasAttachment: false,
41
+ hasStars: false,
42
+ star: "none",
43
+ senderTrust: "unknown",
44
+ muted: false,
45
+ createdAt: 0,
46
+ updatedAt: 0,
47
+ ...overrides,
48
+ });
49
+
50
+ describe("threadSearchTokens", () => {
51
+ test("every token the endpoint has a parameter for reaches the request", () => {
52
+ const { params, residual } = split(
53
+ 'from:dhl subject:"invoice due" category:newsletter is:starred has:attachment',
54
+ );
55
+ assert.deepEqual(params, {
56
+ from: "dhl",
57
+ subject: "invoice due",
58
+ category: ["newsletter"],
59
+ starred: true,
60
+ attachments: true,
61
+ });
62
+ assert.deepEqual(residual, []);
63
+ });
64
+
65
+ test("is:unread and is:read are the two sides of `unread`", () => {
66
+ assert.equal(split("is:unread").params.unread, true);
67
+ assert.equal(split("is:read").params.unread, false);
68
+ });
69
+
70
+ test("is:flagged is the same filter as is:starred", () => {
71
+ assert.equal(split("is:flagged").params.starred, true);
72
+ });
73
+
74
+ test("a category label resolves to the enum the request carries", () => {
75
+ assert.deepEqual(split("category:unclassified").params.category, [
76
+ "uncategorized",
77
+ ]);
78
+ });
79
+
80
+ test("no tokens set no parameters", () => {
81
+ assert.deepEqual(split("just words").params, {});
82
+ assert.deepEqual(split("just words").residual, []);
83
+ });
84
+ });
85
+
86
+ describe("threadSearchTokens residue", () => {
87
+ test("dates and the account have no parameter on this endpoint", () => {
88
+ assert.deepEqual(residualTypes("after:2026-01-01 before:2026-02-01"), [
89
+ "after",
90
+ "before",
91
+ ]);
92
+ assert.deepEqual(residualTypes("account:alice"), ["account"]);
93
+ });
94
+
95
+ test("a mailbox term is residue where it resolves at all", () => {
96
+ assert.deepEqual(residualTypes("in:sent"), ["in"]);
97
+ });
98
+
99
+ test("a second value for a single-valued parameter is residue", () => {
100
+ const { params, residual } = split("from:dhl from:ups");
101
+ assert.equal(params.from, "dhl");
102
+ assert.deepEqual(
103
+ residual.map((token) => token.raw),
104
+ ["from:ups"],
105
+ );
106
+ assert.deepEqual(split("subject:one subject:two").params.subject, "one");
107
+ assert.deepEqual(residualTypes("subject:one subject:two"), ["subject"]);
108
+ assert.deepEqual(residualTypes("category:social category:personal"), [
109
+ "category",
110
+ ]);
111
+ });
112
+
113
+ test("repeating a token asks nothing new, so nothing is left over", () => {
114
+ assert.deepEqual(residualTypes("from:dhl from:DHL"), []);
115
+ assert.deepEqual(residualTypes("is:starred is:starred"), []);
116
+ });
117
+
118
+ test("contradicting state tokens cannot both be sent", () => {
119
+ const { params, residual } = split("is:unread is:read");
120
+ assert.equal(params.unread, true);
121
+ assert.deepEqual(
122
+ residual.map((token) => token.type),
123
+ ["isRead"],
124
+ );
125
+ });
126
+ });
127
+
128
+ describe("threadSearchTokens against the filter chips", () => {
129
+ test("a chip that already asks for it leaves nothing over", () => {
130
+ assert.deepEqual(residualTypes("is:starred", { starred: true }), []);
131
+ assert.deepEqual(residualTypes("is:unread", { unread: true }), []);
132
+ assert.deepEqual(
133
+ residualTypes("category:personal", { category: ["personal"] }),
134
+ [],
135
+ );
136
+ });
137
+
138
+ test("the visible chip wins the parameter and the token becomes residue", () => {
139
+ assert.deepEqual(residualTypes("is:read", { unread: true }), ["isRead"]);
140
+ assert.deepEqual(
141
+ residualTypes("category:social", { category: ["personal"] }),
142
+ ["category"],
143
+ );
144
+ });
145
+ });
146
+
147
+ describe("applyResidualTokens", () => {
148
+ test("no residue leaves the rows untouched", () => {
149
+ const rows = [thread()];
150
+ assert.equal(applyResidualTokens(rows, []), rows);
151
+ });
152
+
153
+ test("a date the request could not carry is applied over the rows", () => {
154
+ const rows = [
155
+ thread({
156
+ messageId: "old",
157
+ sentDate: Date.parse("2026-01-01T00:00:00Z"),
158
+ }),
159
+ thread({
160
+ messageId: "new",
161
+ sentDate: Date.parse("2026-04-01T00:00:00Z"),
162
+ }),
163
+ ];
164
+ const { residual } = split("after:2026-02-01");
165
+ assert.deepEqual(
166
+ applyResidualTokens(rows, residual).map((row) => row.messageId),
167
+ ["new"],
168
+ );
169
+ });
170
+
171
+ test("a contradiction the request could not express matches nothing", () => {
172
+ const rows = [thread({ isRead: false })];
173
+ const { residual } = split("is:unread is:read");
174
+ assert.deepEqual(applyResidualTokens(rows, residual), []);
175
+ });
176
+
177
+ test("an account other than the row's drops the row", () => {
178
+ const { residual } = split("account:alice");
179
+ assert.equal(applyResidualTokens([thread()], residual).length, 1);
180
+ assert.equal(
181
+ applyResidualTokens([thread({ accountId: "acc_2" })], residual).length,
182
+ 0,
183
+ );
184
+ });
185
+
186
+ test("a per-mailbox row takes the account of the view it is listed in", () => {
187
+ const rows = [thread({ accountId: undefined })];
188
+ const { residual } = split("account:alice");
189
+ assert.equal(applyResidualTokens(rows, residual, "acc_1").length, 1);
190
+ assert.equal(applyResidualTokens(rows, residual, "acc_2").length, 0);
191
+ assert.equal(applyResidualTokens(rows, residual).length, 0);
192
+ });
193
+ });