@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
@@ -28,28 +28,43 @@ afterEach(() => {
28
28
  http = undefined;
29
29
  });
30
30
 
31
- const PREDICATE: OrganizeMatchPredicate = {
31
+ const COUNTABLE: OrganizeMatchPredicate = {
32
+ matchOperator: "And",
33
+ literalClauses: [{ field: "From", value: "receipts@shop.example" }],
34
+ };
35
+
36
+ /**
37
+ * A free-text search converts to a body-content clause, which the vector-free
38
+ * matcher refuses. The seed must not ask.
39
+ */
40
+ const UNCOUNTABLE: OrganizeMatchPredicate = {
32
41
  matchOperator: "And",
33
42
  literalClauses: [{ field: "HasWords", value: "receipts" }],
34
43
  };
35
44
 
36
- function Probe() {
37
- const seed = useSearchFilterSeed("acc-1", PREDICATE);
38
- return createElement(
39
- "div",
40
- null,
41
- JSON.stringify({
42
- seedCount: seed.seedCount ?? null,
43
- isPending: seed.isPending,
44
- isError: seed.isError,
45
- }),
46
- );
45
+ function probeFor(predicate: OrganizeMatchPredicate) {
46
+ return function Probe() {
47
+ const seed = useSearchFilterSeed("acc-1", predicate);
48
+ return createElement(
49
+ "div",
50
+ null,
51
+ JSON.stringify({
52
+ seedCount: seed.seedCount ?? null,
53
+ isPending: seed.isPending,
54
+ isError: seed.isError,
55
+ uncountable: seed.uncountable,
56
+ }),
57
+ );
58
+ };
47
59
  }
48
60
 
49
- const mount = (responder: (call: HttpCall) => unknown): DomHarness => {
61
+ const mount = (
62
+ responder: (call: HttpCall) => unknown,
63
+ predicate: OrganizeMatchPredicate = COUNTABLE,
64
+ ): DomHarness => {
50
65
  http = mockFetch(responder);
51
66
  harness = createDomHarness();
52
- harness.renderApp(createElement(Probe));
67
+ harness.renderApp(createElement(probeFor(predicate)));
53
68
  return harness;
54
69
  };
55
70
 
@@ -70,7 +85,7 @@ describe("useSearchFilterSeed", () => {
70
85
  assert.equal(previews.length, 1);
71
86
  assert.equal(previews[0].body?.anchorMessageId, undefined);
72
87
  assert.deepEqual(previews[0].body?.literalClauses, [
73
- { field: "HasWords", value: "receipts" },
88
+ { field: "From", value: "receipts@shop.example" },
74
89
  ]);
75
90
  });
76
91
 
@@ -80,4 +95,24 @@ describe("useSearchFilterSeed", () => {
80
95
  await dom.flush();
81
96
  assert.equal(state(dom).isError, true);
82
97
  });
98
+
99
+ it("never asks for a count the matcher cannot produce", async () => {
100
+ const dom = mount(
101
+ (call) =>
102
+ call.path.endsWith("/organize/preview")
103
+ ? { matchedCount: 12, messageIds: [] }
104
+ : {},
105
+ UNCOUNTABLE,
106
+ );
107
+ await dom.flush();
108
+ await dom.flush();
109
+ assert.equal(http?.to("/organize/preview").length, 0);
110
+ // Not pending and not an error — the editor opens, it just has no number.
111
+ assert.deepEqual(state(dom), {
112
+ seedCount: null,
113
+ isPending: false,
114
+ isError: false,
115
+ uncountable: true,
116
+ });
117
+ });
83
118
  });
@@ -2,11 +2,18 @@ import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-clien
2
2
  import { useMutation } from "@tanstack/react-query";
3
3
  import { useCallback, useEffect } from "react";
4
4
  import { buildOrganizeInput } from "@/lib/organize/organize-model";
5
+ import { isEvaluablePredicate } from "@/lib/organize/rule-model";
5
6
  import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
6
7
 
7
8
  interface SearchFilterSeed {
8
9
  /** The live count for the converted literal predicate, seeding the editor. */
9
10
  seedCount?: number;
11
+ /**
12
+ * The converted predicate is one the vector-free matcher refuses — a free-text
13
+ * search kept as a `HasWords` clause. There is no count to seed and no request
14
+ * to make.
15
+ */
16
+ uncountable: boolean;
10
17
  isPending: boolean;
11
18
  isError: boolean;
12
19
  error: unknown;
@@ -18,6 +25,14 @@ interface SearchFilterSeed {
18
25
  * literal predicate. One `POST /organize/preview` under the account the filter
19
26
  * targets — the count on screen is the set a literal-only filter applies to.
20
27
  *
28
+ * A search whose terms convert to a `HasWords` clause has no count to seed: the
29
+ * vector-free matcher reads no message bodies and rejects the predicate outright
30
+ * (`assertNoBodyContentClause`), so asking is a 500 and a 500 is not a count.
31
+ * That search is still a legitimate standing filter — the index-time matcher
32
+ * does read bodies — so the editor opens on the uncountable reason and holds only
33
+ * the one-time apply, the same way {@link useRulePreview} handles the clause
34
+ * being added by hand.
35
+ *
21
36
  * The deployment's semantic reach is not probed here; it is read from the
22
37
  * search's own "Related" results on the surface that opens the editor (RFC 038
23
38
  * D5), a direct signal that needs no request and cannot hit the wrong account.
@@ -28,9 +43,10 @@ export const useSearchFilterSeed = (
28
43
  ): SearchFilterSeed => {
29
44
  const seed = useMutation(organizeOperationsPreviewOrganizeMutation());
30
45
  const { mutate, reset } = seed;
46
+ const countable = isEvaluablePredicate(literalPredicate);
31
47
 
32
48
  const run = useCallback(() => {
33
- if (!accountId) return;
49
+ if (!accountId || !countable) return;
34
50
  reset();
35
51
  mutate({
36
52
  path: { accountId },
@@ -41,6 +57,7 @@ export const useSearchFilterSeed = (
41
57
  });
42
58
  }, [
43
59
  accountId,
60
+ countable,
44
61
  literalPredicate.matchOperator,
45
62
  literalPredicate.literalClauses,
46
63
  mutate,
@@ -53,8 +70,9 @@ export const useSearchFilterSeed = (
53
70
 
54
71
  return {
55
72
  seedCount: seed.data?.matchedCount,
56
- isPending: seed.isPending || seed.data === undefined,
57
- isError: seed.isError,
73
+ uncountable: !countable,
74
+ isPending: countable && (seed.isPending || seed.data === undefined),
75
+ isError: countable && seed.isError,
58
76
  error: seed.error,
59
77
  retry: run,
60
78
  };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * What the search box offers for the term under the caret (#428 follow-up).
3
+ *
4
+ * The decisions are all in `lib/search-suggestions.ts`; this is the wiring that
5
+ * gets them their data. A caret position names exactly one lookup — token names
6
+ * for a bare word, one token's values once its name is committed — so only that
7
+ * lookup runs, and the address search behind `from:` runs on a debounce rather
8
+ * than per keystroke.
9
+ *
10
+ * Folders and accounts are already loaded: the mailbox lists come from the same
11
+ * per-account query `useMailboxNameIndex` and `useResultFolderIndex` take, on
12
+ * the same key with the same infinite stale time, so react-query serves them
13
+ * from cache and nothing is fetched a second time.
14
+ *
15
+ * `in:` is offered only where the route resolves it. On a scoped view the parser
16
+ * leaves the token as free text (see `useSearchTokenContext`), so offering
17
+ * folders there would advertise a filter that does nothing.
18
+ */
19
+ import {
20
+ addressOperationsSearchAddressesOptions,
21
+ mailboxOperationsListMailboxesOptions,
22
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
23
+ import type { Suggestion } from "@remit/ui";
24
+ import { useQueries, useQuery } from "@tanstack/react-query";
25
+ import { useMemo } from "react";
26
+ import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
27
+ import { useMailContext } from "@/lib/mail-context";
28
+ import {
29
+ buildSearchSuggestions,
30
+ contactSuggestionValue,
31
+ SEARCH_SUGGESTION_LIMIT,
32
+ searchSuggestionRequest,
33
+ } from "@/lib/search-suggestions";
34
+ import {
35
+ buildAccountSuggestionValues,
36
+ buildMailboxSuggestionValues,
37
+ } from "@/lib/search-token-index";
38
+ import { useDebouncedValue } from "./useDebouncedValue";
39
+
40
+ /** How long typing settles before the address lookup fires. */
41
+ export const SEARCH_CONTACT_DEBOUNCE_MS = 250;
42
+
43
+ /** The shortest contact query worth a round-trip. */
44
+ const MIN_CONTACT_QUERY_LENGTH = 2;
45
+
46
+ export interface SearchSuggestionsInput {
47
+ /** The query exactly as typed. */
48
+ query: string;
49
+ /** Where the caret sits in it. */
50
+ cursor: number;
51
+ /** Offer nothing while the field is not being typed in. */
52
+ enabled: boolean;
53
+ }
54
+
55
+ export function useSearchSuggestions({
56
+ query,
57
+ cursor,
58
+ enabled,
59
+ }: SearchSuggestionsInput): Suggestion[] {
60
+ const { accounts } = useMailContext();
61
+ const { mailboxesByName } = useSearchTokenContext();
62
+ const offersFolders = mailboxesByName !== undefined;
63
+
64
+ const request = enabled ? searchSuggestionRequest(query, cursor) : undefined;
65
+ const contactQuery =
66
+ request?.source === "contact" ? request.query.trim() : "";
67
+ const debouncedContactQuery = useDebouncedValue(
68
+ contactQuery,
69
+ SEARCH_CONTACT_DEBOUNCE_MS,
70
+ );
71
+ const { data: addresses } = useQuery({
72
+ ...addressOperationsSearchAddressesOptions({
73
+ query: { q: debouncedContactQuery, limit: SEARCH_SUGGESTION_LIMIT },
74
+ }),
75
+ enabled: debouncedContactQuery.length >= MIN_CONTACT_QUERY_LENGTH,
76
+ });
77
+
78
+ const mailboxQueries = useQueries({
79
+ queries: accounts.map((account) => ({
80
+ ...mailboxOperationsListMailboxesOptions({
81
+ path: { accountId: account.accountId },
82
+ }),
83
+ staleTime: Infinity,
84
+ })),
85
+ });
86
+
87
+ const mailboxes = useMemo(
88
+ () =>
89
+ offersFolders
90
+ ? buildMailboxSuggestionValues(
91
+ mailboxQueries.map(
92
+ (mailboxQuery) => mailboxQuery.data?.items ?? [],
93
+ ),
94
+ accounts,
95
+ )
96
+ : [],
97
+ [offersFolders, mailboxQueries, accounts],
98
+ );
99
+ const accountValues = useMemo(
100
+ () => buildAccountSuggestionValues(accounts),
101
+ [accounts],
102
+ );
103
+ const contacts = useMemo(
104
+ () => (addresses?.items ?? []).map(contactSuggestionValue),
105
+ [addresses],
106
+ );
107
+
108
+ return useMemo(() => {
109
+ if (!enabled) return [];
110
+ const suggestions = buildSearchSuggestions(query, cursor, {
111
+ mailboxes,
112
+ accounts: accountValues,
113
+ contacts,
114
+ });
115
+ if (offersFolders) return suggestions;
116
+ return suggestions.filter((suggestion) => suggestion.value !== "in:");
117
+ }, [
118
+ enabled,
119
+ query,
120
+ cursor,
121
+ mailboxes,
122
+ accountValues,
123
+ contacts,
124
+ offersFolders,
125
+ ]);
126
+ }
Binary file
@@ -6,7 +6,7 @@ import { useRouterState } from "@tanstack/react-router";
6
6
  import { useMailContext } from "@/lib/mail-context";
7
7
  import { normalizeSearchQuery } from "@/lib/search-query";
8
8
  import { semanticMailboxScope } from "@/lib/search-scope";
9
- import { parseSearchTokens } from "@/lib/search-tokens";
9
+ import { parseSearchTokens, type SearchToken } from "@/lib/search-tokens";
10
10
  import { useSearchTokenContext } from "./useSearchTokenContext";
11
11
 
12
12
  /** Cap the "Related" section; the literal "Top matches" is the primary surface. */
@@ -22,6 +22,17 @@ const toCategoryParam = (
22
22
  ? (filterCategory as (typeof MessageCategory)[keyof typeof MessageCategory])
23
23
  : undefined;
24
24
 
25
+ /**
26
+ * `isRead` for the read-state tokens. `is:unread` and `is:read` are opposites,
27
+ * so a query carrying both asks for nothing; unread wins, being the narrower
28
+ * ask and the one the chips also offer.
29
+ */
30
+ const readStateParam = (tokens: SearchToken[]): boolean | undefined => {
31
+ if (tokens.some((t) => t.type === "isUnread")) return false;
32
+ if (tokens.some((t) => t.type === "isRead")) return true;
33
+ return undefined;
34
+ };
35
+
25
36
  interface UseSemanticSearchParams {
26
37
  /**
27
38
  * Restrict results to a single mailbox. A mailbox route pins the scope to its
@@ -43,10 +54,11 @@ interface UseSemanticSearchParams {
43
54
  * chip means this engine respects it like every other. Disabled until the query
44
55
  * is non-empty so an empty field issues no request.
45
56
  *
46
- * Filter tokens (`has:attachment`, `is:unread`, `before:`/`after:`) parsed from
47
- * the query map onto the search API's own filter params. `from:` and
48
- * `account:` have no equivalent on `GET /search/semantic` (no sender or
49
- * account filter) both still render as chips and narrow the literal engine,
57
+ * Filter tokens (`has:attachment`, `is:unread`/`is:read`, `is:starred`,
58
+ * `category:`, `before:`/`after:`) parsed from the query map onto the search
59
+ * API's own filter params. `from:`, `subject:` and `account:` have no
60
+ * equivalent on `GET /search/semantic` (no sender, subject or
61
+ * account filter) — all still render as chips and narrow the literal engine,
50
62
  * but never reach the semantic request (`account:` is a documented gap, see
51
63
  * doc/design/flows/06-search.md — the semantic index is per account config).
52
64
  * `in:` resolves to a mailboxId, so typing `in:archive` re-scopes the search
@@ -77,11 +89,17 @@ export function useSemanticSearch({
77
89
  // to rank.
78
90
  const enabled = freeText.length > 0;
79
91
 
80
- const category = toCategoryParam(filterCategory);
92
+ // The chip wins over the token: it is the visible narrowing, and the two can
93
+ // only disagree when the user set both.
94
+ const categoryToken = tokens.find((t) => t.type === "category");
95
+ const category = toCategoryParam(filterCategory) ?? categoryToken?.category;
81
96
  const hasAttachment = tokens.some((t) => t.type === "hasAttachment")
82
97
  ? true
83
98
  : undefined;
84
- const isRead = tokens.some((t) => t.type === "isUnread") ? false : undefined;
99
+ const hasStars = tokens.some((t) => t.type === "isStarred")
100
+ ? true
101
+ : undefined;
102
+ const isRead = readStateParam(tokens);
85
103
  const afterToken = tokens.find((t) => t.type === "after");
86
104
  const beforeToken = tokens.find((t) => t.type === "before");
87
105
  const inToken = tokens.find((t) => t.type === "in");
@@ -100,6 +118,7 @@ export function useSemanticSearch({
100
118
  limit: SEMANTIC_RESULT_LIMIT,
101
119
  ...(category !== undefined ? { category } : {}),
102
120
  ...(hasAttachment !== undefined ? { hasAttachment } : {}),
121
+ ...(hasStars !== undefined ? { hasStars } : {}),
103
122
  ...(isRead !== undefined ? { isRead } : {}),
104
123
  ...(afterToken ? { sentDateFrom: afterToken.epochSeconds } : {}),
105
124
  ...(beforeToken ? { sentDateTo: beforeToken.epochSeconds } : {}),
@@ -4,12 +4,13 @@
4
4
  * Delete, move, star and the compose requests (reply / reply-all / forward),
5
5
  * over the same mutation hooks the mailbox list uses. The mailbox view keys
6
6
  * them by its route; the brief and Flagged are cross-account, so they key by
7
- * the open thread's own `mailboxId` / `accountConfigId` (#149).
7
+ * the open thread's own `mailboxId` / `accountId` (#149).
8
8
  */
9
9
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
10
10
  import { useCallback, useState } from "react";
11
11
  import type { ComposeMode } from "@/components/compose/ComposeProvider";
12
12
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
13
+ import { useMailboxAccount } from "@/hooks/useMailboxAccount";
13
14
  import { useMoveMessages } from "@/hooks/useMoveMessages";
14
15
  import { useThreadMessageIds } from "@/hooks/useThreadMessageIds";
15
16
  import { useToggleStar } from "@/hooks/useToggleStar";
@@ -42,7 +43,15 @@ export const useThreadActions = ({
42
43
  onAfterOptimisticRemove,
43
44
  }: UseThreadActionsOptions): ThreadActions => {
44
45
  const resolvedMailboxId = mailboxId ?? thread?.mailboxId;
45
- const resolvedAccountId = accountId ?? thread?.accountConfigId;
46
+ // `accountConfigId` is the caller's own identity, not an account: every
47
+ // `/accounts/{accountId}/…` call made with it 404s. The row carries a real
48
+ // `accountId` only when it came from the unified listing, so anything else
49
+ // resolves through the mailbox cache.
50
+ const knownAccountId = accountId ?? thread?.accountId;
51
+ const { accountId: mailboxAccountId } = useMailboxAccount(
52
+ knownAccountId ? undefined : resolvedMailboxId,
53
+ );
54
+ const resolvedAccountId = knownAccountId ?? mailboxAccountId;
46
55
  const threadMessageIds = useThreadMessageIds();
47
56
 
48
57
  const { deleteMessages } = useDeleteMessages({
@@ -433,6 +433,73 @@ describe("matchesSearchTokens", () => {
433
433
  );
434
434
  });
435
435
 
436
+ test("is:read requires isRead true", () => {
437
+ const isRead: SearchToken = { type: "isRead", raw: "is:read" };
438
+ assert.strictEqual(
439
+ matchesSearchTokens(row({ id: "1", isRead: true }), [isRead]),
440
+ true,
441
+ );
442
+ assert.strictEqual(
443
+ matchesSearchTokens(row({ id: "1", isRead: false }), [isRead]),
444
+ false,
445
+ );
446
+ });
447
+
448
+ test("is:starred requires starred true", () => {
449
+ const isStarred: SearchToken = { type: "isStarred", raw: "is:starred" };
450
+ assert.strictEqual(
451
+ matchesSearchTokens(row({ id: "1", starred: true }), [isStarred]),
452
+ true,
453
+ );
454
+ assert.strictEqual(
455
+ matchesSearchTokens(row({ id: "1" }), [isStarred]),
456
+ false,
457
+ );
458
+ });
459
+
460
+ test("subject: matches the subject, case-insensitively", () => {
461
+ const subject: SearchToken = {
462
+ type: "subject",
463
+ raw: "subject:roadmap",
464
+ value: "RoadMap",
465
+ };
466
+ assert.strictEqual(
467
+ matchesSearchTokens(row({ id: "1", subject: "Q3 roadmap" }), [subject]),
468
+ true,
469
+ );
470
+ assert.strictEqual(
471
+ matchesSearchTokens(row({ id: "2", subject: "Invoice" }), [subject]),
472
+ false,
473
+ );
474
+ });
475
+
476
+ test("category: matches the row's category, unclassified included", () => {
477
+ const personal: SearchToken = {
478
+ type: "category",
479
+ raw: "category:personal",
480
+ value: "personal",
481
+ category: "personal",
482
+ };
483
+ const unclassified: SearchToken = {
484
+ type: "category",
485
+ raw: "category:unclassified",
486
+ value: "unclassified",
487
+ category: "uncategorized",
488
+ };
489
+ assert.strictEqual(
490
+ matchesSearchTokens(row({ id: "1", category: "personal" }), [personal]),
491
+ true,
492
+ );
493
+ assert.strictEqual(
494
+ matchesSearchTokens(row({ id: "2", category: "marketing" }), [personal]),
495
+ false,
496
+ );
497
+ assert.strictEqual(
498
+ matchesSearchTokens(row({ id: "3" }), [unclassified]),
499
+ true,
500
+ );
501
+ });
502
+
436
503
  test("after:/before: compare against sentDate (ms)", () => {
437
504
  const jan15 = row({
438
505
  id: "1",
package/src/lib/brief.ts CHANGED
@@ -54,7 +54,7 @@ export function toThreadRowData(
54
54
  const suspicious = thread.authenticity?.dkimMismatch === true;
55
55
  return {
56
56
  id: thread.messageId,
57
- accountId: thread.accountId ?? thread.accountConfigId,
57
+ accountId: thread.accountId,
58
58
  mailboxId: thread.mailboxId,
59
59
  fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
60
60
  fromEmail: thread.fromEmail ?? "",
@@ -212,10 +212,20 @@ export function matchesSearchTokens(
212
212
  t.fromName.toLowerCase().includes(needle)
213
213
  );
214
214
  }
215
+ case "subject":
216
+ return t.subject.toLowerCase().includes(token.value.toLowerCase());
217
+ // A row with no category is `uncategorized` — the pending state has a
218
+ // name (issue #45), so `category:unclassified` finds it.
219
+ case "category":
220
+ return (t.category ?? "uncategorized") === token.category;
215
221
  case "hasAttachment":
216
222
  return t.hasAttachment === true;
217
223
  case "isUnread":
218
224
  return !t.isRead;
225
+ case "isRead":
226
+ return t.isRead === true;
227
+ case "isStarred":
228
+ return t.starred === true;
219
229
  case "after":
220
230
  return t.sentDate != null && t.sentDate >= token.epochSeconds * 1000;
221
231
  case "before":
@@ -124,6 +124,35 @@ export const runChunkedAction = async (
124
124
  return { done, failedIds, cancelled: false };
125
125
  };
126
126
 
127
+ /**
128
+ * The same bounded run, adapted to the optimistic mutation hooks (#453).
129
+ *
130
+ * A selection is not always assembled by a surface that owns a progress bar —
131
+ * the daily brief's toolbar and sheet hand a raw id list to `useDeleteMessages`,
132
+ * `useMoveMessages`, `useToggleReadFor` and `useApplyLabel`, and select-all over
133
+ * a 200-row search would send all 200 in one call, which the endpoint rejects
134
+ * outright. Splitting the send inside those hooks puts the cap where the call is
135
+ * made, so every caller is covered by construction rather than by remembering.
136
+ *
137
+ * Each chunk is a full mutation of its own, so it keeps that hook's optimistic
138
+ * patch, rollback and error banner. The run stops at the first rejected chunk
139
+ * and reports nothing itself: the hook that owns the call has already raised it.
140
+ */
141
+ export const runChunkedMutation = async (
142
+ ids: readonly string[],
143
+ send: (chunk: string[]) => Promise<unknown>,
144
+ ): Promise<void> => {
145
+ await runChunkedAction(
146
+ ids,
147
+ async (chunk) => {
148
+ await send(chunk);
149
+ return { successCount: chunk.length, failureCount: 0 };
150
+ },
151
+ () => {},
152
+ () => false,
153
+ );
154
+ };
155
+
127
156
  export interface FetchIdsPageResult {
128
157
  ids: string[];
129
158
  continuationToken?: string;
package/src/lib/drafts.ts CHANGED
@@ -56,7 +56,7 @@ export function toImapDraftRowData(
56
56
  ): ThreadRowData {
57
57
  return {
58
58
  id: thread.messageId,
59
- accountId: thread.accountConfigId,
59
+ accountId: thread.accountId,
60
60
  fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
61
61
  fromEmail: thread.fromEmail ?? "",
62
62
  subject: thread.subject ?? "(No subject)",
@@ -0,0 +1,113 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ buildClauseSuggestions,
5
+ CLAUSE_SUGGESTION_LIMIT,
6
+ fieldTakesAddressSuggestions,
7
+ type KnownAddress,
8
+ } from "./clause-suggestions";
9
+
10
+ const selection: KnownAddress[] = [
11
+ { email: "receipts@stripe.com", displayName: "Stripe", fromSelection: true },
12
+ { email: "rides@lyft.com", fromSelection: true },
13
+ ];
14
+
15
+ describe("fieldTakesAddressSuggestions", () => {
16
+ it("offers values for the address fields only", () => {
17
+ assert.equal(fieldTakesAddressSuggestions("From"), true);
18
+ assert.equal(fieldTakesAddressSuggestions("FromDomain"), true);
19
+ assert.equal(fieldTakesAddressSuggestions("Subject"), false);
20
+ assert.equal(fieldTakesAddressSuggestions("HasWords"), false);
21
+ assert.equal(fieldTakesAddressSuggestions("ListId"), false);
22
+ });
23
+ });
24
+
25
+ describe("buildClauseSuggestions", () => {
26
+ it("offers the selection's addresses before anything is typed", () => {
27
+ const suggestions = buildClauseSuggestions("From", "", selection);
28
+ assert.deepEqual(
29
+ suggestions.map((s) => s.value),
30
+ ["receipts@stripe.com", "rides@lyft.com"],
31
+ );
32
+ assert.equal(suggestions[0].label, "Stripe");
33
+ assert.equal(suggestions[0].hint, "receipts@stripe.com");
34
+ assert.equal(suggestions[0].source, "selected");
35
+ });
36
+
37
+ it("leaves a free-text field with nothing to offer", () => {
38
+ assert.deepEqual(buildClauseSuggestions("Subject", "", selection), []);
39
+ assert.deepEqual(buildClauseSuggestions("HasWords", "str", selection), []);
40
+ });
41
+
42
+ it("collapses an address to its registrable domain for a domain clause", () => {
43
+ const suggestions = buildClauseSuggestions("FromDomain", "", [
44
+ { email: "receipts@mail.stripe.com" },
45
+ { email: "invoices@stripe.com" },
46
+ { email: "rides@lyft.co.uk" },
47
+ ]);
48
+ assert.deepEqual(
49
+ suggestions.map((s) => s.value),
50
+ ["stripe.com", "lyft.co.uk"],
51
+ );
52
+ });
53
+
54
+ it("drops an address it cannot resolve a domain for", () => {
55
+ assert.deepEqual(
56
+ buildClauseSuggestions("FromDomain", "", [{ email: "postmaster" }]),
57
+ [],
58
+ );
59
+ });
60
+
61
+ it("matches on the typed text, against the address and the display name", () => {
62
+ assert.deepEqual(
63
+ buildClauseSuggestions("From", "stri", selection).map((s) => s.value),
64
+ ["receipts@stripe.com"],
65
+ );
66
+ assert.deepEqual(
67
+ buildClauseSuggestions("From", "LYFT", selection).map((s) => s.value),
68
+ ["rides@lyft.com"],
69
+ );
70
+ });
71
+
72
+ it("offers nothing when the typed value matches none of them", () => {
73
+ assert.deepEqual(buildClauseSuggestions("From", "nobody", selection), []);
74
+ });
75
+
76
+ it("drops an offer the user has already typed in full", () => {
77
+ assert.deepEqual(
78
+ buildClauseSuggestions("From", " Receipts@Stripe.com ", selection).map(
79
+ (s) => s.value,
80
+ ),
81
+ [],
82
+ );
83
+ });
84
+
85
+ it("keeps the first occurrence of a repeated value, marking and all", () => {
86
+ const suggestions = buildClauseSuggestions("From", "", [
87
+ ...selection,
88
+ { email: "Receipts@stripe.com", displayName: "Stripe Billing" },
89
+ ]);
90
+ assert.equal(suggestions.length, 2);
91
+ assert.equal(suggestions[0].source, "selected");
92
+ assert.equal(suggestions[0].label, "Stripe");
93
+ });
94
+
95
+ it("skips a blank address", () => {
96
+ assert.deepEqual(
97
+ buildClauseSuggestions("From", "", [{ email: " " }, ...selection]).map(
98
+ (s) => s.value,
99
+ ),
100
+ ["receipts@stripe.com", "rides@lyft.com"],
101
+ );
102
+ });
103
+
104
+ it("caps the list so it stays a shortcut rather than a directory", () => {
105
+ const many: KnownAddress[] = Array.from({ length: 20 }, (_, index) => ({
106
+ email: `sender-${index}@example.com`,
107
+ }));
108
+ assert.equal(
109
+ buildClauseSuggestions("From", "", many).length,
110
+ CLAUSE_SUGGESTION_LIMIT,
111
+ );
112
+ });
113
+ });