@remit/web-client 0.0.67 → 0.0.69

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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Opening the filter-from-search editor: the seed count comes from the converted
3
+ * literal predicate in one request, under the account the filter targets. No
4
+ * capability probe — the deployment's semantic reach is read from the search's
5
+ * own results on the surface, so a seed success is never blocked by a probe.
6
+ */
7
+
8
+ import assert from "node:assert/strict";
9
+ import { afterEach, describe, it } from "node:test";
10
+ import { createElement } from "react";
11
+ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
12
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
13
+ import {
14
+ type HttpCall,
15
+ type HttpMock,
16
+ httpError,
17
+ mockFetch,
18
+ } from "../test-support/http";
19
+ import { useSearchFilterSeed } from "./useSearchFilterSeed";
20
+
21
+ let harness: DomHarness | undefined;
22
+ let http: HttpMock | undefined;
23
+
24
+ afterEach(() => {
25
+ harness?.close();
26
+ harness = undefined;
27
+ http?.restore();
28
+ http = undefined;
29
+ });
30
+
31
+ const PREDICATE: OrganizeMatchPredicate = {
32
+ matchOperator: "And",
33
+ literalClauses: [{ field: "HasWords", value: "receipts" }],
34
+ };
35
+
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
+ );
47
+ }
48
+
49
+ const mount = (responder: (call: HttpCall) => unknown): DomHarness => {
50
+ http = mockFetch(responder);
51
+ harness = createDomHarness();
52
+ harness.renderApp(createElement(Probe));
53
+ return harness;
54
+ };
55
+
56
+ const state = (dom: DomHarness) => JSON.parse(dom.text());
57
+
58
+ describe("useSearchFilterSeed", () => {
59
+ it("seeds the count from the literal predicate in a single preview", async () => {
60
+ const dom = mount((call) =>
61
+ call.path.endsWith("/organize/preview")
62
+ ? { matchedCount: 12, messageIds: [] }
63
+ : {},
64
+ );
65
+ await dom.flush();
66
+ await dom.flush();
67
+ assert.equal(state(dom).seedCount, 12);
68
+ // One preview, carrying the literal clauses and no anchor.
69
+ const previews = http?.to("/organize/preview") ?? [];
70
+ assert.equal(previews.length, 1);
71
+ assert.equal(previews[0].body?.anchorMessageId, undefined);
72
+ assert.deepEqual(previews[0].body?.literalClauses, [
73
+ { field: "HasWords", value: "receipts" },
74
+ ]);
75
+ });
76
+
77
+ it("surfaces the seed error so the caller can offer a retry", async () => {
78
+ const dom = mount(() => httpError(500));
79
+ await dom.flush();
80
+ await dom.flush();
81
+ assert.equal(state(dom).isError, true);
82
+ });
83
+ });
@@ -0,0 +1,61 @@
1
+ import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { useMutation } from "@tanstack/react-query";
3
+ import { useCallback, useEffect } from "react";
4
+ import { buildOrganizeInput } from "@/lib/organize/organize-model";
5
+ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
6
+
7
+ interface SearchFilterSeed {
8
+ /** The live count for the converted literal predicate, seeding the editor. */
9
+ seedCount?: number;
10
+ isPending: boolean;
11
+ isError: boolean;
12
+ error: unknown;
13
+ retry: () => void;
14
+ }
15
+
16
+ /**
17
+ * Open the filter-from-search editor: seed the live count from the converted
18
+ * literal predicate. One `POST /organize/preview` under the account the filter
19
+ * targets — the count on screen is the set a literal-only filter applies to.
20
+ *
21
+ * The deployment's semantic reach is not probed here; it is read from the
22
+ * search's own "Related" results on the surface that opens the editor (RFC 038
23
+ * D5), a direct signal that needs no request and cannot hit the wrong account.
24
+ */
25
+ export const useSearchFilterSeed = (
26
+ accountId: string | undefined,
27
+ literalPredicate: OrganizeMatchPredicate,
28
+ ): SearchFilterSeed => {
29
+ const seed = useMutation(organizeOperationsPreviewOrganizeMutation());
30
+ const { mutate, reset } = seed;
31
+
32
+ const run = useCallback(() => {
33
+ if (!accountId) return;
34
+ reset();
35
+ mutate({
36
+ path: { accountId },
37
+ body: buildOrganizeInput({
38
+ matchOperator: literalPredicate.matchOperator,
39
+ literalClauses: literalPredicate.literalClauses,
40
+ }),
41
+ });
42
+ }, [
43
+ accountId,
44
+ literalPredicate.matchOperator,
45
+ literalPredicate.literalClauses,
46
+ mutate,
47
+ reset,
48
+ ]);
49
+
50
+ useEffect(() => {
51
+ run();
52
+ }, [run]);
53
+
54
+ return {
55
+ seedCount: seed.data?.matchedCount,
56
+ isPending: seed.isPending || seed.data === undefined,
57
+ isError: seed.isError,
58
+ error: seed.error,
59
+ retry: run,
60
+ };
61
+ };
@@ -0,0 +1,146 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { parseSearchTokens, type SearchTokenContext } from "../search-tokens";
4
+ import {
5
+ buildSearchRule,
6
+ convertSearchToRule,
7
+ isConvertible,
8
+ } from "./search-to-rule";
9
+
10
+ const CONTEXT: SearchTokenContext = {
11
+ mailboxesByName: new Map([["archive", "mbx-archive"]]),
12
+ accountsByName: new Map([["work", "acc-work"]]),
13
+ };
14
+
15
+ const convert = (
16
+ query: string,
17
+ options: { searchHadSemanticReach?: boolean } = {},
18
+ ) =>
19
+ convertSearchToRule(parseSearchTokens(query, CONTEXT), {
20
+ searchHadSemanticReach: options.searchHadSemanticReach ?? false,
21
+ });
22
+
23
+ describe("convertSearchToRule — facet → clause mapping", () => {
24
+ it("maps literal terms to a HasWords clause", () => {
25
+ const conversion = convert("quarterly report");
26
+ assert.deepEqual(conversion.clauses, [
27
+ { field: "HasWords", value: "quarterly report" },
28
+ ]);
29
+ assert.equal(conversion.keptTerms, true);
30
+ });
31
+
32
+ it("maps a from: facet to a From clause", () => {
33
+ const conversion = convert("from:alerts@github.com");
34
+ assert.deepEqual(conversion.clauses, [
35
+ { field: "From", value: "alerts@github.com" },
36
+ ]);
37
+ assert.equal(conversion.keptTerms, false);
38
+ });
39
+
40
+ it("combines terms and a sender under an all-match operator", () => {
41
+ const conversion = convert("from:alerts@github.com pull request");
42
+ assert.equal(conversion.matchOperator, "all");
43
+ assert.deepEqual(conversion.clauses, [
44
+ { field: "From", value: "alerts@github.com" },
45
+ { field: "HasWords", value: "pull request" },
46
+ ]);
47
+ });
48
+ });
49
+
50
+ describe("convertSearchToRule — facets with no clause equivalent", () => {
51
+ it("keeps a folder-scoped search OUT of the rule, never silently unscoping it", () => {
52
+ const conversion = convert("in:archive receipts");
53
+ assert.deepEqual(conversion.scopedOut, {
54
+ mailboxId: "mbx-archive",
55
+ label: "archive",
56
+ });
57
+ // The folder never becomes a clause.
58
+ assert.deepEqual(conversion.clauses, [
59
+ { field: "HasWords", value: "receipts" },
60
+ ]);
61
+ });
62
+
63
+ it("drops attachment, unread and date facets, naming each", () => {
64
+ const conversion = convert(
65
+ "invoice has:attachment is:unread before:2026-01-01 after:2025-01-01",
66
+ );
67
+ const labels = conversion.droppedFacets.map((facet) => facet.label);
68
+ assert.deepEqual(labels, [
69
+ "Has attachment",
70
+ "Unread",
71
+ "Before 2026-01-01",
72
+ "After 2025-01-01",
73
+ ]);
74
+ assert.deepEqual(conversion.clauses, [
75
+ { field: "HasWords", value: "invoice" },
76
+ ]);
77
+ });
78
+
79
+ it("targets the account an account: facet names, without a clause for it", () => {
80
+ const conversion = convert("account:work standup");
81
+ assert.equal(conversion.targetAccountId, "acc-work");
82
+ assert.deepEqual(conversion.clauses, [
83
+ { field: "HasWords", value: "standup" },
84
+ ]);
85
+ });
86
+ });
87
+
88
+ describe("convertSearchToRule — semantic honesty (RFC 038 D5)", () => {
89
+ it("states the dropped reach when the search surfaced similar mail", () => {
90
+ // The literal filter cannot reproduce the reach the search just showed.
91
+ const conversion = convert("things like this", {
92
+ searchHadSemanticReach: true,
93
+ });
94
+ assert.equal(conversion.droppedSemantic, true);
95
+ // The literal words are still kept.
96
+ assert.deepEqual(conversion.clauses, [
97
+ { field: "HasWords", value: "things like this" },
98
+ ]);
99
+ });
100
+
101
+ it("says nothing when the search had no semantic reach to drop", () => {
102
+ const conversion = convert("things like this", {
103
+ searchHadSemanticReach: false,
104
+ });
105
+ assert.equal(conversion.droppedSemantic, false);
106
+ });
107
+
108
+ it("never reports dropped semantics for a facet-only search", () => {
109
+ const conversion = convert("from:a@b.com", {
110
+ searchHadSemanticReach: true,
111
+ });
112
+ assert.equal(conversion.keptTerms, false);
113
+ assert.equal(conversion.droppedSemantic, false);
114
+ });
115
+ });
116
+
117
+ describe("isConvertible / buildSearchRule", () => {
118
+ it("is not convertible when the search yields no clause", () => {
119
+ assert.equal(isConvertible(convert("has:attachment")), false);
120
+ assert.equal(isConvertible(convert("in:archive")), false);
121
+ });
122
+
123
+ it("is convertible once a term or sender is present", () => {
124
+ assert.equal(isConvertible(convert("in:archive receipts")), true);
125
+ });
126
+
127
+ it("builds a standing rule with stable clause ids, no widen, empty name", () => {
128
+ const rule = buildSearchRule(convert("from:a@b.com nightly"));
129
+ assert.equal(rule.scope, "standing");
130
+ assert.equal(rule.widen, undefined);
131
+ assert.equal(rule.name, "");
132
+ assert.deepEqual(
133
+ rule.clauses.map((clause) => clause.id),
134
+ ["search-0", "search-1"],
135
+ );
136
+ });
137
+
138
+ it("honors an explicit scope and move target", () => {
139
+ const rule = buildSearchRule(convert("nightly"), {
140
+ scope: "once",
141
+ moveMailboxId: "mbx-archive",
142
+ });
143
+ assert.equal(rule.scope, "once");
144
+ assert.equal(rule.moveMailboxId, "mbx-archive");
145
+ });
146
+ });
@@ -0,0 +1,161 @@
1
+ import type {
2
+ ClauseField,
3
+ FilterRule,
4
+ MatchOperator,
5
+ RuleScope,
6
+ } from "@remit/ui";
7
+ import {
8
+ type ParsedSearchQuery,
9
+ type SearchToken,
10
+ searchTokenLabel,
11
+ } from "../search-tokens";
12
+
13
+ /**
14
+ * Filter-from-search (RFC 038 D5). A search is literal terms and facets; a filter
15
+ * is clause chips. This is the conversion between them — the current search
16
+ * becomes a pre-filled rule the shared chip editor opens on.
17
+ *
18
+ * The mapping is honest about what a filter cannot carry. A facet with no clause
19
+ * equivalent is never silently folded into the rule: a folder scope is reported
20
+ * as dropped-and-kept-out (the filter matches everywhere, not just there), the
21
+ * attribute facets (attachment / unread / date) are reported as left out, and a
22
+ * free-text query kept as a literal `HasWords` clause is reported as having lost
23
+ * its semantic "similar mail" reach on a deployment that cannot embed the query
24
+ * (D5). Pure functions only — the capability is injected, not probed here.
25
+ */
26
+
27
+ export interface DroppedFacet {
28
+ /** The facet that had no clause equivalent. */
29
+ type: SearchToken["type"];
30
+ /** What was dropped, named for the user (e.g. "Has attachment", "Before 2026-01-01"). */
31
+ label: string;
32
+ }
33
+
34
+ export interface ScopedOutFolder {
35
+ mailboxId: string;
36
+ /** The folder the search was limited to. */
37
+ label: string;
38
+ }
39
+
40
+ export interface SearchConversion {
41
+ /** Clauses derived from the search — `From` (a `from:` facet) and `HasWords` (the free text). */
42
+ clauses: { field: ClauseField; value: string }[];
43
+ matchOperator: MatchOperator;
44
+ /** A folder an `in:` facet scoped the search to — kept OUT of the rule (never silently unscoped). */
45
+ scopedOut?: ScopedOutFolder;
46
+ /** Facets with no clause equivalent, each named. */
47
+ droppedFacets: DroppedFacet[];
48
+ /** The account an `account:` facet targets — the filter is created for it. */
49
+ targetAccountId?: string;
50
+ /** The search carried free-text terms, kept as a `HasWords` clause. */
51
+ keptTerms: boolean;
52
+ /**
53
+ * The filter this conversion builds is always literal-only — free text has no
54
+ * anchor message for a semantic widen — so the search's semantic "similar mail"
55
+ * reach is dropped whenever the search had one (RFC 038 D5). True exactly when
56
+ * free text was kept AND the search surfaced semantically-similar mail; on a
57
+ * deployment with no semantic reach there was nothing to drop, so no note.
58
+ */
59
+ droppedSemantic: boolean;
60
+ }
61
+
62
+ interface ConvertOptions {
63
+ /**
64
+ * Whether the current search surfaced semantically-similar mail (a non-empty
65
+ * "Related" section). This is the reach the literal filter cannot reproduce —
66
+ * the direct existing signal, read from the search's own semantic results, not
67
+ * a probe of deployment capability.
68
+ */
69
+ searchHadSemanticReach: boolean;
70
+ }
71
+
72
+ const FACET_HAS_NO_CLAUSE: ReadonlySet<SearchToken["type"]> = new Set([
73
+ "hasAttachment",
74
+ "isUnread",
75
+ "before",
76
+ "after",
77
+ ]);
78
+
79
+ /**
80
+ * Convert the current search into a rule's clauses and a record of what could not
81
+ * be carried. Terms become a `HasWords` clause; a `from:` facet a `From` clause;
82
+ * an `in:` facet a kept-out folder scope; `account:` the target account; the
83
+ * attribute facets are dropped. The search's terms are ANDed with its facets, so
84
+ * the rule matches all of them (`all`).
85
+ */
86
+ export const convertSearchToRule = (
87
+ parsed: ParsedSearchQuery,
88
+ { searchHadSemanticReach }: ConvertOptions,
89
+ ): SearchConversion => {
90
+ const clauses: SearchConversion["clauses"] = [];
91
+ const droppedFacets: DroppedFacet[] = [];
92
+ let scopedOut: ScopedOutFolder | undefined;
93
+ let targetAccountId: string | undefined;
94
+
95
+ for (const token of parsed.tokens) {
96
+ if (token.type === "from") {
97
+ clauses.push({ field: "From", value: token.value });
98
+ continue;
99
+ }
100
+ if (token.type === "in") {
101
+ scopedOut = { mailboxId: token.mailboxId, label: token.value };
102
+ continue;
103
+ }
104
+ if (token.type === "account") {
105
+ targetAccountId = token.accountId;
106
+ continue;
107
+ }
108
+ if (FACET_HAS_NO_CLAUSE.has(token.type)) {
109
+ droppedFacets.push({ type: token.type, label: searchTokenLabel(token) });
110
+ }
111
+ }
112
+
113
+ const freeText = parsed.freeText.trim();
114
+ const keptTerms = freeText.length > 0;
115
+ if (keptTerms) clauses.push({ field: "HasWords", value: freeText });
116
+
117
+ return {
118
+ clauses,
119
+ matchOperator: "all",
120
+ scopedOut,
121
+ droppedFacets,
122
+ targetAccountId,
123
+ keptTerms,
124
+ droppedSemantic: keptTerms && searchHadSemanticReach,
125
+ };
126
+ };
127
+
128
+ /**
129
+ * Whether the conversion yields a rule with something to match. A search of only
130
+ * dropped facets or a bare folder scope converts to no clauses, so there is no
131
+ * filter to open — the entry point offers nothing rather than an empty editor.
132
+ */
133
+ export const isConvertible = (conversion: SearchConversion): boolean =>
134
+ conversion.clauses.length > 0;
135
+
136
+ interface BuildRuleOptions {
137
+ scope?: RuleScope;
138
+ moveMailboxId?: string;
139
+ }
140
+
141
+ /**
142
+ * The rule the editor opens on, from a conversion. A search-derived rule defaults
143
+ * to a standing filter — "make this a filter" is a request to keep applying it —
144
+ * and the editor lets the user drop it back to a one-time apply. It carries no
145
+ * widen: a free-text query has no message anchor, so the semantic chip is not
146
+ * offered on this surface (its loss is stated in the conversion notice instead).
147
+ */
148
+ export const buildSearchRule = (
149
+ conversion: SearchConversion,
150
+ { scope = "standing", moveMailboxId }: BuildRuleOptions = {},
151
+ ): FilterRule => ({
152
+ clauses: conversion.clauses.map((clause, index) => ({
153
+ id: `search-${index}`,
154
+ field: clause.field,
155
+ value: clause.value,
156
+ })),
157
+ matchOperator: conversion.matchOperator,
158
+ moveMailboxId,
159
+ scope,
160
+ name: "",
161
+ });
@@ -112,9 +112,10 @@ export function mapUpdatePhase(phase: RemitImapSystemUpdatePhase): UpdatePhase {
112
112
 
113
113
  /**
114
114
  * The surface has no entry point for this caller: `404` (no manifest URL
115
- * configured), `403` (authenticated but not the instance owner), or `401` (not
116
- * authenticated). All three render nothing and stop the poll, so a probe cannot
117
- * tell an off surface from a forbidden one.
115
+ * configured) or `401` (not authenticated). Both render nothing and stop the
116
+ * poll. `403` is treated the same way as defensive cover for any authenticated
117
+ * edge state, though the backend no longer distinguishes callers once the
118
+ * surface is on.
118
119
  */
119
120
  export function isSurfaceAbsent(error: unknown): boolean {
120
121
  const status = getErrorStatus(error);