@remit/web-client 0.0.128 → 0.0.130
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/components/mail/AutoMovedIndicator.tsx +14 -8
- package/src/components/mail/BriefPane.tsx +14 -1
- package/src/components/mail/FlaggedPane.tsx +14 -1
- package/src/components/mail/IntelligencePane.test.ts +13 -84
- package/src/components/mail/IntelligencePane.tsx +45 -96
- package/src/components/mail/MailboxPane.tsx +22 -2
- package/src/components/mail/MessageCard.tsx +11 -9
- package/src/hooks/useAutoMovedBadge.ts +57 -20
- package/src/hooks/useIntelligenceData.test.ts +13 -3
- package/src/hooks/useIntelligenceData.ts +24 -9
- package/src/hooks/useReportSpam.integration.test.ts +93 -0
- package/src/hooks/useReportSpam.render.test.ts +113 -0
- package/src/hooks/useReportSpam.test.ts +115 -0
- package/src/hooks/useReportSpam.ts +244 -0
- package/src/lib/auto-moved.test.ts +8 -0
- package/src/lib/auto-moved.ts +9 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mailboxOperationsListMailboxesQueryKey,
|
|
3
|
+
threadDetailOperationsListThreadMessagesQueryKey,
|
|
4
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
+
import {
|
|
6
|
+
messageBulkOperationsNotSpam,
|
|
7
|
+
messageBulkOperationsReportSpam,
|
|
8
|
+
} from "@remit/api-http-client/sdk.gen.ts";
|
|
9
|
+
import type { RemitImapSpamReportBulkResult } from "@remit/api-http-client/types.gen.ts";
|
|
10
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
11
|
+
import { useCallback } from "react";
|
|
12
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
13
|
+
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
14
|
+
import { ApiError } from "@/lib/api";
|
|
15
|
+
import { runChunkedMutation } from "@/lib/bulk-actions";
|
|
16
|
+
import {
|
|
17
|
+
invalidateThreadListQueries,
|
|
18
|
+
threadListCacheKeys,
|
|
19
|
+
} from "@/lib/thread-list-cache";
|
|
20
|
+
|
|
21
|
+
interface UseReportSpamOptions {
|
|
22
|
+
/** The mailbox the message currently sits in — scopes which list caches settle-time invalidation reaches. */
|
|
23
|
+
mailboxId: string;
|
|
24
|
+
threadId?: string;
|
|
25
|
+
accountId?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Called once a report or undo succeeds, with the message ids it acted on
|
|
28
|
+
* — the same shape as `useMoveMessages`/`useDeleteMessages`'s option of the
|
|
29
|
+
* same name, so a host wires the identical `handleDeselectIfRemoved` it
|
|
30
|
+
* already has. Fires on SUCCESS here, not optimistically like those two:
|
|
31
|
+
* neither endpoint tells the client whether the message actually left
|
|
32
|
+
* `mailboxId` (reporting or undoing a message already in Junk is a real
|
|
33
|
+
* no-op-move, and the client has no way to predict it — see
|
|
34
|
+
* `throwOnBulkFailure`'s neighbour below), so this fires for every success,
|
|
35
|
+
* no-op included — a no-op report/undo still deselects even though the row
|
|
36
|
+
* never actually left the list the host is watching. That trades an
|
|
37
|
+
* occasional unnecessary pane-close for never leaving a reported message
|
|
38
|
+
* rendering a pre-report snapshot forever, which is the bug this exists to
|
|
39
|
+
* fix (issue #648 review).
|
|
40
|
+
*/
|
|
41
|
+
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The one designed, allowlisted reason returned by the server as-is (e.g.
|
|
46
|
+
* `notSpam`'s move-not-settled-yet message) is safe to show verbatim; an
|
|
47
|
+
* unexpected failure is already flattened server-side to this same generic
|
|
48
|
+
* text (`GENERIC_FAILURE_REASON` in `packages/backend/src/handlers/message.ts`)
|
|
49
|
+
* before it reaches the client. Used only as a fallback for the case neither
|
|
50
|
+
* hits: a `failures` entry missing its `reason`, or no `failures` array at all
|
|
51
|
+
* despite a non-zero `failureCount`.
|
|
52
|
+
*/
|
|
53
|
+
export const GENERIC_SPAM_ACTION_FAILURE =
|
|
54
|
+
"This message could not be processed. Please try again.";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The server's designed failure text names the message by embedding its raw
|
|
58
|
+
* id as a possessive subject — e.g. "Message 4kv0xxyfhg4dhzqvxd105v840's move
|
|
59
|
+
* to Junk has not settled yet; try again in a moment." (message ids here are
|
|
60
|
+
* 25-char base36, `translator.generate()` in `packages/data-ports/src/id.ts`
|
|
61
|
+
* — not a dashed UUID). Accurate, but not something to put in front of a
|
|
62
|
+
* person. `messageId` is the exact id the failure names — `SpamReportFailure`
|
|
63
|
+
* pairs it with `reason` for precisely this — so this strips that literal
|
|
64
|
+
* substring rather than pattern-matching an id shape that could change under
|
|
65
|
+
* it. Not a parse of the reason's meaning (the field is documented "not
|
|
66
|
+
* intended to be parsed programmatically"): removing a known, opaque
|
|
67
|
+
* identifier from where it renders reads nothing into what the sentence says.
|
|
68
|
+
*/
|
|
69
|
+
export const humanizeSpamFailureReason = (
|
|
70
|
+
reason: string,
|
|
71
|
+
messageId: string,
|
|
72
|
+
): string => reason.replace(`Message ${messageId}'s`, "This message's");
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `settleSpamReportBulk` (backend) runs the batch with `Promise.allSettled`
|
|
76
|
+
* and always answers 200, folding per-message outcomes into
|
|
77
|
+
* `successCount`/`failureCount`/`failures` rather than rejecting the HTTP
|
|
78
|
+
* call — a partial (or total) failure is not a thrown error on the wire. The
|
|
79
|
+
* generated mutation helpers only ever reject on a non-2xx response, so
|
|
80
|
+
* without this the hook would report success for a message whose report or
|
|
81
|
+
* undo never actually happened. Every caller here sends exactly one message
|
|
82
|
+
* per call, so surfacing the first failure's reason is surfacing the whole
|
|
83
|
+
* story, not truncating a real batch.
|
|
84
|
+
*
|
|
85
|
+
* Throws `ApiError` — not a bare `Error` — carrying a 4xx status. The
|
|
86
|
+
* client's fail-fast contract (`lib/error-classifier.ts`'s `shouldEscalate`,
|
|
87
|
+
* wired globally on the `MutationCache` in `lib/query-error-handler.ts`)
|
|
88
|
+
* escalates anything that isn't a 5xx by default UNLESS the call site opts
|
|
89
|
+
* out via `meta.softError` — a statusless `Error` escalates too, as a client
|
|
90
|
+
* bug. Either way, without both the status AND `meta.softError` (set on the
|
|
91
|
+
* `useMutation` calls below) this would crash to the full-screen fatal
|
|
92
|
+
* overlay for a designed, expected, retryable outcome the backend wrote
|
|
93
|
+
* user-facing copy for. Every other call site in this app forwards a
|
|
94
|
+
* status-carrying error the generated client already threw and never needs
|
|
95
|
+
* the opt-out because none of them are a routine, expected-failure signal
|
|
96
|
+
* like this one; this is the one place synthesizing a failure from a 200
|
|
97
|
+
* body, so it has to synthesize both.
|
|
98
|
+
*/
|
|
99
|
+
export const throwOnBulkFailure = (
|
|
100
|
+
data: RemitImapSpamReportBulkResult,
|
|
101
|
+
): void => {
|
|
102
|
+
if (data.failureCount === 0) return;
|
|
103
|
+
const failure = data.failures?.[0];
|
|
104
|
+
const reason = failure?.reason ?? GENERIC_SPAM_ACTION_FAILURE;
|
|
105
|
+
const message = failure
|
|
106
|
+
? humanizeSpamFailureReason(reason, failure.messageId)
|
|
107
|
+
: reason;
|
|
108
|
+
throw new ApiError(message, 422);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Composes `POST /messages/report-spam` and `POST /messages/not-spam`
|
|
113
|
+
* (issue #648). Both fold the sender-flag write, the Junk move and the
|
|
114
|
+
* `$Junk` keyword marker into one server-side operation
|
|
115
|
+
* (`SpamReportService`) — the client only sends `messageIds` and reflects the
|
|
116
|
+
* result, it does not sequence the three writes itself, and it never derives
|
|
117
|
+
* "reported" from placement (a report on a message already in Junk — the
|
|
118
|
+
* provider's own filter put it there — is a real, no-op-move case).
|
|
119
|
+
*
|
|
120
|
+
* No optimistic cache patch, unlike `useMoveMessages`/`useDeleteMessages`:
|
|
121
|
+
* neither endpoint's response says whether the message actually changed
|
|
122
|
+
* mailboxes (a report or undo against a message already in Junk is a real,
|
|
123
|
+
* silent no-op), so predicting the row should vanish would flicker it back
|
|
124
|
+
* on invalidation for a no-op — worst on `notSpam`, whose R2 wait can run
|
|
125
|
+
* several seconds before the row "pops back". The list settles from
|
|
126
|
+
* `onSuccess`'s invalidation instead, which is always correct. `isReporting`/
|
|
127
|
+
* `isRestoring` below exist to pay for the UX this trades away: without them
|
|
128
|
+
* a press produces no visible change at all until the request lands — the
|
|
129
|
+
* caller wires them into the quick action's pending state.
|
|
130
|
+
*/
|
|
131
|
+
export function useReportSpam({
|
|
132
|
+
mailboxId,
|
|
133
|
+
threadId,
|
|
134
|
+
accountId,
|
|
135
|
+
onAfterOptimisticRemove,
|
|
136
|
+
}: UseReportSpamOptions) {
|
|
137
|
+
const queryClient = useQueryClient();
|
|
138
|
+
const { pushError } = useErrorBanners();
|
|
139
|
+
|
|
140
|
+
const listPrefixes = threadListCacheKeys([mailboxId]);
|
|
141
|
+
const threadMessagesPrefix = threadId
|
|
142
|
+
? threadDetailOperationsListThreadMessagesQueryKey({ path: { threadId } })
|
|
143
|
+
: [];
|
|
144
|
+
|
|
145
|
+
const invalidateAffectedQueries = () => {
|
|
146
|
+
if (threadId) {
|
|
147
|
+
queryClient.invalidateQueries({ queryKey: threadMessagesPrefix });
|
|
148
|
+
}
|
|
149
|
+
invalidateThreadListQueries(queryClient, listPrefixes);
|
|
150
|
+
if (accountId) {
|
|
151
|
+
queryClient.invalidateQueries({
|
|
152
|
+
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
153
|
+
path: { accountId },
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const buildOnSuccess =
|
|
160
|
+
() => (_data: unknown, variables: { body: { messageIds: string[] } }) => {
|
|
161
|
+
invalidateAffectedQueries();
|
|
162
|
+
onAfterOptimisticRemove?.(variables.body.messageIds);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const buildOnError =
|
|
166
|
+
(failureTitle: (count: number) => string) =>
|
|
167
|
+
(err: unknown, vars: { body: { messageIds: string[] } }) => {
|
|
168
|
+
pushError({
|
|
169
|
+
title: failureTitle(vars.body.messageIds.length),
|
|
170
|
+
detail: formatErrorDetail(err),
|
|
171
|
+
error: err,
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const report = useMutation({
|
|
176
|
+
mutationFn: async (variables: { body: { messageIds: string[] } }) => {
|
|
177
|
+
const { data } = await messageBulkOperationsReportSpam({
|
|
178
|
+
...variables,
|
|
179
|
+
throwOnError: true,
|
|
180
|
+
});
|
|
181
|
+
throwOnBulkFailure(data);
|
|
182
|
+
return data;
|
|
183
|
+
},
|
|
184
|
+
// A per-message report failure is a routine, expected, retryable outcome
|
|
185
|
+
// with backend-written user-facing copy — never the fatal overlay. See
|
|
186
|
+
// `throwOnBulkFailure`'s doc for why the thrown ApiError's status alone
|
|
187
|
+
// isn't enough: `shouldEscalate` defaults to escalate for a non-5xx that
|
|
188
|
+
// doesn't opt out here.
|
|
189
|
+
meta: { softError: true },
|
|
190
|
+
onSuccess: buildOnSuccess(),
|
|
191
|
+
onError: buildOnError((count) =>
|
|
192
|
+
count > 1
|
|
193
|
+
? `Couldn't report ${count} messages as spam`
|
|
194
|
+
: "Couldn't report this message as spam",
|
|
195
|
+
),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const restore = useMutation({
|
|
199
|
+
mutationFn: async (variables: { body: { messageIds: string[] } }) => {
|
|
200
|
+
const { data } = await messageBulkOperationsNotSpam({
|
|
201
|
+
...variables,
|
|
202
|
+
throwOnError: true,
|
|
203
|
+
});
|
|
204
|
+
throwOnBulkFailure(data);
|
|
205
|
+
return data;
|
|
206
|
+
},
|
|
207
|
+
meta: { softError: true },
|
|
208
|
+
onSuccess: buildOnSuccess(),
|
|
209
|
+
onError: buildOnError((count) =>
|
|
210
|
+
count > 1
|
|
211
|
+
? `Couldn't undo the spam report for ${count} messages`
|
|
212
|
+
: "Couldn't undo the spam report",
|
|
213
|
+
),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const reportSpam = useCallback(
|
|
217
|
+
(messageIds: string[]) => {
|
|
218
|
+
if (messageIds.length === 0) return;
|
|
219
|
+
void runChunkedMutation(messageIds, (chunk) =>
|
|
220
|
+
report.mutateAsync({ body: { messageIds: chunk } }),
|
|
221
|
+
);
|
|
222
|
+
},
|
|
223
|
+
[report.mutateAsync],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const notSpam = useCallback(
|
|
227
|
+
(messageIds: string[]) => {
|
|
228
|
+
if (messageIds.length === 0) return;
|
|
229
|
+
void runChunkedMutation(messageIds, (chunk) =>
|
|
230
|
+
restore.mutateAsync({ body: { messageIds: chunk } }),
|
|
231
|
+
);
|
|
232
|
+
},
|
|
233
|
+
[restore.mutateAsync],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
reportSpam,
|
|
238
|
+
notSpam,
|
|
239
|
+
/** True while a report is in flight — wire into the "Report spam" quick action's pending state; a press with no visible response is the dead-button failure mode. */
|
|
240
|
+
isReporting: report.isPending,
|
|
241
|
+
/** True while an undo is in flight — wire into the "Not spam" quick action's pending state. */
|
|
242
|
+
isRestoring: restore.isPending,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
autoMovedLabel,
|
|
8
8
|
isAutoMoveInEffect,
|
|
9
9
|
resolveUndoTargetMailboxId,
|
|
10
|
+
spamReportLabel,
|
|
10
11
|
} from "./auto-moved.js";
|
|
11
12
|
|
|
12
13
|
const ROLE_MAILBOXES: AutoMovedRoleMailboxes = {
|
|
@@ -79,6 +80,13 @@ describe("autoMovedLabel", () => {
|
|
|
79
80
|
});
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
describe("spamReportLabel (#648)", () => {
|
|
84
|
+
test("plain language, no folder or jargon", () => {
|
|
85
|
+
assert.equal(spamReportLabel, "Reported as spam");
|
|
86
|
+
assert.doesNotMatch(spamReportLabel, /confiden|dry.?run|verdict|junk/i);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
82
90
|
describe("isAutoMoveInEffect", () => {
|
|
83
91
|
test("false when autoMoved is absent", () => {
|
|
84
92
|
assert.equal(
|
package/src/lib/auto-moved.ts
CHANGED
|
@@ -72,6 +72,15 @@ export const isAutoMoveInEffect = (
|
|
|
72
72
|
return destination !== undefined && destination === currentMailboxId;
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Plain-language label for a user-initiated spam report (issue #648).
|
|
77
|
+
* Deliberately not derived from placement the way `autoMovedLabel` is: a
|
|
78
|
+
* report on a message already in Junk (the provider's own filter put it
|
|
79
|
+
* there) is a real, no-op-move case, so the badge can't name a "from" folder
|
|
80
|
+
* the way a classifier/filter move can.
|
|
81
|
+
*/
|
|
82
|
+
export const spamReportLabel = "Reported as spam";
|
|
83
|
+
|
|
75
84
|
/**
|
|
76
85
|
* Resolve the undo destination: where the message was before the move. A
|
|
77
86
|
* standing-filter move recorded the exact source mailbox (`fromMailboxId`),
|