@remit/web-client 0.0.24 → 0.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,244 @@
1
+ import {
2
+ mailboxOperationsListMailboxesQueryKey,
3
+ threadOperationsListThreadsQueryKey,
4
+ threadOperationsSearchThreadsQueryKey,
5
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
+ import {
7
+ messageBulkOperationsDeleteMessages,
8
+ threadOperationsSearchThreads,
9
+ } from "@remit/api-http-client/sdk.gen.ts";
10
+ import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
11
+ import { useQueryClient } from "@tanstack/react-query";
12
+ import { useCallback, useEffect, useRef, useState } from "react";
13
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
14
+ import { buildMutationErrorBanner } from "@/components/ui/error-banners";
15
+ import {
16
+ type BulkDeleteProgress,
17
+ countMatches,
18
+ type DeleteRunOutcome,
19
+ type FetchIdsPage,
20
+ runChunkedDelete,
21
+ runPredicateDelete,
22
+ } from "@/lib/bulk-delete";
23
+
24
+ /** The predicate a search-scoped delete re-issues on every page — the same
25
+ * filters the visible list is searching with, minus pagination/count knobs. */
26
+ export type EscalationSearchQuery = Pick<
27
+ NonNullable<ThreadOperationsSearchThreadsData["query"]>,
28
+ "order" | "query" | "subject" | "from" | "unread" | "starred" | "attachments"
29
+ >;
30
+
31
+ /** Page size for both the counting and the execution loop. Set to the write
32
+ * side's own 100-id cap so an execution page IS a delete chunk — no
33
+ * in-memory accumulation step between reading ids and sending them. Counting
34
+ * doesn't have that constraint but reuses the same page size rather than
35
+ * adding a second one to reason about. */
36
+ const PAGE_SIZE = 100;
37
+
38
+ export type EscalationPhase =
39
+ | { kind: "idle" }
40
+ | { kind: "counting"; countSoFar: number }
41
+ | { kind: "escalated"; total: number };
42
+
43
+ interface UseEscalatedDeleteOptions {
44
+ mailboxId: string;
45
+ /** Owning account, forwarded to the unseen-count invalidation on completion. */
46
+ accountId?: string;
47
+ /** Disables escalation entirely (e.g. not searching, or desktop — this is a
48
+ * mobile-only affordance). Resets any in-flight phase back to idle. */
49
+ enabled: boolean;
50
+ /** Identifies the active predicate; escalation resets to idle whenever this
51
+ * changes (a different search is a different question). */
52
+ predicateKey: string;
53
+ searchQuery: EscalationSearchQuery;
54
+ }
55
+
56
+ export interface UseEscalatedDeleteResult {
57
+ phase: EscalationPhase;
58
+ /** Begin paging the predicate's full match set to find its total. */
59
+ escalate: () => void;
60
+ /** Stop whatever's running — counting or a delete — at the next page
61
+ * boundary. A no-op when nothing is running. */
62
+ stop: () => void;
63
+ /** Drop an escalated selection back to bounded without confirming anything. */
64
+ clear: () => void;
65
+ /** True while a chunked delete (bounded->100 ids, or the escalated
66
+ * predicate) is running. */
67
+ isDeleting: boolean;
68
+ deleteProgress: BulkDeleteProgress | undefined;
69
+ /**
70
+ * Runs a chunked delete. Pass `ids` for a materialized (bounded) selection;
71
+ * omit it to delete the escalated predicate (`phase` must be "escalated").
72
+ * Resolves once the run ends for any reason — cancelled, errored, or
73
+ * complete — with a `done`/`failedIds` outcome the caller feeds to
74
+ * `resolveSelectionAfterDelete` to decide what selection looks like next.
75
+ * Infrastructure failures are reported through the app's existing
76
+ * escalation seam (`pushError`, which itself escalates a 5xx/exception to
77
+ * the fatal overlay) — not swallowed here.
78
+ */
79
+ runDelete: (ids?: string[]) => Promise<DeleteRunOutcome>;
80
+ }
81
+
82
+ export const useEscalatedDelete = ({
83
+ mailboxId,
84
+ accountId,
85
+ enabled,
86
+ predicateKey,
87
+ searchQuery,
88
+ }: UseEscalatedDeleteOptions): UseEscalatedDeleteResult => {
89
+ const [phase, setPhase] = useState<EscalationPhase>({ kind: "idle" });
90
+ const [isDeleting, setIsDeleting] = useState(false);
91
+ const [deleteProgress, setDeleteProgress] = useState<
92
+ BulkDeleteProgress | undefined
93
+ >(undefined);
94
+ const cancelRef = useRef(false);
95
+ const queryClient = useQueryClient();
96
+ const { pushError } = useErrorBanners();
97
+
98
+ // A different search (or leaving search/desktop) makes any in-flight
99
+ // escalation meaningless — it would otherwise keep counting or offering to
100
+ // delete a predicate the visible list no longer reflects.
101
+ // biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only — the reset itself is unconditional, not a value read from either.
102
+ useEffect(() => {
103
+ cancelRef.current = true;
104
+ setPhase({ kind: "idle" });
105
+ }, [enabled, predicateKey]);
106
+
107
+ const searchQueryRef = useRef(searchQuery);
108
+ searchQueryRef.current = searchQuery;
109
+
110
+ const fetchIdsPage = useCallback<FetchIdsPage>(
111
+ async (continuationToken) => {
112
+ const { data } = await threadOperationsSearchThreads({
113
+ path: { mailboxId },
114
+ query: {
115
+ ...searchQueryRef.current,
116
+ continuationToken,
117
+ limit: PAGE_SIZE,
118
+ },
119
+ throwOnError: true,
120
+ });
121
+ return {
122
+ ids: (data.items ?? []).map((item) => item.messageId),
123
+ continuationToken: data.continuationToken,
124
+ };
125
+ },
126
+ [mailboxId],
127
+ );
128
+
129
+ const deleteBatch = useCallback(async (ids: string[]) => {
130
+ const { data } = await messageBulkOperationsDeleteMessages({
131
+ body: { messageIds: ids },
132
+ throwOnError: true,
133
+ });
134
+ return data;
135
+ }, []);
136
+
137
+ const invalidateAfterDelete = useCallback(() => {
138
+ queryClient.invalidateQueries({
139
+ queryKey: threadOperationsListThreadsQueryKey({ path: { mailboxId } }),
140
+ });
141
+ queryClient.invalidateQueries({
142
+ queryKey: threadOperationsSearchThreadsQueryKey({ path: { mailboxId } }),
143
+ });
144
+ if (accountId) {
145
+ queryClient.invalidateQueries({
146
+ queryKey: mailboxOperationsListMailboxesQueryKey({
147
+ path: { accountId },
148
+ }),
149
+ });
150
+ }
151
+ }, [queryClient, mailboxId, accountId]);
152
+
153
+ const escalate = useCallback(() => {
154
+ cancelRef.current = false;
155
+ setPhase({ kind: "counting", countSoFar: 0 });
156
+ countMatches(
157
+ fetchIdsPage,
158
+ (countSoFar) => setPhase({ kind: "counting", countSoFar }),
159
+ () => cancelRef.current,
160
+ ).then((result) => {
161
+ if (result.error) {
162
+ pushError(
163
+ buildMutationErrorBanner(
164
+ "Couldn't count matching messages",
165
+ "The count didn't finish.",
166
+ result.error,
167
+ ),
168
+ );
169
+ setPhase({ kind: "idle" });
170
+ return;
171
+ }
172
+ if (result.cancelled) {
173
+ setPhase({ kind: "idle" });
174
+ return;
175
+ }
176
+ setPhase({ kind: "escalated", total: result.total });
177
+ });
178
+ }, [fetchIdsPage, pushError]);
179
+
180
+ const stop = useCallback(() => {
181
+ cancelRef.current = true;
182
+ }, []);
183
+
184
+ const clear = useCallback(() => {
185
+ cancelRef.current = true;
186
+ setPhase({ kind: "idle" });
187
+ }, []);
188
+
189
+ const runDelete = useCallback(
190
+ async (ids?: string[]): Promise<DeleteRunOutcome> => {
191
+ cancelRef.current = false;
192
+ setIsDeleting(true);
193
+ const onProgress = (progress: BulkDeleteProgress) =>
194
+ setDeleteProgress(progress);
195
+
196
+ const outcome =
197
+ ids !== undefined
198
+ ? await runChunkedDelete(
199
+ ids,
200
+ deleteBatch,
201
+ onProgress,
202
+ () => cancelRef.current,
203
+ )
204
+ : await runPredicateDelete(
205
+ fetchIdsPage,
206
+ phase.kind === "escalated" ? phase.total : 0,
207
+ deleteBatch,
208
+ onProgress,
209
+ () => cancelRef.current,
210
+ );
211
+
212
+ setIsDeleting(false);
213
+ setDeleteProgress(undefined);
214
+ setPhase({ kind: "idle" });
215
+
216
+ if (outcome.error) {
217
+ pushError(
218
+ buildMutationErrorBanner(
219
+ outcome.done > 0
220
+ ? `Stopped after ${outcome.done} — some messages weren't deleted`
221
+ : "Couldn't delete these messages",
222
+ "The delete didn't finish.",
223
+ outcome.error,
224
+ ),
225
+ );
226
+ }
227
+ if (outcome.done > 0) {
228
+ invalidateAfterDelete();
229
+ }
230
+ return outcome;
231
+ },
232
+ [deleteBatch, fetchIdsPage, phase, pushError, invalidateAfterDelete],
233
+ );
234
+
235
+ return {
236
+ phase,
237
+ escalate,
238
+ stop,
239
+ clear,
240
+ isDeleting,
241
+ deleteProgress,
242
+ runDelete,
243
+ };
244
+ };
@@ -1,5 +1,12 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
+ import {
4
+ QUARANTINE_REPORT_DISCLAIMER,
5
+ type QuarantineEntry,
6
+ quarantineDemoEntries,
7
+ quarantineIssueTitle,
8
+ quarantineReportSections,
9
+ } from "@remit/ui";
3
10
  import type { BugReportContext } from "./bug-report";
4
11
  import { buildBugReportDetails, buildGitHubIssueUrl } from "./bug-report";
5
12
 
@@ -211,3 +218,88 @@ describe("buildGitHubIssueUrl — truncation to fit the URL budget", () => {
211
218
  );
212
219
  });
213
220
  });
221
+
222
+ describe("quarantine reports", () => {
223
+ // A flat multipart/mixed of 200 parts — the shape that first broke the
224
+ // fence. A message with a pathological part count is precisely the message
225
+ // that fails to parse, so a long report is the common case, not the tail.
226
+ const entry: QuarantineEntry = {
227
+ ...quarantineDemoEntries[0],
228
+ structure: {
229
+ contentType: "multipart/mixed",
230
+ parts: Array.from({ length: 200 }, () => ({
231
+ contentType: "application/octet-stream",
232
+ })),
233
+ },
234
+ };
235
+
236
+ const ctx = (over?: Partial<BugReportContext>): BugReportContext => ({
237
+ ...baseCtx,
238
+ title: quarantineIssueTitle(entry),
239
+ quarantine: quarantineReportSections(entry),
240
+ ...over,
241
+ });
242
+
243
+ const fenceCount = (body: string): number =>
244
+ (body.match(/^```/gm) ?? []).length;
245
+
246
+ it("carries the diagnostics section and the supplied title", () => {
247
+ const decoded = decode(
248
+ buildGitHubIssueUrl(
249
+ ctx({ quarantine: quarantineReportSections(quarantineDemoEntries[0]) }),
250
+ ),
251
+ );
252
+ assert.ok(decoded.includes("UnterminatedMultipartBoundary"));
253
+ assert.ok(decoded.includes("Message quarantined:"));
254
+ });
255
+
256
+ it("truncates a long report without cutting inside the code fence", () => {
257
+ const url = buildGitHubIssueUrl(ctx());
258
+ const body = decode(url);
259
+ assert.ok(
260
+ url.length <= 8000,
261
+ `Expected a URL under 8000 chars, got ${url.length}`,
262
+ );
263
+ assert.ok(body.includes("truncated"), "Expected a truncation marker");
264
+ assert.equal(
265
+ fenceCount(body) % 2,
266
+ 0,
267
+ `Unbalanced code fence in truncated body:\n${body.slice(-400)}`,
268
+ );
269
+ });
270
+
271
+ it("keeps the redaction disclaimer on a truncated report", () => {
272
+ const body = decode(buildGitHubIssueUrl(ctx()));
273
+ assert.ok(
274
+ body.includes(QUARANTINE_REPORT_DISCLAIMER),
275
+ "A truncated report must still state what was withheld",
276
+ );
277
+ });
278
+
279
+ it("keeps the reproduction template outside the code block", () => {
280
+ const body = decode(buildGitHubIssueUrl(ctx()));
281
+ const afterLastFence = body.slice(body.lastIndexOf("```") + 3);
282
+ assert.ok(afterLastFence.includes("## Steps to reproduce"));
283
+ });
284
+
285
+ it("truncates the longer section when a stack is present too", () => {
286
+ const url = buildGitHubIssueUrl(
287
+ ctx({ stack: "at foo\n".repeat(2000), errorMessage: "boom" }),
288
+ );
289
+ assert.ok(
290
+ url.length <= 8000,
291
+ `Expected a URL under 8000 chars, got ${url.length}`,
292
+ );
293
+ });
294
+
295
+ it("Copy full details keeps every part the URL had to drop", () => {
296
+ const parts = (text: string): number =>
297
+ (text.match(/- application\/octet-stream/g) ?? []).length;
298
+ const details = buildBugReportDetails(ctx());
299
+ assert.equal(parts(details), 200);
300
+ assert.ok(
301
+ parts(decode(buildGitHubIssueUrl(ctx()))) < 200,
302
+ "Expected the URL body to have dropped parts",
303
+ );
304
+ });
305
+ });
@@ -1,3 +1,4 @@
1
+ import type { QuarantineReportSections } from "@remit/ui";
1
2
  import {
2
3
  APP_BUILD_TIME,
3
4
  APP_SHA,
@@ -15,7 +16,7 @@ import { getRecentErrors } from "./console-errors";
15
16
  const MAX_ISSUE_URL_LENGTH = 7500;
16
17
 
17
18
  const TRUNCATION_MARKER =
18
- "\n… (truncated — use “Copy full details” for the full stacktrace)";
19
+ "\n… (truncated — the copy action on this screen has the full report)";
19
20
 
20
21
  export interface BugReportContext {
21
22
  appSha: string;
@@ -33,6 +34,14 @@ export interface BugReportContext {
33
34
  stack?: string;
34
35
  /** React component stack from the error boundary, when available. */
35
36
  componentStack?: string;
37
+ /**
38
+ * A quarantined message's diagnostics, when the report is filed from the
39
+ * quarantine surface. Split into sections by `quarantineReportSections` in
40
+ * @remit/ui so the MIME tree is fenced after truncation, never before.
41
+ */
42
+ quarantine?: QuarantineReportSections;
43
+ /** Overrides the derived title, e.g. for a quarantine report. */
44
+ title?: string;
36
45
  }
37
46
 
38
47
  /** Fields callers can seed from a caught error. */
@@ -40,6 +49,9 @@ export interface BugReportSeed {
40
49
  errorMessage?: string;
41
50
  stack?: string;
42
51
  componentStack?: string;
52
+ quarantine?: QuarantineReportSections;
53
+ /** Overrides the derived title, e.g. for a quarantine report. */
54
+ title?: string;
43
55
  }
44
56
 
45
57
  export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
@@ -56,6 +68,8 @@ export function buildBugReportContext(seed?: BugReportSeed): BugReportContext {
56
68
  errorMessage: seed?.errorMessage,
57
69
  stack: seed?.stack,
58
70
  componentStack: seed?.componentStack,
71
+ quarantine: seed?.quarantine,
72
+ title: seed?.title,
59
73
  };
60
74
  }
61
75
 
@@ -63,7 +77,16 @@ function fencedBlock(content: string): string {
63
77
  return ["```", content, "```"].join("\n");
64
78
  }
65
79
 
66
- function buildIssueBody(ctx: BugReportContext, stack?: string): string {
80
+ interface BodyOverrides {
81
+ stack?: string;
82
+ /** Replaces the MIME-tree section only; it is fenced after this is applied. */
83
+ quarantineStructure?: string;
84
+ }
85
+
86
+ function buildIssueBody(
87
+ ctx: BugReportContext,
88
+ overrides?: BodyOverrides,
89
+ ): string {
67
90
  const errorSection =
68
91
  ctx.recentErrors.length > 0
69
92
  ? ctx.recentErrors.map((e) => ` - ${e}`).join("\n")
@@ -85,7 +108,20 @@ function buildIssueBody(ctx: BugReportContext, stack?: string): string {
85
108
  lines.push("## Error", ctx.errorMessage, "");
86
109
  }
87
110
 
88
- const resolvedStack = stack ?? ctx.stack;
111
+ if (ctx.quarantine) {
112
+ const structure =
113
+ overrides?.quarantineStructure ?? ctx.quarantine.structure;
114
+ lines.push(
115
+ ctx.quarantine.head,
116
+ "",
117
+ fencedBlock(structure),
118
+ "",
119
+ ctx.quarantine.disclaimer,
120
+ "",
121
+ );
122
+ }
123
+
124
+ const resolvedStack = overrides?.stack ?? ctx.stack;
89
125
  if (resolvedStack) {
90
126
  lines.push("## Stacktrace", fencedBlock(resolvedStack), "");
91
127
  }
@@ -111,6 +147,7 @@ function buildIssueBody(ctx: BugReportContext, stack?: string): string {
111
147
  }
112
148
 
113
149
  function buildIssueTitle(ctx: BugReportContext): string {
150
+ if (ctx.title) return ctx.title;
114
151
  if (!ctx.errorMessage) return "Bug: ";
115
152
  const firstLine = ctx.errorMessage.split("\n")[0].trim();
116
153
  const clipped =
@@ -118,10 +155,10 @@ function buildIssueTitle(ctx: BugReportContext): string {
118
155
  return `Bug: ${clipped}`;
119
156
  }
120
157
 
121
- function issueUrl(ctx: BugReportContext, stack?: string): string {
158
+ function issueUrl(ctx: BugReportContext, overrides?: BodyOverrides): string {
122
159
  const params = new URLSearchParams({
123
160
  title: buildIssueTitle(ctx),
124
- body: buildIssueBody(ctx, stack),
161
+ body: buildIssueBody(ctx, overrides),
125
162
  });
126
163
  return `${GITHUB_NEW_ISSUE_URL}?${params.toString()}`;
127
164
  }
@@ -137,40 +174,63 @@ export function buildBugReportDetails(ctx: BugReportContext): string {
137
174
 
138
175
  /**
139
176
  * Build a prefilled GitHub new-issue URL. When the full body would exceed the
140
- * URL budget, the stacktrace is truncated (binary-searched to the longest
141
- * prefix that fits) and marked; everything else is preserved. The component
142
- * stack is dropped from the URL when truncation kicks in — it survives in the
143
- * "Copy full details" report.
177
+ * URL budget, the longest truncatable section a stacktrace, or a quarantine
178
+ * report's MIME tree is binary-searched to the longest prefix that fits and
179
+ * marked; everything else is preserved. The component stack is dropped from
180
+ * the URL when truncation kicks in — it survives in the copy-details report.
181
+ *
182
+ * The quarantine MIME tree is handed over unfenced and fenced by
183
+ * `buildIssueBody` afterwards, so a cut can never land inside a code fence.
184
+ * The disclaimer is appended by the builder rather than carried in the
185
+ * truncated text, so a truncated report still states what was withheld.
144
186
  */
145
187
  export function buildGitHubIssueUrl(ctx: BugReportContext): string {
146
188
  const full = issueUrl(ctx);
147
189
  if (full.length <= MAX_ISSUE_URL_LENGTH) return full;
148
190
 
149
- const stack = ctx.stack ?? "";
150
191
  const withoutComponentStack: BugReportContext = {
151
192
  ...ctx,
152
193
  componentStack: undefined,
153
194
  };
154
195
 
155
- let lo = 0;
156
- let hi = stack.length;
157
- let best = 0;
158
- while (lo <= hi) {
159
- const mid = (lo + hi) >> 1;
160
- const candidate = issueUrl(
161
- withoutComponentStack,
162
- stack.slice(0, mid) + TRUNCATION_MARKER,
163
- );
164
- if (candidate.length <= MAX_ISSUE_URL_LENGTH) {
165
- best = mid;
166
- lo = mid + 1;
167
- } else {
168
- hi = mid - 1;
196
+ // Both sections can be present — a seed is not discriminated — and cutting
197
+ // one to nothing does not necessarily fit the other. Truncate the longest
198
+ // first and keep going while the budget is still exceeded.
199
+ const sections: Array<[keyof BodyOverrides, string]> = [];
200
+ if (ctx.stack) sections.push(["stack", ctx.stack]);
201
+ if (ctx.quarantine?.structure) {
202
+ sections.push(["quarantineStructure", ctx.quarantine.structure]);
203
+ }
204
+ sections.sort((a, b) => b[1].length - a[1].length);
205
+
206
+ let overrides: BodyOverrides = {};
207
+ for (const [field, text] of sections) {
208
+ if (
209
+ issueUrl(withoutComponentStack, overrides).length <= MAX_ISSUE_URL_LENGTH
210
+ ) {
211
+ break;
169
212
  }
213
+ let lo = 0;
214
+ let hi = text.length;
215
+ let best = 0;
216
+ while (lo <= hi) {
217
+ const mid = (lo + hi) >> 1;
218
+ const candidate = issueUrl(withoutComponentStack, {
219
+ ...overrides,
220
+ [field]: text.slice(0, mid) + TRUNCATION_MARKER,
221
+ });
222
+ if (candidate.length <= MAX_ISSUE_URL_LENGTH) {
223
+ best = mid;
224
+ lo = mid + 1;
225
+ } else {
226
+ hi = mid - 1;
227
+ }
228
+ }
229
+ overrides = {
230
+ ...overrides,
231
+ [field]: text.slice(0, best) + TRUNCATION_MARKER,
232
+ };
170
233
  }
171
234
 
172
- return issueUrl(
173
- withoutComponentStack,
174
- stack.slice(0, best) + TRUNCATION_MARKER,
175
- );
235
+ return issueUrl(withoutComponentStack, overrides);
176
236
  }