@remit/web-client 0.0.86 → 0.0.88

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 (70) 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 +157 -38
  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 +5 -6
  22. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
  23. package/src/components/mail/organize/SearchFilterEditor.tsx +10 -6
  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/hooks/bulk-chunking.render.test.ts +157 -0
  30. package/src/hooks/useApplyLabel.ts +6 -3
  31. package/src/hooks/useClauseSuggestions.ts +57 -0
  32. package/src/hooks/useDeleteMessages.ts +6 -3
  33. package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
  34. package/src/hooks/useFollowFocusOpen.ts +81 -0
  35. package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
  36. package/src/hooks/useInitialSyncProgress.ts +134 -0
  37. package/src/hooks/useListCursor.ts +36 -5
  38. package/src/hooks/useMarkAsRead.ts +6 -3
  39. package/src/hooks/useMoveMessages.ts +6 -3
  40. package/src/hooks/useOrganizeWiden.ts +1 -2
  41. package/src/hooks/useRulePreview.ts +11 -1
  42. package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
  43. package/src/hooks/useSearchFilterSeed.ts +21 -3
  44. package/src/hooks/useSearchSuggestions.ts +126 -0
  45. package/src/hooks/useSelectedSubjects.ts +0 -0
  46. package/src/hooks/useSemanticSearch.ts +26 -7
  47. package/src/hooks/useThreadActions.ts +11 -2
  48. package/src/lib/brief.test.ts +67 -0
  49. package/src/lib/brief.ts +11 -1
  50. package/src/lib/bulk-actions.ts +29 -0
  51. package/src/lib/drafts.ts +1 -1
  52. package/src/lib/organize/clause-suggestions.test.ts +113 -0
  53. package/src/lib/organize/clause-suggestions.ts +89 -0
  54. package/src/lib/organize/rule-model.test.ts +57 -0
  55. package/src/lib/organize/rule-model.ts +117 -38
  56. package/src/lib/organize/search-to-rule.test.ts +8 -28
  57. package/src/lib/organize/search-to-rule.ts +33 -89
  58. package/src/lib/organize/sender-fallback.test.ts +1 -97
  59. package/src/lib/organize/sender-fallback.ts +8 -71
  60. package/src/lib/search-result.ts +2 -0
  61. package/src/lib/search-suggestions.test.ts +249 -0
  62. package/src/lib/search-suggestions.ts +296 -0
  63. package/src/lib/search-token-index.test.ts +99 -0
  64. package/src/lib/search-token-index.ts +67 -1
  65. package/src/lib/search-tokens.test.ts +236 -0
  66. package/src/lib/search-tokens.ts +303 -36
  67. package/src/lib/thread-cache.ts +1 -1
  68. package/src/lib/thread-search-tokens.test.ts +193 -0
  69. package/src/lib/thread-search-tokens.ts +148 -0
  70. package/src/routes/onboarding.tsx +9 -4
@@ -6,7 +6,9 @@ import type {
6
6
  } from "@remit/api-http-client/types.gen.ts";
7
7
  import {
8
8
  buildAccountNameIndex,
9
+ buildAccountSuggestionValues,
9
10
  buildMailboxNameIndex,
11
+ buildMailboxSuggestionValues,
10
12
  } from "./search-token-index.js";
11
13
 
12
14
  const account = (
@@ -110,3 +112,100 @@ describe("buildMailboxNameIndex", () => {
110
112
  assert.deepEqual(buildMailboxNameIndex([]), new Map());
111
113
  });
112
114
  });
115
+
116
+ describe("buildMailboxSuggestionValues", () => {
117
+ it("offers the leaf when no other folder shares it", () => {
118
+ const [offer] = buildMailboxSuggestionValues([
119
+ [mailbox({ mailboxId: "mb-1", fullPath: "INBOX/Archive" })],
120
+ ]);
121
+ assert.equal(offer?.value, "Archive");
122
+ assert.equal(offer?.label, "Archive");
123
+ });
124
+
125
+ it("offers the full path where the leaf would be ambiguous", () => {
126
+ const offers = buildMailboxSuggestionValues([
127
+ [mailbox({ mailboxId: "mb-1", fullPath: "INBOX/Archive" })],
128
+ [mailbox({ mailboxId: "mb-2", fullPath: "Work/archive" })],
129
+ ]);
130
+ assert.deepEqual(
131
+ offers.map((offer) => offer.value),
132
+ ["INBOX/Archive", "Work/archive"],
133
+ );
134
+ });
135
+
136
+ it("every offer resolves back through the index it was built beside", () => {
137
+ const lists = [
138
+ [
139
+ mailbox({ mailboxId: "mb-1", fullPath: "INBOX" }),
140
+ mailbox({ mailboxId: "mb-2", fullPath: "INBOX/Archive" }),
141
+ ],
142
+ [mailbox({ mailboxId: "mb-3", fullPath: "Team/Archive" })],
143
+ ];
144
+ const index = buildMailboxNameIndex(lists);
145
+ for (const offer of buildMailboxSuggestionValues(lists)) {
146
+ assert.ok(index.has(offer.value.toLowerCase()), offer.value);
147
+ }
148
+ });
149
+
150
+ it("reads the folder's own name, a rename winning over the path", () => {
151
+ const [offer] = buildMailboxSuggestionValues([
152
+ [
153
+ mailbox({
154
+ mailboxId: "mb-1",
155
+ fullPath: "INBOX/Archief",
156
+ displayNameOverride: " Archive ",
157
+ }),
158
+ ],
159
+ ]);
160
+ assert.equal(offer?.value, "Archief");
161
+ assert.equal(offer?.label, "Archive");
162
+ });
163
+
164
+ it("names the account only where more than one is loaded", () => {
165
+ const lists = [[mailbox({ mailboxId: "mb-1", fullPath: "Archive" })]];
166
+ const accounts = [
167
+ account({ accountId: "account-1", email: "me@example.com" }),
168
+ ];
169
+ assert.equal(
170
+ buildMailboxSuggestionValues(lists, accounts)[0]?.hint,
171
+ undefined,
172
+ );
173
+ assert.equal(
174
+ buildMailboxSuggestionValues(lists, [
175
+ ...accounts,
176
+ account({ accountId: "account-2", email: "work@example.com" }),
177
+ ])[0]?.hint,
178
+ "me@example.com",
179
+ );
180
+ });
181
+ });
182
+
183
+ describe("buildAccountSuggestionValues", () => {
184
+ it("offers the local part, read as the account's own name", () => {
185
+ const [offer] = buildAccountSuggestionValues([
186
+ account({
187
+ accountId: "acct-1",
188
+ email: "work@company.com",
189
+ displayName: "Work",
190
+ }),
191
+ ]);
192
+ assert.equal(offer?.value, "work");
193
+ assert.equal(offer?.label, "Work");
194
+ assert.equal(offer?.hint, "work@company.com");
195
+ });
196
+
197
+ it("offers the whole address where the local part is ambiguous", () => {
198
+ const offers = buildAccountSuggestionValues([
199
+ account({ accountId: "acct-1", email: "work@company.com" }),
200
+ account({ accountId: "acct-2", email: "work@other.com" }),
201
+ ]);
202
+ assert.deepEqual(
203
+ offers.map((offer) => offer.value),
204
+ ["work@company.com", "work@other.com"],
205
+ );
206
+ assert.deepEqual(
207
+ offers.map((offer) => offer.label),
208
+ ["work@company.com", "work@other.com"],
209
+ );
210
+ });
211
+ });
@@ -1,13 +1,20 @@
1
1
  /**
2
2
  * Builds the name -> id indexes `parseSearchTokens` uses to resolve `in:` and
3
- * `account:` tokens (#428 follow-up, see doc/design/flows/06-search.md). Pure
3
+ * `account:` tokens (#428 follow-up, see doc/design/flows/06-search.md), and the
4
+ * same names as the values the search box offers for those tokens. Pure
4
5
  * functions over already-loaded data; the caller owns fetching accounts and
5
6
  * mailboxes (`useMailboxNameIndex` fans out the mailbox-list queries).
7
+ *
8
+ * The offers are built from the same lists as the indexes so that every value on
9
+ * the list resolves back to the folder or account it was built from: what the
10
+ * box suggests and what the parser accepts cannot drift apart.
6
11
  */
7
12
  import type {
8
13
  RemitImapAccountResponse,
9
14
  RemitImapMailboxResponse,
10
15
  } from "@remit/api-http-client/types.gen.ts";
16
+ import { getMailboxDisplayName } from "./folder-roles.js";
17
+ import type { SearchSuggestionValue } from "./search-suggestions.js";
11
18
 
12
19
  /**
13
20
  * Account email (and its local-part before `@`) -> accountId, lower-cased.
@@ -54,3 +61,62 @@ export function buildMailboxNameIndex(
54
61
  }
55
62
  return index;
56
63
  }
64
+
65
+ /**
66
+ * The folders `in:` can name, in the shortest spelling that still resolves to
67
+ * the folder it names: the leaf where no other folder shares it, the full path
68
+ * where one does — the same rule the index resolves by, from the other side.
69
+ *
70
+ * The row reads the folder's own name (a user's rename wins) and carries the
71
+ * account it belongs to whenever more than one is loaded, because two accounts
72
+ * with an Archive each are otherwise two identical rows.
73
+ */
74
+ export function buildMailboxSuggestionValues(
75
+ mailboxesByAccount: readonly RemitImapMailboxResponse[][],
76
+ accounts: readonly RemitImapAccountResponse[] = [],
77
+ ): SearchSuggestionValue[] {
78
+ const all = mailboxesByAccount.flat().filter((mailbox) => mailbox.fullPath);
79
+ const leafCounts = new Map<string, number>();
80
+ for (const mailbox of all) {
81
+ const leaf = getMailboxDisplayName(mailbox.fullPath).toLowerCase();
82
+ leafCounts.set(leaf, (leafCounts.get(leaf) ?? 0) + 1);
83
+ }
84
+ const emailByAccountId = new Map(
85
+ accounts.map((account) => [account.accountId, account.email]),
86
+ );
87
+ return all.map((mailbox) => {
88
+ const leaf = getMailboxDisplayName(mailbox.fullPath);
89
+ const unique = leafCounts.get(leaf.toLowerCase()) === 1;
90
+ const hint =
91
+ accounts.length > 1 ? emailByAccountId.get(mailbox.accountId) : undefined;
92
+ return {
93
+ value: unique ? leaf : mailbox.fullPath,
94
+ label: mailbox.displayNameOverride?.trim() || leaf,
95
+ ...(hint ? { hint } : {}),
96
+ };
97
+ });
98
+ }
99
+
100
+ /**
101
+ * The accounts `account:` can name — the local part where it is unambiguous,
102
+ * the whole address where it is not — read as the account's own name.
103
+ */
104
+ export function buildAccountSuggestionValues(
105
+ accounts: readonly RemitImapAccountResponse[],
106
+ ): SearchSuggestionValue[] {
107
+ const localPartCounts = new Map<string, number>();
108
+ for (const account of accounts) {
109
+ const localPart = account.email.toLowerCase().split("@")[0] ?? "";
110
+ localPartCounts.set(localPart, (localPartCounts.get(localPart) ?? 0) + 1);
111
+ }
112
+ return accounts.map((account) => {
113
+ const localPart = account.email.split("@")[0] ?? account.email;
114
+ const unique = localPartCounts.get(localPart.toLowerCase()) === 1;
115
+ const displayName = account.displayName?.trim();
116
+ return {
117
+ value: unique ? localPart : account.email,
118
+ label: displayName || account.email,
119
+ ...(displayName ? { hint: account.email } : {}),
120
+ };
121
+ });
122
+ }
@@ -2,8 +2,14 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import {
4
4
  parseSearchTokens,
5
+ quoteSearchTokenValue,
5
6
  removeSearchToken,
7
+ SEARCH_TOKEN_SPECS,
8
+ type SearchToken,
6
9
  searchTokenLabel,
10
+ searchTokenSpec,
11
+ splitSearchTerm,
12
+ splitSearchWords,
7
13
  } from "./search-tokens.js";
8
14
 
9
15
  describe("parseSearchTokens", () => {
@@ -25,6 +31,14 @@ describe("parseSearchTokens", () => {
25
31
  ]);
26
32
  });
27
33
 
34
+ it("parses subject:", () => {
35
+ const result = parseSearchTokens("subject:invoice tax");
36
+ assert.equal(result.freeText, "tax");
37
+ assert.deepEqual(result.tokens, [
38
+ { type: "subject", raw: "subject:invoice", value: "invoice" },
39
+ ]);
40
+ });
41
+
28
42
  it("parses has:attachment", () => {
29
43
  const result = parseSearchTokens("receipts has:attachment");
30
44
  assert.equal(result.freeText, "receipts");
@@ -33,12 +47,70 @@ describe("parseSearchTokens", () => {
33
47
  ]);
34
48
  });
35
49
 
50
+ it("leaves an unknown has: value as free text", () => {
51
+ const result = parseSearchTokens("has:pictures receipts");
52
+ assert.equal(result.freeText, "has:pictures receipts");
53
+ assert.deepEqual(result.tokens, []);
54
+ });
55
+
36
56
  it("parses is:unread case-insensitively", () => {
37
57
  const result = parseSearchTokens("IS:UNREAD receipts");
38
58
  assert.equal(result.freeText, "receipts");
39
59
  assert.deepEqual(result.tokens, [{ type: "isUnread", raw: "IS:UNREAD" }]);
40
60
  });
41
61
 
62
+ it("parses is:read as its own state, not the absence of unread", () => {
63
+ const result = parseSearchTokens("is:read receipts");
64
+ assert.equal(result.freeText, "receipts");
65
+ assert.deepEqual(result.tokens, [{ type: "isRead", raw: "is:read" }]);
66
+ });
67
+
68
+ it("parses is:starred and its is:flagged wire spelling alike", () => {
69
+ assert.deepEqual(parseSearchTokens("is:starred").tokens, [
70
+ { type: "isStarred", raw: "is:starred" },
71
+ ]);
72
+ assert.deepEqual(parseSearchTokens("is:flagged").tokens, [
73
+ { type: "isStarred", raw: "is:flagged" },
74
+ ]);
75
+ });
76
+
77
+ it("leaves an unknown is: value as free text", () => {
78
+ const result = parseSearchTokens("is:important receipts");
79
+ assert.equal(result.freeText, "is:important receipts");
80
+ assert.deepEqual(result.tokens, []);
81
+ });
82
+
83
+ it("parses category: against the message-category vocabulary", () => {
84
+ const result = parseSearchTokens("category:Personal invoice");
85
+ assert.equal(result.freeText, "invoice");
86
+ assert.deepEqual(result.tokens, [
87
+ {
88
+ type: "category",
89
+ raw: "category:Personal",
90
+ value: "Personal",
91
+ category: "personal",
92
+ },
93
+ ]);
94
+ });
95
+
96
+ it("accepts the label the chips show for the pending category", () => {
97
+ const result = parseSearchTokens("category:unclassified");
98
+ assert.deepEqual(result.tokens, [
99
+ {
100
+ type: "category",
101
+ raw: "category:unclassified",
102
+ value: "unclassified",
103
+ category: "uncategorized",
104
+ },
105
+ ]);
106
+ });
107
+
108
+ it("leaves an unknown category as free text", () => {
109
+ const result = parseSearchTokens("category:urgent invoice");
110
+ assert.equal(result.freeText, "category:urgent invoice");
111
+ assert.deepEqual(result.tokens, []);
112
+ });
113
+
42
114
  it("parses before: and after: as epoch seconds", () => {
43
115
  const result = parseSearchTokens("after:2024-01-01 before:2024-02-01 tax");
44
116
  assert.equal(result.freeText, "tax");
@@ -143,6 +215,137 @@ describe("parseSearchTokens", () => {
143
215
  assert.equal(result.freeText, "from:");
144
216
  assert.deepEqual(result.tokens, []);
145
217
  });
218
+
219
+ it("leaves an unknown token as free text rather than erroring", () => {
220
+ const result = parseSearchTokens("label:urgent to:bob invoice");
221
+ assert.equal(result.freeText, "label:urgent to:bob invoice");
222
+ assert.deepEqual(result.tokens, []);
223
+ });
224
+
225
+ it("takes a quoted value as one term", () => {
226
+ const result = parseSearchTokens('in:"Sent Items" invoice', {
227
+ mailboxesByName: new Map([["sent items", "mailbox-2"]]),
228
+ });
229
+ assert.equal(result.freeText, "invoice");
230
+ assert.deepEqual(result.tokens, [
231
+ {
232
+ type: "in",
233
+ raw: 'in:"Sent Items"',
234
+ value: "Sent Items",
235
+ mailboxId: "mailbox-2",
236
+ },
237
+ ]);
238
+ });
239
+
240
+ it("reads an unterminated quote to the end, so a half-typed value still parses", () => {
241
+ const result = parseSearchTokens('subject:"quarterly report');
242
+ assert.deepEqual(result.tokens, [
243
+ {
244
+ type: "subject",
245
+ raw: 'subject:"quarterly report',
246
+ value: "quarterly report",
247
+ },
248
+ ]);
249
+ });
250
+
251
+ it("keeps quoted free text as it was typed", () => {
252
+ const result = parseSearchTokens('"exact phrase" is:unread');
253
+ assert.equal(result.freeText, '"exact phrase"');
254
+ assert.equal(result.tokens.length, 1);
255
+ });
256
+ });
257
+
258
+ describe("splitSearchWords", () => {
259
+ it("reports where each term sits in the query", () => {
260
+ assert.deepEqual(splitSearchWords("a bc"), [
261
+ { raw: "a", start: 0, end: 1 },
262
+ { raw: "bc", start: 3, end: 5 },
263
+ ]);
264
+ });
265
+
266
+ it("keeps a quoted run together", () => {
267
+ assert.deepEqual(splitSearchWords('in:"Sent Items" x'), [
268
+ { raw: 'in:"Sent Items"', start: 0, end: 15 },
269
+ { raw: "x", start: 16, end: 17 },
270
+ ]);
271
+ });
272
+
273
+ it("has no terms in an empty query", () => {
274
+ assert.deepEqual(splitSearchWords(" "), []);
275
+ });
276
+ });
277
+
278
+ describe("splitSearchTerm", () => {
279
+ it("splits at the first colon and lower-cases the name", () => {
280
+ assert.deepEqual(splitSearchTerm("From:a:b"), {
281
+ name: "from",
282
+ value: "a:b",
283
+ rawValue: "a:b",
284
+ });
285
+ });
286
+
287
+ it("unquotes the value", () => {
288
+ assert.deepEqual(splitSearchTerm('in:"Sent Items"'), {
289
+ name: "in",
290
+ value: "Sent Items",
291
+ rawValue: '"Sent Items"',
292
+ });
293
+ });
294
+
295
+ it("is not a token attempt without a name before the colon", () => {
296
+ assert.equal(splitSearchTerm("invoice"), undefined);
297
+ assert.equal(splitSearchTerm(":30"), undefined);
298
+ });
299
+ });
300
+
301
+ describe("quoteSearchTokenValue", () => {
302
+ it("quotes a value with whitespace and leaves a bare one alone", () => {
303
+ assert.equal(quoteSearchTokenValue("Sent Items"), '"Sent Items"');
304
+ assert.equal(quoteSearchTokenValue("Archive"), "Archive");
305
+ });
306
+
307
+ it("round-trips through the parser", () => {
308
+ const query = `in:${quoteSearchTokenValue("Sent Items")}`;
309
+ const result = parseSearchTokens(query, {
310
+ mailboxesByName: new Map([["sent items", "mailbox-2"]]),
311
+ });
312
+ assert.equal(result.tokens.length, 1);
313
+ assert.equal(result.freeText, "");
314
+ });
315
+ });
316
+
317
+ describe("SEARCH_TOKEN_SPECS", () => {
318
+ it("carries a spec for every name the parser accepts", () => {
319
+ for (const spec of SEARCH_TOKEN_SPECS) {
320
+ assert.equal(searchTokenSpec(spec.name)?.name, spec.name);
321
+ }
322
+ });
323
+
324
+ it("offers only values that parse to a token", () => {
325
+ for (const spec of SEARCH_TOKEN_SPECS) {
326
+ for (const option of spec.options ?? []) {
327
+ const { tokens } = parseSearchTokens(`${spec.name}:${option.value}`);
328
+ assert.equal(
329
+ tokens.length,
330
+ 1,
331
+ `${spec.name}:${option.value} did not parse`,
332
+ );
333
+ }
334
+ }
335
+ });
336
+
337
+ it("names every category the API carries", () => {
338
+ const category = SEARCH_TOKEN_SPECS.find((s) => s.name === "category");
339
+ assert.deepEqual(category?.options?.map((o) => o.value).sort(), [
340
+ "automated",
341
+ "marketing",
342
+ "newsletter",
343
+ "personal",
344
+ "social",
345
+ "transactional",
346
+ "uncategorized",
347
+ ]);
348
+ });
146
349
  });
147
350
 
148
351
  describe("removeSearchToken", () => {
@@ -155,6 +358,17 @@ describe("removeSearchToken", () => {
155
358
  assert.equal(next, "parcel delivery");
156
359
  });
157
360
 
361
+ it("removes a quoted token whole", () => {
362
+ const query = 'invoice in:"Sent Items" tax';
363
+ const token: SearchToken = {
364
+ type: "in",
365
+ raw: 'in:"Sent Items"',
366
+ value: "Sent Items",
367
+ mailboxId: "mailbox-2",
368
+ };
369
+ assert.equal(removeSearchToken(query, token), "invoice tax");
370
+ });
371
+
158
372
  it("is a no-op when the token isn't present", () => {
159
373
  const next = removeSearchToken("parcel delivery", {
160
374
  type: "hasAttachment",
@@ -170,6 +384,23 @@ describe("searchTokenLabel", () => {
170
384
  searchTokenLabel({ type: "from", raw: "from:alice", value: "alice" }),
171
385
  "From: alice",
172
386
  );
387
+ assert.equal(
388
+ searchTokenLabel({
389
+ type: "subject",
390
+ raw: "subject:invoice",
391
+ value: "invoice",
392
+ }),
393
+ "Subject: invoice",
394
+ );
395
+ assert.equal(
396
+ searchTokenLabel({
397
+ type: "category",
398
+ raw: "category:uncategorized",
399
+ value: "uncategorized",
400
+ category: "uncategorized",
401
+ }),
402
+ "Category: Unclassified",
403
+ );
173
404
  assert.equal(
174
405
  searchTokenLabel({ type: "hasAttachment", raw: "has:attachment" }),
175
406
  "Has attachment",
@@ -178,6 +409,11 @@ describe("searchTokenLabel", () => {
178
409
  searchTokenLabel({ type: "isUnread", raw: "is:unread" }),
179
410
  "Unread",
180
411
  );
412
+ assert.equal(searchTokenLabel({ type: "isRead", raw: "is:read" }), "Read");
413
+ assert.equal(
414
+ searchTokenLabel({ type: "isStarred", raw: "is:starred" }),
415
+ "Starred",
416
+ );
181
417
  assert.equal(
182
418
  searchTokenLabel({
183
419
  type: "before",