@remit/web-client 0.0.271 → 0.0.273

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.
@@ -47,9 +47,17 @@ interface MessageBodyProps {
47
47
  /**
48
48
  * Whether the From-address has the `trusted` flag set. Trusted senders
49
49
  * auto-load images. The "Load images" bar is suppressed entirely for
50
- * trusted senders.
50
+ * trusted senders. Ignored when `isBlocked` is set.
51
51
  */
52
52
  isTrusted?: boolean;
53
+ /**
54
+ * Whether the From-address has the `blocked` flag set. A blocked sender
55
+ * never loads images, "even on explicit click" (`BlockedFlag`), so this
56
+ * suppresses auto-load and removes the manual load affordances rather than
57
+ * greying them out. Blocked outranks trusted: it is the more restrictive
58
+ * instruction, and a sender carrying both is still blocked.
59
+ */
60
+ isBlocked?: boolean;
53
61
  /**
54
62
  * The current message id. Required for the body-content query cache key
55
63
  * and by `useToggleTrusted` for the optimistic cache patch.
@@ -99,13 +107,23 @@ export const MessageBody = ({
99
107
  html,
100
108
  text,
101
109
  isTrusted = false,
110
+ isBlocked = false,
102
111
  messageId,
103
112
  fromAddressId,
104
113
  category,
105
114
  className,
106
115
  }: MessageBodyProps) => {
116
+ // "Load once" is a decision about one message. Reset it when the instance is
117
+ // handed a different message so the grant can't ride along — today every
118
+ // caller keys this component by message, which makes the leak unreachable,
119
+ // but the guarantee should hold structurally rather than by a caller's key.
107
120
  const [allowImagesOnce, setAllowImagesOnce] = useState(false);
108
- const allowImages = isTrusted || allowImagesOnce;
121
+ const [grantedFor, setGrantedFor] = useState(messageId);
122
+ if (grantedFor !== messageId) {
123
+ setGrantedFor(messageId);
124
+ setAllowImagesOnce(false);
125
+ }
126
+ const allowImages = !isBlocked && (isTrusted || allowImagesOnce);
109
127
  const isDark = useIsDark();
110
128
  // `useToggleTrusted` surfaces its own failure (banner + rollback) and a fatal
111
129
  // 5xx escalates globally — no consumer-side error effect needed here.
@@ -197,6 +215,7 @@ export const MessageBody = ({
197
215
  blockedImageCount={blockedImageCount}
198
216
  canAlwaysTrust={canAlwaysTrust}
199
217
  isTrustPending={isTrustPending}
218
+ isSenderBlocked={isBlocked}
200
219
  onLoadOnce={() => setAllowImagesOnce(true)}
201
220
  onAlwaysTrust={handleAlwaysTrust}
202
221
  />
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Image gating at the `MessageCard` seam (#352). The expanded card resolves the
3
+ * From-address flags and hands them to the body: `blocked` is read there
4
+ * alongside `trusted`, and wins when both are set. Without that read the promise
5
+ * Settings > Senders makes — a blocked sender never loads images — has no reach
6
+ * into the one surface that renders mail.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import { afterEach, describe, it } from "node:test";
11
+ import type {
12
+ RemitImapAddressFlags,
13
+ RemitImapDescribeMessageResponse,
14
+ } from "@remit/api-http-client/types.gen.ts";
15
+ import {
16
+ type AnyRouter,
17
+ createMemoryHistory,
18
+ createRootRoute,
19
+ createRoute,
20
+ createRouter,
21
+ Outlet,
22
+ RouterProvider,
23
+ } from "@tanstack/react-router";
24
+ import { createElement } from "react";
25
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
26
+ import { makeThreadMessage } from "@/test-support/fixtures";
27
+ import { type HttpMock, mockFetch } from "@/test-support/http";
28
+ import { MessageCard } from "./MessageCard";
29
+
30
+ const MAILBOX_ID = "mbx-inbox";
31
+ const THREAD_ID = "thread-1";
32
+ const MESSAGE_ID = "msg-1";
33
+ const CONTENT_PATH = "/content/parts/1";
34
+
35
+ const MARKETING_HTML = `<div><img src="https://tracker.example/hero.png" alt="Hero"><p>Sale ends Sunday.</p></div>`;
36
+
37
+ let harness: DomHarness | undefined;
38
+ let http: HttpMock | undefined;
39
+
40
+ // The router reads `self` at construction; the shared jsdom globals stop at
41
+ // `window`.
42
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
43
+
44
+ // The sandboxed frame measures itself once the iframe loads. jsdom fires that
45
+ // load but has no ResizeObserver, and the resulting ReferenceError is raised
46
+ // inside a listener where no assertion can see it.
47
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
48
+ observe() {}
49
+ unobserve() {}
50
+ disconnect() {}
51
+ };
52
+
53
+ afterEach(() => {
54
+ harness?.close();
55
+ harness = undefined;
56
+ http?.restore();
57
+ http = undefined;
58
+ });
59
+
60
+ const threadMessage = makeThreadMessage({
61
+ messageId: MESSAGE_ID,
62
+ threadId: THREAD_ID,
63
+ mailboxId: MAILBOX_ID,
64
+ subject: "Sale ends Sunday",
65
+ fromName: "Deals",
66
+ fromEmail: "deals@shop.example",
67
+ category: "marketing",
68
+ isRead: true,
69
+ });
70
+
71
+ const describeMessage = (
72
+ flags: RemitImapAddressFlags,
73
+ ): RemitImapDescribeMessageResponse =>
74
+ ({
75
+ message: {
76
+ messageId: MESSAGE_ID,
77
+ mailboxId: MAILBOX_ID,
78
+ uid: 1,
79
+ rfc822Size: 512,
80
+ internalDate: 1_767_225_600_000,
81
+ status: "active",
82
+ syncStatus: "pending",
83
+ },
84
+ envelope: {
85
+ messageId: MESSAGE_ID,
86
+ date: 1_767_225_600_000,
87
+ subject: "Sale ends Sunday",
88
+ messageIdValue: "<deals-1@shop.example>",
89
+ from: [
90
+ {
91
+ addressId: "addr-deals",
92
+ displayName: "Deals",
93
+ normalizedEmail: "deals@shop.example",
94
+ addressRole: "from",
95
+ addressOrder: 0,
96
+ flags,
97
+ },
98
+ ],
99
+ to: [],
100
+ cc: [],
101
+ bcc: [],
102
+ replyTo: [],
103
+ category: "marketing",
104
+ senderTrust: "unknown",
105
+ },
106
+ flags: ["\\Seen"],
107
+ bodyParts: [
108
+ {
109
+ bodyPartId: "part-1",
110
+ mediaType: "TEXT",
111
+ mediaSubtype: "HTML",
112
+ contentUrl: `http://localhost${CONTENT_PATH}`,
113
+ isMultipart: false,
114
+ },
115
+ ],
116
+ references: [],
117
+ }) as unknown as RemitImapDescribeMessageResponse;
118
+
119
+ const testRouter = (): AnyRouter => {
120
+ const rootRoute = createRootRoute({ component: () => createElement(Outlet) });
121
+ const mailboxRoute = createRoute({
122
+ getParentRoute: () => rootRoute,
123
+ path: "/mail/$mailboxId",
124
+ validateSearch: (search: Record<string, unknown>) => search,
125
+ });
126
+ const threadRoute = createRoute({
127
+ getParentRoute: () => mailboxRoute,
128
+ path: "$threadId",
129
+ });
130
+ const messageRoute = createRoute({
131
+ getParentRoute: () => threadRoute,
132
+ path: "$messageId",
133
+ component: () =>
134
+ createElement(MessageCard, {
135
+ threadMessage,
136
+ isExpanded: true,
137
+ onToggle: () => undefined,
138
+ onToggleStar: () => undefined,
139
+ }),
140
+ });
141
+ const routeTree = rootRoute.addChildren([
142
+ mailboxRoute.addChildren([threadRoute.addChildren([messageRoute])]),
143
+ ]);
144
+ return createRouter({
145
+ routeTree,
146
+ history: createMemoryHistory({
147
+ initialEntries: [`/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`],
148
+ }),
149
+ }) as unknown as AnyRouter;
150
+ };
151
+
152
+ const mount = async (flags: RemitImapAddressFlags): Promise<DomHarness> => {
153
+ http = mockFetch((call) => {
154
+ if (call.path === CONTENT_PATH) {
155
+ return new Response(MARKETING_HTML, {
156
+ status: 200,
157
+ headers: { "content-type": "text/html" },
158
+ });
159
+ }
160
+ if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) {
161
+ return describeMessage(flags);
162
+ }
163
+ return { items: [] };
164
+ });
165
+
166
+ const router = testRouter();
167
+ await router.load();
168
+ const dom = createDomHarness();
169
+ harness = dom;
170
+ dom.renderApp(createElement(RouterProvider, { router }));
171
+ await dom.waitFor(
172
+ () => dom.query("iframe") !== null,
173
+ "the message body to render",
174
+ );
175
+ return dom;
176
+ };
177
+
178
+ const frameHtml = (dom: DomHarness): string =>
179
+ dom.query<HTMLIFrameElement>("iframe")?.getAttribute("srcdoc") ?? "";
180
+
181
+ // A blocked image keeps its original URL in `data-blocked-src`; only a live
182
+ // `src=` means the browser will fetch it.
183
+ const REMOTE_SRC = /\ssrc="https:\/\/tracker\.example\/hero\.png"/;
184
+
185
+ const notice = (dom: DomHarness) =>
186
+ dom.query('[data-testid="blocked-images-notice"]');
187
+
188
+ // A boolean, not the node: an assertion that fails on a jsdom element makes the
189
+ // runner serialize the whole tree and the process dies on memory, not on a diff.
190
+ const hasTrustedBadge = (dom: DomHarness): boolean =>
191
+ dom.query('[data-testid="trusted-sender-badge"]') !== null;
192
+
193
+ describe("MessageCard reads flags.blocked on the render path (#352)", () => {
194
+ it("keeps a blocked sender's images out and offers no way to load them", async () => {
195
+ const dom = await mount({ blocked: { value: true, setAt: 1 } });
196
+
197
+ assert.doesNotMatch(frameHtml(dom), REMOTE_SRC);
198
+ assert.match(frameHtml(dom), /data-blocked-src/);
199
+ assert.match(notice(dom)?.textContent ?? "", /you blocked this sender/);
200
+ assert.equal(
201
+ dom.queryAll('[data-testid="blocked-images-notice"] button').length,
202
+ 0,
203
+ "never load, even on explicit click",
204
+ );
205
+ });
206
+
207
+ it("stays blocked when the sender carries both blocked and trusted", async () => {
208
+ const dom = await mount({
209
+ blocked: { value: true, setAt: 1 },
210
+ trusted: { value: true, setAt: 1 },
211
+ });
212
+
213
+ assert.doesNotMatch(frameHtml(dom), REMOTE_SRC);
214
+ assert.match(notice(dom)?.textContent ?? "", /you blocked this sender/);
215
+ assert.equal(
216
+ hasTrustedBadge(dom),
217
+ false,
218
+ "a trusted badge beside 'you blocked this sender' tells the user two opposite things",
219
+ );
220
+ });
221
+
222
+ it("still auto-loads a trusted sender's images", async () => {
223
+ const dom = await mount({ trusted: { value: true, setAt: 1 } });
224
+
225
+ assert.match(frameHtml(dom), REMOTE_SRC);
226
+ assert.equal(notice(dom), null);
227
+ assert.equal(hasTrustedBadge(dom), true);
228
+ });
229
+
230
+ it("still offers the load affordances to a merely untrusted sender", async () => {
231
+ const dom = await mount({});
232
+
233
+ assert.doesNotMatch(frameHtml(dom), REMOTE_SRC);
234
+ assert.match(notice(dom)?.textContent ?? "", /blocked for privacy/);
235
+ assert.ok(
236
+ dom.queryAll('[data-testid="blocked-images-notice"] button').length > 0,
237
+ );
238
+ });
239
+ });
@@ -200,6 +200,12 @@ const ExpandedCard = ({
200
200
  const isUnread = !threadMessage.isRead;
201
201
  const isTrusted =
202
202
  messageData?.envelope.from[0]?.flags?.trusted?.value === true;
203
+ // `BlockedFlag`: never load images, even on explicit click. Read alongside
204
+ // `trusted` and handed to the body, which lets blocked win over trusted —
205
+ // including on the header, where a trusted badge beside "you blocked this
206
+ // sender" would tell the user two opposite things about the same address.
207
+ const isBlocked =
208
+ messageData?.envelope.from[0]?.flags?.blocked?.value === true;
203
209
  const fromAddressId = messageData?.envelope.from[0]?.addressId;
204
210
  const message = toThreadMessageData(threadMessage, date);
205
211
 
@@ -220,7 +226,7 @@ const ExpandedCard = ({
220
226
  message={message}
221
227
  isFocused={mobile ? false : isFocused}
222
228
  onHeaderClick={onToggle}
223
- senderBadge={isTrusted ? <TrustedSenderBadge /> : undefined}
229
+ senderBadge={isTrusted && !isBlocked ? <TrustedSenderBadge /> : undefined}
224
230
  trailing={
225
231
  <span data-testid="message-date" className="text-2xs text-fg-subtle">
226
232
  {date}
@@ -328,6 +334,7 @@ const ExpandedCard = ({
328
334
  messageId={threadMessage.messageId}
329
335
  fromAddressId={messageData?.envelope.from[0]?.addressId}
330
336
  isTrusted={isTrusted}
337
+ isBlocked={isBlocked}
331
338
  category={toDisplayCategory(threadMessage.category)}
332
339
  />
333
340
  <MessageAttachments
@@ -24,7 +24,6 @@ import { formatErrorMessage } from "@/components/ui/ErrorState";
24
24
  import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
25
25
  import { useDeleteOutcome } from "@/hooks/useDeleteOutcome";
26
26
  import {
27
- type EscalatedAction,
28
27
  type EscalationSearchQuery,
29
28
  useEscalatedActions,
30
29
  } from "@/hooks/useEscalatedActions";
@@ -355,21 +354,12 @@ export const MessageList = ({
355
354
  // are loaded. `orderedIds` below feeds `allLoadedSelected`; declared after
356
355
  // this hook so its callback deps stay simple — see the
357
356
  // `orderedIds`/`handleRowSelect` block.
358
- const escalationEnabled = isSearching && !!searchPredicate;
359
- const predicateKey = `${mailboxId}|${JSON.stringify(searchPredicate ?? {})}`;
360
- const escalation = useEscalatedActions({
361
- mailboxId,
362
- accountId,
363
- enabled: escalationEnabled,
364
- predicateKey,
365
- searchQuery: searchPredicate ?? {},
366
- });
367
-
368
- // How a run ended, for the user who is no longer looking at the run screen.
369
- // Closing the wizard mid-run is a movement the screen invites, and the run
370
- // keeps going past it — so the list is what states the ending, whether the
371
- // run covered everything or stopped short of it. The wizard calls this only
372
- // once the user has left it, so an ending is never said twice.
357
+ // How a run ended, for the user who is no longer looking at any screen that
358
+ // could show it. Closing the wizard mid-run is a movement the screen invites,
359
+ // and leaving the mailbox on top of that is one nothing prevents — the run
360
+ // keeps going past both, so this states the ending, whether the run covered
361
+ // everything or stopped short of it. The run's owner calls it only once no
362
+ // screen is left reporting in place, so an ending is never said twice.
373
363
  const reportRunOutcome = useCallback(
374
364
  (kind: BulkActionKind, matched: number, outcome: BulkRunOutcome) => {
375
365
  const banner = runEndingBanner(kind, matched, outcome, deleteOutcome);
@@ -380,6 +370,18 @@ export const MessageList = ({
380
370
  [pushError, deleteOutcome],
381
371
  );
382
372
 
373
+ const escalationEnabled = isSearching && !!searchPredicate;
374
+ const predicateKey = `${mailboxId}|${JSON.stringify(searchPredicate ?? {})}`;
375
+ const escalation = useEscalatedActions({
376
+ mailboxId,
377
+ mailboxLabel: listTitle,
378
+ accountId,
379
+ enabled: escalationEnabled,
380
+ predicateKey,
381
+ searchQuery: searchPredicate ?? {},
382
+ reportEnding: reportRunOutcome,
383
+ });
384
+
383
385
  // The one way selection mode ends (#115): cancel, a completed delete or
384
386
  // move, a plain click that collapses a range, switching mailboxes, the back
385
387
  // gesture. Nothing calls the selection hook's `clearSelection` directly, so
@@ -690,7 +692,8 @@ export const MessageList = ({
690
692
  scope: describeSearchScope(searchPredicate ?? {}),
691
693
  total: escalation.phase.total,
692
694
  searchQuery: searchPredicate ?? {},
693
- run: (action: EscalatedAction) => escalation.runAction(action),
695
+ run: (action, claimEnding) =>
696
+ escalation.runAction(action, undefined, claimEnding),
694
697
  stop: escalation.stop,
695
698
  }
696
699
  : undefined;
@@ -1086,10 +1089,16 @@ export const MessageList = ({
1086
1089
  // The escalation-derived surface state — viewport-independent, fed to both
1087
1090
  // the mobile sheet and the desktop toolbar so the two never diverge (#212).
1088
1091
  const selectionIsBusy = isDeleting || isMoving || escalation.isRunning;
1092
+ // A run outlives the selection it came from, and leaving the mailbox and
1093
+ // coming back leaves no selection at all — so while one is in flight the run's
1094
+ // own count is what the bar is about, or the bar would be an empty header over
1095
+ // mail that is still being deleted (#112).
1089
1096
  const selectionCount =
1090
1097
  escalation.phase.kind === "escalated"
1091
1098
  ? escalation.phase.total
1092
- : selectedCount;
1099
+ : escalation.progress
1100
+ ? escalation.progress.total
1101
+ : selectedCount;
1093
1102
 
1094
1103
  // A run reports on the bar from the moment it starts, not from its first
1095
1104
  // finished batch: the wizard invites the user back here mid-run, and a run
@@ -1128,17 +1137,22 @@ export const MessageList = ({
1128
1137
  : undefined;
1129
1138
 
1130
1139
  // At most one escalation notice at a time, ranked by how actionable it is:
1131
- // an in-progress counting/escalated state and its own action always wins;
1132
- // otherwise a fresh escalation offer. The (rare) cross-account move hint is
1133
- // layered on behind them below.
1140
+ // a run in flight and a count both carry Stop, and that always wins; then an
1141
+ // escalated selection's way out; otherwise a fresh escalation offer. The
1142
+ // (rare) cross-account move hint is layered on behind them below.
1143
+ //
1144
+ // Stop rides on the bar rather than only on the wizard's run screen because
1145
+ // the run outlives both the wizard and the route: a user who comes back to
1146
+ // the mailbox mid-run finds the progress there, and has to be able to end it
1147
+ // from the same place (#112).
1134
1148
  const escalationNotice =
1135
- escalation.phase.kind === "counting"
1149
+ escalation.isRunning || escalation.phase.kind === "counting"
1136
1150
  ? {
1137
1151
  tone: "info" as const,
1138
1152
  text: "",
1139
1153
  action: { label: "Stop", onClick: escalation.stop },
1140
1154
  }
1141
- : escalation.phase.kind === "escalated" && !escalation.isRunning
1155
+ : escalation.phase.kind === "escalated"
1142
1156
  ? {
1143
1157
  tone: "info" as const,
1144
1158
  text: "",
@@ -1343,6 +1357,7 @@ export const MessageList = ({
1343
1357
  verb={wizardVerb}
1344
1358
  accountId={accountId}
1345
1359
  mailboxId={mailboxId}
1360
+ mailboxLabel={listTitle}
1346
1361
  selection={wizardSelection}
1347
1362
  selectionRestriction={moveDisabledHint ? "spansAccounts" : undefined}
1348
1363
  escalated={escalatedSelection}
@@ -39,7 +39,6 @@ import {
39
39
  import { useClauseSuggestions } from "@/hooks/useClauseSuggestions";
40
40
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
41
41
  import {
42
- type EscalatedAction,
43
42
  type EscalationSearchQuery,
44
43
  useEscalatedActions,
45
44
  } from "@/hooks/useEscalatedActions";
@@ -55,6 +54,8 @@ import type {
55
54
  BulkActionProgress,
56
55
  BulkActionTarget,
57
56
  BulkRunOutcome,
57
+ BulkRunStart,
58
+ EscalatedAction,
58
59
  } from "@/lib/bulk-actions";
59
60
  import { NO_JUNK_FOLDER_REASON } from "@/lib/junk-destination";
60
61
  import { useMailContext } from "@/lib/mail-context";
@@ -122,7 +123,15 @@ export interface EscalatedSelection {
122
123
  * screen is what now stands in front of it. The run belongs to the list, so
123
124
  * it survives the wizard closing over it — as the run screen promises.
124
125
  */
125
- run: (action: EscalatedAction) => Promise<BulkRunOutcome>;
126
+ run: (
127
+ action: EscalatedAction,
128
+ /**
129
+ * Handed the release for the ending of the run it starts, so the wizard's
130
+ * run screen states that ending in place and the list does not banner it a
131
+ * second time (#112).
132
+ */
133
+ claimEnding: (release: () => void) => void,
134
+ ) => Promise<BulkRunStart>;
126
135
  /** Ends that run at the next page boundary, which leaving the wizard does not. */
127
136
  stop: () => void;
128
137
  }
@@ -134,6 +143,8 @@ export interface SelectionWizardHostProps {
134
143
  accountId?: string;
135
144
  /** Where the selection was made, so a run invalidates the listing it changed. */
136
145
  mailboxId?: string;
146
+ /** That mailbox in the user's words, for a commit refused while it runs. */
147
+ mailboxLabel?: string;
137
148
  selection: readonly WizardSelectionMessage[];
138
149
  /**
139
150
  * Which scope the ticked rows span more of than the folder and rule steps can
@@ -154,10 +165,11 @@ export interface SelectionWizardHostProps {
154
165
  /** The wizard is done with the selection, and the list drops it. */
155
166
  onFinished: () => void;
156
167
  /**
157
- * How a run ended, once the screen that was reporting on it is gone. The run
158
- * screen invites the user to close it and keep the run going, so the surface
159
- * that outlives the wizard is what states the ending. Called only for a run
160
- * the user walked away from; a run screen still up reports in place.
168
+ * How a run ended, once no screen reporting on it is left. Handed to the run's
169
+ * owner rather than called here: the run screen invites the user to close it
170
+ * and keep the run going, and leaving the mailbox on top of that takes this
171
+ * component with it — so the ending is stated by something that outlives both
172
+ * (#112). A run screen still up reports in place instead.
161
173
  */
162
174
  onRunEnded?: (
163
175
  kind: EscalatedAction["kind"],
@@ -298,6 +310,7 @@ function SelectionWizardSession({
298
310
  verb,
299
311
  accountId,
300
312
  mailboxId,
313
+ mailboxLabel,
301
314
  selection,
302
315
  selectionRestriction,
303
316
  escalated: escalatedSelection,
@@ -361,11 +374,6 @@ function SelectionWizardSession({
361
374
  // cleared, so a failed start can be retried.
362
375
  const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
363
376
  const commitSent = useRef(false);
364
- // The user left the run screen while it was still reporting. Held here rather
365
- // than read back off the URL: the rewind the exit starts lands a frame or two
366
- // later, and an outcome arriving in between would find a screen that is on
367
- // its way out and say nothing.
368
- const walkedAway = useRef(false);
369
377
 
370
378
  const messageIds = useMemo(
371
379
  () => selection.map((message) => message.id),
@@ -491,14 +499,36 @@ function SelectionWizardSession({
491
499
  senders,
492
500
  );
493
501
 
502
+ // While this screen is up it states the ending itself, so the run's owner
503
+ // holds its banner back. The claim is over the run this screen starts and no
504
+ // other: a commit that starts none — a filter, a back-apply job — has no
505
+ // ending of its own to hold, and holding one anyway swallowed the banner of
506
+ // whatever run was going elsewhere. Leaving — closing the wizard, or leaving
507
+ // the mailbox altogether — unmounts this and releases it, and the ending is
508
+ // bannered where the user now is (#112, #521).
509
+ const releaseReport = useRef<(() => void) | undefined>(undefined);
510
+ const claimEnding = useCallback((release: () => void) => {
511
+ releaseReport.current?.();
512
+ releaseReport.current = release;
513
+ }, []);
514
+ useEffect(
515
+ () => () => {
516
+ releaseReport.current?.();
517
+ releaseReport.current = undefined;
518
+ },
519
+ [],
520
+ );
521
+
494
522
  const organizeJob = useOrganizeJob(accountId);
495
523
  const createFilter = useCreateFilter(accountId);
496
524
  const bulk = useEscalatedActions({
497
525
  mailboxId: mailboxId ?? "",
526
+ mailboxLabel,
498
527
  accountId,
499
528
  enabled: false,
500
529
  predicateKey: "selection-wizard",
501
530
  searchQuery: {},
531
+ reportEnding: onRunEnded,
502
532
  });
503
533
  const { runAction } = bulk;
504
534
 
@@ -661,13 +691,15 @@ function SelectionWizardSession({
661
691
  return;
662
692
  }
663
693
  setBulkRun({ matched: targets.length, sent: targets });
664
- const outcome = await runAction(action, targets);
665
- setBulkRun({ matched: targets.length, sent: targets, outcome });
666
- if (walkedAway.current) {
667
- onRunEnded?.(action.kind, targets.length, outcome);
668
- }
694
+ const started = await runAction(action, targets, claimEnding);
695
+ setBulkRun({
696
+ matched: targets.length,
697
+ sent: targets,
698
+ outcome: started.kind === "ran" ? started.outcome : undefined,
699
+ failureReason: started.kind === "refused" ? started.reason : undefined,
700
+ });
669
701
  },
670
- [verb, named.moveMailboxId, junkMailboxId, runAction, onRunEnded],
702
+ [verb, named.moveMailboxId, junkMailboxId, runAction, claimEnding],
671
703
  );
672
704
 
673
705
  // The escalated predicate, run by the chunked runner the list already owns.
@@ -685,12 +717,14 @@ function SelectionWizardSession({
685
717
  return;
686
718
  }
687
719
  setBulkRun({ matched: escalated.total, sent: [] });
688
- const outcome = await escalated.run(action);
689
- setBulkRun({ matched: escalated.total, sent: [], outcome });
690
- if (walkedAway.current) {
691
- onRunEnded?.(action.kind, escalated.total, outcome);
692
- }
693
- }, [escalated, verb, named.moveMailboxId, junkMailboxId, onRunEnded]);
720
+ const started = await escalated.run(action, claimEnding);
721
+ setBulkRun({
722
+ matched: escalated.total,
723
+ sent: [],
724
+ outcome: started.kind === "ran" ? started.outcome : undefined,
725
+ failureReason: started.kind === "refused" ? started.reason : undefined,
726
+ });
727
+ }, [escalated, verb, named.moveMailboxId, junkMailboxId, claimEnding]);
694
728
 
695
729
  const { start: startJob } = organizeJob;
696
730
  const { createFilterAsync } = createFilter;
@@ -909,7 +943,6 @@ function SelectionWizardSession({
909
943
  // run alone: the screen says so in as many words, and stopping it is a
910
944
  // control of its own.
911
945
  const dismiss = useCallback(() => {
912
- walkedAway.current = true;
913
946
  closeWizard(steps, current);
914
947
  onFinished();
915
948
  }, [closeWizard, steps, current, onFinished]);