@remit/web-client 0.0.195 → 0.0.196

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.195",
3
+ "version": "0.0.196",
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": {
@@ -64,6 +64,7 @@ i18n.use(initReactI18next).init({
64
64
 
65
65
  let container: HTMLElement;
66
66
  let root: Root;
67
+ let queryClient: QueryClient;
67
68
  const originalFetch = globalThis.fetch;
68
69
 
69
70
  interface FetchCall {
@@ -71,7 +72,7 @@ interface FetchCall {
71
72
  method: string;
72
73
  body: string;
73
74
  }
74
- type FetchRoute = (call: FetchCall) => Response;
75
+ type FetchRoute = (call: FetchCall) => Response | Promise<Response>;
75
76
  let route: FetchRoute = () => new Response("{}", { status: 200 });
76
77
 
77
78
  const json = (body: unknown): Response =>
@@ -80,6 +81,31 @@ const json = (body: unknown): Response =>
80
81
  headers: { "Content-Type": "application/json" },
81
82
  });
82
83
 
84
+ /**
85
+ * `GET /sync/status`, the read the empty-folder delete waits on. `lastSyncedAt`
86
+ * only advances once `POST /sync` has been answered, so a count read before the
87
+ * trigger is distinguishable from one a triggered round wrote.
88
+ */
89
+ const syncStatus = (
90
+ counts: Readonly<Record<string, number>>,
91
+ rounds: number,
92
+ ) => ({
93
+ accountId: "acc-1",
94
+ syncPhase: "complete",
95
+ mailboxes: mailboxes.map((box) => ({
96
+ mailboxId: box.mailboxId,
97
+ fullPath: box.fullPath,
98
+ phase: "complete",
99
+ messagesTotal: counts[box.mailboxId] ?? box.messageCount,
100
+ messagesSynced: 0,
101
+ lastSyncedAt: 1000 + rounds,
102
+ })),
103
+ });
104
+
105
+ const isSyncStatus = (url: string): boolean => url.includes("/sync/status");
106
+ const isSyncTrigger = (url: string, method: string): boolean =>
107
+ method === "POST" && url.endsWith("/sync");
108
+
83
109
  const threadItems = (ids: readonly string[]) => ({
84
110
  items: ids.map((id) => ({
85
111
  threadMessageId: `t-${id}`,
@@ -95,16 +121,22 @@ beforeEach(() => {
95
121
  container = document.createElement("div");
96
122
  document.body.appendChild(container);
97
123
  root = createRoot(container);
124
+ queryClient = new QueryClient({
125
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
126
+ });
98
127
  globalThis.fetch = (async (input: RequestInfo | URL) => {
99
128
  const request = input as Request;
100
129
  const body = request.method === "GET" ? "" : await request.clone().text();
101
- return route({ url: request.url, method: request.method, body });
130
+ return await route({ url: request.url, method: request.method, body });
102
131
  }) as typeof fetch;
103
132
  });
104
133
 
105
134
  afterEach(() => {
106
135
  act(() => root.unmount());
107
136
  container.remove();
137
+ // Drops the cached queries with their gc timers, which otherwise hold the
138
+ // test process open for their full gcTime.
139
+ queryClient.clear();
108
140
  globalThis.fetch = originalFetch;
109
141
  });
110
142
 
@@ -122,7 +154,7 @@ const render = (props: {
122
154
  { i18n },
123
155
  createElement(
124
156
  QueryClientProvider,
125
- { client: new QueryClient() },
157
+ { client: queryClient },
126
158
  createElement(DeleteFolderDialog, {
127
159
  open: props.open,
128
160
  accountId: "acc-1",
@@ -353,9 +385,17 @@ describe("DeleteFolderDialog", () => {
353
385
 
354
386
  it("deletes an empty folder and closes on success", async () => {
355
387
  let closed = false;
356
- route = ({ method }) => {
388
+ let rounds = 0;
389
+ let triggered = 0;
390
+ route = ({ url, method }) => {
357
391
  if (method === "DELETE") return new Response(null, { status: 204 });
358
- return new Response("{}", { status: 200 });
392
+ if (isSyncStatus(url)) return json(syncStatus({}, rounds));
393
+ if (isSyncTrigger(url, method)) {
394
+ triggered += 1;
395
+ rounds += 1;
396
+ return json({ triggered: true, message: "ok" });
397
+ }
398
+ return json({ items: mailboxes });
359
399
  };
360
400
  render({
361
401
  open: true,
@@ -368,9 +408,184 @@ describe("DeleteFolderDialog", () => {
368
408
  buttonByText(/^Delete folder$/)?.click();
369
409
  });
370
410
  await flush();
411
+ assert.equal(triggered, 1, "the delete asks the server for a sync round");
371
412
  assert.equal(closed, true);
372
413
  });
373
414
 
415
+ it("refuses to delete a folder the server says still holds mail", async () => {
416
+ let deleted = false;
417
+ let closed = false;
418
+ let rounds = 0;
419
+ route = ({ url, method }) => {
420
+ if (method === "DELETE") {
421
+ deleted = true;
422
+ return new Response(null, { status: 204 });
423
+ }
424
+ // Mail landed in Empty since its last sync round, so the count the
425
+ // round reports contradicts the zero the dialog opened on.
426
+ if (isSyncStatus(url)) return json(syncStatus({ empty: 2 }, rounds));
427
+ if (isSyncTrigger(url, method)) {
428
+ rounds += 1;
429
+ return json({ triggered: true, message: "ok" });
430
+ }
431
+ return json({ items: mailboxes });
432
+ };
433
+ render({
434
+ open: true,
435
+ folder: mailboxes[2] as RemitImapMailboxResponse,
436
+ onClose: () => {
437
+ closed = true;
438
+ },
439
+ });
440
+ assert.match(container.textContent ?? "", /empty and will be removed/);
441
+ await act(async () => {
442
+ buttonByText(/^Delete folder$/)?.click();
443
+ });
444
+ await flush();
445
+ assert.equal(deleted, false, "a fresh count above zero blocks the delete");
446
+ assert.equal(closed, false, "the dialog stays open on the mail it found");
447
+ assert.match(container.textContent ?? "", /This folder is not empty/);
448
+ assert.match(container.textContent ?? "", /reports 2 emails/);
449
+ assert.match(container.textContent ?? "", /holds 2 emails/);
450
+ assert.ok(
451
+ buttonByText(/Move them to another folder/),
452
+ "the non-empty flow takes over",
453
+ );
454
+ });
455
+
456
+ it("refuses the delete when the count cannot be read", async () => {
457
+ let deleted = false;
458
+ route = ({ url, method }) => {
459
+ if (method === "DELETE") {
460
+ deleted = true;
461
+ return new Response(null, { status: 204 });
462
+ }
463
+ if (isSyncStatus(url))
464
+ return new Response(JSON.stringify({ detail: "server exploded" }), {
465
+ status: 500,
466
+ headers: { "Content-Type": "application/json" },
467
+ });
468
+ if (isSyncTrigger(url, method))
469
+ return json({ triggered: true, message: "ok" });
470
+ return json({ items: mailboxes });
471
+ };
472
+ render({ open: true, folder: mailboxes[2] as RemitImapMailboxResponse });
473
+ await act(async () => {
474
+ buttonByText(/^Delete folder$/)?.click();
475
+ });
476
+ await flush();
477
+ assert.equal(deleted, false, "an unreadable count is not an empty folder");
478
+ assert.ok(
479
+ buttonByText(/^Close$/),
480
+ "the failure is stated, not swallowed into a delete",
481
+ );
482
+ });
483
+
484
+ it("does not delete when the dialog is closed mid-check", async () => {
485
+ let deleted = false;
486
+ let closed = false;
487
+ let rounds = 0;
488
+ let releaseStatus: (() => void) | undefined;
489
+ route = ({ url, method }) => {
490
+ if (method === "DELETE") {
491
+ deleted = true;
492
+ return new Response(null, { status: 204 });
493
+ }
494
+ if (isSyncStatus(url)) {
495
+ const answer = json(syncStatus({}, rounds));
496
+ if (rounds === 0) return answer;
497
+ // The round has landed but the answer is still in flight; the user
498
+ // gets to cancel before it arrives.
499
+ return new Promise<Response>((resolve) => {
500
+ releaseStatus = () => resolve(answer);
501
+ });
502
+ }
503
+ if (isSyncTrigger(url, method)) {
504
+ rounds += 1;
505
+ return json({ triggered: true, message: "ok" });
506
+ }
507
+ return json({ items: mailboxes });
508
+ };
509
+ render({
510
+ open: true,
511
+ folder: mailboxes[2] as RemitImapMailboxResponse,
512
+ onClose: () => {
513
+ closed = true;
514
+ },
515
+ });
516
+ await act(async () => {
517
+ buttonByText(/^Delete folder$/)?.click();
518
+ });
519
+ await flush();
520
+ assert.ok(releaseStatus, "the check is waiting on the server");
521
+ await act(async () => {
522
+ container
523
+ .querySelector<HTMLButtonElement>('button[aria-label="Cancel"]')
524
+ ?.click();
525
+ });
526
+ await act(async () => {
527
+ releaseStatus?.();
528
+ });
529
+ await flush();
530
+ assert.equal(closed, true, "the cancel closes the dialog");
531
+ assert.equal(deleted, false, "a cancelled check never reaches the delete");
532
+ });
533
+
534
+ it("asks whether to keep waiting, and resumes without a second round", async () => {
535
+ let deleted = false;
536
+ let reported = false;
537
+ let triggered = 0;
538
+ route = ({ url, method }) => {
539
+ if (method === "DELETE") {
540
+ deleted = true;
541
+ return new Response(null, { status: 204 });
542
+ }
543
+ if (isSyncStatus(url)) return json(syncStatus({}, reported ? 1 : 0));
544
+ if (isSyncTrigger(url, method)) {
545
+ triggered += 1;
546
+ return json({ triggered: true, message: "ok" });
547
+ }
548
+ return json({ items: mailboxes });
549
+ };
550
+ render({ open: true, folder: mailboxes[2] as RemitImapMailboxResponse });
551
+
552
+ // A sync fans the whole account out, so the folder's round can land long
553
+ // after the trigger. Run the clock forward instead of waiting on it.
554
+ const realNow = Date.now;
555
+ let fake = realNow();
556
+ // Past the segment in one step, so the stall is reached without a poll
557
+ // sleeping through it.
558
+ Date.now = () => {
559
+ fake += 200_000;
560
+ return fake;
561
+ };
562
+ try {
563
+ await act(async () => {
564
+ buttonByText(/^Delete folder$/)?.click();
565
+ });
566
+ await flush();
567
+ } finally {
568
+ Date.now = realNow;
569
+ // The reads taken under the fake clock cached a future timestamp, which
570
+ // reads as fresh once the real clock is back; drop them so the resumed
571
+ // wait talks to the server again.
572
+ queryClient.clear();
573
+ }
574
+
575
+ assert.equal(triggered, 1, "one round is asked for");
576
+ assert.equal(deleted, false, "an unreported folder is not deleted");
577
+ assert.match(container.textContent ?? "", /hasn't reported back/);
578
+ assert.ok(buttonByText(/^Keep waiting$/), "the wait is the user's call");
579
+
580
+ reported = true;
581
+ await act(async () => {
582
+ buttonByText(/^Keep waiting$/)?.click();
583
+ });
584
+ await flush();
585
+ assert.equal(triggered, 1, "resuming asks for no second round");
586
+ assert.equal(deleted, true, "the reported count settles the delete");
587
+ });
588
+
374
589
  it("moves the mail in batches then deletes the emptied folder", async () => {
375
590
  let closed = false;
376
591
  let moved: string[] = [];
@@ -14,7 +14,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
14
14
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
15
15
  import { useDeleteFolder } from "@/hooks/useDeleteFolder";
16
16
  import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
17
- import { initialStage, moveProgressLabel } from "@/lib/delete-folder";
17
+ import {
18
+ elapsedLabel,
19
+ initialStage,
20
+ moveProgressLabel,
21
+ } from "@/lib/delete-folder";
18
22
  import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
19
23
  import { buildMoveOptions } from "@/lib/move-options";
20
24
 
@@ -37,11 +41,14 @@ const emailCount = (count: number): string =>
37
41
  `${count} ${count === 1 ? "email" : "emails"}`;
38
42
 
39
43
  /**
40
- * Per-folder delete wizard. An empty folder is a single destructive confirm; a
41
- * folder with mail first asks what happens to the messages delete them with
42
- * the folder, or move them elsewhere (batched, with visible progress) before
43
- * the now-empty folder is removed. Closing mid-move keeps already-moved mail
44
- * moved; re-opening enumerates what's left and continues.
44
+ * Per-folder delete wizard. An empty folder is a single destructive confirm,
45
+ * settled against a sync round asked for on the spot rather than the folder's
46
+ * last synced count a wait the user can watch, keep, or cancel, and which
47
+ * deletes nothing until the server answers; a folder with mail asks what
48
+ * happens to the messages first delete them with the folder, or move them
49
+ * elsewhere (batched, with visible progress) before the now-empty folder is
50
+ * removed. Closing mid-move keeps
51
+ * already-moved mail moved; re-opening enumerates what's left and continues.
45
52
  */
46
53
  export function DeleteFolderDialog({
47
54
  open,
@@ -54,6 +61,7 @@ export function DeleteFolderDialog({
54
61
  const [stage, setStage] = useState<FateStage>(() =>
55
62
  initialStage(folder.messageCount),
56
63
  );
64
+ const [arrivedSinceSync, setArrivedSinceSync] = useState<number>();
57
65
  const [destinationId, setDestinationId] = useState<string>();
58
66
  const { createFolderIn } = useCreateMailbox(accountId);
59
67
  const translator = useFolderLabelTranslator();
@@ -61,7 +69,9 @@ export function DeleteFolderDialog({
61
69
  phase,
62
70
  progress,
63
71
  errorMessage,
72
+ checkStartedAt,
64
73
  deleteMailbox,
74
+ deleteIfEmpty,
65
75
  moveThenDelete,
66
76
  cancel,
67
77
  reset,
@@ -70,12 +80,30 @@ export function DeleteFolderDialog({
70
80
  mailboxId: folder.mailboxId,
71
81
  onDeleted: onClose,
72
82
  });
83
+ const [checkElapsed, setCheckElapsed] = useState("0:00");
84
+
85
+ // The check has no progress to report — only that it is still waiting — so
86
+ // the wait itself is what the surface shows, ticking, for as long as it runs.
87
+ const waiting = phase === "checking" || phase === "check-stalled";
88
+ useEffect(() => {
89
+ if (!waiting || checkStartedAt === undefined) return;
90
+ const update = () =>
91
+ setCheckElapsed(elapsedLabel(Date.now() - checkStartedAt));
92
+ update();
93
+ const timer = setInterval(update, 1000);
94
+ return () => clearInterval(timer);
95
+ }, [waiting, checkStartedAt]);
73
96
 
97
+ // Opening the dialog stages it; a count arriving later deliberately does not.
98
+ // The refusal below invalidates the folder list on purpose, and re-staging on
99
+ // the count that refetch brings back would wipe the refusal off the screen.
100
+ // biome-ignore lint/correctness/useExhaustiveDependencies: folder.messageCount is read at open and must not re-run this.
74
101
  useEffect(() => {
75
102
  if (!open) return;
76
103
  setStage(initialStage(folder.messageCount));
104
+ setArrivedSinceSync(undefined);
77
105
  reset();
78
- }, [open, folder.messageCount, reset]);
106
+ }, [open, reset]);
79
107
 
80
108
  useEffect(() => {
81
109
  if (!open) return;
@@ -89,6 +117,13 @@ export function DeleteFolderDialog({
89
117
  onClose();
90
118
  }, [cancel, onClose]);
91
119
 
120
+ const handleDeleteEmpty = useCallback(async () => {
121
+ const outcome = await deleteIfEmpty();
122
+ if (outcome.status !== "blocked") return;
123
+ setArrivedSinceSync(outcome.messageCount);
124
+ setStage("choose-fate");
125
+ }, [deleteIfEmpty]);
126
+
92
127
  const destinations = useMemo<FolderTreeNode[]>(
93
128
  () =>
94
129
  buildMoveOptions({
@@ -117,6 +152,7 @@ export function DeleteFolderDialog({
117
152
  if (!open) return null;
118
153
 
119
154
  const title = `Delete ${name}`;
155
+ const messageCount = arrivedSinceSync ?? folder.messageCount;
120
156
 
121
157
  const body = (() => {
122
158
  if (phase === "moving") {
@@ -131,6 +167,42 @@ export function DeleteFolderDialog({
131
167
  );
132
168
  }
133
169
 
170
+ if (phase === "checking") {
171
+ return (
172
+ <div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
173
+ <Loader2 className="size-8 animate-spin text-accent-2" />
174
+ <p className="text-sm font-medium text-fg">
175
+ Checking the folder on the mail server…
176
+ </p>
177
+ <p className="text-xs text-fg-muted" role="status" aria-live="polite">
178
+ {`Waiting ${checkElapsed}. Nothing is deleted until the server answers.`}
179
+ </p>
180
+ </div>
181
+ );
182
+ }
183
+
184
+ if (phase === "check-stalled") {
185
+ return (
186
+ <div className="space-y-4 px-5 py-4">
187
+ <Banner tone="warning" variant="soft">
188
+ {`The mail server hasn't reported back on this folder yet — ${checkElapsed} so far. Nothing has been deleted.`}
189
+ </Banner>
190
+ <p className="text-xs text-fg-muted">
191
+ A sync covers the whole account, so a busy mailbox can take a while
192
+ to come round to this folder.
193
+ </p>
194
+ <div className="flex justify-end gap-2">
195
+ <Button variant="secondary" size="sm" onClick={handleClose}>
196
+ Cancel
197
+ </Button>
198
+ <Button size="sm" onClick={() => handleDeleteEmpty()}>
199
+ Keep waiting
200
+ </Button>
201
+ </div>
202
+ </div>
203
+ );
204
+ }
205
+
134
206
  if (phase === "deleting" || phase === "done") {
135
207
  return (
136
208
  <div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
@@ -178,7 +250,7 @@ export function DeleteFolderDialog({
178
250
  variant="danger"
179
251
  size="sm"
180
252
  icon={<Trash2 className="size-3.5" />}
181
- onClick={() => deleteMailbox()}
253
+ onClick={() => handleDeleteEmpty()}
182
254
  >
183
255
  Delete folder
184
256
  </Button>
@@ -191,9 +263,14 @@ export function DeleteFolderDialog({
191
263
  return (
192
264
  <>
193
265
  <div className="space-y-3 px-5 py-4 text-sm text-fg-muted">
266
+ {arrivedSinceSync !== undefined && (
267
+ <Banner tone="warning" variant="soft">
268
+ {`This folder is not empty: the mail server reports ${emailCount(arrivedSinceSync)} in it. Nothing was deleted.`}
269
+ </Banner>
270
+ )}
194
271
  <p>
195
272
  <strong className="text-fg">{name}</strong> holds{" "}
196
- {emailCount(folder.messageCount)}. What should happen to them?
273
+ {emailCount(messageCount)}. What should happen to them?
197
274
  </p>
198
275
  <div className="space-y-2">
199
276
  <button
@@ -244,8 +321,8 @@ export function DeleteFolderDialog({
244
321
  <div className="space-y-3 px-5 py-4 text-sm text-fg-muted">
245
322
  <p>
246
323
  Delete <strong className="text-fg">{name}</strong> and its{" "}
247
- {emailCount(folder.messageCount)}? The messages are removed from
248
- the server with the folder and can't be recovered.
324
+ {emailCount(messageCount)}? The messages are removed from the
325
+ server with the folder and can't be recovered.
249
326
  </p>
250
327
  </div>
251
328
  <footer className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
@@ -272,7 +349,7 @@ export function DeleteFolderDialog({
272
349
  return (
273
350
  <div className="flex h-[26rem] flex-col">
274
351
  <p className="px-5 pt-4 text-sm text-fg-muted">
275
- Move the {emailCount(folder.messageCount)} in{" "}
352
+ Move the {emailCount(messageCount)} in{" "}
276
353
  <strong className="text-fg">{name}</strong> to:
277
354
  </p>
278
355
  <div className="flex min-h-0 flex-1 overflow-hidden">
@@ -301,7 +378,7 @@ export function DeleteFolderDialog({
301
378
  className="h-11 w-full font-semibold"
302
379
  >
303
380
  <span className="truncate">
304
- {`Move ${emailCount(folder.messageCount)} to ${destination.label}`}
381
+ {`Move ${emailCount(messageCount)} to ${destination.label}`}
305
382
  </span>
306
383
  </Button>
307
384
  </footer>
@@ -1,11 +1,13 @@
1
1
  import {
2
2
  configOperationsGetConfigQueryKey,
3
3
  mailboxOperationsListMailboxesQueryKey,
4
+ syncOperationsGetSyncStatusOptions,
4
5
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
6
  import {
6
7
  mailboxDetailOperationsDeleteMailbox,
7
8
  mailboxOperationsListMailboxes,
8
9
  messageBulkOperationsMoveMessages,
10
+ syncOperationsTriggerSync,
9
11
  threadOperationsListThreads,
10
12
  } from "@remit/api-http-client/sdk.gen.ts";
11
13
  import { useQueryClient } from "@tanstack/react-query";
@@ -17,6 +19,12 @@ import {
17
19
  MOVE_BATCH_SIZE,
18
20
  type MoveProgress,
19
21
  } from "@/lib/delete-folder";
22
+ import {
23
+ awaitFreshMailboxCount,
24
+ type FreshCountOutcome,
25
+ type MailboxCountReading,
26
+ mailboxSyncStamp,
27
+ } from "@/lib/fresh-mailbox-count";
20
28
 
21
29
  const PAGE_CAP = 50;
22
30
 
@@ -101,11 +109,25 @@ const liveMessageCount = async (
101
109
 
102
110
  export type DeleteFolderPhase =
103
111
  | "idle"
112
+ | "checking"
113
+ | "check-stalled"
104
114
  | "moving"
105
115
  | "deleting"
106
116
  | "done"
107
117
  | "error";
108
118
 
119
+ /**
120
+ * What a delete-as-empty did. `blocked` carries the count that stopped it;
121
+ * `pending` means the server has not reported yet and the user decides whether
122
+ * to wait on; `failed` means nothing was established. Neither is ever treated
123
+ * as empty.
124
+ */
125
+ export type EmptyDeleteOutcome =
126
+ | { status: "deleted" }
127
+ | { status: "blocked"; messageCount: number }
128
+ | { status: "pending" }
129
+ | { status: "failed" };
130
+
109
131
  interface UseDeleteFolderOptions {
110
132
  accountId: string;
111
133
  mailboxId: string;
@@ -121,7 +143,11 @@ export function useDeleteFolder({
121
143
  const [phase, setPhase] = useState<DeleteFolderPhase>("idle");
122
144
  const [progress, setProgress] = useState<MoveProgress | null>(null);
123
145
  const [errorMessage, setErrorMessage] = useState<string>();
146
+ const [checkStartedAt, setCheckStartedAt] = useState<number>();
124
147
  const abortRef = useRef<AbortController | null>(null);
148
+ /** The folder's sync stamp before the round was asked for; set while a check
149
+ * is running or paused, so resuming it re-uses the same baseline. */
150
+ const sinceRef = useRef<number | undefined>(undefined);
125
151
 
126
152
  const invalidate = useCallback(() => {
127
153
  queryClient.invalidateQueries({
@@ -163,6 +189,88 @@ export function useDeleteFolder({
163
189
  [accountId, mailboxId],
164
190
  );
165
191
 
192
+ const readMailboxSyncStatus = useCallback(
193
+ (): Promise<readonly MailboxCountReading[]> =>
194
+ queryClient
195
+ .fetchQuery({
196
+ ...syncOperationsGetSyncStatusOptions({ path: { accountId } }),
197
+ staleTime: 0,
198
+ })
199
+ .then((data) => data.mailboxes ?? []),
200
+ [queryClient, accountId],
201
+ );
202
+
203
+ /**
204
+ * Delete a folder the user was told is empty. Every count the client holds is
205
+ * the last sync round's, so this waits (R2 of the IMAP mutation rules) for a
206
+ * round asked for here to report on the folder, and deletes only on the count
207
+ * that round read. A read failure or a folder gone from the account refuses
208
+ * the delete: not knowing what a folder holds is never permission.
209
+ *
210
+ * The round can take minutes — it fans the whole account out behind INBOX —
211
+ * so the wait runs in segments. A segment that ends unreported returns
212
+ * `pending` and the user decides; calling again resumes the same wait against
213
+ * the same baseline, and never asks for a second round.
214
+ */
215
+ const deleteIfEmpty = useCallback(async (): Promise<EmptyDeleteOutcome> => {
216
+ const controller = new AbortController();
217
+ abortRef.current = controller;
218
+ const { signal } = controller;
219
+ setPhase("checking");
220
+ setErrorMessage(undefined);
221
+
222
+ const resuming = sinceRef.current !== undefined;
223
+ const counted = await attempt(
224
+ (async (): Promise<FreshCountOutcome> => {
225
+ if (!resuming) {
226
+ signal.throwIfAborted();
227
+ sinceRef.current = mailboxSyncStamp(
228
+ await readMailboxSyncStatus(),
229
+ mailboxId,
230
+ );
231
+ signal.throwIfAborted();
232
+ setCheckStartedAt(Date.now());
233
+ await syncOperationsTriggerSync({
234
+ path: { accountId },
235
+ throwOnError: true,
236
+ });
237
+ }
238
+ return awaitFreshMailboxCount({
239
+ readMailboxes: readMailboxSyncStatus,
240
+ mailboxId,
241
+ since: sinceRef.current ?? 0,
242
+ signal,
243
+ });
244
+ })(),
245
+ );
246
+
247
+ if (signal.aborted) {
248
+ // The dialog is closing or the user cancelled; leave nothing running
249
+ // and no phase for a later caller to inherit.
250
+ setPhase("idle");
251
+ sinceRef.current = undefined;
252
+ return { status: "failed" };
253
+ }
254
+ if (!counted.ok) {
255
+ sinceRef.current = undefined;
256
+ setErrorMessage(counted.error);
257
+ setPhase("error");
258
+ return { status: "failed" };
259
+ }
260
+ if (counted.value.status === "pending") {
261
+ setPhase("check-stalled");
262
+ return { status: "pending" };
263
+ }
264
+ sinceRef.current = undefined;
265
+ if (counted.value.messageCount > 0) {
266
+ setPhase("idle");
267
+ invalidate();
268
+ return { status: "blocked", messageCount: counted.value.messageCount };
269
+ }
270
+ await deleteMailbox();
271
+ return { status: "deleted" };
272
+ }, [accountId, mailboxId, readMailboxSyncStatus, deleteMailbox, invalidate]);
273
+
166
274
  const cancel = useCallback(() => {
167
275
  abortRef.current?.abort();
168
276
  }, []);
@@ -250,16 +358,21 @@ export function useDeleteFolder({
250
358
  const reset = useCallback(() => {
251
359
  abortRef.current?.abort();
252
360
  abortRef.current = null;
361
+ sinceRef.current = undefined;
253
362
  setPhase("idle");
254
363
  setProgress(null);
255
364
  setErrorMessage(undefined);
365
+ setCheckStartedAt(undefined);
256
366
  }, []);
257
367
 
258
368
  return {
259
369
  phase,
260
370
  progress,
261
371
  errorMessage,
372
+ /** When the running check asked for its round; the surface shows the wait. */
373
+ checkStartedAt,
262
374
  deleteMailbox,
375
+ deleteIfEmpty,
263
376
  moveThenDelete,
264
377
  cancel,
265
378
  reset,
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
3
3
  import {
4
4
  advanceMove,
5
5
  beginMove,
6
+ elapsedLabel,
6
7
  excludeFolder,
7
8
  type FolderNode,
8
9
  failMove,
@@ -262,6 +263,19 @@ describe("move progress", () => {
262
263
  });
263
264
  });
264
265
 
266
+ describe("elapsedLabel", () => {
267
+ it("counts a wait in minutes and padded seconds", () => {
268
+ assert.equal(elapsedLabel(0), "0:00");
269
+ assert.equal(elapsedLabel(9_400), "0:09");
270
+ assert.equal(elapsedLabel(65_000), "1:05");
271
+ assert.equal(elapsedLabel(600_000), "10:00");
272
+ });
273
+
274
+ it("reads a backwards clock as no time at all", () => {
275
+ assert.equal(elapsedLabel(-5_000), "0:00");
276
+ });
277
+ });
278
+
265
279
  describe("initialStage", () => {
266
280
  it("opens on the empty confirm for a folder with no mail", () => {
267
281
  assert.equal(initialStage(0), "confirm-empty");
@@ -172,6 +172,12 @@ export function moveProgressLabel(progress: MoveProgress): string {
172
172
  return `Moved ${progress.moved} of ${progress.total}`;
173
173
  }
174
174
 
175
+ /** How long a wait has run, as `m:ss` — clamped at zero so a clock skew reads as 0:00. */
176
+ export function elapsedLabel(ms: number): string {
177
+ const seconds = Math.max(0, Math.floor(ms / 1000));
178
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
179
+ }
180
+
175
181
  /** Where the wizard opens: a straight confirm for an empty folder, otherwise the fate step. */
176
182
  export function initialStage(
177
183
  messageCount: number,
@@ -0,0 +1,145 @@
1
+ /**
2
+ * awaitFreshMailboxCount — the gate a folder delete holds behind while the
3
+ * server is asked what the folder actually holds. It reports a count only from
4
+ * a round that stamped past the baseline, reports `pending` rather than a count
5
+ * when the segment runs out, and refuses outright on a folder the account does
6
+ * not list.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import { describe, it } from "node:test";
11
+ import {
12
+ awaitFreshMailboxCount,
13
+ FRESH_COUNT_MISSING_MESSAGE,
14
+ type MailboxCountReading,
15
+ mailboxSyncStamp,
16
+ } from "./fresh-mailbox-count.js";
17
+
18
+ const reading = (
19
+ messagesTotal: number,
20
+ lastSyncedAt?: number,
21
+ ): MailboxCountReading => ({
22
+ mailboxId: "mbx-1",
23
+ messagesTotal,
24
+ lastSyncedAt,
25
+ });
26
+
27
+ const noDelay = () => Promise.resolve();
28
+
29
+ /** A clock that jumps a minute per reading, so a segment expires in two polls. */
30
+ const impatientClock = () => {
31
+ let clock = 0;
32
+ return () => {
33
+ clock += 60_000;
34
+ return clock;
35
+ };
36
+ };
37
+
38
+ describe("mailboxSyncStamp", () => {
39
+ it("reads the folder's stamp, and zero for a folder never synced", () => {
40
+ assert.equal(mailboxSyncStamp([reading(0, 100)], "mbx-1"), 100);
41
+ assert.equal(mailboxSyncStamp([reading(0)], "mbx-1"), 0);
42
+ });
43
+
44
+ it("refuses a folder the account does not list", () => {
45
+ assert.throws(() => mailboxSyncStamp([], "mbx-1"), {
46
+ message: FRESH_COUNT_MISSING_MESSAGE,
47
+ });
48
+ });
49
+ });
50
+
51
+ describe("awaitFreshMailboxCount", () => {
52
+ it("resolves with the count a round stamped past the baseline", async () => {
53
+ const responses = [[reading(0, 100)], [reading(0, 100)], [reading(3, 200)]];
54
+ let call = 0;
55
+ const outcome = await awaitFreshMailboxCount({
56
+ mailboxId: "mbx-1",
57
+ since: 100,
58
+ readMailboxes: async () => responses[call++] as MailboxCountReading[],
59
+ delay: noDelay,
60
+ });
61
+ assert.deepEqual(outcome, { status: "fresh", messageCount: 3 });
62
+ assert.equal(call, 3);
63
+ });
64
+
65
+ it("never reports a count from a round older than the baseline", async () => {
66
+ // The stamp stands still — the folder was never re-read, so the zero
67
+ // sitting in the row is exactly the stale count that must not be trusted.
68
+ const outcome = await awaitFreshMailboxCount({
69
+ mailboxId: "mbx-1",
70
+ since: 100,
71
+ readMailboxes: async () => [reading(0, 100)],
72
+ delay: noDelay,
73
+ now: impatientClock(),
74
+ });
75
+ assert.deepEqual(outcome, { status: "pending" });
76
+ });
77
+
78
+ it("resumes against the same baseline and then reports the count", async () => {
79
+ let stamp = 100;
80
+ const readMailboxes = async () => [reading(2, stamp)];
81
+ const first = await awaitFreshMailboxCount({
82
+ mailboxId: "mbx-1",
83
+ since: 100,
84
+ readMailboxes,
85
+ delay: noDelay,
86
+ now: impatientClock(),
87
+ });
88
+ assert.deepEqual(first, { status: "pending" });
89
+
90
+ stamp = 300;
91
+ const second = await awaitFreshMailboxCount({
92
+ mailboxId: "mbx-1",
93
+ since: 100,
94
+ readMailboxes,
95
+ delay: noDelay,
96
+ now: impatientClock(),
97
+ });
98
+ assert.deepEqual(second, { status: "fresh", messageCount: 2 });
99
+ });
100
+
101
+ it("refuses a folder the account no longer lists", async () => {
102
+ await assert.rejects(
103
+ awaitFreshMailboxCount({
104
+ mailboxId: "mbx-1",
105
+ since: 100,
106
+ readMailboxes: async () => [],
107
+ delay: noDelay,
108
+ }),
109
+ { message: FRESH_COUNT_MISSING_MESSAGE },
110
+ );
111
+ });
112
+
113
+ it("propagates a failed read rather than counting it as zero", async () => {
114
+ await assert.rejects(
115
+ awaitFreshMailboxCount({
116
+ mailboxId: "mbx-1",
117
+ since: 100,
118
+ readMailboxes: async () => {
119
+ throw new Error("sync status 500");
120
+ },
121
+ delay: noDelay,
122
+ }),
123
+ { message: "sync status 500" },
124
+ );
125
+ });
126
+
127
+ it("stops on abort and never reports a count", async () => {
128
+ const controller = new AbortController();
129
+ let call = 0;
130
+ controller.abort();
131
+ await assert.rejects(
132
+ awaitFreshMailboxCount({
133
+ mailboxId: "mbx-1",
134
+ since: 100,
135
+ readMailboxes: async () => {
136
+ call += 1;
137
+ return [reading(0, 200)];
138
+ },
139
+ signal: controller.signal,
140
+ delay: noDelay,
141
+ }),
142
+ );
143
+ assert.equal(call, 0, "an aborted wait reads nothing");
144
+ });
145
+ });
@@ -0,0 +1,114 @@
1
+ import { abortableDelay } from "./mailbox-sync-wait";
2
+
3
+ /**
4
+ * How many messages a folder holds *on the mail server*, rather than how many
5
+ * the last sync round left in the local row.
6
+ *
7
+ * Every count the client can read — the mailbox row's `messageCount`, and so
8
+ * the folder list and the sync-status projection over it — is whatever the last
9
+ * round wrote. Mail that arrived since is invisible in it, which is fine for a
10
+ * badge and fatal for a delete: `deleteMailbox` takes the folder's mail with it
11
+ * and IMAP has no undo.
12
+ *
13
+ * So the count is taken from a round asked for on the spot: trigger a sync,
14
+ * then wait for the folder's `lastSyncedAt` to advance past the stamp read
15
+ * before the trigger, and read the count that round wrote alongside it (every
16
+ * message-sync round writes both from the same IMAP STATUS).
17
+ *
18
+ * What the advancing stamp proves is that *some* round's write landed after the
19
+ * baseline read — not necessarily the round this triggered. A round already in
20
+ * flight can land first and satisfy the wait. That is accepted: its STATUS was
21
+ * taken within milliseconds of the baseline, and the error it can carry is a
22
+ * count from a moment too early, which either agrees with the trigger's round
23
+ * or reports mail the folder had and the delete then refuses. The mistake lands
24
+ * on the side of not deleting.
25
+ *
26
+ * Nothing here decides on a count read before the trigger, and every way out
27
+ * other than an advanced stamp throws or reports `pending`: a folder missing
28
+ * from the account, a failed read, an aborted wait. Uncertainty about what a
29
+ * folder holds is never permission to delete it.
30
+ */
31
+
32
+ /** The read fields the wait needs off a sync-status entry. */
33
+ export interface MailboxCountReading {
34
+ mailboxId: string;
35
+ messagesTotal: number;
36
+ lastSyncedAt?: number;
37
+ }
38
+
39
+ /** A count from a round that reported after the baseline, or no round yet. */
40
+ export type FreshCountOutcome =
41
+ | { status: "fresh"; messageCount: number }
42
+ | { status: "pending" };
43
+
44
+ export interface AwaitFreshMailboxCountOptions {
45
+ /** Reads every mailbox's sync-status entry; called once per poll. */
46
+ readMailboxes: () => Promise<readonly MailboxCountReading[]>;
47
+ /** The folder to count. */
48
+ mailboxId: string;
49
+ /** The folder's `lastSyncedAt` as read before the sync was triggered. */
50
+ since: number;
51
+ /** Aborts the wait; a round that lands afterwards resolves nothing. */
52
+ signal?: AbortSignal;
53
+ /** How long this stretch of waiting runs before reporting `pending`. */
54
+ segmentMs?: number;
55
+ pollIntervalMs?: number;
56
+ /** Injectable clock/sleep for tests. */
57
+ delay?: (ms: number, signal?: AbortSignal) => Promise<void>;
58
+ now?: () => number;
59
+ }
60
+
61
+ /**
62
+ * How long one stretch of waiting runs before handing the decision back to the
63
+ * user. An explicit sync fans the whole account out on one FIFO group with
64
+ * INBOX first, so a folder on a large account can sit behind minutes of other
65
+ * mailboxes: this is not long enough to conclude anything, only long enough
66
+ * that someone watching a spinner deserves to be asked whether to keep waiting.
67
+ */
68
+ export const FRESH_COUNT_SEGMENT_MS = 120_000;
69
+ export const FRESH_COUNT_POLL_INTERVAL_MS = 2_000;
70
+
71
+ export const FRESH_COUNT_MISSING_MESSAGE =
72
+ "This folder is no longer in the account's folder list, so nothing was deleted.";
73
+
74
+ const entryFor = (
75
+ mailboxes: readonly MailboxCountReading[],
76
+ mailboxId: string,
77
+ ): MailboxCountReading => {
78
+ const entry = mailboxes.find((mailbox) => mailbox.mailboxId === mailboxId);
79
+ if (!entry) throw new Error(FRESH_COUNT_MISSING_MESSAGE);
80
+ return entry;
81
+ };
82
+
83
+ /** The folder's last sync stamp, or a refusal when the account does not list it. */
84
+ export const mailboxSyncStamp = (
85
+ mailboxes: readonly MailboxCountReading[],
86
+ mailboxId: string,
87
+ ): number => entryFor(mailboxes, mailboxId).lastSyncedAt ?? 0;
88
+
89
+ /**
90
+ * Poll for one segment. Resolves `fresh` with the count once a round reports
91
+ * past `since`, `pending` when the segment runs out with the folder still
92
+ * unreported — the caller asks the user whether to wait on, and calling again
93
+ * with the same `since` resumes without triggering a second round.
94
+ */
95
+ export async function awaitFreshMailboxCount({
96
+ readMailboxes,
97
+ mailboxId,
98
+ since,
99
+ signal,
100
+ segmentMs = FRESH_COUNT_SEGMENT_MS,
101
+ pollIntervalMs = FRESH_COUNT_POLL_INTERVAL_MS,
102
+ delay = abortableDelay,
103
+ now = Date.now,
104
+ }: AwaitFreshMailboxCountOptions): Promise<FreshCountOutcome> {
105
+ const deadline = now() + segmentMs;
106
+ for (;;) {
107
+ signal?.throwIfAborted();
108
+ const entry = entryFor(await readMailboxes(), mailboxId);
109
+ if ((entry.lastSyncedAt ?? 0) > since)
110
+ return { status: "fresh", messageCount: entry.messagesTotal };
111
+ if (now() >= deadline) return { status: "pending" };
112
+ await delay(pollIntervalMs, signal);
113
+ }
114
+ }
@@ -50,7 +50,11 @@ export const MAILBOX_SYNC_FAILED_MESSAGE =
50
50
  export const MAILBOX_SYNC_TIMEOUT_MESSAGE =
51
51
  "The folder was created but the mail server hasn't confirmed it yet, so nothing was attached to it. It's in your folder list — try again in a moment.";
52
52
 
53
- const defaultDelay = (ms: number, signal?: AbortSignal): Promise<void> =>
53
+ /** `setTimeout` that rejects with the signal's reason instead of outliving it. */
54
+ export const abortableDelay = (
55
+ ms: number,
56
+ signal?: AbortSignal,
57
+ ): Promise<void> =>
54
58
  new Promise((resolve, reject) => {
55
59
  if (signal?.aborted) {
56
60
  reject(signal.reason);
@@ -81,7 +85,7 @@ export async function waitForMailboxSynced<T extends MailboxSyncSignal>({
81
85
  signal,
82
86
  timeoutMs = MAILBOX_SYNC_TIMEOUT_MS,
83
87
  pollIntervalMs = MAILBOX_SYNC_POLL_INTERVAL_MS,
84
- delay = defaultDelay,
88
+ delay = abortableDelay,
85
89
  now = Date.now,
86
90
  }: WaitForMailboxSyncedOptions<T>): Promise<T> {
87
91
  const deadline = now() + timeoutMs;