@remit/web-client 0.0.278 → 0.0.280

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.
@@ -11,11 +11,7 @@ import {
11
11
  BRIEF_CATEGORIES,
12
12
  type BriefCategoryResult,
13
13
  briefSections,
14
- briefSectionTotal,
15
- excludeMutedSenders,
16
- matchesBriefSearch,
17
14
  matchesSearchTokens,
18
- mergeSearchRows,
19
15
  toThreadRowData,
20
16
  } from "./brief.js";
21
17
  import type { SearchToken } from "./search-tokens.js";
@@ -288,157 +284,6 @@ describe("briefSections", () => {
288
284
  });
289
285
  });
290
286
 
291
- // issue #301: `Address.flags.muted` is denormalized onto each row as
292
- // `muted`; the brief excludes those rows before grouping into sections.
293
- describe("excludeMutedSenders", () => {
294
- test("drops a row whose sender is muted", () => {
295
- const kept = threadResponse({ threadMessageId: "keep" });
296
- const muted = threadResponse({ threadMessageId: "mute-me", muted: true });
297
- const result = excludeMutedSenders([kept, muted]);
298
- assert.deepStrictEqual(
299
- result.map((t) => t.threadMessageId),
300
- ["keep"],
301
- );
302
- });
303
-
304
- test("keeps rows whose sender is not muted", () => {
305
- const rows = [
306
- threadResponse({ threadMessageId: "a", muted: false }),
307
- threadResponse({ threadMessageId: "b" }),
308
- ];
309
- assert.strictEqual(excludeMutedSenders(rows).length, 2);
310
- });
311
-
312
- test("returns an empty array when every sender is muted", () => {
313
- const rows = [
314
- threadResponse({ threadMessageId: "a", muted: true }),
315
- threadResponse({ threadMessageId: "b", muted: true }),
316
- ];
317
- assert.deepStrictEqual(excludeMutedSenders(rows), []);
318
- });
319
-
320
- test("a muted sender's message is excluded from every section, not folded into uncategorized", () => {
321
- const rows = [
322
- threadResponse({
323
- threadMessageId: "muted-personal",
324
- messageId: "muted-personal",
325
- category: "personal",
326
- muted: true,
327
- }),
328
- threadResponse({
329
- threadMessageId: "kept-personal",
330
- messageId: "kept-personal",
331
- category: "personal",
332
- }),
333
- ];
334
- const kept = excludeMutedSenders(rows).map(toThreadRowData);
335
- const sections = briefSections([
336
- {
337
- category: "personal",
338
- rows: kept,
339
- total: { kind: "exact", value: kept.length },
340
- atCap: false,
341
- loading: false,
342
- failed: false,
343
- },
344
- ]);
345
- const allIds = sections.flatMap((s) => s.threads.map((t) => t.id));
346
- assert.deepStrictEqual(allIds, ["kept-personal"]);
347
- });
348
-
349
- test("muting every candidate row leaves no sections (brief empty state)", () => {
350
- const rows = [
351
- threadResponse({
352
- threadMessageId: "a",
353
- messageId: "a",
354
- category: "personal",
355
- muted: true,
356
- }),
357
- threadResponse({
358
- threadMessageId: "b",
359
- messageId: "b",
360
- category: "newsletter",
361
- muted: true,
362
- }),
363
- ];
364
- const kept = excludeMutedSenders(rows).map(toThreadRowData);
365
- const sections = briefSections([
366
- {
367
- category: "personal",
368
- rows: kept,
369
- total: { kind: "exact", value: 0 },
370
- atCap: false,
371
- loading: false,
372
- failed: false,
373
- },
374
- ]);
375
- assert.deepStrictEqual(sections, []);
376
- });
377
- });
378
-
379
- // The count is the server's, taken over a scope that includes senders the reader
380
- // muted — `listAllThreads` has no `muted` parameter. The list drops their mail,
381
- // so the number would overstate what is on screen.
382
- describe("briefSectionTotal", () => {
383
- const counted: ResultCount = { kind: "exact", value: 4753 };
384
-
385
- test("keeps the count when no sender in the rows is muted", () => {
386
- assert.deepStrictEqual(
387
- briefSectionTotal(counted, [
388
- threadResponse({ threadMessageId: "a" }),
389
- threadResponse({ threadMessageId: "b", muted: false }),
390
- ]),
391
- counted,
392
- );
393
- });
394
-
395
- test("withholds the count when the rows show a muted sender", () => {
396
- assert.deepStrictEqual(
397
- briefSectionTotal(counted, [
398
- threadResponse({ threadMessageId: "a" }),
399
- threadResponse({ threadMessageId: "b", muted: true }),
400
- ]),
401
- { kind: "unknown" },
402
- );
403
- });
404
-
405
- test("a count nobody took stays absent", () => {
406
- assert.deepStrictEqual(briefSectionTotal({ kind: "unknown" }, []), {
407
- kind: "unknown",
408
- });
409
- });
410
- });
411
-
412
- describe("matchesBriefSearch", () => {
413
- const r = row({
414
- id: "1",
415
- fromName: "Alice Tan",
416
- fromEmail: "alice@example.com",
417
- subject: "Q3 roadmap",
418
- snippet: "See the attached deck",
419
- });
420
-
421
- test("matches on fromName (case-insensitive)", () => {
422
- assert.strictEqual(matchesBriefSearch(r, "alice"), true);
423
- });
424
-
425
- test("matches on fromEmail", () => {
426
- assert.strictEqual(matchesBriefSearch(r, "alice@example"), true);
427
- });
428
-
429
- test("matches on subject", () => {
430
- assert.strictEqual(matchesBriefSearch(r, "roadmap"), true);
431
- });
432
-
433
- test("matches on snippet", () => {
434
- assert.strictEqual(matchesBriefSearch(r, "deck"), true);
435
- });
436
-
437
- test("returns false when query matches nothing", () => {
438
- assert.strictEqual(matchesBriefSearch(r, "zyxwvuts"), false);
439
- });
440
- });
441
-
442
287
  describe("matchesSearchTokens", () => {
443
288
  const from = (value: string): SearchToken => ({
444
289
  type: "from",
@@ -646,82 +491,3 @@ describe("matchesSearchTokens", () => {
646
491
  );
647
492
  });
648
493
  });
649
-
650
- // #49: a listing complete under its own criteria still cannot see a snippet
651
- // match, and the server's cross-folder search cannot see one either. The two
652
- // halves are merged. The brief no longer uses this — its sections are pages, and
653
- // merging a page with a search window orders two truncated lists (#312).
654
- describe("mergeSearchRows", () => {
655
- test("keeps rows the server found in other folders", () => {
656
- const merged = mergeSearchRows(
657
- [row({ id: "inbox-hit", sentDate: 300 })],
658
- [
659
- row({ id: "archive-hit", sentDate: 200 }),
660
- row({ id: "spam-hit", sentDate: 100 }),
661
- ],
662
- );
663
-
664
- assert.deepEqual(
665
- merged.map((r) => r.id),
666
- ["inbox-hit", "archive-hit", "spam-hit"],
667
- );
668
- });
669
-
670
- test("keeps a snippet-only match the server cannot see", () => {
671
- const merged = mergeSearchRows(
672
- [row({ id: "snippet-only", sentDate: 100 })],
673
- [],
674
- );
675
-
676
- assert.deepEqual(
677
- merged.map((r) => r.id),
678
- ["snippet-only"],
679
- );
680
- });
681
-
682
- test("the two sources overlap on INBOX, so rows are deduped", () => {
683
- const merged = mergeSearchRows(
684
- [row({ id: "shared", sentDate: 200, subject: "from the brief" })],
685
- [
686
- row({ id: "shared", sentDate: 200, subject: "from the search" }),
687
- row({ id: "archive-hit", sentDate: 100 }),
688
- ],
689
- );
690
-
691
- assert.deepEqual(
692
- merged.map((r) => r.id),
693
- ["shared", "archive-hit"],
694
- );
695
- assert.equal(merged[0].subject, "from the brief");
696
- });
697
-
698
- test("the union reads newest first, interleaving both sources", () => {
699
- const merged = mergeSearchRows(
700
- [
701
- row({ id: "brief-new", sentDate: 400 }),
702
- row({ id: "brief-old", sentDate: 200 }),
703
- ],
704
- [
705
- row({ id: "search-mid", sentDate: 300 }),
706
- row({ id: "search-oldest", sentDate: 100 }),
707
- ],
708
- );
709
-
710
- assert.deepEqual(
711
- merged.map((r) => r.id),
712
- ["brief-new", "search-mid", "brief-old", "search-oldest"],
713
- );
714
- });
715
-
716
- test("a row without a sentDate sorts last", () => {
717
- const merged = mergeSearchRows(
718
- [row({ id: "undated" })],
719
- [row({ id: "dated", sentDate: 100 })],
720
- );
721
-
722
- assert.deepEqual(
723
- merged.map((r) => r.id),
724
- ["dated", "undated"],
725
- );
726
- });
727
- });
package/src/lib/brief.ts CHANGED
@@ -28,11 +28,11 @@
28
28
  * a high-volume mailbox read≠handled and unread≠important; unread is a
29
29
  * user-selectable filter chip instead.
30
30
  *
31
- * Muted senders and categories the scope holds none of are excluded. Mute
32
- * filtering happens in `excludeMutedSenders`, applied by the caller to the raw
33
- * thread rows before `toThreadRowData` — the server denormalizes `muted` onto
34
- * each row from the From address's flags (RFC 039 Decision 3, issue #301), so no
35
- * client-side Address lookup is needed.
31
+ * Muted senders and categories the scope holds none of are excluded. Mute is
32
+ * `muted=false` on each section's request, so the rows and the count answer the
33
+ * same predicate — dropping muted rows here instead left a header stating a
34
+ * number larger than the list under it, and a "Show all" that opened mail the
35
+ * brief would not render (#1137).
36
36
  */
37
37
 
38
38
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
@@ -80,71 +80,6 @@ export function toThreadRowData(
80
80
  };
81
81
  }
82
82
 
83
- /**
84
- * Excludes rows whose From address is muted (`thread.muted === true`,
85
- * denormalized server-side from `Address.flags.muted`). Muting hides a
86
- * sender from the brief only — it never deletes, marks read, or moves their
87
- * mail, so callers outside the brief (mailbox listings, search) must not
88
- * apply this filter.
89
- */
90
- /**
91
- * The section's count, or no number, once mute is taken into account.
92
- *
93
- * Mute is the one criterion the request cannot be asked about: `listAllThreads`
94
- * has no `muted` parameter, so a scope holding a muted sender is counted with
95
- * that sender's mail in it while the list renders without it. The header would
96
- * overstate, and "Show all" would offer rows the brief will not show.
97
- *
98
- * Withheld per section rather than for the whole brief — one muted marketer must
99
- * not take the number off Personal — and only where a mute is visible in the
100
- * rows on hand. A muted sender below a section's page is not visible, so this
101
- * suppresses a number known to be wrong rather than certifying the rest.
102
- */
103
- export function briefSectionTotal(
104
- total: ResultCount,
105
- rows: RemitImapThreadMessageResponse[],
106
- ): ResultCount {
107
- return rows.some((row) => row.muted === true) ? { kind: "unknown" } : total;
108
- }
109
-
110
- export function excludeMutedSenders(
111
- threads: RemitImapThreadMessageResponse[],
112
- ): RemitImapThreadMessageResponse[] {
113
- return threads.filter((t) => t.muted !== true);
114
- }
115
-
116
- /**
117
- * Union of a complete server-filtered listing with the rows the server's
118
- * cross-folder text search returned, newest first.
119
- *
120
- * Both lists are needed where the listing itself is complete under its criteria
121
- * — the Flagged collection is, and pages to its end. The server matches subject
122
- * and From only, so a row whose snippet carries the term is found only by the
123
- * client-side pass; the server pass is the only one that reaches Archive, Sent,
124
- * Spam and custom folders. The two overlap, so rows are deduped by id, the first
125
- * occurrence winning.
126
- *
127
- * Not for a paginated prefix. The brief's sections are per-category pages, so
128
- * merging one with a search window and re-sorting would order two truncated
129
- * lists against each other and call the result newest-first (#312).
130
- *
131
- * Rows without a `sentDate` sort last; the brief's own list is already newest
132
- * first, so this only has to re-interleave the two sources.
133
- */
134
- export function mergeSearchRows(
135
- briefRows: ThreadRowData[],
136
- searchRows: ThreadRowData[],
137
- ): ThreadRowData[] {
138
- const seen = new Set<string>();
139
- const merged: ThreadRowData[] = [];
140
- for (const row of [...briefRows, ...searchRows]) {
141
- if (seen.has(row.id)) continue;
142
- seen.add(row.id);
143
- merged.push(row);
144
- }
145
- return merged.sort((a, b) => (b.sentDate ?? 0) - (a.sentDate ?? 0));
146
- }
147
-
148
83
  /**
149
84
  * The categories the brief asks for, in the order it renders them. A category
150
85
  * is both the section's own scope and the query parameter its request carries;
@@ -210,19 +145,6 @@ export function briefSections(
210
145
  return sections;
211
146
  }
212
147
 
213
- /**
214
- * Returns true when `t` matches the free-text `query` (lower-cased).
215
- * Checked against fromName, fromEmail, subject, and snippet.
216
- */
217
- export function matchesBriefSearch(t: ThreadRowData, query: string): boolean {
218
- return (
219
- t.fromName.toLowerCase().includes(query) ||
220
- t.fromEmail.toLowerCase().includes(query) ||
221
- t.subject.toLowerCase().includes(query) ||
222
- t.snippet.toLowerCase().includes(query)
223
- );
224
- }
225
-
226
148
  /**
227
149
  * Returns true when `t` satisfies every token handed to it.
228
150
  *
@@ -234,10 +156,12 @@ export function matchesBriefSearch(t: ThreadRowData, query: string): boolean {
234
156
  * the rows fetched so far" (#312).
235
157
  *
236
158
  * Every token is implemented so that a request carrying fewer parameters still
237
- * has somewhere to put the rest: `listAllThreads` has no `from` or `subject` of
238
- * its own, and no endpoint has `before:`, `after:`, `in:` or `account:`. A row
239
- * without the data a token needs (no `sentDate` for `before:`/`after:`) never
240
- * matches it, so it drops out rather than showing under an unverifiable filter.
159
+ * has somewhere to put the rest: no endpoint has `before:`, `after:`, `in:` or
160
+ * `account:` in the set these tokens are split against, and each takes one
161
+ * `from` and one `subject`, so a second of either — or one a chip overruled —
162
+ * lands here. A row without the data a token needs (no `sentDate` for
163
+ * `before:`/`after:`) never matches it, so it drops out rather than showing
164
+ * under an unverifiable filter.
241
165
  */
242
166
  export function matchesSearchTokens(
243
167
  t: ThreadRowData,
@@ -71,16 +71,31 @@ describe("flaggedCriteria", () => {
71
71
  assert.deepEqual(residualTypes("is:starred"), []);
72
72
  });
73
73
 
74
- // No `from`/`subject` parameter on this endpoint: its one text parameter
75
- // matches both at once, so these stay a pass over the returned rows rather
76
- // than being dropped from a request that never carried them.
74
+ // #1128: the free-text parameter matches subject and From at once, so
75
+ // neither token could be asked for on its own. Both applied over the pages
76
+ // already fetched, and a starred collection is paged — a match below them
77
+ // never appeared. Each has its own parameter now.
78
+ test("from: and subject: travel as parameters", () => {
79
+ assert.deepEqual(criteriaOf("from:alice"), { from: "alice" });
80
+ assert.deepEqual(residualTypes("from:alice"), []);
81
+ assert.deepEqual(criteriaOf("subject:invoice"), { subject: "invoice" });
82
+ assert.deepEqual(residualTypes("subject:invoice"), []);
83
+ });
84
+
77
85
  test("keeps the tokens no parameter carries", () => {
78
- assert.deepEqual(residualTypes("from:alice"), ["from"]);
79
86
  assert.deepEqual(residualTypes("before:2026-01-01"), ["before"]);
80
87
  assert.deepEqual(residualTypes("in:sent"), ["in"]);
81
88
  assert.deepEqual(residualTypes("account:alice"), ["account"]);
82
89
  });
83
90
 
91
+ // One parameter per field, so only the first value can be expressed.
92
+ test("a second subject: stays residue", () => {
93
+ assert.deepEqual(criteriaOf("subject:one subject:two"), {
94
+ subject: "one",
95
+ });
96
+ assert.deepEqual(residualTypes("subject:one subject:two"), ["subject"]);
97
+ });
98
+
84
99
  test("the chip wins over a token that contradicts it", () => {
85
100
  const { criteria, residual } = flaggedCriteria(
86
101
  chips("all", ["unread"]),
@@ -2,12 +2,12 @@
2
2
  * What the Flagged view asks `listAllThreads` for, and what is left over.
3
3
  *
4
4
  * Every criterion the view offers — the category chip, the Unread and Has
5
- * attachment chips, and the `category:` / `is:unread` / `is:read` /
6
- * `has:attachment` / `is:starred` tokens typed in the field — is a column on
7
- * the ThreadMessage row, so all of them travel as query parameters. A criterion
8
- * applied over the pages loaded so far is the defect this replaces: a starred
9
- * collection is paged, so anything below the newest page was invisible to it
10
- * (#308).
5
+ * attachment chips, and the `from:` / `subject:` / `category:` / `is:unread` /
6
+ * `is:read` / `has:attachment` / `is:starred` tokens typed in the field — is a
7
+ * field on the ThreadMessage row, so all of them travel as query parameters. A
8
+ * criterion applied over the pages loaded so far is the defect this replaces: a
9
+ * starred collection is paged, so anything below the newest page was invisible
10
+ * to it (#308, #1128).
11
11
  *
12
12
  * `starred` is the view rather than a criterion: the request always carries it,
13
13
  * which is what makes `is:starred` a carried token instead of residue.
@@ -16,24 +16,25 @@
16
16
  * beats text in a field — and the overruled token drops to the residue, where
17
17
  * it matches nothing, which is the honest answer to a contradiction.
18
18
  */
19
- import type {
20
- InboxFilterCriteria,
21
- InboxFilterParams,
22
- } from "./inbox-filters.js";
19
+ import type { InboxFilterCriteria } from "./inbox-filters.js";
23
20
  import { inboxFilterParams } from "./inbox-filters.js";
24
21
  import type { SearchToken } from "./search-tokens.js";
25
22
  import {
26
23
  type ThreadSearchTokenParamName,
24
+ type ThreadSearchTokenParams,
27
25
  threadSearchTokens,
28
26
  } from "./thread-search-tokens.js";
29
27
 
30
28
  /**
31
- * The token parameters `listAllThreads` carries. It has no `from` or `subject`
32
- * of its own — its one text parameter matches both at once — so those two
33
- * tokens stay residue rather than being dropped from a request that never had
34
- * them.
29
+ * The token parameters `listAllThreads` carries, which is every parameter it
30
+ * takes. `from` and `subject` joined them once the operation grew a parameter
31
+ * for each field: its one free-text parameter matches both at once, so neither
32
+ * token could be asked for on its own and both were applied over the pages
33
+ * already fetched — a match below them never appeared (#1128).
35
34
  */
36
35
  export const FLAGGED_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] = [
36
+ "from",
37
+ "subject",
37
38
  "category",
38
39
  "unread",
39
40
  "starred",
@@ -42,7 +43,7 @@ export const FLAGGED_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] = [
42
43
 
43
44
  export interface FlaggedCriteria {
44
45
  /** What the request narrows by, chips and carried tokens together. */
45
- criteria: InboxFilterParams;
46
+ criteria: ThreadSearchTokenParams;
46
47
  /** The tokens no parameter carries; apply these over the returned rows. */
47
48
  residual: SearchToken[];
48
49
  }
@@ -89,11 +89,28 @@ export const filterReach = (query: ThreadSearchQuery): FilterReach =>
89
89
  ? "loaded-pages"
90
90
  : "whole-folder";
91
91
 
92
+ /**
93
+ * A list request's narrowing parameters, as far as the previous-data guard
94
+ * reads them. The chips, plus the two scopes a view sets for itself: the
95
+ * account the pills name, and whether muted senders are in.
96
+ *
97
+ * The text parameters are deliberately absent, `from` and `subject` with the
98
+ * free text: all three are typed in one field, and a query being refined is the
99
+ * one change that may keep the previous answer on screen while the next is in
100
+ * flight.
101
+ */
102
+ export type ListFilterParams = InboxFilterParams & {
103
+ accountId?: string;
104
+ muted?: boolean;
105
+ };
106
+
92
107
  const FILTER_PARAM_NAMES = [
93
108
  "category",
94
109
  "unread",
95
110
  "starred",
96
111
  "attachments",
112
+ "accountId",
113
+ "muted",
97
114
  ] as const;
98
115
 
99
116
  const filterIdentity = (query: Record<string, unknown> | undefined): string =>
@@ -121,7 +138,7 @@ const queryOf = (queryKey: unknown): Record<string, unknown> | undefined => {
121
138
  */
122
139
  export const sameInboxFilter = (
123
140
  queryKey: unknown,
124
- params: InboxFilterParams,
141
+ params: ListFilterParams,
125
142
  ): boolean => {
126
143
  const previous = queryOf(queryKey);
127
144
  if (!previous) return false;
@@ -4,7 +4,10 @@ import { buildSenderFallbackDraft } from "./sender-fallback";
4
4
 
5
5
  describe("buildSenderFallbackDraft", () => {
6
6
  it("combines the sender clauses with Or and carries no anchor", () => {
7
- const draft = buildSenderFallbackDraft(["npm@github.com", "a@x.com"]);
7
+ const draft = buildSenderFallbackDraft(
8
+ ["npm@github.com", "a@x.com"],
9
+ ["Deploy failed", "Standup notes"],
10
+ );
8
11
  assert.equal(draft.matchOperator, "Or");
9
12
  assert.equal(draft.anchorMessageId, undefined);
10
13
  assert.deepEqual(draft.literalClauses, [
@@ -14,10 +17,36 @@ describe("buildSenderFallbackDraft", () => {
14
17
  });
15
18
 
16
19
  it("carries a single FromDomain clause when the senders share a domain", () => {
17
- const draft = buildSenderFallbackDraft(["npm@github.com", "ci@github.com"]);
20
+ const draft = buildSenderFallbackDraft(
21
+ ["npm@github.com", "ci@github.com"],
22
+ ["Deploy failed", "Standup notes"],
23
+ );
18
24
  assert.equal(draft.matchOperator, "Or");
19
25
  assert.deepEqual(draft.literalClauses, [
20
26
  { field: "FromDomain", value: "github.com" },
21
27
  ]);
22
28
  });
29
+
30
+ it("falls back to a shared subject when mixed senders share no domain (#458)", () => {
31
+ const draft = buildSenderFallbackDraft(
32
+ ["billing@acme.test", "accounts@globex.test"],
33
+ ["Invoice 1841", "Invoice 1902"],
34
+ );
35
+ assert.equal(draft.matchOperator, "Or");
36
+ assert.deepEqual(draft.literalClauses, [
37
+ { field: "Subject", value: "Invoice" },
38
+ ]);
39
+ });
40
+
41
+ it("offers one sender chip each when mixed senders share no subject either", () => {
42
+ const draft = buildSenderFallbackDraft(
43
+ ["billing@acme.test", "accounts@globex.test"],
44
+ ["Invoice 1841", "Standup notes"],
45
+ );
46
+ assert.equal(draft.matchOperator, "Or");
47
+ assert.deepEqual(draft.literalClauses, [
48
+ { field: "From", value: "billing@acme.test" },
49
+ { field: "From", value: "accounts@globex.test" },
50
+ ]);
51
+ });
23
52
  });
@@ -1,26 +1,30 @@
1
1
  import type { RemitImapOrganizeInput } from "@remit/api-http-client/types.gen.ts";
2
- import { deriveSenderClauses } from "@remit/ui";
2
+ import { derivePropertyClauses } from "@remit/ui";
3
3
  import type { OrganizeDraft } from "./organize-model";
4
4
 
5
5
  /**
6
6
  * The widen fallback for a deployment that ships no vector pipeline (self-host
7
7
  * sqlite — semantic-capability.ts). The semantic anchor matches nothing there,
8
8
  * so a widen degrades to the literal vocabulary RFC 031 already matches
9
- * vector-free: the sender clauses `@remit/ui` derives, combined with `Or`, no
10
- * anchor. The same predicate matches at index time (RFC 034), so a standing
11
- * filter built from it keeps working on future mail.
9
+ * vector-free: what the whole selection agrees on, combined with `Or`, no
10
+ * anchor (#458) — the same evidence order the properties door prefills from,
11
+ * so a selection with nothing in common on sender still falls back to a
12
+ * subject it shares rather than one `From` chip per message. The same
13
+ * predicate matches at index time (RFC 034), so a standing filter built from
14
+ * it keeps working on future mail.
12
15
  */
13
16
 
14
17
  /**
15
- * The literal predicate that stands in for the semantic anchor: the sender
16
- * clauses combined with `Or` and no anchor. The preview, the one-time
17
- * back-apply, and the standing filter all carry exactly this.
18
+ * The literal predicate that stands in for the semantic anchor: the clauses
19
+ * the selection agrees on, combined with `Or` and no anchor. The preview, the
20
+ * one-time back-apply, and the standing filter all carry exactly this.
18
21
  */
19
22
  export const buildSenderFallbackDraft = (
20
23
  senders: readonly string[],
24
+ subjects: readonly string[],
21
25
  ): OrganizeDraft => ({
22
26
  matchOperator: "Or",
23
- literalClauses: deriveSenderClauses(senders),
27
+ literalClauses: derivePropertyClauses(senders, subjects),
24
28
  });
25
29
 
26
30
  /**
@@ -39,9 +39,10 @@ export type ThreadSearchTokenParamName = keyof ThreadSearchTokenParams;
39
39
  /**
40
40
  * Every parameter `searchThreads` takes, the default set.
41
41
  *
42
- * `listAllThreads` takes all of these except `from` and `subject`, so a caller
43
- * on that endpoint names its own set and the two tokens come back as residue
44
- * rather than being silently dropped from a request that never carried them.
42
+ * `listAllThreads` takes all six as well (#1128), so both callers carry the
43
+ * same tokens. A caller still names its own set rather than inheriting this
44
+ * one: the set is what decides which tokens come back as residue, and an
45
+ * endpoint that grows or loses a parameter has to say so where it is called.
45
46
  */
46
47
  export const THREAD_SEARCH_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] =
47
48
  ["from", "subject", "category", "unread", "starred", "attachments"];