@remit/web-client 0.0.64 → 0.0.66

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.
@@ -2,6 +2,7 @@ import type {
2
2
  RemitImapFilterClause,
3
3
  RemitImapOrganizeInput,
4
4
  } from "@remit/api-http-client/types.gen.ts";
5
+ import { getDomain } from "tldts";
5
6
  import type { OrganizeDraft } from "./organize-model";
6
7
 
7
8
  /**
@@ -31,15 +32,47 @@ export const distinctSenders = (senders: readonly string[]): string[] => {
31
32
  return out;
32
33
  };
33
34
 
35
+ const hostOf = (address: string): string => {
36
+ const at = address.lastIndexOf("@");
37
+ return at >= 0 ? address.slice(at + 1) : address;
38
+ };
39
+
34
40
  /**
35
- * One `From` literal clause per distinct sender address. A `From` clause matches
36
- * the sender address or display name (match.ts `clauseMatches`), so the address
37
- * is the precise, stable key.
41
+ * The single registrable domain the whole selection collapses to, or `null` when
42
+ * it does not collapse. A collapse needs at least two distinct senders that all
43
+ * resolve to one registrable domain (public-suffix aware, via tldts `getDomain`)
44
+ * — the "anyone at this domain" signal (RFC 038 D2). One sender stays a precise
45
+ * `From` clause rather than widening a single address to its whole domain, and a
46
+ * sender whose domain can't be resolved blocks the collapse.
47
+ */
48
+ export const collapsibleDomain = (
49
+ senders: readonly string[],
50
+ ): string | null => {
51
+ const distinct = distinctSenders(senders);
52
+ if (distinct.length < 2) return null;
53
+ let shared: string | null = null;
54
+ for (const sender of distinct) {
55
+ const domain = getDomain(hostOf(sender));
56
+ if (domain === null) return null;
57
+ if (shared === null) shared = domain;
58
+ else if (shared !== domain) return null;
59
+ }
60
+ return shared;
61
+ };
62
+
63
+ /**
64
+ * The literal clauses standing in for the selection. When every sender shares one
65
+ * registrable domain, a single `FromDomain` clause replaces the per-address `From`
66
+ * chips (RFC 038 D2); otherwise one `From` clause per distinct sender, each
67
+ * matching the sender address or display name (match.ts `clauseMatches`).
38
68
  */
39
69
  export const deriveSenderClauses = (
40
70
  senders: readonly string[],
41
- ): RemitImapFilterClause[] =>
42
- distinctSenders(senders).map((value) => ({ field: "From", value }));
71
+ ): RemitImapFilterClause[] => {
72
+ const domain = collapsibleDomain(senders);
73
+ if (domain !== null) return [{ field: "FromDomain", value: domain }];
74
+ return distinctSenders(senders).map((value) => ({ field: "From", value }));
75
+ };
43
76
 
44
77
  /**
45
78
  * The literal predicate that stands in for the semantic anchor: the sender `From`
@@ -1,113 +0,0 @@
1
- /**
2
- * The back-apply progress copy. On a deployment without the vector pipeline the
3
- * "all like these" job moves the sender-matched set, so the in-progress line must
4
- * state the sender semantics — never "similar mail" (#250's honesty
5
- * requirement). The semantic path keeps the "similar mail" wording.
6
- */
7
-
8
- import assert from "node:assert/strict";
9
- import { afterEach, describe, it } from "node:test";
10
- import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
11
- import { createElement } from "react";
12
- import type { OrganizeMatchPredicate } from "../../../lib/organize/sender-fallback";
13
- import { createDomHarness, type DomHarness } from "../../../test-support/dom";
14
- import { makeMailbox } from "../../../test-support/fixtures";
15
- import { type HttpMock, mockFetch } from "../../../test-support/http";
16
- import { OrganizePanel } from "./OrganizePanel";
17
-
18
- let harness: DomHarness | undefined;
19
- let http: HttpMock | undefined;
20
-
21
- afterEach(() => {
22
- harness?.close();
23
- harness = undefined;
24
- http?.restore();
25
- http = undefined;
26
- });
27
-
28
- const ACCOUNT_ID = "acc-1";
29
-
30
- const MAILBOXES = [
31
- makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
32
- makeMailbox({ mailboxId: "mbx-archive", fullPath: "Archive" }),
33
- ];
34
-
35
- const SEMANTIC_PREDICATE: OrganizeMatchPredicate = {
36
- anchorMessageId: "msg-1",
37
- matchOperator: "And",
38
- literalClauses: [],
39
- };
40
-
41
- const SENDER_PREDICATE: OrganizeMatchPredicate = {
42
- matchOperator: "Or",
43
- literalClauses: [{ field: "From", value: "npm@github.com" }],
44
- };
45
-
46
- const mount = (
47
- props: Partial<Parameters<typeof OrganizePanel>[0]>,
48
- ): DomHarness => {
49
- http = mockFetch((call) => {
50
- if (call.path.endsWith("/organize") && call.method === "POST") {
51
- return { organizeJobId: "job-1", state: "Processing" };
52
- }
53
- if (call.path.endsWith("/organize/job-1") && call.method === "GET") {
54
- return {
55
- organizeJobId: "job-1",
56
- state: "Processing",
57
- matchedCount: 128,
58
- appliedCount: 0,
59
- failedCount: 0,
60
- };
61
- }
62
- return {};
63
- });
64
- harness = createDomHarness();
65
- harness.queryClient.setQueryData(
66
- mailboxOperationsListMailboxesQueryKey({ path: { accountId: ACCOUNT_ID } }),
67
- { items: MAILBOXES },
68
- );
69
- harness.renderApp(
70
- createElement(OrganizePanel, {
71
- accountId: ACCOUNT_ID,
72
- mailboxId: "mbx-inbox",
73
- selectedMessageIds: ["msg-1", "msg-2"],
74
- matchPredicate: SEMANTIC_PREDICATE,
75
- matchedCount: 128,
76
- seedMailboxId: "mbx-archive",
77
- onClose: () => undefined,
78
- ...props,
79
- }),
80
- );
81
- return harness;
82
- };
83
-
84
- async function startJob(dom: DomHarness): Promise<void> {
85
- dom.click(dom.byText("button", "Organize"));
86
- for (let attempt = 0; attempt < 20; attempt += 1) {
87
- await dom.flush();
88
- if (/Organizing/.test(dom.text())) return;
89
- await dom.wait(1);
90
- }
91
- }
92
-
93
- describe("OrganizePanel back-apply progress copy", () => {
94
- it("states the sender semantics while organizing in the sender fallback", async () => {
95
- const dom = mount({
96
- matchPredicate: SENDER_PREDICATE,
97
- semanticUnavailable: true,
98
- senders: ["npm@github.com"],
99
- });
100
- await startJob(dom);
101
-
102
- assert.match(dom.text(), /Organizing mail from these senders/);
103
- assert.doesNotMatch(dom.text(), /Organizing similar mail/);
104
- });
105
-
106
- it("keeps the similar-mail wording on the semantic path", async () => {
107
- const dom = mount({});
108
- await startJob(dom);
109
-
110
- assert.match(dom.text(), /Organizing similar mail/);
111
- assert.doesNotMatch(dom.text(), /from these senders/);
112
- });
113
- });
@@ -1,151 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { organizeOperationsCreateOrganizeJobMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
4
- import { MutationObserver } from "@tanstack/query-core";
5
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
- import React, { createElement } from "react";
7
- import { renderToString } from "react-dom/server";
8
- import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
9
- import { buildOrganizeInput } from "@/lib/organize/organize-model";
10
- import { OrganizePanel } from "./OrganizePanel";
11
-
12
- // The node test loader transpiles remit-ui's `.tsx` with the classic JSX
13
- // runtime, which references a global `React`. Vite uses the automatic runtime,
14
- // so this shim only exists for the SSR test harness.
15
- (globalThis as { React?: typeof React }).React = React;
16
-
17
- const render = (overrides: Partial<Parameters<typeof OrganizePanel>[0]> = {}) =>
18
- renderToString(
19
- createElement(
20
- QueryClientProvider,
21
- { client: new QueryClient() },
22
- createElement(
23
- ErrorBannerProvider,
24
- null,
25
- createElement(OrganizePanel, {
26
- accountId: "acc-1",
27
- mailboxId: "mbx-inbox",
28
- selectedMessageIds: ["msg-1", "msg-2"],
29
- matchPredicate: {
30
- anchorMessageId: "msg-1",
31
- matchOperator: "And",
32
- literalClauses: [],
33
- },
34
- matchedCount: 47,
35
- onClose: () => undefined,
36
- ...overrides,
37
- }),
38
- ),
39
- ) as never,
40
- );
41
-
42
- describe("OrganizePanel", () => {
43
- it("renders the organize sentence with the widened count", () => {
44
- const html = render();
45
- assert.match(html, /similar message/);
46
- assert.match(html, /from 2 selected/);
47
- });
48
-
49
- it("surfaces the disabled reason until a folder is chosen (ux.md — say why)", () => {
50
- const html = render();
51
- assert.match(html, /Pick a folder to move these into/);
52
- assert.match(html, /available yet/i);
53
- });
54
-
55
- it("offers all four commit scopes", () => {
56
- const html = render();
57
- assert.match(html, /Just these/);
58
- assert.match(html, /All like these/);
59
- assert.match(html, /These and new mail like this/);
60
- assert.match(html, /Until a date/);
61
- });
62
-
63
- it("says it is organizing just the selection when the widen matched nothing (#211 — no dead end)", () => {
64
- const html = render({ matchedCount: 0, fallback: true });
65
- assert.match(html, /No similar messages found/);
66
- assert.match(html, /organizing just your 2 selected/);
67
- });
68
-
69
- it("names the missing vector pipeline and organizes just the selection when the widen is unavailable and no senders are known (#226/#201)", () => {
70
- const html = render({ matchedCount: 0, semanticUnavailable: true });
71
- assert.match(html, /available on this server/);
72
- assert.match(html, /organizing just your 2 selected/);
73
- });
74
-
75
- it("names the senders and states it is matching all mail from them when the widen fell back (no vector pipeline)", () => {
76
- const html = render({
77
- matchedCount: 128,
78
- semanticUnavailable: true,
79
- senders: ["npm@github.com", "notifications@github.com"],
80
- matchPredicate: {
81
- matchOperator: "Or",
82
- literalClauses: [
83
- { field: "From", value: "npm@github.com" },
84
- { field: "From", value: "notifications@github.com" },
85
- ],
86
- },
87
- });
88
- assert.match(html, /matching all mail from/);
89
- assert.match(html, /npm@github\.com/);
90
- assert.match(html, /notifications@github\.com/);
91
- assert.match(html, /128 matches/);
92
- // Never claims the matched set is semantically similar (only denies that
93
- // similar-mail matching is available).
94
- assert.doesNotMatch(html, /similar message/i);
95
- assert.doesNotMatch(html, /similar ones found/i);
96
- // The sender fallback is a real widen: the scopes reach it, so the
97
- // default scope is "all like these", not "just these".
98
- assert.match(html, /All like these/);
99
- });
100
-
101
- it("pre-selects the seeded scope (a 'Something else' shortcut seeds the sentence)", () => {
102
- const html = render({ initialScope: "standing" });
103
- // The standing scope's "Always keep" phrasing only renders when it is active.
104
- assert.match(html, /Always keep/);
105
- });
106
- });
107
-
108
- // The commit button's `disabled` prop is
109
- // `!!disabledReason || createFilter.isPending || organizeJob.isStarting`
110
- // (#1279) — a slow POST to /organize must block a second click just like an
111
- // in-flight createFilter does, or the back-apply runs twice. `renderToString`
112
- // never dispatches the click that would exercise that wiring end-to-end (SSR
113
- // doesn't run event handlers), so this drives the exact mutation config
114
- // `useOrganizeJob().start()` calls — `organizeOperationsCreateOrganizeJobMutation()`
115
- // — through a real `MutationObserver` and confirms it reports `isPending`
116
- // (what `organizeJob.isStarting` is) for the whole time the POST is in
117
- // flight, which is the invariant the disabled expression relies on.
118
- describe("organizeJob.isStarting — the create-organize-job POST stays pending until it resolves (#1279)", () => {
119
- it("reports isPending immediately after start() and keeps it pending while the POST hasn't come back", () => {
120
- const queryClient = new QueryClient();
121
- const observer = new MutationObserver(
122
- queryClient,
123
- organizeOperationsCreateOrganizeJobMutation(),
124
- );
125
-
126
- assert.equal(
127
- observer.getCurrentResult().isPending,
128
- false,
129
- "idle before the first submit — same as a fresh commit button",
130
- );
131
-
132
- observer.mutate({
133
- path: { accountId: "acc-1" },
134
- body: buildOrganizeInput({
135
- anchorMessageId: "msg-1",
136
- matchOperator: "And",
137
- literalClauses: [],
138
- moveMailboxId: "mbx-work",
139
- }),
140
- // Stands in for a slow POST that hasn't come back yet — the exact
141
- // window a double click must be blocked in.
142
- fetch: () => new Promise<Response>(() => {}),
143
- });
144
-
145
- assert.equal(
146
- observer.getCurrentResult().isPending,
147
- true,
148
- "a second click during this window must be blocked — this is what organizeJob.isStarting disables the button on",
149
- );
150
- });
151
- });