@remit/web-client 0.0.188 → 0.0.189

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.188",
3
+ "version": "0.0.189",
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": {
@@ -106,15 +106,13 @@ import {
106
106
  type SelectionWizardControl,
107
107
  useSelectionWizard,
108
108
  } from "@/lib/wizard-history";
109
+ import { wizardSelectionFrom } from "@/lib/wizard-selection";
109
110
  import type { OpenThreadTarget } from "@/routing";
110
111
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
111
112
  import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
112
113
  import type { MessageListCommands } from "./MessageList";
113
114
  import { MessageRow } from "./MessageRow";
114
- import {
115
- SelectionWizardHost,
116
- type WizardSelectionMessage,
117
- } from "./SelectionWizardHost";
115
+ import { SelectionWizardHost } from "./SelectionWizardHost";
118
116
  import {
119
117
  type OpenMessageOptions,
120
118
  ThreadListInteraction,
@@ -283,17 +281,8 @@ function BriefSelectionChrome({
283
281
  );
284
282
  // The ticked rows as the wizard reads them — the sample under every screen
285
283
  // that names a match, and the senders its widen falls back to.
286
- const wizardSelection = useMemo<WizardSelectionMessage[]>(
287
- () =>
288
- rows
289
- .filter((row) => selectedIds.has(row.id))
290
- .map((row) => ({
291
- id: row.id,
292
- sender: row.fromName,
293
- email: row.fromEmail,
294
- subject: row.subject,
295
- date: row.timeLabel,
296
- })),
284
+ const wizardSelection = useMemo(
285
+ () => wizardSelectionFrom(rows, selectedIds),
297
286
  [rows, selectedIds],
298
287
  );
299
288
 
@@ -38,14 +38,12 @@ import { rowToSearchResult } from "@/lib/search-result";
38
38
  import { parseSearchTokens } from "@/lib/search-tokens";
39
39
  import { dedupeByThread } from "@/lib/starred-rows";
40
40
  import { useSelectionWizard } from "@/lib/wizard-history";
41
+ import { wizardSelectionFrom } from "@/lib/wizard-selection";
41
42
  import type { OpenThreadTarget } from "@/routing";
42
43
  import { MailViewChrome } from "./MailViewChrome";
43
44
  import type { MessageListCommands } from "./MessageList";
44
45
  import { MessageRow } from "./MessageRow";
45
- import {
46
- SelectionWizardHost,
47
- type WizardSelectionMessage,
48
- } from "./SelectionWizardHost";
46
+ import { SelectionWizardHost } from "./SelectionWizardHost";
49
47
  import {
50
48
  type OpenMessageOptions,
51
49
  ThreadListInteraction,
@@ -78,17 +76,8 @@ function StarredWizardHost({
78
76
  verb: Verb;
79
77
  }) {
80
78
  const { selectedIds, exitSelection } = useThreadListSelection();
81
- const selection = useMemo<WizardSelectionMessage[]>(
82
- () =>
83
- rows
84
- .filter((row) => selectedIds.has(row.id))
85
- .map((row) => ({
86
- id: row.id,
87
- sender: row.fromName,
88
- email: row.fromEmail,
89
- subject: row.subject,
90
- date: row.timeLabel,
91
- })),
79
+ const selection = useMemo(
80
+ () => wizardSelectionFrom(rows, selectedIds),
92
81
  [rows, selectedIds],
93
82
  );
94
83
  return (
@@ -52,13 +52,13 @@ import { useListHeaderChrome } from "@/lib/list-header-chrome";
52
52
  import { listVerbRequest } from "@/lib/list-verb-request";
53
53
  import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
54
54
  import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
55
+ import type { WizardSelectionMessage } from "@/lib/wizard-selection";
55
56
  import { useRetainOpenPanels } from "@/routing";
56
57
  import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
57
58
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
58
59
  import {
59
60
  type EscalatedSelection,
60
61
  SelectionWizardHost,
61
- type WizardSelectionMessage,
62
62
  } from "./SelectionWizardHost";
63
63
  import { SwipeableMessageRow } from "./SwipeableMessageRow";
64
64
 
@@ -497,10 +497,11 @@ export const MessageList = ({
497
497
  email: thread.fromEmail ?? "",
498
498
  subject: thread.subject ?? "(No subject)",
499
499
  date: formatEmailDate(thread.sentDate),
500
+ accountId,
500
501
  });
501
502
  }
502
503
  return rows;
503
- }, [threads, selectedIds]);
504
+ }, [threads, selectedIds, accountId]);
504
505
  const handleRowSelect = useCallback(
505
506
  (messageId: string, modifiers: SelectionModifiers): boolean => {
506
507
  if (modifiers.shiftKey) {
@@ -47,7 +47,11 @@ import { useOrganizeJob } from "@/hooks/useOrganizeJob";
47
47
  import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
48
48
  import { useRulePreview } from "@/hooks/useRulePreview";
49
49
  import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
50
- import type { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
50
+ import type {
51
+ BulkActionProgress,
52
+ BulkActionTarget,
53
+ BulkRunOutcome,
54
+ } from "@/lib/bulk-actions";
51
55
  import { NO_JUNK_FOLDER_REASON } from "@/lib/junk-destination";
52
56
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
53
57
  import { useMailContext } from "@/lib/mail-context";
@@ -66,16 +70,11 @@ import {
66
70
  import { searchRuleAccountId } from "@/lib/organize/search-to-rule";
67
71
  import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
68
72
  import { useWizardEntryValue, useWizardStep } from "@/lib/wizard-history";
73
+ import type { WizardSelectionMessage } from "@/lib/wizard-selection";
69
74
  import { organizeRunState } from "./organize-run-state";
70
75
 
71
76
  const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "any" };
72
77
 
73
- /** A ticked row, as the wizard's samples and its clause prefill read it. */
74
- export interface WizardSelectionMessage extends WizardMessage {
75
- /** Sender address — the widen's literal fallback and the prefill match on it. */
76
- email: string;
77
- }
78
-
79
78
  interface SelectionWizardSessionProps extends SelectionWizardHostProps {
80
79
  /** The step the URL holds. The session is mounted only while there is one. */
81
80
  step: StepId;
@@ -307,7 +306,14 @@ function SelectionWizardSession({
307
306
  OrganizeScope | undefined
308
307
  >(undefined);
309
308
  const [bulkRun, setBulkRun] = useState<
310
- | { matched: number; outcome?: BulkRunOutcome; failureReason?: string }
309
+ | {
310
+ matched: number;
311
+ /** What the run was sent, so a retry of what it missed keeps each
312
+ * id's account. Empty for an escalated run, which has no id list. */
313
+ sent: readonly BulkActionTarget[];
314
+ outcome?: BulkRunOutcome;
315
+ failureReason?: string;
316
+ }
311
317
  | undefined
312
318
  >(undefined);
313
319
  // The predicate the create chains its pass to. Undefined when the rule cannot
@@ -326,6 +332,16 @@ function SelectionWizardSession({
326
332
  () => selection.map((message) => message.id),
327
333
  [selection],
328
334
  );
335
+ // What a bulk run over the ticked rows covers, each row still naming its
336
+ // account so the run can batch per account.
337
+ const bulkTargets = useMemo<BulkActionTarget[]>(
338
+ () =>
339
+ selection.map((message) => ({
340
+ id: message.id,
341
+ accountId: message.accountId,
342
+ })),
343
+ [selection],
344
+ );
329
345
  const senders = useMemo(
330
346
  () => selection.map((message) => message.email).filter(Boolean),
331
347
  [selection],
@@ -586,19 +602,22 @@ function SelectionWizardSession({
586
602
  }, [blockedReason, current, steps, goToStep]);
587
603
 
588
604
  const runBulk = useCallback(
589
- async (ids: readonly string[]) => {
605
+ async (targets: readonly BulkActionTarget[]) => {
590
606
  const action = bulkActionFor(verb, named.moveMailboxId, junkMailboxId);
591
607
  if (!action) {
592
608
  setBulkRun({
593
- matched: ids.length,
609
+ matched: targets.length,
610
+ sent: targets,
594
611
  failureReason: noDestinationReason(verb),
595
612
  });
596
613
  return;
597
614
  }
598
- setBulkRun({ matched: ids.length });
599
- const outcome = await runAction(action, [...ids]);
600
- setBulkRun({ matched: ids.length, outcome });
601
- if (walkedAway.current) onRunEnded?.(action.kind, ids.length, outcome);
615
+ setBulkRun({ matched: targets.length, sent: targets });
616
+ const outcome = await runAction(action, targets);
617
+ setBulkRun({ matched: targets.length, sent: targets, outcome });
618
+ if (walkedAway.current) {
619
+ onRunEnded?.(action.kind, targets.length, outcome);
620
+ }
602
621
  },
603
622
  [verb, named.moveMailboxId, junkMailboxId, runAction, onRunEnded],
604
623
  );
@@ -612,13 +631,14 @@ function SelectionWizardSession({
612
631
  if (!action) {
613
632
  setBulkRun({
614
633
  matched: escalated.total,
634
+ sent: [],
615
635
  failureReason: noDestinationReason(verb),
616
636
  });
617
637
  return;
618
638
  }
619
- setBulkRun({ matched: escalated.total });
639
+ setBulkRun({ matched: escalated.total, sent: [] });
620
640
  const outcome = await escalated.run(action);
621
- setBulkRun({ matched: escalated.total, outcome });
641
+ setBulkRun({ matched: escalated.total, sent: [], outcome });
622
642
  if (walkedAway.current) {
623
643
  onRunEnded?.(action.kind, escalated.total, outcome);
624
644
  }
@@ -670,7 +690,13 @@ function SelectionWizardSession({
670
690
  startJob(organizeDraft);
671
691
  return;
672
692
  }
673
- void runBulk(scope === "just-these" ? messageIds : matchedIds);
693
+ // A widened match is resolved by the account's own preview, so every id it
694
+ // returned belongs to the account the wizard is scoped to.
695
+ void runBulk(
696
+ scope === "just-these"
697
+ ? bulkTargets
698
+ : matchedIds.map((id) => ({ id, accountId })),
699
+ );
674
700
  }, [
675
701
  escalated,
676
702
  runEscalated,
@@ -678,7 +704,8 @@ function SelectionWizardSession({
678
704
  named,
679
705
  anchorMessageId,
680
706
  verb,
681
- messageIds,
707
+ accountId,
708
+ bulkTargets,
682
709
  matchedIds,
683
710
  createFilterAsync,
684
711
  startJob,
@@ -850,8 +877,16 @@ function SelectionWizardSession({
850
877
  sendCommit();
851
878
  return;
852
879
  }
853
- const outstanding = bulkRun?.outcome?.failedIds ?? [];
854
- void runBulk(outstanding.length > 0 ? outstanding : messageIds);
880
+ const outstanding = new Set(bulkRun?.outcome?.failedIds ?? []);
881
+ // A run hands back ids, and each one's account came from the batch it was
882
+ // sent in — so a retry re-sends the targets the run was given, filtered to
883
+ // what it never reached.
884
+ const sent = bulkRun?.sent ?? bulkTargets;
885
+ void runBulk(
886
+ outstanding.size > 0
887
+ ? sent.filter((target) => outstanding.has(target.id))
888
+ : sent,
889
+ );
855
890
  };
856
891
 
857
892
  // Cancel rewinds the entries the wizard owns and leaves the selection where
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The brief and Flagged are the two cross-account lists, and both offer Delete
3
+ * and Mark read over whatever is ticked (#872).
4
+ *
5
+ * The bulk endpoints refuse a batch spanning accounts before applying any of
6
+ * it, so a selection with one row from each account deleted nothing at all and
7
+ * marked nothing read — the user got "Couldn't delete these messages" and no
8
+ * mail moved. The split has to happen where the call is made, so this mounts
9
+ * the real hook against the real fetch seam and reads the requests that
10
+ * actually left: every batch carries one account, and between them they carry
11
+ * every ticked row.
12
+ *
13
+ * Which account each ticked row belongs to is the surfaces' half of the same
14
+ * fix, and it is pinned in `../lib/wizard-selection.test.ts`. How the run
15
+ * sequences the batches it splits into — progress over the whole selection,
16
+ * cancellation at whichever boundary comes next — is `../lib/bulk-actions.test.ts`,
17
+ * where a batch is a value rather than a request in flight.
18
+ */
19
+
20
+ import assert from "node:assert/strict";
21
+ import { afterEach, beforeEach, describe, it } from "node:test";
22
+ import { act, createElement } from "react";
23
+ import type { BulkActionTarget } from "../lib/bulk-actions";
24
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
25
+ import { type HttpMock, httpError, mockFetch } from "../test-support/http";
26
+ import {
27
+ type EscalatedAction,
28
+ type UseEscalatedActionsResult,
29
+ useEscalatedActions,
30
+ } from "./useEscalatedActions";
31
+
32
+ const ACCOUNT_A = "acc-work";
33
+ const ACCOUNT_B = "acc-personal";
34
+
35
+ /** A brief selection: two rows from one account, one from another. */
36
+ const MIXED: BulkActionTarget[] = [
37
+ { id: "msg-work-1", accountId: ACCOUNT_A },
38
+ { id: "msg-personal-1", accountId: ACCOUNT_B },
39
+ { id: "msg-work-2", accountId: ACCOUNT_A },
40
+ ];
41
+
42
+ let harness: DomHarness | undefined;
43
+ let http: HttpMock;
44
+
45
+ const mountRunner = (): (() => UseEscalatedActionsResult) => {
46
+ let value: UseEscalatedActionsResult | undefined;
47
+ const Probe = () => {
48
+ value = useEscalatedActions({
49
+ // The brief's selection belongs to no single mailbox, which is how the
50
+ // wizard mounts this hook over a cross-account list.
51
+ mailboxId: "",
52
+ enabled: false,
53
+ predicateKey: "selection-wizard",
54
+ searchQuery: {},
55
+ });
56
+ return null;
57
+ };
58
+ harness = createDomHarness();
59
+ harness.renderApp(createElement(Probe));
60
+ return () => {
61
+ if (!value) throw new Error("hook did not render");
62
+ return value;
63
+ };
64
+ };
65
+
66
+ /** Enough turns for a run of sequential batches to finish. */
67
+ const settle = async (): Promise<void> => {
68
+ if (!harness) throw new Error("nothing mounted");
69
+ for (let round = 0; round < 10; round += 1) await harness.flush();
70
+ };
71
+
72
+ const run = async (
73
+ hook: () => UseEscalatedActionsResult,
74
+ action: EscalatedAction,
75
+ targets: readonly BulkActionTarget[],
76
+ ) => {
77
+ let started: ReturnType<UseEscalatedActionsResult["runAction"]> | undefined;
78
+ act(() => {
79
+ started = hook().runAction(action, targets);
80
+ });
81
+ await settle();
82
+ if (!started) throw new Error("the run never started");
83
+ return started;
84
+ };
85
+
86
+ /** The message-id list of every bulk request that left, in order. */
87
+ const batches = (suffix: string): string[][] =>
88
+ http
89
+ .to(suffix)
90
+ .map((call) => (call.body as { messageIds?: string[] })?.messageIds ?? []);
91
+
92
+ const accountOf = (id: string): string | undefined =>
93
+ MIXED.find((target) => target.id === id)?.accountId;
94
+
95
+ const assertOneBatchPerAccount = (sent: string[][]): void => {
96
+ assert.equal(sent.length, 2, "the selection was not split by account");
97
+ for (const batch of sent) {
98
+ assert.equal(
99
+ new Set(batch.map(accountOf)).size,
100
+ 1,
101
+ "a batch spanned accounts, which the endpoint refuses whole",
102
+ );
103
+ }
104
+ assert.deepEqual(
105
+ [...sent.flat()].sort(),
106
+ MIXED.map((target) => target.id).sort(),
107
+ "a ticked row was never sent",
108
+ );
109
+ };
110
+
111
+ beforeEach(() => {
112
+ http = mockFetch(() => ({ successCount: 1, failureCount: 0 }));
113
+ });
114
+
115
+ afterEach(() => {
116
+ harness?.close();
117
+ harness = undefined;
118
+ http.restore();
119
+ });
120
+
121
+ describe("a selection spanning accounts", () => {
122
+ it("deletes every ticked row, one batch per account", async () => {
123
+ const hook = mountRunner();
124
+
125
+ const outcome = await run(hook, { kind: "delete" }, MIXED);
126
+
127
+ assertOneBatchPerAccount(batches("/messages/delete"));
128
+ assert.equal(outcome.done, MIXED.length);
129
+ assert.deepEqual(outcome.failedIds, []);
130
+ });
131
+
132
+ it("marks every ticked row read, one batch per account", async () => {
133
+ const hook = mountRunner();
134
+
135
+ const outcome = await run(hook, { kind: "markRead" }, MIXED);
136
+
137
+ assertOneBatchPerAccount(batches("/messages/flags"));
138
+ for (const call of http.calls) {
139
+ assert.equal((call.body as { isRead?: boolean })?.isRead, true);
140
+ }
141
+ assert.equal(outcome.done, MIXED.length);
142
+ });
143
+
144
+ it("reports how far it got when one account's batch fails", async () => {
145
+ http.restore();
146
+ http = mockFetch((call) =>
147
+ (call.body as { messageIds?: string[] })?.messageIds?.[0] ===
148
+ "msg-personal-1"
149
+ ? httpError(409, "mailbox is locked")
150
+ : { successCount: 1, failureCount: 0 },
151
+ );
152
+ const hook = mountRunner();
153
+
154
+ const outcome = await run(hook, { kind: "delete" }, MIXED);
155
+
156
+ assert.equal(outcome.done, 2, "the account that succeeded is not counted");
157
+ assert.deepEqual(
158
+ outcome.failedIds,
159
+ ["msg-personal-1"],
160
+ "the untouched rows are not handed back to retry",
161
+ );
162
+ assert.notEqual(outcome.error, undefined);
163
+ });
164
+ });
@@ -17,6 +17,7 @@ import {
17
17
  import {
18
18
  type ApplyBatch,
19
19
  type BulkActionProgress,
20
+ type BulkActionTarget,
20
21
  type BulkRunOutcome,
21
22
  type FetchIdsPage,
22
23
  honestProgress,
@@ -100,18 +101,21 @@ export interface UseEscalatedActionsResult {
100
101
  runningAction: EscalatedAction | undefined;
101
102
  progress: BulkActionProgress | undefined;
102
103
  /**
103
- * Runs `action` in chunks. Pass `ids` for a materialized (bounded)
104
- * selection; omit it to run against the escalated predicate (`phase` must
105
- * be "escalated"). Resolves once the run ends for any reason — cancelled,
106
- * errored, or complete with a `done`/`failedIds` outcome the caller reads
107
- * to decide what is still outstanding.
104
+ * Runs `action` in chunks. Pass `targets` for a materialized (bounded)
105
+ * selection; omit them to run against the escalated predicate (`phase` must
106
+ * be "escalated"). Each target names the account that owns it, so a
107
+ * selection spanning accounts is sent as one batch per account rather than
108
+ * as one batch the endpoint refuses whole (#872). Resolves once the run ends
109
+ * for any reason — cancelled, errored, or complete — with a
110
+ * `done`/`failedIds` outcome the caller reads to decide what is still
111
+ * outstanding.
108
112
  * Infrastructure failures are reported through the app's existing
109
113
  * escalation seam (`pushError`, which itself escalates a 5xx/exception to
110
114
  * the fatal overlay) — not swallowed here.
111
115
  */
112
116
  runAction: (
113
117
  action: EscalatedAction,
114
- ids?: string[],
118
+ targets?: readonly BulkActionTarget[],
115
119
  ) => Promise<BulkRunOutcome>;
116
120
  }
117
121
 
@@ -225,16 +229,26 @@ export const useEscalatedActions = ({
225
229
  [],
226
230
  );
227
231
 
232
+ /**
233
+ * The unseen counts a run moved, per account. A cross-account selection has
234
+ * no single owning account — the surface leaves the option undefined exactly
235
+ * then — so the run's own targets are what name the accounts to refresh.
236
+ */
228
237
  const invalidateAfterRun = useCallback(
229
- (action: EscalatedAction) => {
238
+ (action: EscalatedAction, targets: readonly BulkActionTarget[]) => {
230
239
  invalidateThreadListQueries(
231
240
  queryClient,
232
241
  threadListCacheKeys(mailboxesTouchedBy(action, mailboxId)),
233
242
  );
234
- if (accountId) {
243
+ const touched = new Set<string>();
244
+ if (accountId) touched.add(accountId);
245
+ for (const target of targets) {
246
+ if (target.accountId) touched.add(target.accountId);
247
+ }
248
+ for (const touchedAccountId of touched) {
235
249
  queryClient.invalidateQueries({
236
250
  queryKey: mailboxOperationsListMailboxesQueryKey({
237
- path: { accountId },
251
+ path: { accountId: touchedAccountId },
238
252
  }),
239
253
  });
240
254
  }
@@ -279,7 +293,7 @@ export const useEscalatedActions = ({
279
293
  const runAction = useCallback(
280
294
  async (
281
295
  action: EscalatedAction,
282
- ids?: string[],
296
+ targets?: readonly BulkActionTarget[],
283
297
  ): Promise<BulkRunOutcome> => {
284
298
  cancelRef.current = false;
285
299
  runningRef.current = true;
@@ -301,9 +315,9 @@ export const useEscalatedActions = ({
301
315
  let outcome: BulkRunOutcome;
302
316
  try {
303
317
  outcome =
304
- ids !== undefined
318
+ targets !== undefined
305
319
  ? await runChunkedAction(
306
- ids,
320
+ targets,
307
321
  applyBatch,
308
322
  onProgress,
309
323
  () => cancelRef.current,
@@ -333,7 +347,7 @@ export const useEscalatedActions = ({
333
347
  );
334
348
  }
335
349
  if (outcome.done > 0) {
336
- invalidateAfterRun(action);
350
+ invalidateAfterRun(action, targets ?? []);
337
351
  }
338
352
  return outcome;
339
353
  },
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
2
2
  import { describe, test } from "node:test";
3
3
  import {
4
4
  BULK_ACTION_CHUNK_SIZE,
5
+ type BulkActionTarget,
5
6
  chunkIds,
7
+ chunkTargets,
6
8
  type FetchIdsPageResult,
7
9
  honestProgress,
8
10
  runChunkedAction,
@@ -12,6 +14,13 @@ import {
12
14
  const ids = (count: number, prefix = "m"): string[] =>
13
15
  Array.from({ length: count }, (_, i) => `${prefix}${i}`);
14
16
 
17
+ /** Ids from one account, as a materialized selection hands them over. */
18
+ const targets = (
19
+ count: number,
20
+ accountId: string | undefined = undefined,
21
+ prefix = "m",
22
+ ): BulkActionTarget[] => ids(count, prefix).map((id) => ({ id, accountId }));
23
+
15
24
  describe("chunkIds", () => {
16
25
  test("empty input yields no chunks", () => {
17
26
  assert.deepEqual(chunkIds([]), []);
@@ -45,6 +54,63 @@ describe("chunkIds", () => {
45
54
  });
46
55
  });
47
56
 
57
+ describe("chunkTargets", () => {
58
+ // Regression for #872: the bulk endpoints reject a batch spanning accounts
59
+ // before applying any of it, and the brief and Flagged both span accounts.
60
+ test("never puts two accounts in one chunk", () => {
61
+ assert.deepEqual(
62
+ chunkTargets(
63
+ [
64
+ { id: "a1", accountId: "acct-a" },
65
+ { id: "b1", accountId: "acct-b" },
66
+ { id: "a2", accountId: "acct-a" },
67
+ ],
68
+ 100,
69
+ ),
70
+ [["a1", "a2"], ["b1"]],
71
+ );
72
+ });
73
+
74
+ test("chunks each account by size on its own", () => {
75
+ assert.deepEqual(
76
+ chunkTargets(
77
+ [
78
+ { id: "a1", accountId: "acct-a" },
79
+ { id: "a2", accountId: "acct-a" },
80
+ { id: "a3", accountId: "acct-a" },
81
+ { id: "b1", accountId: "acct-b" },
82
+ ],
83
+ 2,
84
+ ),
85
+ [["a1", "a2"], ["a3"], ["b1"]],
86
+ );
87
+ });
88
+
89
+ test("targets with no account keep their place in the run as a group of their own", () => {
90
+ assert.deepEqual(
91
+ chunkTargets(
92
+ [
93
+ { id: "u1", accountId: undefined },
94
+ { id: "a1", accountId: "acct-a" },
95
+ { id: "u2", accountId: undefined },
96
+ ],
97
+ 100,
98
+ ),
99
+ [["u1", "u2"], ["a1"]],
100
+ );
101
+ });
102
+
103
+ test("a single-account selection is one run of chunks, as before", () => {
104
+ const got = chunkTargets(targets(BULK_ACTION_CHUNK_SIZE + 1, "acct-a"));
105
+ assert.equal(got.length, 2);
106
+ assert.equal(got[0].length, BULK_ACTION_CHUNK_SIZE);
107
+ });
108
+
109
+ test("empty input yields no chunks", () => {
110
+ assert.deepEqual(chunkTargets([]), []);
111
+ });
112
+ });
113
+
48
114
  describe("runChunkedAction", () => {
49
115
  const neverCancelled = () => false;
50
116
  const noopProgress = () => undefined;
@@ -69,7 +135,7 @@ describe("runChunkedAction", () => {
69
135
  });
70
136
 
71
137
  test("sequences one call per 100-id chunk, in order", async () => {
72
- const input = ids(BULK_ACTION_CHUNK_SIZE + 1);
138
+ const input = targets(BULK_ACTION_CHUNK_SIZE + 1);
73
139
  const calls: string[][] = [];
74
140
  const outcome = await runChunkedAction(
75
141
  input,
@@ -88,7 +154,7 @@ describe("runChunkedAction", () => {
88
154
  });
89
155
 
90
156
  test("a returned batch counts every id in it as accepted", async () => {
91
- const input = ids(5);
157
+ const input = targets(5);
92
158
  const outcome = await runChunkedAction(
93
159
  input,
94
160
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
@@ -100,7 +166,7 @@ describe("runChunkedAction", () => {
100
166
  });
101
167
 
102
168
  test("cancelling mid-run folds every unreached chunk into failedIds", async () => {
103
- const input = ids(BULK_ACTION_CHUNK_SIZE * 3);
169
+ const input = targets(BULK_ACTION_CHUNK_SIZE * 3);
104
170
  let calls = 0;
105
171
  let cancelled = false;
106
172
  const outcome = await runChunkedAction(
@@ -121,7 +187,7 @@ describe("runChunkedAction", () => {
121
187
  });
122
188
 
123
189
  test("an infra failure mid-run stops the run and reports the error", async () => {
124
- const input = ids(BULK_ACTION_CHUNK_SIZE * 2);
190
+ const input = targets(BULK_ACTION_CHUNK_SIZE * 2);
125
191
  const boom = new Error("network blip");
126
192
  const outcome = await runChunkedAction(
127
193
  input,
@@ -136,8 +202,107 @@ describe("runChunkedAction", () => {
136
202
  assert.equal(outcome.failedIds.length, input.length);
137
203
  });
138
204
 
205
+ test("a selection spanning accounts is sent as one batch per account", async () => {
206
+ const calls: string[][] = [];
207
+ const outcome = await runChunkedAction(
208
+ [
209
+ { id: "a1", accountId: "acct-a" },
210
+ { id: "b1", accountId: "acct-b" },
211
+ { id: "a2", accountId: "acct-a" },
212
+ ],
213
+ async (chunk) => {
214
+ calls.push(chunk);
215
+ return { successCount: chunk.length, failureCount: 0 };
216
+ },
217
+ noopProgress,
218
+ neverCancelled,
219
+ );
220
+ assert.deepEqual(calls, [["a1", "a2"], ["b1"]]);
221
+ assert.equal(outcome.done, 3);
222
+ assert.deepEqual(outcome.failedIds, []);
223
+ });
224
+
225
+ test("progress counts toward the whole selection, not toward each account", async () => {
226
+ const seen: { done: number; total: number }[] = [];
227
+ await runChunkedAction(
228
+ [
229
+ { id: "a1", accountId: "acct-a" },
230
+ { id: "b1", accountId: "acct-b" },
231
+ ],
232
+ async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
233
+ (p) => seen.push(p),
234
+ neverCancelled,
235
+ );
236
+ assert.deepEqual(seen, [
237
+ { done: 1, total: 2 },
238
+ { done: 2, total: 2 },
239
+ ]);
240
+ });
241
+
242
+ test("cancelling at an account boundary hands back the accounts never reached", async () => {
243
+ let cancelled = false;
244
+ const outcome = await runChunkedAction(
245
+ [
246
+ { id: "a1", accountId: "acct-a" },
247
+ { id: "b1", accountId: "acct-b" },
248
+ { id: "c1", accountId: "acct-c" },
249
+ ],
250
+ async (chunk) => {
251
+ cancelled = true;
252
+ return { successCount: chunk.length, failureCount: 0 };
253
+ },
254
+ () => undefined,
255
+ () => cancelled,
256
+ );
257
+ assert.equal(outcome.cancelled, true);
258
+ assert.equal(outcome.done, 1);
259
+ assert.deepEqual(outcome.failedIds, ["b1", "c1"]);
260
+ });
261
+
262
+ test("one account's batch failing leaves the rest unsent and says how far it got", async () => {
263
+ const boom = new Error("500");
264
+ const outcome = await runChunkedAction(
265
+ [
266
+ { id: "a1", accountId: "acct-a" },
267
+ { id: "b1", accountId: "acct-b" },
268
+ ],
269
+ async (chunk) => {
270
+ if (chunk[0] === "b1") throw boom;
271
+ return { successCount: chunk.length, failureCount: 0 };
272
+ },
273
+ () => undefined,
274
+ neverCancelled,
275
+ );
276
+ assert.equal(outcome.error, boom);
277
+ assert.equal(outcome.done, 1);
278
+ assert.deepEqual(outcome.failedIds, ["b1"]);
279
+ });
280
+
281
+ test("the accounts behind a failed one are handed back whole, never half-sent", async () => {
282
+ const calls: string[][] = [];
283
+ const outcome = await runChunkedAction(
284
+ [
285
+ { id: "a1", accountId: "acct-a" },
286
+ { id: "b1", accountId: "acct-b" },
287
+ { id: "c1", accountId: "acct-c" },
288
+ ],
289
+ async (chunk) => {
290
+ calls.push(chunk);
291
+ throw new Error("auth expired");
292
+ },
293
+ () => undefined,
294
+ neverCancelled,
295
+ );
296
+ // The run stops where it threw rather than trying the accounts behind it:
297
+ // what it hands back is exactly what is still untouched, which is what a
298
+ // retry re-sends.
299
+ assert.deepEqual(calls, [["a1"]]);
300
+ assert.equal(outcome.done, 0);
301
+ assert.deepEqual(outcome.failedIds, ["a1", "b1", "c1"]);
302
+ });
303
+
139
304
  test("reports progress after each chunk", async () => {
140
- const input = ids(BULK_ACTION_CHUNK_SIZE + 1);
305
+ const input = targets(BULK_ACTION_CHUNK_SIZE + 1);
141
306
  const progressCalls: { done: number; total: number }[] = [];
142
307
  await runChunkedAction(
143
308
  input,
@@ -51,6 +51,53 @@ export const chunkIds = (
51
51
  return chunks;
52
52
  };
53
53
 
54
+ /** One message a run covers, as the surface that selected it knows it. */
55
+ export interface BulkActionTarget {
56
+ id: string;
57
+ /**
58
+ * Owning IMAP account — the `accountId` of the account API, never the
59
+ * caller's `accountConfigId` (#456). Undefined on a row from a per-mailbox
60
+ * listing, which does not carry one because every row in it shares the same
61
+ * account.
62
+ */
63
+ accountId: string | undefined;
64
+ }
65
+
66
+ /**
67
+ * Split `targets` into chunks of at most `size` that each carry exactly one
68
+ * account (#872).
69
+ *
70
+ * The bulk endpoints reject a batch spanning accounts outright, before applying
71
+ * any of it, and the daily brief and Flagged are both cross-account lists — so
72
+ * a selection ticked across two accounts sent as one batch deleted nothing.
73
+ * Account is a property of the batch, not of the verb: delete and mark-read
74
+ * cover whatever was ticked, and this is where that becomes one call per
75
+ * account.
76
+ *
77
+ * Targets with no account form a group of their own, which keeps the id in the
78
+ * run rather than dropping a message the user asked to be acted on. A surface
79
+ * that carries no account is single-account by construction; one that carries
80
+ * it for some rows and not others sends the unattributed ones together, and if
81
+ * the server refuses that batch the run reports it like any other failure.
82
+ *
83
+ * Accounts and ids keep the order they were selected in.
84
+ */
85
+ export const chunkTargets = (
86
+ targets: readonly BulkActionTarget[],
87
+ size = BULK_ACTION_CHUNK_SIZE,
88
+ ): string[][] => {
89
+ const byAccount = new Map<string | undefined, string[]>();
90
+ for (const target of targets) {
91
+ const held = byAccount.get(target.accountId);
92
+ if (held) {
93
+ held.push(target.id);
94
+ continue;
95
+ }
96
+ byAccount.set(target.accountId, [target.id]);
97
+ }
98
+ return [...byAccount.values()].flatMap((ids) => chunkIds(ids, size));
99
+ };
100
+
54
101
  export interface BatchResult {
55
102
  successCount: number;
56
103
  failureCount: number;
@@ -93,15 +140,20 @@ export interface BulkActionOutcome {
93
140
  * caller always gets back exactly the ids the action never reached, ready to
94
141
  * retry as-is: every action here is idempotent (re-trashing, re-moving or
95
142
  * re-marking a message it already applied to is a no-op).
143
+ *
144
+ * Chunks never mix accounts (see `chunkTargets`), and the run walks them as one
145
+ * sequence: cancellation lands at whichever boundary comes next, whether or not
146
+ * that is an account boundary, and progress counts toward the whole selection
147
+ * rather than restarting per account.
96
148
  */
97
149
  export const runChunkedAction = async (
98
- ids: readonly string[],
150
+ targets: readonly BulkActionTarget[],
99
151
  applyBatch: ApplyBatch,
100
152
  onProgress: (progress: BulkActionProgress) => void,
101
153
  isCancelled: () => boolean,
102
154
  ): Promise<BulkActionOutcome> => {
103
- const chunks = chunkIds(ids);
104
- const total = ids.length;
155
+ const chunks = chunkTargets(targets);
156
+ const total = targets.length;
105
157
  let done = 0;
106
158
  const failedIds: string[] = [];
107
159
 
@@ -137,13 +189,18 @@ export const runChunkedAction = async (
137
189
  * Each chunk is a full mutation of its own, so it keeps that hook's optimistic
138
190
  * patch, rollback and error banner. The run stops at the first rejected chunk
139
191
  * and reports nothing itself: the hook that owns the call has already raised it.
192
+ *
193
+ * Every call site on this path hands over ids from a single account — a
194
+ * focused row, or a selection the surface already scoped — so nothing here
195
+ * carries an account to split by. A caller with a cross-account selection
196
+ * belongs on `runChunkedAction`, whose targets name one (#872).
140
197
  */
141
198
  export const runChunkedMutation = async (
142
199
  ids: readonly string[],
143
200
  send: (chunk: string[]) => Promise<unknown>,
144
201
  ): Promise<void> => {
145
202
  await runChunkedAction(
146
- ids,
203
+ ids.map((id) => ({ id, accountId: undefined })),
147
204
  async (chunk) => {
148
205
  await send(chunk);
149
206
  return { successCount: chunk.length, failureCount: 0 };
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { ThreadRowData } from "@remit/ui";
4
+ import { wizardSelectionFrom } from "./wizard-selection.js";
5
+
6
+ const row = (over: Partial<ThreadRowData> & { id: string }): ThreadRowData => ({
7
+ fromName: "Alice",
8
+ fromEmail: "alice@example.com",
9
+ subject: "Quarterly report",
10
+ snippet: "",
11
+ timeLabel: "Jan 1",
12
+ ...over,
13
+ });
14
+
15
+ describe("the ticked rows the wizard is handed", () => {
16
+ // Regression for #872: a bulk run batches per account, and a row that
17
+ // arrives without its own account is one the run cannot place.
18
+ it("carries each row's own account, not the first one it saw", () => {
19
+ const got = wizardSelectionFrom(
20
+ [
21
+ row({ id: "m1", accountId: "acc-work" }),
22
+ row({ id: "m2", accountId: "acc-personal" }),
23
+ ],
24
+ new Set(["m1", "m2"]),
25
+ );
26
+ assert.deepEqual(
27
+ got.map((message) => message.accountId),
28
+ ["acc-work", "acc-personal"],
29
+ );
30
+ });
31
+
32
+ it("keeps a row whose list carries no account rather than dropping it", () => {
33
+ const got = wizardSelectionFrom([row({ id: "m1" })], new Set(["m1"]));
34
+ assert.deepEqual(
35
+ got.map((message) => ({ id: message.id, accountId: message.accountId })),
36
+ [{ id: "m1", accountId: undefined }],
37
+ );
38
+ });
39
+
40
+ it("takes only the ticked rows", () => {
41
+ const got = wizardSelectionFrom(
42
+ [row({ id: "m1" }), row({ id: "m2" })],
43
+ new Set(["m2"]),
44
+ );
45
+ assert.deepEqual(
46
+ got.map((message) => message.id),
47
+ ["m2"],
48
+ );
49
+ });
50
+ });
@@ -0,0 +1,37 @@
1
+ import type { ThreadRowData, WizardMessage } from "@remit/ui";
2
+
3
+ /** A ticked row, as the wizard's samples and its clause prefill read it. */
4
+ export interface WizardSelectionMessage extends WizardMessage {
5
+ /** Sender address — the widen's literal fallback and the prefill match on it. */
6
+ email: string;
7
+ /**
8
+ * Owning account, which a bulk run splits its batches by (#872) — the bulk
9
+ * endpoints refuse a batch spanning accounts before applying any of it.
10
+ * Stated by every surface rather than inferred: a per-mailbox list has one
11
+ * account for all its rows and carries none on the row, while the brief and
12
+ * Flagged span accounts and carry each row's own. Never `accountConfigId`,
13
+ * which every account of one user shares (#456).
14
+ */
15
+ accountId: string | undefined;
16
+ }
17
+
18
+ /**
19
+ * The ticked rows of a thread list, as the wizard reads them. The brief and
20
+ * Flagged both list rows from every account and hand their selection over the
21
+ * same way, so they read it from here rather than each keeping a copy that has
22
+ * to learn about a new field twice.
23
+ */
24
+ export const wizardSelectionFrom = (
25
+ rows: readonly ThreadRowData[],
26
+ selectedIds: ReadonlySet<string>,
27
+ ): WizardSelectionMessage[] =>
28
+ rows
29
+ .filter((row) => selectedIds.has(row.id))
30
+ .map((row) => ({
31
+ id: row.id,
32
+ sender: row.fromName,
33
+ email: row.fromEmail,
34
+ subject: row.subject,
35
+ date: row.timeLabel,
36
+ accountId: row.accountId,
37
+ }));