@remit/web-client 0.0.29 → 0.0.31
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.
- package/package.json +1 -1
- package/src/auth/auth-interceptor.test.ts +23 -1
- package/src/auth/auth-interceptor.ts +5 -0
- package/src/auth/better-auth-config.ts +52 -7
- package/src/auth/better-auth-token.test.ts +152 -0
- package/src/auth/provider.tsx +8 -3
- package/src/components/mail/DailyBrief.tsx +9 -5
- package/src/components/mail/FlaggedList.tsx +5 -2
- package/src/components/mail/MailListHeader.tsx +56 -2
- package/src/components/mail/MailboxPane.tsx +7 -4
- package/src/hooks/useResultFolderIndex.ts +54 -0
- package/src/lib/mail-context.ts +12 -0
- package/src/lib/result-folder.test.ts +115 -0
- package/src/lib/result-folder.ts +90 -0
- package/src/lib/search-result.ts +23 -3
- package/src/lib/search-scope.test.ts +55 -0
- package/src/lib/search-scope.ts +28 -0
- package/src/lib/spam-offer.test.ts +84 -0
- package/src/lib/spam-offer.ts +37 -0
- package/src/routes/mail.tsx +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.31",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
+
import { AuthTokenError } from "./better-auth-config";
|
|
3
4
|
import { type AuthProvider, noneAuthProvider } from "./provider";
|
|
4
5
|
|
|
5
6
|
type RequestFn = (req: Request) => Promise<Request>;
|
|
@@ -47,7 +48,28 @@ describe("installAuthInterceptor", () => {
|
|
|
47
48
|
assert.equal(out.headers.get("Authorization"), "Bearer ID-TOKEN-123");
|
|
48
49
|
});
|
|
49
50
|
|
|
50
|
-
test("
|
|
51
|
+
test("abandons the request when the token cannot be minted — never sends it unauthenticated", async () => {
|
|
52
|
+
const mod = await loadInterceptor();
|
|
53
|
+
mod.installAuthInterceptor(
|
|
54
|
+
providerWithToken(async () => {
|
|
55
|
+
throw new AuthTokenError("Could not mint a session token: 429", 429);
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
|
|
59
|
+
assert.ok(fn);
|
|
60
|
+
const req = new Request("https://api.example.com/thing");
|
|
61
|
+
await assert.rejects(
|
|
62
|
+
() => fn(req),
|
|
63
|
+
(error: unknown) => {
|
|
64
|
+
assert.ok(error instanceof AuthTokenError);
|
|
65
|
+
assert.equal(error.status, 429);
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
assert.equal(req.headers.get("Authorization"), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("omits Authorization header when the deployment presents no identity", async () => {
|
|
51
73
|
const mod = await loadInterceptor();
|
|
52
74
|
mod.installAuthInterceptor(providerWithToken(async () => null));
|
|
53
75
|
const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
|
|
@@ -7,6 +7,11 @@ let installed = false;
|
|
|
7
7
|
* Attach the composed provider's bearer token to every API request. Called once
|
|
8
8
|
* from `mountApp` with the app's chosen provider, so the token source is the
|
|
9
9
|
* same `AuthProvider` the shell and screens read — no separate seam.
|
|
10
|
+
*
|
|
11
|
+
* A provider that cannot produce a token throws, and that throw is left to
|
|
12
|
+
* propagate: the request is abandoned rather than sent without an Authorization
|
|
13
|
+
* header. Only the deployments that present no identity at all resolve to null,
|
|
14
|
+
* and for those an unauthenticated request is the intended one.
|
|
10
15
|
*/
|
|
11
16
|
export const installAuthInterceptor = (authProvider: AuthProvider): void => {
|
|
12
17
|
if (installed) return;
|
|
@@ -51,11 +51,32 @@ const decodeExp = (token: string): number => {
|
|
|
51
51
|
}
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* A session exists but no bearer token could be produced for it. Distinct from
|
|
56
|
+
* "signed out": there is nothing to fall back to, so the request that needed the
|
|
57
|
+
* token must not be sent. Carries the mint's HTTP status where there was one, so
|
|
58
|
+
* the shared classifier reads it like any other API failure.
|
|
59
|
+
*/
|
|
60
|
+
export class AuthTokenError extends Error {
|
|
61
|
+
readonly status: number | undefined;
|
|
62
|
+
|
|
63
|
+
constructor(message: string, status?: number) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = "AuthTokenError";
|
|
66
|
+
this.status = status;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const requestToken = async (): Promise<string> => {
|
|
55
71
|
const res = await taggedFetch("/api/auth/token", {
|
|
56
72
|
credentials: "include",
|
|
57
73
|
});
|
|
58
|
-
if (!res.ok)
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
throw new AuthTokenError(
|
|
76
|
+
`Could not mint a session token: ${res.status} ${res.statusText}`,
|
|
77
|
+
res.status,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
59
80
|
const body: unknown = await res.json();
|
|
60
81
|
if (
|
|
61
82
|
body &&
|
|
@@ -65,7 +86,29 @@ const requestToken = async (): Promise<string | null> => {
|
|
|
65
86
|
) {
|
|
66
87
|
return (body as { token: string }).token;
|
|
67
88
|
}
|
|
68
|
-
|
|
89
|
+
throw new AuthTokenError("The token endpoint returned no token");
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
let inFlight: Promise<string> | null = null;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One mint at a time. A cold load renders many screens at once and each of their
|
|
96
|
+
* requests needs the same token; without this every one of them mints its own,
|
|
97
|
+
* and the burst is large enough to spend the auth tier's rate-limit budget. The
|
|
98
|
+
* slot is released once the mint settles, so a failure is not replayed to later
|
|
99
|
+
* callers — they mint again.
|
|
100
|
+
*/
|
|
101
|
+
const mint = (): Promise<string> => {
|
|
102
|
+
if (inFlight) return inFlight;
|
|
103
|
+
inFlight = requestToken()
|
|
104
|
+
.then((token) => {
|
|
105
|
+
cached = { value: token, expiresAt: decodeExp(token) };
|
|
106
|
+
return token;
|
|
107
|
+
})
|
|
108
|
+
.finally(() => {
|
|
109
|
+
inFlight = null;
|
|
110
|
+
});
|
|
111
|
+
return inFlight;
|
|
69
112
|
};
|
|
70
113
|
|
|
71
114
|
/**
|
|
@@ -73,14 +116,16 @@ const requestToken = async (): Promise<string | null> => {
|
|
|
73
116
|
* when the cached token is missing or within the skew window of expiry. The API
|
|
74
117
|
* interceptor attaches it as a Bearer token; the backend verifies it against the
|
|
75
118
|
* JWKS.
|
|
119
|
+
*
|
|
120
|
+
* Never resolves to null: a failed mint throws. Returning null would put the
|
|
121
|
+
* request on the wire without an Authorization header, and the backend answers
|
|
122
|
+
* that with a 401 that names a missing token rather than the mint that failed.
|
|
76
123
|
*/
|
|
77
|
-
export const fetchBetterAuthToken = async (): Promise<string
|
|
124
|
+
export const fetchBetterAuthToken = async (): Promise<string> => {
|
|
78
125
|
if (cached && cached.expiresAt - EXPIRY_SKEW_SECONDS > nowSeconds()) {
|
|
79
126
|
return cached.value;
|
|
80
127
|
}
|
|
81
|
-
|
|
82
|
-
cached = token ? { value: token, expiresAt: decodeExp(token) } : null;
|
|
83
|
-
return token;
|
|
128
|
+
return mint();
|
|
84
129
|
};
|
|
85
130
|
|
|
86
131
|
export const resetBetterAuthTokenCache = (): void => {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { afterEach, describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
AuthTokenError,
|
|
5
|
+
fetchBetterAuthToken,
|
|
6
|
+
resetBetterAuthTokenCache,
|
|
7
|
+
} from "./better-auth-config";
|
|
8
|
+
|
|
9
|
+
const base64url = (value: string): string =>
|
|
10
|
+
Buffer.from(value, "utf8")
|
|
11
|
+
.toString("base64")
|
|
12
|
+
.replace(/\+/g, "-")
|
|
13
|
+
.replace(/\//g, "_")
|
|
14
|
+
.replace(/=+$/, "");
|
|
15
|
+
|
|
16
|
+
/** A token shaped like the one better-auth mints, valid for an hour. */
|
|
17
|
+
const jwt = (label: string): string =>
|
|
18
|
+
`header.${base64url(
|
|
19
|
+
JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, label }),
|
|
20
|
+
)}.signature`;
|
|
21
|
+
|
|
22
|
+
const realFetch = globalThis.fetch;
|
|
23
|
+
|
|
24
|
+
interface Stub {
|
|
25
|
+
calls: number;
|
|
26
|
+
release: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Stand in for the token endpoint, holding every request open until released. */
|
|
30
|
+
const stubTokenEndpoint = (respond: (call: number) => Response): Stub => {
|
|
31
|
+
let open: () => void = () => {};
|
|
32
|
+
const gate = new Promise<void>((resolve) => {
|
|
33
|
+
open = resolve;
|
|
34
|
+
});
|
|
35
|
+
const stub: Stub = { calls: 0, release: () => open() };
|
|
36
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
37
|
+
assert.match(String(input), /\/api\/auth\/token$/);
|
|
38
|
+
stub.calls += 1;
|
|
39
|
+
const call = stub.calls;
|
|
40
|
+
await gate;
|
|
41
|
+
return respond(call);
|
|
42
|
+
}) as typeof fetch;
|
|
43
|
+
return stub;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const tokenResponse = (token: string): Response =>
|
|
47
|
+
new Response(JSON.stringify({ token }), {
|
|
48
|
+
status: 200,
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
globalThis.fetch = realFetch;
|
|
54
|
+
resetBetterAuthTokenCache();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("fetchBetterAuthToken", () => {
|
|
58
|
+
test("concurrent callers share one mint instead of each firing their own", async () => {
|
|
59
|
+
const stub = stubTokenEndpoint((call) => tokenResponse(jwt(`t${call}`)));
|
|
60
|
+
|
|
61
|
+
const pending = Array.from({ length: 12 }, () => fetchBetterAuthToken());
|
|
62
|
+
stub.release();
|
|
63
|
+
const tokens = await Promise.all(pending);
|
|
64
|
+
|
|
65
|
+
assert.equal(stub.calls, 1);
|
|
66
|
+
assert.equal(new Set(tokens).size, 1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a token minted once is reused from cache, without a second request", async () => {
|
|
70
|
+
const stub = stubTokenEndpoint(() => tokenResponse(jwt("cached")));
|
|
71
|
+
stub.release();
|
|
72
|
+
|
|
73
|
+
const first = await fetchBetterAuthToken();
|
|
74
|
+
const second = await fetchBetterAuthToken();
|
|
75
|
+
|
|
76
|
+
assert.equal(stub.calls, 1);
|
|
77
|
+
assert.equal(second, first);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a rejected mint throws with its status rather than resolving to null", async () => {
|
|
81
|
+
const stub = stubTokenEndpoint(
|
|
82
|
+
() => new Response("", { status: 429, statusText: "Too Many Requests" }),
|
|
83
|
+
);
|
|
84
|
+
stub.release();
|
|
85
|
+
|
|
86
|
+
await assert.rejects(
|
|
87
|
+
() => fetchBetterAuthToken(),
|
|
88
|
+
(error: unknown) => {
|
|
89
|
+
assert.ok(error instanceof AuthTokenError);
|
|
90
|
+
assert.equal(error.status, 429);
|
|
91
|
+
return true;
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a response carrying no token throws rather than resolving to null", async () => {
|
|
97
|
+
const stub = stubTokenEndpoint(
|
|
98
|
+
() =>
|
|
99
|
+
new Response(JSON.stringify({}), {
|
|
100
|
+
status: 200,
|
|
101
|
+
headers: { "Content-Type": "application/json" },
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
stub.release();
|
|
105
|
+
|
|
106
|
+
await assert.rejects(() => fetchBetterAuthToken(), AuthTokenError);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("every caller sharing a failed mint sees the failure", async () => {
|
|
110
|
+
const stub = stubTokenEndpoint(
|
|
111
|
+
() => new Response("", { status: 429, statusText: "Too Many Requests" }),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const pending = Array.from({ length: 5 }, () =>
|
|
115
|
+
fetchBetterAuthToken().then(
|
|
116
|
+
() => "resolved",
|
|
117
|
+
(error: unknown) =>
|
|
118
|
+
error instanceof AuthTokenError ? "threw" : "other",
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
stub.release();
|
|
122
|
+
const outcomes = await Promise.all(pending);
|
|
123
|
+
|
|
124
|
+
assert.equal(stub.calls, 1);
|
|
125
|
+
assert.deepEqual(new Set(outcomes), new Set(["threw"]));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a failed mint is not replayed — the next caller mints again", async () => {
|
|
129
|
+
const failing = stubTokenEndpoint(() => new Response("", { status: 500 }));
|
|
130
|
+
failing.release();
|
|
131
|
+
await assert.rejects(() => fetchBetterAuthToken());
|
|
132
|
+
|
|
133
|
+
const succeeding = stubTokenEndpoint(() =>
|
|
134
|
+
tokenResponse(jwt("after-failure")),
|
|
135
|
+
);
|
|
136
|
+
succeeding.release();
|
|
137
|
+
|
|
138
|
+
assert.ok(await fetchBetterAuthToken());
|
|
139
|
+
assert.equal(succeeding.calls, 1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a network failure propagates instead of yielding a tokenless request", async () => {
|
|
143
|
+
globalThis.fetch = (async () => {
|
|
144
|
+
throw new TypeError("Failed to fetch");
|
|
145
|
+
}) as typeof fetch;
|
|
146
|
+
|
|
147
|
+
await assert.rejects(
|
|
148
|
+
() => fetchBetterAuthToken(),
|
|
149
|
+
/could not be completed|Failed to fetch/i,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
});
|
package/src/auth/provider.tsx
CHANGED
|
@@ -22,9 +22,14 @@ export interface AuthProvider {
|
|
|
22
22
|
/** Called once at boot, before render, to initialize the identity SDK. */
|
|
23
23
|
configure(): void;
|
|
24
24
|
/**
|
|
25
|
-
* The current session's bearer token
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* The current session's bearer token. The API interceptor and the raw content
|
|
26
|
+
* fetch read it through here, so the identity SDK stays inside the provider.
|
|
27
|
+
*
|
|
28
|
+
* `null` means this deployment has no identity to present — no identity system
|
|
29
|
+
* is composed, or none is configured — and the request is expected to travel
|
|
30
|
+
* unauthenticated. It never means the token could not be produced: a provider
|
|
31
|
+
* that holds a session and fails to mint a token throws, because a request
|
|
32
|
+
* that cannot be authenticated must not be sent.
|
|
28
33
|
*/
|
|
29
34
|
getToken(): Promise<string | null>;
|
|
30
35
|
/** Drop any cached token so the next `getToken` re-mints. */
|
|
@@ -174,7 +174,7 @@ export function DailyBrief({
|
|
|
174
174
|
onSelectMessage,
|
|
175
175
|
onSelectSearchResult,
|
|
176
176
|
}: DailyBriefProps) {
|
|
177
|
-
const { searchQuery } = useMailContext();
|
|
177
|
+
const { searchQuery, resultFolderIndex } = useMailContext();
|
|
178
178
|
const tokenContext = useSearchTokenContext();
|
|
179
179
|
const isDesktop = useIsDesktop();
|
|
180
180
|
|
|
@@ -346,8 +346,8 @@ export function DailyBrief({
|
|
|
346
346
|
(selectedCategory === "all" || t.category === selectedCategory) &&
|
|
347
347
|
predicates.every((p) => p(t)),
|
|
348
348
|
)
|
|
349
|
-
.map(rowToSearchResult);
|
|
350
|
-
}, [filteredRows, selectedCategory, searchAttributes]);
|
|
349
|
+
.map((row) => rowToSearchResult(row, resultFolderIndex));
|
|
350
|
+
}, [filteredRows, selectedCategory, searchAttributes, resultFolderIndex]);
|
|
351
351
|
|
|
352
352
|
// "Related" (semantic) spans every account here — the brief is the
|
|
353
353
|
// cross-account view, so no mailbox scope. Dedupe against the literal "Top
|
|
@@ -362,8 +362,12 @@ export function DailyBrief({
|
|
|
362
362
|
const literalThreadIds = searchResults
|
|
363
363
|
.map((result) => threadByMessageId.get(result.id))
|
|
364
364
|
.filter((id): id is string => id != null);
|
|
365
|
-
return relatedSearchResults(
|
|
366
|
-
|
|
365
|
+
return relatedSearchResults(
|
|
366
|
+
semanticHits,
|
|
367
|
+
literalThreadIds,
|
|
368
|
+
resultFolderIndex,
|
|
369
|
+
);
|
|
370
|
+
}, [semanticHits, searchResults, threadsData, resultFolderIndex]);
|
|
367
371
|
|
|
368
372
|
const searchFilterConfig = useMemo<Omit<FilterSheetProps, "children">>(() => {
|
|
369
373
|
const preset = briefFilterConfig(
|
|
@@ -50,7 +50,7 @@ export function FlaggedList({
|
|
|
50
50
|
selectedMessageId,
|
|
51
51
|
onSelectMessage,
|
|
52
52
|
}: FlaggedListProps) {
|
|
53
|
-
const { searchQuery } = useMailContext();
|
|
53
|
+
const { searchQuery, resultFolderIndex } = useMailContext();
|
|
54
54
|
const tokenContext = useSearchTokenContext();
|
|
55
55
|
const isDesktop = useIsDesktop();
|
|
56
56
|
|
|
@@ -106,7 +106,10 @@ export function FlaggedList({
|
|
|
106
106
|
|
|
107
107
|
const preset = useMemo(() => flaggedFilterConfig(), []);
|
|
108
108
|
|
|
109
|
-
const searchResults = useMemo(
|
|
109
|
+
const searchResults = useMemo(
|
|
110
|
+
() => rows.map((row) => rowToSearchResult(row, resultFolderIndex)),
|
|
111
|
+
[rows, resultFolderIndex],
|
|
112
|
+
);
|
|
110
113
|
|
|
111
114
|
const unreadCount = useMemo(
|
|
112
115
|
() => rows.filter((t) => !t.isRead).length,
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
* drops out kit-side, so a semantic-only result still shows when the literal
|
|
22
22
|
* search finds nothing. The hamburger opens the nav drawer via the enclosing
|
|
23
23
|
* `AppShellSlotted`.
|
|
24
|
+
*
|
|
25
|
+
* This is also the one place the route's scope reaches the results list, so both
|
|
26
|
+
* tiers agree on it: a global search labels every row with the folder it came
|
|
27
|
+
* from and offers the spam it held out, and a scoped search does neither. See
|
|
28
|
+
* `resultsScopeForRoute` and `lib/spam-offer.ts`.
|
|
24
29
|
*/
|
|
25
30
|
import {
|
|
26
31
|
FilterSheet,
|
|
@@ -30,18 +35,23 @@ import {
|
|
|
30
35
|
type SearchResult,
|
|
31
36
|
type SearchResultSection,
|
|
32
37
|
SearchResults,
|
|
38
|
+
type SearchScope,
|
|
33
39
|
useAppShellLayout,
|
|
34
40
|
} from "@remit/ui";
|
|
41
|
+
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
35
42
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
36
43
|
import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
|
|
44
|
+
import { useSearchScope } from "@/hooks/useSearchScope";
|
|
37
45
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
38
46
|
import { useMailContext } from "@/lib/mail-context";
|
|
39
47
|
import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
|
|
48
|
+
import { resultsScopeForRoute, routeMailboxId } from "@/lib/search-scope";
|
|
40
49
|
import {
|
|
41
50
|
parseSearchTokens,
|
|
42
51
|
removeSearchToken,
|
|
43
52
|
searchTokenLabel,
|
|
44
53
|
} from "@/lib/search-tokens";
|
|
54
|
+
import { spamOfferForResults } from "@/lib/spam-offer";
|
|
45
55
|
|
|
46
56
|
interface MailListHeaderProps {
|
|
47
57
|
title: string;
|
|
@@ -82,9 +92,17 @@ export function MailListHeader({
|
|
|
82
92
|
searchResultsLabel = "Top matches",
|
|
83
93
|
relatedResultsLabel = "Related",
|
|
84
94
|
}: MailListHeaderProps) {
|
|
85
|
-
const {
|
|
86
|
-
|
|
95
|
+
const {
|
|
96
|
+
accounts,
|
|
97
|
+
resultFolderIndex,
|
|
98
|
+
searchQuery,
|
|
99
|
+
searchInput,
|
|
100
|
+
onSearchChange,
|
|
101
|
+
onSearchClear,
|
|
102
|
+
searchViewKey,
|
|
103
|
+
} = useMailContext();
|
|
87
104
|
const tokenContext = useSearchTokenContext();
|
|
105
|
+
const navigate = useNavigate();
|
|
88
106
|
const layout = useAppShellLayout();
|
|
89
107
|
const tier = useLayoutTier();
|
|
90
108
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
@@ -130,6 +148,40 @@ export function MailListHeader({
|
|
|
130
148
|
const resultsLoading =
|
|
131
149
|
!hasAnyResult && (searchLoading === true || relatedLoading === true);
|
|
132
150
|
|
|
151
|
+
// The mailbox's appointed role is what lets a search scoped to Spam show its
|
|
152
|
+
// rows rather than drop them.
|
|
153
|
+
const { scope } = useSearchScope(accounts);
|
|
154
|
+
const matches = useRouterState({ select: (s) => s.matches });
|
|
155
|
+
const scopedMailboxId = routeMailboxId(matches);
|
|
156
|
+
const routeScope = resultsScopeForRoute(
|
|
157
|
+
matches,
|
|
158
|
+
scope,
|
|
159
|
+
scopedMailboxId ? resultFolderIndex.get(scopedMailboxId)?.role : undefined,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// The offer counts results for the *committed* query, so that is the query it
|
|
163
|
+
// carries into Spam. The count the banner states is the results list's own,
|
|
164
|
+
// over every row it held out; the app supplies only where "Go to Spam" goes.
|
|
165
|
+
const spamOffer =
|
|
166
|
+
routeScope.kind === "global"
|
|
167
|
+
? spamOfferForResults([...topMatches, ...related])
|
|
168
|
+
: undefined;
|
|
169
|
+
const resultsScope: SearchScope = spamOffer
|
|
170
|
+
? {
|
|
171
|
+
kind: "global",
|
|
172
|
+
onScopeToSpam: () =>
|
|
173
|
+
navigate({
|
|
174
|
+
to: "/mail/$mailboxId",
|
|
175
|
+
params: { mailboxId: spamOffer.mailboxId },
|
|
176
|
+
search: {
|
|
177
|
+
q: searchQuery || undefined,
|
|
178
|
+
selectedMessageId: undefined,
|
|
179
|
+
selectedThreadId: undefined,
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
182
|
+
}
|
|
183
|
+
: routeScope;
|
|
184
|
+
|
|
133
185
|
if (tier === "phone" && searchOpen) {
|
|
134
186
|
const handleSelectResult = (result: SearchResult) => {
|
|
135
187
|
setRecentSearches(saveRecentSearch(searchInput));
|
|
@@ -152,6 +204,7 @@ export function MailListHeader({
|
|
|
152
204
|
loading={resultsLoading}
|
|
153
205
|
onSelectResult={handleSelectResult}
|
|
154
206
|
tokens={tokenChips}
|
|
207
|
+
scope={resultsScope}
|
|
155
208
|
/>
|
|
156
209
|
);
|
|
157
210
|
}
|
|
@@ -171,6 +224,7 @@ export function MailListHeader({
|
|
|
171
224
|
loading={resultsLoading}
|
|
172
225
|
onSelectResult={handleSelectInlineResult}
|
|
173
226
|
tokens={tokenChips}
|
|
227
|
+
scope={resultsScope}
|
|
174
228
|
/>
|
|
175
229
|
);
|
|
176
230
|
const body = !showInlineResults ? (
|
|
@@ -947,7 +947,8 @@ function MailboxList() {
|
|
|
947
947
|
onClearFilters,
|
|
948
948
|
searchPredicate,
|
|
949
949
|
} = useMailboxPane();
|
|
950
|
-
const { searchQuery, searchInput, accounts } =
|
|
950
|
+
const { searchQuery, searchInput, accounts, resultFolderIndex } =
|
|
951
|
+
useMailContext();
|
|
951
952
|
const tier = useLayoutTier();
|
|
952
953
|
const navigate = useNavigate();
|
|
953
954
|
|
|
@@ -955,8 +956,9 @@ function MailboxList() {
|
|
|
955
956
|
const preset = useMemo(() => inboxFilterConfig(), []);
|
|
956
957
|
|
|
957
958
|
const searchResults = useMemo(
|
|
958
|
-
() =>
|
|
959
|
-
|
|
959
|
+
() =>
|
|
960
|
+
threads.map((thread) => threadToSearchResult(thread, resultFolderIndex)),
|
|
961
|
+
[threads, resultFolderIndex],
|
|
960
962
|
);
|
|
961
963
|
// The route scopes this view and the top bar's chip says so, so every engine
|
|
962
964
|
// here respects it: the literal engine searches this mailbox, and the
|
|
@@ -972,8 +974,9 @@ function MailboxList() {
|
|
|
972
974
|
relatedSearchResults(
|
|
973
975
|
semanticHits,
|
|
974
976
|
threads.map((t) => t.threadId),
|
|
977
|
+
resultFolderIndex,
|
|
975
978
|
),
|
|
976
|
-
[semanticHits, threads],
|
|
979
|
+
[semanticHits, threads, resultFolderIndex],
|
|
977
980
|
);
|
|
978
981
|
const handleSelectSearchResult = useCallback(
|
|
979
982
|
(result: SearchResult) =>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fans out the per-account mailbox-list query (the same one `MailSidebarAdapter`
|
|
3
|
+
* and `useMailboxNameIndex` run — cached forever, react-query dedupes the
|
|
4
|
+
* identical key across call sites) and reduces it to the mailboxId → folder map
|
|
5
|
+
* search results resolve their provenance against. See `lib/result-folder.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
8
|
+
import type {
|
|
9
|
+
RemitImapAccountResponse,
|
|
10
|
+
RemitImapMailboxResponse,
|
|
11
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
12
|
+
import { useQueries } from "@tanstack/react-query";
|
|
13
|
+
import { useMemo } from "react";
|
|
14
|
+
import {
|
|
15
|
+
buildResultFolderIndex,
|
|
16
|
+
type ResultFolderIndex,
|
|
17
|
+
} from "@/lib/result-folder";
|
|
18
|
+
|
|
19
|
+
type MailboxItems = RemitImapMailboxResponse[];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hoisted, not inline: `useQueries` skips re-running `combine` when it is given
|
|
23
|
+
* the same function, so a stable reference is what makes this a memo rather than
|
|
24
|
+
* a per-render reduce. Without it the index — and every search-result memo keyed
|
|
25
|
+
* on it — rebuilds on every render.
|
|
26
|
+
*/
|
|
27
|
+
const combineMailboxItems = (
|
|
28
|
+
results: { data?: { items: MailboxItems } }[],
|
|
29
|
+
): MailboxItems[] => results.map((result) => result.data?.items ?? []);
|
|
30
|
+
|
|
31
|
+
export function useResultFolderIndex(
|
|
32
|
+
accounts: RemitImapAccountResponse[],
|
|
33
|
+
): ResultFolderIndex {
|
|
34
|
+
const mailboxItems = useQueries({
|
|
35
|
+
queries: accounts.map((account) => ({
|
|
36
|
+
...mailboxOperationsListMailboxesOptions({
|
|
37
|
+
path: { accountId: account.accountId },
|
|
38
|
+
}),
|
|
39
|
+
staleTime: Infinity,
|
|
40
|
+
})),
|
|
41
|
+
combine: combineMailboxItems,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return useMemo(
|
|
45
|
+
() =>
|
|
46
|
+
buildResultFolderIndex(
|
|
47
|
+
accounts.map((account, i) => ({
|
|
48
|
+
folderAppointments: account.folderAppointments,
|
|
49
|
+
mailboxes: mailboxItems[i] ?? [],
|
|
50
|
+
})),
|
|
51
|
+
),
|
|
52
|
+
[accounts, mailboxItems],
|
|
53
|
+
);
|
|
54
|
+
}
|
package/src/lib/mail-context.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
2
|
import { createContext, useContext } from "react";
|
|
3
|
+
import {
|
|
4
|
+
EMPTY_RESULT_FOLDER_INDEX,
|
|
5
|
+
type ResultFolderIndex,
|
|
6
|
+
} from "./result-folder.js";
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Shared mail-layout context. Lives in `lib/` — NOT in `routes/mail.tsx` — on
|
|
@@ -22,6 +26,13 @@ export interface MailContextValue {
|
|
|
22
26
|
*/
|
|
23
27
|
mailboxNameIndex: ReadonlyMap<string, string>;
|
|
24
28
|
accountNameIndex: ReadonlyMap<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* mailboxId → the folder a search result read from that mailbox came from.
|
|
31
|
+
* Computed once here for the same reason as the name indexes: the row labels,
|
|
32
|
+
* the spam hold-out and the scope chip must agree on which folder is which.
|
|
33
|
+
* See `lib/result-folder.ts`.
|
|
34
|
+
*/
|
|
35
|
+
resultFolderIndex: ResultFolderIndex;
|
|
25
36
|
searchQuery: string;
|
|
26
37
|
/** Live (pre-debounce) search input — the search field binds this. */
|
|
27
38
|
searchInput: string;
|
|
@@ -57,6 +68,7 @@ export const useMailContext = (): MailContextValue => {
|
|
|
57
68
|
accounts: [],
|
|
58
69
|
mailboxNameIndex: new Map(),
|
|
59
70
|
accountNameIndex: new Map(),
|
|
71
|
+
resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
|
|
60
72
|
searchQuery: "",
|
|
61
73
|
searchInput: "",
|
|
62
74
|
searchViewKey: "",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
type AccountMailboxes,
|
|
5
|
+
buildResultFolderIndex,
|
|
6
|
+
resolveResultFolder,
|
|
7
|
+
} from "./result-folder";
|
|
8
|
+
|
|
9
|
+
const account = (
|
|
10
|
+
appointments: { role: string; mailboxId: string }[],
|
|
11
|
+
mailboxes: { mailboxId: string; fullPath: string }[],
|
|
12
|
+
): AccountMailboxes =>
|
|
13
|
+
({
|
|
14
|
+
folderAppointments: appointments,
|
|
15
|
+
mailboxes,
|
|
16
|
+
}) as unknown as AccountMailboxes;
|
|
17
|
+
|
|
18
|
+
describe("buildResultFolderIndex", () => {
|
|
19
|
+
it("maps a mailbox to its appointed role and provider path", () => {
|
|
20
|
+
const index = buildResultFolderIndex([
|
|
21
|
+
account(
|
|
22
|
+
[{ role: "Junk", mailboxId: "mb-junk" }],
|
|
23
|
+
[
|
|
24
|
+
{ mailboxId: "mb-junk", fullPath: "Bulk Mail" },
|
|
25
|
+
{ mailboxId: "mb-work", fullPath: "Projects/Bookkeeping" },
|
|
26
|
+
],
|
|
27
|
+
),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(index.get("mb-junk"), {
|
|
31
|
+
role: "junk",
|
|
32
|
+
providerPath: "Bulk Mail",
|
|
33
|
+
});
|
|
34
|
+
assert.deepEqual(index.get("mb-work"), {
|
|
35
|
+
providerPath: "Projects/Bookkeeping",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps every account's mailboxes", () => {
|
|
40
|
+
const index = buildResultFolderIndex([
|
|
41
|
+
account(
|
|
42
|
+
[{ role: "Inbox", mailboxId: "a-inbox" }],
|
|
43
|
+
[{ mailboxId: "a-inbox", fullPath: "INBOX" }],
|
|
44
|
+
),
|
|
45
|
+
account(
|
|
46
|
+
[{ role: "Inbox", mailboxId: "b-inbox" }],
|
|
47
|
+
[{ mailboxId: "b-inbox", fullPath: "INBOX" }],
|
|
48
|
+
),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
assert.equal(index.get("a-inbox")?.role, "inbox");
|
|
52
|
+
assert.equal(index.get("b-inbox")?.role, "inbox");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("resolveResultFolder", () => {
|
|
57
|
+
const index = buildResultFolderIndex([
|
|
58
|
+
account(
|
|
59
|
+
[
|
|
60
|
+
{ role: "Inbox", mailboxId: "mb-inbox" },
|
|
61
|
+
{ role: "All", mailboxId: "mb-all" },
|
|
62
|
+
{ role: "Junk", mailboxId: "mb-junk" },
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
{ mailboxId: "mb-inbox", fullPath: "INBOX" },
|
|
66
|
+
{ mailboxId: "mb-all", fullPath: "[Gmail]/All Mail" },
|
|
67
|
+
{ mailboxId: "mb-junk", fullPath: "[Gmail]/Spam" },
|
|
68
|
+
],
|
|
69
|
+
),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
it("skips a virtual folder for one that names a real place", () => {
|
|
73
|
+
assert.deepEqual(resolveResultFolder(index, ["mb-all", "mb-inbox"]), {
|
|
74
|
+
mailboxId: "mb-inbox",
|
|
75
|
+
folder: { role: "inbox", providerPath: "INBOX" },
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("resolves Spam by its appointed role, not its path", () => {
|
|
80
|
+
assert.equal(resolveResultFolder(index, ["mb-junk"]).folder?.role, "junk");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("falls back to the first id when none names a real place", () => {
|
|
84
|
+
assert.deepEqual(resolveResultFolder(index, ["mb-all"]), {
|
|
85
|
+
mailboxId: "mb-all",
|
|
86
|
+
folder: { role: "all", providerPath: "[Gmail]/All Mail" },
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns the id but no folder when the mailbox is unknown", () => {
|
|
91
|
+
assert.deepEqual(resolveResultFolder(index, ["mb-gone"]), {
|
|
92
|
+
mailboxId: "mb-gone",
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns nothing for a result with no mailboxes", () => {
|
|
97
|
+
assert.deepEqual(resolveResultFolder(index, []), {});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("still names a mailbox before the index has loaded", () => {
|
|
101
|
+
assert.deepEqual(resolveResultFolder(undefined, ["mb-inbox"]), {
|
|
102
|
+
mailboxId: "mb-inbox",
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("buildResultFolderIndex before the mailbox list arrives", () => {
|
|
108
|
+
it("still knows the appointed role, so spam is never let through", () => {
|
|
109
|
+
const index = buildResultFolderIndex([
|
|
110
|
+
account([{ role: "Junk", mailboxId: "mb-junk" }], []),
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
assert.deepEqual(index.get("mb-junk"), { role: "junk" });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a search result was read from.
|
|
3
|
+
*
|
|
4
|
+
* A global search returns rows from every folder of every account, so each row
|
|
5
|
+
* says where it came from and spam is held out of the list. Both questions are
|
|
6
|
+
* answered by joining the row's mailbox ids against the mailbox lists the
|
|
7
|
+
* sidebar already loads: the id → folder map here is the only thing either
|
|
8
|
+
* needs, so neither the search API nor the semantic index has to carry a folder.
|
|
9
|
+
*
|
|
10
|
+
* The role comes from the account's `folderAppointments` map (RFC 032), which
|
|
11
|
+
* the server resolves from the IMAP SPECIAL-USE attributes — so `junk` means the
|
|
12
|
+
* folder the account advertises as `\Junk`, whatever it is called. Names are
|
|
13
|
+
* never consulted.
|
|
14
|
+
*/
|
|
15
|
+
import type {
|
|
16
|
+
RemitImapFolderAppointment,
|
|
17
|
+
RemitImapMailboxResponse,
|
|
18
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
19
|
+
import { provenanceFolderLabel, type ResultFolder } from "@remit/ui";
|
|
20
|
+
import { buildMailboxRoleMap } from "./folder-roles.js";
|
|
21
|
+
|
|
22
|
+
export type ResultFolderIndex = ReadonlyMap<string, ResultFolder>;
|
|
23
|
+
|
|
24
|
+
export const EMPTY_RESULT_FOLDER_INDEX: ResultFolderIndex = new Map();
|
|
25
|
+
|
|
26
|
+
export interface AccountMailboxes {
|
|
27
|
+
folderAppointments: readonly RemitImapFolderAppointment[];
|
|
28
|
+
mailboxes: readonly Pick<
|
|
29
|
+
RemitImapMailboxResponse,
|
|
30
|
+
"mailboxId" | "fullPath"
|
|
31
|
+
>[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The appointments and the mailbox list arrive from two different queries, so
|
|
36
|
+
* the roles are seeded first and the provider paths laid over them. A role on
|
|
37
|
+
* its own is enough to hold spam out of a global search, which means the list
|
|
38
|
+
* loading late cannot let a Spam row through into the results.
|
|
39
|
+
*/
|
|
40
|
+
export function buildResultFolderIndex(
|
|
41
|
+
accounts: readonly AccountMailboxes[],
|
|
42
|
+
): ResultFolderIndex {
|
|
43
|
+
const index = new Map<string, ResultFolder>();
|
|
44
|
+
for (const account of accounts) {
|
|
45
|
+
const roles = buildMailboxRoleMap(account.folderAppointments);
|
|
46
|
+
for (const [mailboxId, role] of roles) index.set(mailboxId, { role });
|
|
47
|
+
for (const mailbox of account.mailboxes) {
|
|
48
|
+
const role = roles.get(mailbox.mailboxId);
|
|
49
|
+
index.set(mailbox.mailboxId, {
|
|
50
|
+
...(role ? { role } : {}),
|
|
51
|
+
providerPath: mailbox.fullPath,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return index;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ResolvedResultFolder {
|
|
59
|
+
mailboxId?: string;
|
|
60
|
+
folder?: ResultFolder;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The mailbox a result should be attributed to, and its folder.
|
|
65
|
+
*
|
|
66
|
+
* A message can sit in several mailboxes at once — Gmail files a copy of most
|
|
67
|
+
* mail in All Mail — and the order the ids arrive in carries no meaning, so the
|
|
68
|
+
* first id that names a real place wins. `provenanceFolderLabel` is the test:
|
|
69
|
+
* it is undefined exactly for the folders that are views rather than places.
|
|
70
|
+
* With nothing resolvable the first id is still returned, because opening the
|
|
71
|
+
* result needs a mailbox even when labelling it does not.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveResultFolder(
|
|
74
|
+
folders: ResultFolderIndex | undefined,
|
|
75
|
+
mailboxIds: readonly string[],
|
|
76
|
+
): ResolvedResultFolder {
|
|
77
|
+
const first = mailboxIds[0];
|
|
78
|
+
if (!folders) return first ? { mailboxId: first } : {};
|
|
79
|
+
|
|
80
|
+
for (const mailboxId of mailboxIds) {
|
|
81
|
+
const folder = folders.get(mailboxId);
|
|
82
|
+
if (folder && provenanceFolderLabel(folder) !== undefined) {
|
|
83
|
+
return { mailboxId, folder };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!first) return {};
|
|
88
|
+
const folder = folders.get(first);
|
|
89
|
+
return { mailboxId: first, ...(folder ? { folder } : {}) };
|
|
90
|
+
}
|
package/src/lib/search-result.ts
CHANGED
|
@@ -11,6 +11,10 @@ import type {
|
|
|
11
11
|
} from "@remit/api-http-client/types.gen.ts";
|
|
12
12
|
import type { SearchResult, ThreadRowData } from "@remit/ui";
|
|
13
13
|
import { formatEmailDate } from "./format.js";
|
|
14
|
+
import {
|
|
15
|
+
type ResultFolderIndex,
|
|
16
|
+
resolveResultFolder,
|
|
17
|
+
} from "./result-folder.js";
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
20
|
* Plain-language label for the `matched: …` chip, so the user understands why
|
|
@@ -34,7 +38,9 @@ export function matchedChunkLabel(
|
|
|
34
38
|
|
|
35
39
|
export function threadToSearchResult(
|
|
36
40
|
thread: RemitImapThreadMessageResponse,
|
|
41
|
+
folders?: ResultFolderIndex,
|
|
37
42
|
): SearchResult {
|
|
43
|
+
const { folder } = resolveResultFolder(folders, [thread.mailboxId]);
|
|
38
44
|
return {
|
|
39
45
|
id: thread.messageId,
|
|
40
46
|
sender: thread.fromName ?? thread.fromEmail ?? "Unknown",
|
|
@@ -45,10 +51,18 @@ export function threadToSearchResult(
|
|
|
45
51
|
flagged: thread.hasStars === true,
|
|
46
52
|
threadId: thread.threadId,
|
|
47
53
|
mailboxId: thread.mailboxId,
|
|
54
|
+
...(folder ? { folder } : {}),
|
|
48
55
|
};
|
|
49
56
|
}
|
|
50
57
|
|
|
51
|
-
export function rowToSearchResult(
|
|
58
|
+
export function rowToSearchResult(
|
|
59
|
+
row: ThreadRowData,
|
|
60
|
+
folders?: ResultFolderIndex,
|
|
61
|
+
): SearchResult {
|
|
62
|
+
const { folder } = resolveResultFolder(
|
|
63
|
+
folders,
|
|
64
|
+
row.mailboxId ? [row.mailboxId] : [],
|
|
65
|
+
);
|
|
52
66
|
return {
|
|
53
67
|
id: row.id,
|
|
54
68
|
sender: row.fromName,
|
|
@@ -57,6 +71,8 @@ export function rowToSearchResult(row: ThreadRowData): SearchResult {
|
|
|
57
71
|
date: row.timeLabel,
|
|
58
72
|
unread: !row.isRead,
|
|
59
73
|
flagged: row.starred === true,
|
|
74
|
+
...(row.mailboxId ? { mailboxId: row.mailboxId } : {}),
|
|
75
|
+
...(folder ? { folder } : {}),
|
|
60
76
|
};
|
|
61
77
|
}
|
|
62
78
|
|
|
@@ -68,7 +84,9 @@ export function rowToSearchResult(row: ThreadRowData): SearchResult {
|
|
|
68
84
|
*/
|
|
69
85
|
export function semanticToSearchResult(
|
|
70
86
|
hit: RemitImapSemanticSearchResult,
|
|
87
|
+
folders?: ResultFolderIndex,
|
|
71
88
|
): SearchResult {
|
|
89
|
+
const { mailboxId, folder } = resolveResultFolder(folders, hit.mailboxIds);
|
|
72
90
|
return {
|
|
73
91
|
id: hit.messageId,
|
|
74
92
|
sender: hit.fromName ?? "Unknown",
|
|
@@ -78,7 +96,8 @@ export function semanticToSearchResult(
|
|
|
78
96
|
threadId: hit.threadId,
|
|
79
97
|
matchedChunkLabel: matchedChunkLabel(hit.matchedChunkType),
|
|
80
98
|
score: hit.score,
|
|
81
|
-
...(
|
|
99
|
+
...(mailboxId ? { mailboxId } : {}),
|
|
100
|
+
...(folder ? { folder } : {}),
|
|
82
101
|
};
|
|
83
102
|
}
|
|
84
103
|
|
|
@@ -90,13 +109,14 @@ export function semanticToSearchResult(
|
|
|
90
109
|
export function relatedSearchResults(
|
|
91
110
|
hits: RemitImapSemanticSearchResult[],
|
|
92
111
|
literalThreadIds: Iterable<string>,
|
|
112
|
+
folders?: ResultFolderIndex,
|
|
93
113
|
): SearchResult[] {
|
|
94
114
|
const seenThreadIds = new Set<string>(literalThreadIds);
|
|
95
115
|
const results: SearchResult[] = [];
|
|
96
116
|
for (const hit of [...hits].sort((a, b) => b.score - a.score)) {
|
|
97
117
|
if (seenThreadIds.has(hit.threadId)) continue;
|
|
98
118
|
seenThreadIds.add(hit.threadId);
|
|
99
|
-
results.push(semanticToSearchResult(hit));
|
|
119
|
+
results.push(semanticToSearchResult(hit, folders));
|
|
100
120
|
}
|
|
101
121
|
return results;
|
|
102
122
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "./mail-route";
|
|
9
9
|
import {
|
|
10
10
|
isScopedRoute,
|
|
11
|
+
resultsScopeForRoute,
|
|
11
12
|
SEARCH_SCOPE_CHIP_ID,
|
|
12
13
|
scopeLabelForMailboxName,
|
|
13
14
|
searchScopeForRoute,
|
|
@@ -195,3 +196,57 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
|
|
|
195
196
|
}
|
|
196
197
|
});
|
|
197
198
|
});
|
|
199
|
+
|
|
200
|
+
describe("resultsScopeForRoute", () => {
|
|
201
|
+
it("passes the global search through as global", () => {
|
|
202
|
+
assert.deepEqual(
|
|
203
|
+
resultsScopeForRoute(matches(MAIL_BRIEF_ROUTE_ID), { kind: "global" }),
|
|
204
|
+
{
|
|
205
|
+
kind: "global",
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("treats a mailbox whose name has not loaded as a folder, not global", () => {
|
|
211
|
+
assert.deepEqual(
|
|
212
|
+
resultsScopeForRoute(matches(MAIL_MAILBOX_ROUTE_ID), { kind: "pending" }),
|
|
213
|
+
{
|
|
214
|
+
kind: "folder",
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("carries the mailbox's role so a Spam search shows its rows", () => {
|
|
220
|
+
assert.deepEqual(
|
|
221
|
+
resultsScopeForRoute(
|
|
222
|
+
matches(MAIL_MAILBOX_ROUTE_ID),
|
|
223
|
+
{
|
|
224
|
+
kind: "scoped",
|
|
225
|
+
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:spam" },
|
|
226
|
+
},
|
|
227
|
+
"junk",
|
|
228
|
+
),
|
|
229
|
+
{ kind: "folder", role: "junk" },
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("calls starred a collection, so its spam is not dropped", () => {
|
|
234
|
+
assert.deepEqual(
|
|
235
|
+
resultsScopeForRoute(matches(MAIL_FLAGGED_ROUTE_ID), {
|
|
236
|
+
kind: "scoped",
|
|
237
|
+
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "is:starred" },
|
|
238
|
+
}),
|
|
239
|
+
{ kind: "collection" },
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("keeps the outbox a folder — it is one queue, and never holds spam", () => {
|
|
244
|
+
assert.deepEqual(
|
|
245
|
+
resultsScopeForRoute(matches(MAIL_OUTBOX_ROUTE_ID), {
|
|
246
|
+
kind: "scoped",
|
|
247
|
+
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:outbox" },
|
|
248
|
+
}),
|
|
249
|
+
{ kind: "folder" },
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
});
|
package/src/lib/search-scope.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Pure functions only. `useSearchScope` binds these to the router.
|
|
23
23
|
*/
|
|
24
|
+
import type { FolderRole, SearchScope as ResultsScope } from "@remit/ui";
|
|
24
25
|
import {
|
|
25
26
|
isBriefRoute,
|
|
26
27
|
isFlaggedRoute,
|
|
@@ -152,3 +153,30 @@ export function searchScopeForRoute(
|
|
|
152
153
|
}
|
|
153
154
|
return { kind: "global" };
|
|
154
155
|
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The route's scope as the results list understands it.
|
|
159
|
+
*
|
|
160
|
+
* `is:starred` is a collection, not a folder: it spans every folder a star can
|
|
161
|
+
* be set in, so its rows carry provenance, and it is the user's own hand-picked
|
|
162
|
+
* mail, so a starred spam message is shown rather than held back. Only a search
|
|
163
|
+
* confined to a folder drops spam.
|
|
164
|
+
*
|
|
165
|
+
* The bar's third state, `pending`, is a mailbox route whose name has not
|
|
166
|
+
* loaded. It maps to `folder` with no role yet, because the list underneath is
|
|
167
|
+
* already one mailbox; calling it global for that one frame would flash folder
|
|
168
|
+
* labels and a spam offer and then retract them.
|
|
169
|
+
*
|
|
170
|
+
* `role` is the appointed role of the mailbox the route is on, which is what
|
|
171
|
+
* makes a search scoped to Spam show its rows instead of dropping them. A
|
|
172
|
+
* folder nobody appointed carries no role.
|
|
173
|
+
*/
|
|
174
|
+
export function resultsScopeForRoute(
|
|
175
|
+
matches: readonly MailRouteMatch[],
|
|
176
|
+
state: SearchScopeState,
|
|
177
|
+
role?: FolderRole,
|
|
178
|
+
): ResultsScope {
|
|
179
|
+
if (state.kind === "global") return { kind: "global" };
|
|
180
|
+
if (isFlaggedRoute(matches)) return { kind: "collection" };
|
|
181
|
+
return { kind: "folder", ...(role ? { role } : {}) };
|
|
182
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { SearchResult } from "@remit/ui";
|
|
4
|
+
import { spamOfferForResults } from "./spam-offer";
|
|
5
|
+
|
|
6
|
+
const row = (
|
|
7
|
+
id: string,
|
|
8
|
+
mailboxId: string,
|
|
9
|
+
role?: "junk" | "inbox",
|
|
10
|
+
): SearchResult => ({
|
|
11
|
+
id,
|
|
12
|
+
sender: "Someone",
|
|
13
|
+
subject: "Subject",
|
|
14
|
+
snippet: "",
|
|
15
|
+
date: "",
|
|
16
|
+
mailboxId,
|
|
17
|
+
...(role ? { folder: { role } } : {}),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("spamOfferForResults", () => {
|
|
21
|
+
it("makes no offer when nothing came from Spam", () => {
|
|
22
|
+
assert.equal(
|
|
23
|
+
spamOfferForResults([row("1", "mb-inbox", "inbox"), row("2", "mb-a")]),
|
|
24
|
+
undefined,
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("counts the spam rows and names their mailbox", () => {
|
|
29
|
+
assert.deepEqual(
|
|
30
|
+
spamOfferForResults([
|
|
31
|
+
row("1", "mb-inbox", "inbox"),
|
|
32
|
+
row("2", "mb-junk", "junk"),
|
|
33
|
+
row("3", "mb-junk", "junk"),
|
|
34
|
+
]),
|
|
35
|
+
{ mailboxId: "mb-junk", count: 2 },
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("targets the Spam folder with the most matches when accounts compete", () => {
|
|
40
|
+
assert.deepEqual(
|
|
41
|
+
spamOfferForResults([
|
|
42
|
+
row("1", "mb-junk-a", "junk"),
|
|
43
|
+
row("2", "mb-junk-b", "junk"),
|
|
44
|
+
row("3", "mb-junk-b", "junk"),
|
|
45
|
+
]),
|
|
46
|
+
{ mailboxId: "mb-junk-b", count: 2 },
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("breaks a tie on result order, so the offer does not flip", () => {
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
spamOfferForResults([
|
|
53
|
+
row("1", "mb-junk-a", "junk"),
|
|
54
|
+
row("2", "mb-junk-b", "junk"),
|
|
55
|
+
]),
|
|
56
|
+
{ mailboxId: "mb-junk-a", count: 1 },
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("picks a destination without claiming to have counted every account", () => {
|
|
61
|
+
// Three spam rows across two accounts: the destination is the bigger folder,
|
|
62
|
+
// and its share is 2 — deliberately not the total, which the results list
|
|
63
|
+
// states for itself so the banner cannot under-report.
|
|
64
|
+
const offer = spamOfferForResults([
|
|
65
|
+
row("1", "mb-junk-a", "junk"),
|
|
66
|
+
row("2", "mb-junk-b", "junk"),
|
|
67
|
+
row("3", "mb-junk-b", "junk"),
|
|
68
|
+
]);
|
|
69
|
+
assert.equal(offer?.mailboxId, "mb-junk-b");
|
|
70
|
+
assert.equal(offer?.count, 2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("ignores a spam row that carries no mailbox to navigate to", () => {
|
|
74
|
+
const orphan: SearchResult = {
|
|
75
|
+
id: "1",
|
|
76
|
+
sender: "Someone",
|
|
77
|
+
subject: "Subject",
|
|
78
|
+
snippet: "",
|
|
79
|
+
date: "",
|
|
80
|
+
folder: { role: "junk" },
|
|
81
|
+
};
|
|
82
|
+
assert.equal(spamOfferForResults([orphan]), undefined);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where taking the Spam offer lands.
|
|
3
|
+
*
|
|
4
|
+
* Scoping is a route and a route names one mailbox, so the offer has to pick a
|
|
5
|
+
* single Spam folder even when several accounts matched; it picks the one
|
|
6
|
+
* holding the most matches. `count` is that folder's share, used only to choose
|
|
7
|
+
* between folders — the banner states the full number of rows held out, which
|
|
8
|
+
* the results list counts for itself.
|
|
9
|
+
*/
|
|
10
|
+
import { partitionSpamResults, type SearchResult } from "@remit/ui";
|
|
11
|
+
|
|
12
|
+
export interface SpamOffer {
|
|
13
|
+
mailboxId: string;
|
|
14
|
+
count: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function spamOfferForResults(
|
|
18
|
+
results: readonly SearchResult[],
|
|
19
|
+
): SpamOffer | undefined {
|
|
20
|
+
const { spam } = partitionSpamResults([...results]);
|
|
21
|
+
const countByMailbox = new Map<string, number>();
|
|
22
|
+
for (const result of spam) {
|
|
23
|
+
if (!result.mailboxId) continue;
|
|
24
|
+
countByMailbox.set(
|
|
25
|
+
result.mailboxId,
|
|
26
|
+
(countByMailbox.get(result.mailboxId) ?? 0) + 1,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let offer: SpamOffer | undefined;
|
|
31
|
+
// Insertion order is result order, so the first-seen folder wins a tie and
|
|
32
|
+
// the offer does not flip between renders of the same results.
|
|
33
|
+
for (const [mailboxId, count] of countByMailbox) {
|
|
34
|
+
if (!offer || count > offer.count) offer = { mailboxId, count };
|
|
35
|
+
}
|
|
36
|
+
return offer;
|
|
37
|
+
}
|
package/src/routes/mail.tsx
CHANGED
|
@@ -27,6 +27,7 @@ import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
|
|
27
27
|
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
|
28
28
|
import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
|
|
29
29
|
import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
|
|
30
|
+
import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
|
|
30
31
|
import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
|
|
31
32
|
import { writeIntelligencePref } from "@/lib/intelligence-pref";
|
|
32
33
|
import { MailContext } from "@/lib/mail-context";
|
|
@@ -232,6 +233,7 @@ function MailLayout() {
|
|
|
232
233
|
|
|
233
234
|
const accounts = config?.accounts ?? [];
|
|
234
235
|
const mailboxNameIndex = useMailboxNameIndex(accounts);
|
|
236
|
+
const resultFolderIndex = useResultFolderIndex(accounts);
|
|
235
237
|
const accountNameIndex = useMemo(
|
|
236
238
|
() => buildAccountNameIndex(accounts),
|
|
237
239
|
[accounts],
|
|
@@ -290,6 +292,7 @@ function MailLayout() {
|
|
|
290
292
|
accounts,
|
|
291
293
|
mailboxNameIndex,
|
|
292
294
|
accountNameIndex,
|
|
295
|
+
resultFolderIndex,
|
|
293
296
|
// The committed local value is the source of truth for search; it is
|
|
294
297
|
// mirrored to the URL for shareable links.
|
|
295
298
|
searchQuery: committedQuery,
|