@remit/web-client 0.0.281 → 0.0.283
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/BetterAuthShell.tsx +39 -4
- package/src/auth/SessionRateLimited.stories.tsx +26 -0
- package/src/auth/SessionRateLimited.tsx +39 -0
- package/src/auth/auth-error.test.ts +20 -0
- package/src/auth/auth-error.ts +15 -2
- package/src/auth/session-gate.render.test.ts +77 -0
- package/src/auth/session-state.test.ts +66 -0
- package/src/auth/session-state.ts +41 -0
- package/src/components/mail/DailyBrief.tsx +49 -5
- package/src/components/mail/MailListHeader.tsx +19 -14
- package/src/components/mail/spam-offer-server-count.render.test.ts +349 -0
- package/src/hooks/useSpamMatchCounts.ts +102 -0
- package/src/lib/result-folder.ts +16 -0
- package/src/lib/spam-offer.test.ts +166 -58
- package/src/lib/spam-offer.ts +150 -24
|
@@ -1,84 +1,192 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import type
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
3
|
+
import { type SpamMailboxCount, spamOfferFromCounts } from "./spam-offer";
|
|
4
|
+
|
|
5
|
+
const exact = (mailboxId: string, value: number): SpamMailboxCount => ({
|
|
6
|
+
mailboxId,
|
|
7
|
+
count: { kind: "exact", value },
|
|
8
|
+
failed: false,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const uncounted = (mailboxId: string): SpamMailboxCount => ({
|
|
12
|
+
mailboxId,
|
|
13
|
+
count: { kind: "unknown" },
|
|
14
|
+
failed: false,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const errored = (mailboxId: string): SpamMailboxCount => ({
|
|
16
18
|
mailboxId,
|
|
17
|
-
|
|
19
|
+
count: { kind: "unknown" },
|
|
20
|
+
failed: true,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** The loaded page held no junk row of its own. */
|
|
24
|
+
const NO_PAGE_ROWS = { pageRowsByMailbox: new Map<string, number>() };
|
|
25
|
+
|
|
26
|
+
const pageRows = (
|
|
27
|
+
entries: Record<string, number>,
|
|
28
|
+
): { pageRowsByMailbox: ReadonlyMap<string, number> } => ({
|
|
29
|
+
pageRowsByMailbox: new Map(Object.entries(entries)),
|
|
18
30
|
});
|
|
19
31
|
|
|
20
|
-
describe("
|
|
21
|
-
it("makes no offer when
|
|
32
|
+
describe("spamOfferFromCounts", () => {
|
|
33
|
+
it("makes no offer when there is no junk folder to count", () => {
|
|
34
|
+
assert.equal(spamOfferFromCounts([], NO_PAGE_ROWS), undefined);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("makes no offer when every junk folder counted zero", () => {
|
|
22
38
|
assert.equal(
|
|
23
|
-
|
|
39
|
+
spamOfferFromCounts(
|
|
40
|
+
[exact("mb-junk-a", 0), exact("mb-junk-b", 0)],
|
|
41
|
+
NO_PAGE_ROWS,
|
|
42
|
+
),
|
|
24
43
|
undefined,
|
|
25
44
|
);
|
|
26
45
|
});
|
|
27
46
|
|
|
28
|
-
it("
|
|
47
|
+
it("states the server's count for the one junk folder", () => {
|
|
48
|
+
assert.deepEqual(
|
|
49
|
+
spamOfferFromCounts([exact("mb-junk", 42)], NO_PAGE_ROWS),
|
|
50
|
+
{
|
|
51
|
+
mailboxId: "mb-junk",
|
|
52
|
+
count: { kind: "exact", value: 42 },
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("sums every junk folder, so several accounts read as one number", () => {
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
spamOfferFromCounts(
|
|
60
|
+
[exact("mb-junk-a", 3), exact("mb-junk-b", 7), exact("mb-junk-c", 2)],
|
|
61
|
+
NO_PAGE_ROWS,
|
|
62
|
+
),
|
|
63
|
+
{ mailboxId: "mb-junk-b", count: { kind: "exact", value: 12 } },
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("sends the reader to the junk folder holding the most matches", () => {
|
|
68
|
+
assert.equal(
|
|
69
|
+
spamOfferFromCounts(
|
|
70
|
+
[exact("mb-junk-a", 1), exact("mb-junk-b", 9)],
|
|
71
|
+
NO_PAGE_ROWS,
|
|
72
|
+
)?.mailboxId,
|
|
73
|
+
"mb-junk-b",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("breaks a tie on folder order, so the destination does not flip", () => {
|
|
78
|
+
assert.equal(
|
|
79
|
+
spamOfferFromCounts(
|
|
80
|
+
[exact("mb-junk-a", 4), exact("mb-junk-b", 4)],
|
|
81
|
+
NO_PAGE_ROWS,
|
|
82
|
+
)?.mailboxId,
|
|
83
|
+
"mb-junk-a",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("states no number when one junk folder went uncounted", () => {
|
|
88
|
+
// Summing only the folders that answered would state a figure that is exact
|
|
89
|
+
// in form and short in fact.
|
|
29
90
|
assert.deepEqual(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
91
|
+
spamOfferFromCounts(
|
|
92
|
+
[exact("mb-junk-a", 5), uncounted("mb-junk-b")],
|
|
93
|
+
NO_PAGE_ROWS,
|
|
94
|
+
),
|
|
95
|
+
{ mailboxId: "mb-junk-a", count: { kind: "unknown" } },
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("stands down when nothing is known and the page held no spam either", () => {
|
|
100
|
+
assert.equal(
|
|
101
|
+
spamOfferFromCounts(
|
|
102
|
+
[uncounted("mb-junk-a"), uncounted("mb-junk-b")],
|
|
103
|
+
NO_PAGE_ROWS,
|
|
104
|
+
),
|
|
105
|
+
undefined,
|
|
36
106
|
);
|
|
37
107
|
});
|
|
38
108
|
|
|
39
|
-
|
|
109
|
+
// A failed count is not a folder saying it holds nothing. Dropping the offer
|
|
110
|
+
// on one would make a lapsed session look exactly like a clean search.
|
|
111
|
+
it("keeps the offer when a count request failed", () => {
|
|
40
112
|
assert.deepEqual(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
{ mailboxId: "mb-junk-b", count: 2 },
|
|
113
|
+
spamOfferFromCounts(
|
|
114
|
+
[exact("mb-junk-a", 0), errored("mb-junk-b")],
|
|
115
|
+
NO_PAGE_ROWS,
|
|
116
|
+
),
|
|
117
|
+
{ mailboxId: "mb-junk-b", count: { kind: "unknown" } },
|
|
47
118
|
);
|
|
48
119
|
});
|
|
49
120
|
|
|
50
|
-
it("
|
|
121
|
+
it("does not read an uncounted folder as an empty one", () => {
|
|
51
122
|
assert.deepEqual(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
{ mailboxId: "mb-junk-
|
|
123
|
+
spamOfferFromCounts(
|
|
124
|
+
[exact("mb-junk-a", 0), uncounted("mb-junk-b")],
|
|
125
|
+
pageRows({ "mb-junk-b": 1 }),
|
|
126
|
+
),
|
|
127
|
+
{ mailboxId: "mb-junk-b", count: { kind: "unknown" } },
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("spamOfferFromCounts destination", () => {
|
|
133
|
+
// The regression: with counts switched off, every folder is `unknown` and the
|
|
134
|
+
// destination fell to whichever folder happened to be first. Two accounts,
|
|
135
|
+
// every held-out row in B's Spam, and the button opened A's — "No matches"
|
|
136
|
+
// over mail the list was holding out on the very same screen.
|
|
137
|
+
it("follows the page's junk rows when no folder was counted", () => {
|
|
138
|
+
assert.equal(
|
|
139
|
+
spamOfferFromCounts(
|
|
140
|
+
[uncounted("mb-junk-a"), uncounted("mb-junk-b")],
|
|
141
|
+
pageRows({ "mb-junk-b": 3 }),
|
|
142
|
+
)?.mailboxId,
|
|
143
|
+
"mb-junk-b",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// An exact zero is the one folder known to hold nothing, so it is the one
|
|
148
|
+
// destination guaranteed to show an empty list — below a folder that simply
|
|
149
|
+
// has not answered.
|
|
150
|
+
it("never sends the reader to a folder that counted zero", () => {
|
|
151
|
+
assert.equal(
|
|
152
|
+
spamOfferFromCounts(
|
|
153
|
+
[exact("mb-junk-a", 0), uncounted("mb-junk-b"), errored("mb-junk-c")],
|
|
154
|
+
NO_PAGE_ROWS,
|
|
155
|
+
)?.mailboxId,
|
|
156
|
+
"mb-junk-b",
|
|
57
157
|
);
|
|
58
158
|
});
|
|
59
159
|
|
|
60
|
-
it("
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
160
|
+
it("prefers a counted match over the folder the page's rows came from", () => {
|
|
161
|
+
assert.equal(
|
|
162
|
+
spamOfferFromCounts(
|
|
163
|
+
[exact("mb-junk-a", 9), uncounted("mb-junk-b")],
|
|
164
|
+
pageRows({ "mb-junk-b": 4 }),
|
|
165
|
+
)?.mailboxId,
|
|
166
|
+
"mb-junk-a",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// The mailbox lists are still loading, or the folder arrived after the count
|
|
171
|
+
// requests went out. Its rows are already held out of the list, so it has to
|
|
172
|
+
// be reachable.
|
|
173
|
+
it("offers a junk folder the page names and the counts do not", () => {
|
|
174
|
+
assert.deepEqual(spamOfferFromCounts([], pageRows({ "mb-junk-late": 2 })), {
|
|
175
|
+
mailboxId: "mb-junk-late",
|
|
176
|
+
count: { kind: "unknown" },
|
|
177
|
+
});
|
|
71
178
|
});
|
|
72
179
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
180
|
+
// A count that says nothing over a page that is holding junk rows is stale by
|
|
181
|
+
// up to its half-minute. The rows are on screen; the offer stands and states
|
|
182
|
+
// no figure rather than "0 results from Spam".
|
|
183
|
+
it("states no number when an exact zero contradicts the page's rows", () => {
|
|
184
|
+
assert.deepEqual(
|
|
185
|
+
spamOfferFromCounts(
|
|
186
|
+
[exact("mb-junk-a", 0), exact("mb-junk-b", 0)],
|
|
187
|
+
pageRows({ "mb-junk-b": 2 }),
|
|
188
|
+
),
|
|
189
|
+
{ mailboxId: "mb-junk-b", count: { kind: "unknown" } },
|
|
190
|
+
);
|
|
83
191
|
});
|
|
84
192
|
});
|
package/src/lib/spam-offer.ts
CHANGED
|
@@ -1,37 +1,163 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The Spam offer: how much of a search sits in Spam, and where taking the offer
|
|
3
|
+
* lands.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Both answers come from one server count per junk folder the search reached
|
|
6
|
+
* (`useSpamMatchCounts`). The number is their sum, because a search spans every
|
|
7
|
+
* account and every account has its own Spam folder — "N in Spam" that named
|
|
8
|
+
* one folder's share would be smaller than the mail the phrase describes.
|
|
9
|
+
*
|
|
10
|
+
* The destination is a single folder even so: scoping is a route and a route
|
|
11
|
+
* names one mailbox. It is the folder holding the most matches, which is the
|
|
12
|
+
* best a single click can do, and `SpamResultsOffer` says where the button goes
|
|
13
|
+
* rather than promising to show every match. Where no count ranks the folders —
|
|
14
|
+
* a short query, a residual token, a failed request — the page's own junk rows
|
|
15
|
+
* do, because they are the only evidence left of where the mail is.
|
|
16
|
+
*
|
|
17
|
+
* This used to group the search results the header held and count each group.
|
|
18
|
+
* That array is a page, so the figure was a count of the loaded window offered
|
|
19
|
+
* as a count of a mailbox: a Spam match below the page was missing from it, and
|
|
20
|
+
* the number climbed as further pages loaded (#313).
|
|
21
|
+
*
|
|
22
|
+
* A folder whose count did not arrive makes the total `unknown`. Summing the
|
|
23
|
+
* ones that did would state a figure that is exact in form and short in fact,
|
|
24
|
+
* and nothing distinguishes it from a true one on screen.
|
|
9
25
|
*/
|
|
10
|
-
import {
|
|
26
|
+
import type { ResultCount } from "@remit/ui";
|
|
27
|
+
|
|
28
|
+
export interface SpamMailboxCount {
|
|
29
|
+
mailboxId: string;
|
|
30
|
+
count: ResultCount;
|
|
31
|
+
/**
|
|
32
|
+
* The count request failed. Distinct from `unknown`, which also covers a
|
|
33
|
+
* request still in flight or one nobody made: a failure is a folder that
|
|
34
|
+
* could not answer, and it must never read as a folder holding nothing.
|
|
35
|
+
*/
|
|
36
|
+
failed: boolean;
|
|
37
|
+
}
|
|
11
38
|
|
|
12
39
|
export interface SpamOffer {
|
|
40
|
+
/** The junk folder "Go to Spam" scopes the search to. */
|
|
41
|
+
mailboxId: string;
|
|
42
|
+
/** Matches across every junk folder, or `unknown` if any went uncounted. */
|
|
43
|
+
count: ResultCount;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SpamOfferEvidence {
|
|
47
|
+
/**
|
|
48
|
+
* Junk rows the loaded page held, per junk folder.
|
|
49
|
+
*
|
|
50
|
+
* Never the number the offer states — a page is a page. It is what says
|
|
51
|
+
* spam was reached at all while the counts are absent, and, crucially, WHICH
|
|
52
|
+
* folder the reader is being sent to: with no count to rank the folders by,
|
|
53
|
+
* the rows on screen are the only evidence of where the mail is, and a
|
|
54
|
+
* "Go to Spam" that lands on the wrong account's folder shows "No matches"
|
|
55
|
+
* over mail this very page is holding out.
|
|
56
|
+
*/
|
|
57
|
+
pageRowsByMailbox: ReadonlyMap<string, number>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Candidate {
|
|
13
61
|
mailboxId: string;
|
|
14
|
-
count:
|
|
62
|
+
count: ResultCount;
|
|
63
|
+
failed: boolean;
|
|
64
|
+
pageRows: number;
|
|
15
65
|
}
|
|
16
66
|
|
|
17
|
-
|
|
18
|
-
|
|
67
|
+
/**
|
|
68
|
+
* How good a destination a folder is, before ties are broken.
|
|
69
|
+
*
|
|
70
|
+
* A counted match is the best evidence; junk rows on the page are the next
|
|
71
|
+
* best, and they outrank the folder's own count because the rows are on screen
|
|
72
|
+
* while the count may be half a minute old. A folder that answered zero and
|
|
73
|
+
* shows no row ranks LAST — it is the only one known to hold nothing, so
|
|
74
|
+
* sending the reader there is the one choice guaranteed to show an empty list.
|
|
75
|
+
*/
|
|
76
|
+
const tier = (candidate: Candidate): number => {
|
|
77
|
+
if (candidate.count.kind === "exact" && candidate.count.value > 0) return 3;
|
|
78
|
+
if (candidate.pageRows > 0) return 2;
|
|
79
|
+
return candidate.count.kind === "unknown" ? 1 : 0;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const exactValue = (candidate: Candidate): number =>
|
|
83
|
+
candidate.count.kind === "exact" ? candidate.count.value : 0;
|
|
84
|
+
|
|
85
|
+
/** Strict, so equal candidates leave the earlier one standing. */
|
|
86
|
+
const isBetterDestination = (
|
|
87
|
+
candidate: Candidate,
|
|
88
|
+
standing: Candidate,
|
|
89
|
+
): boolean => {
|
|
90
|
+
if (tier(candidate) !== tier(standing)) {
|
|
91
|
+
return tier(candidate) > tier(standing);
|
|
92
|
+
}
|
|
93
|
+
if (exactValue(candidate) !== exactValue(standing)) {
|
|
94
|
+
return exactValue(candidate) > exactValue(standing);
|
|
95
|
+
}
|
|
96
|
+
return candidate.pageRows > standing.pageRows;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The offer these per-folder counts support, or none.
|
|
101
|
+
*
|
|
102
|
+
* None when every junk folder answered and they sum to zero with no junk row on
|
|
103
|
+
* the page either — the search reaches no spam. Anything else is an offer: rows
|
|
104
|
+
* held out of the list are always offered a way back, because hiding mail
|
|
105
|
+
* without one is worse than hiding a number.
|
|
106
|
+
*
|
|
107
|
+
* The figure is the sum, and only when every folder answered. One folder
|
|
108
|
+
* missing takes it off, because summing the folders that replied states a
|
|
109
|
+
* number exact in form and short in fact. A sum of zero over a page that is
|
|
110
|
+
* holding junk rows takes it off too: the two disagree, so neither is stated.
|
|
111
|
+
*/
|
|
112
|
+
export function spamOfferFromCounts(
|
|
113
|
+
counts: readonly SpamMailboxCount[],
|
|
114
|
+
{ pageRowsByMailbox }: SpamOfferEvidence,
|
|
19
115
|
): SpamOffer | undefined {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
116
|
+
const candidates: Candidate[] = counts.map((entry) => ({
|
|
117
|
+
...entry,
|
|
118
|
+
pageRows: pageRowsByMailbox.get(entry.mailboxId) ?? 0,
|
|
119
|
+
}));
|
|
120
|
+
// A folder the page names but the counts do not — the mailbox lists are still
|
|
121
|
+
// loading, or the folder arrived after the requests went out. Its rows are
|
|
122
|
+
// already being held out of the list, so it has to be reachable.
|
|
123
|
+
for (const [mailboxId, pageRows] of pageRowsByMailbox) {
|
|
124
|
+
if (pageRows <= 0) continue;
|
|
125
|
+
if (candidates.some((entry) => entry.mailboxId === mailboxId)) continue;
|
|
126
|
+
candidates.push({
|
|
127
|
+
mailboxId,
|
|
128
|
+
count: { kind: "unknown" },
|
|
129
|
+
failed: false,
|
|
130
|
+
pageRows,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const first = candidates[0];
|
|
135
|
+
if (!first) return undefined;
|
|
136
|
+
|
|
137
|
+
let total = 0;
|
|
138
|
+
let everyFolderAnswered = true;
|
|
139
|
+
let best = first;
|
|
140
|
+
for (const candidate of candidates) {
|
|
141
|
+
if (candidate.count.kind === "unknown") everyFolderAnswered = false;
|
|
142
|
+
else total += candidate.count.value;
|
|
143
|
+
if (isBetterDestination(candidate, best)) best = candidate;
|
|
28
144
|
}
|
|
29
145
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (!offer || count > offer.count) offer = { mailboxId, count };
|
|
146
|
+
const pageHeldSpam = candidates.some((candidate) => candidate.pageRows > 0);
|
|
147
|
+
const anyFailed = candidates.some((candidate) => candidate.failed);
|
|
148
|
+
if (total === 0 && !pageHeldSpam && (everyFolderAnswered || !anyFailed)) {
|
|
149
|
+
return undefined;
|
|
35
150
|
}
|
|
36
|
-
|
|
151
|
+
|
|
152
|
+
// The count and the page disagree: an exact zero under rows the list is
|
|
153
|
+
// holding out. The rows are on screen and the count is up to half a minute
|
|
154
|
+
// old, so the offer stands and states nothing.
|
|
155
|
+
const contradicted = everyFolderAnswered && total === 0 && pageHeldSpam;
|
|
156
|
+
return {
|
|
157
|
+
mailboxId: best.mailboxId,
|
|
158
|
+
count:
|
|
159
|
+
everyFolderAnswered && !contradicted
|
|
160
|
+
? { kind: "exact", value: total }
|
|
161
|
+
: { kind: "unknown" },
|
|
162
|
+
};
|
|
37
163
|
}
|