@remit/web-client 0.0.194 → 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 +2 -1
- package/src/components/settings/DeleteFolderDialog.render.test.ts +220 -5
- package/src/components/settings/DeleteFolderDialog.tsx +90 -13
- package/src/hooks/useAutoMovedBadge.ts +2 -2
- package/src/hooks/useCreateMailbox.render.test.ts +2 -0
- package/src/hooks/useCreateMailbox.ts +2 -2
- package/src/hooks/useDeleteFolder.ts +113 -0
- package/src/lib/delete-folder.test.ts +14 -0
- package/src/lib/delete-folder.ts +6 -0
- package/src/lib/folder-roles.test.ts +59 -16
- package/src/lib/folder-roles.ts +6 -8
- package/src/lib/fresh-mailbox-count.test.ts +145 -0
- package/src/lib/fresh-mailbox-count.ts +114 -0
- package/src/lib/mailbox-sync-wait.ts +6 -2
- package/src/lib/move-targets.test.ts +19 -0
- package/src/lib/move-targets.ts +7 -11
- package/src/lib/search-token-index.test.ts +29 -0
- package/src/lib/search-token-index.ts +5 -7
- package/src/routes/settings/folders.tsx +5 -1
|
@@ -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");
|
package/src/lib/delete-folder.ts
CHANGED
|
@@ -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,
|
|
@@ -3,7 +3,6 @@ import { describe, test } from "node:test";
|
|
|
3
3
|
import type { RemitImapFolderAppointment } from "@remit/api-http-client/types.gen.ts";
|
|
4
4
|
import {
|
|
5
5
|
buildMailboxRoleMap,
|
|
6
|
-
getMailboxDisplayName,
|
|
7
6
|
labelForMailbox,
|
|
8
7
|
shouldShowUnreadBadgeForRole,
|
|
9
8
|
} from "./folder-roles.js";
|
|
@@ -43,16 +42,6 @@ describe("buildMailboxRoleMap", () => {
|
|
|
43
42
|
});
|
|
44
43
|
});
|
|
45
44
|
|
|
46
|
-
describe("getMailboxDisplayName", () => {
|
|
47
|
-
test("returns the leaf segment of a nested path", () => {
|
|
48
|
-
assert.equal(getMailboxDisplayName("INBOX/Sent Messages"), "Sent Messages");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("returns the whole path when there is no delimiter", () => {
|
|
52
|
-
assert.equal(getMailboxDisplayName("INBOX"), "INBOX");
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
45
|
describe("labelForMailbox", () => {
|
|
57
46
|
const t = (key: string, fallback: string) =>
|
|
58
47
|
key === "sidebar.sent" ? "Verzonden" : fallback;
|
|
@@ -60,7 +49,11 @@ describe("labelForMailbox", () => {
|
|
|
60
49
|
test("a trimmed displayNameOverride wins over everything", () => {
|
|
61
50
|
assert.equal(
|
|
62
51
|
labelForMailbox(
|
|
63
|
-
{
|
|
52
|
+
{
|
|
53
|
+
fullPath: "INBOX/Sent",
|
|
54
|
+
hierarchyDelimiter: "/",
|
|
55
|
+
displayNameOverride: " My Sent ",
|
|
56
|
+
},
|
|
64
57
|
"sent",
|
|
65
58
|
t,
|
|
66
59
|
),
|
|
@@ -70,26 +63,44 @@ describe("labelForMailbox", () => {
|
|
|
70
63
|
|
|
71
64
|
test("falls back to the translated canonical role label", () => {
|
|
72
65
|
assert.equal(
|
|
73
|
-
labelForMailbox(
|
|
66
|
+
labelForMailbox(
|
|
67
|
+
{ fullPath: "INBOX/Verzonden", hierarchyDelimiter: "/" },
|
|
68
|
+
"sent",
|
|
69
|
+
t,
|
|
70
|
+
),
|
|
74
71
|
"Verzonden",
|
|
75
72
|
);
|
|
76
73
|
});
|
|
77
74
|
|
|
78
75
|
test("falls back to the provider leaf when there is no role", () => {
|
|
79
76
|
assert.equal(
|
|
80
|
-
labelForMailbox(
|
|
77
|
+
labelForMailbox(
|
|
78
|
+
{ fullPath: "INBOX/Nieuwsbrieven", hierarchyDelimiter: "/" },
|
|
79
|
+
undefined,
|
|
80
|
+
t,
|
|
81
|
+
),
|
|
81
82
|
"Nieuwsbrieven",
|
|
82
83
|
);
|
|
83
84
|
});
|
|
84
85
|
|
|
85
86
|
test("falls back to the leaf when no translator is supplied", () => {
|
|
86
|
-
assert.equal(
|
|
87
|
+
assert.equal(
|
|
88
|
+
labelForMailbox(
|
|
89
|
+
{ fullPath: "INBOX/Sent", hierarchyDelimiter: "/" },
|
|
90
|
+
"sent",
|
|
91
|
+
),
|
|
92
|
+
"Sent",
|
|
93
|
+
);
|
|
87
94
|
});
|
|
88
95
|
|
|
89
96
|
test("a blank/whitespace override is ignored", () => {
|
|
90
97
|
assert.equal(
|
|
91
98
|
labelForMailbox(
|
|
92
|
-
{
|
|
99
|
+
{
|
|
100
|
+
fullPath: "INBOX/Sent",
|
|
101
|
+
hierarchyDelimiter: "/",
|
|
102
|
+
displayNameOverride: " ",
|
|
103
|
+
},
|
|
93
104
|
"sent",
|
|
94
105
|
t,
|
|
95
106
|
),
|
|
@@ -98,6 +109,38 @@ describe("labelForMailbox", () => {
|
|
|
98
109
|
});
|
|
99
110
|
});
|
|
100
111
|
|
|
112
|
+
describe("labelForMailbox — the server’s own delimiter (#877)", () => {
|
|
113
|
+
test("a dot-delimited nested folder renders its leaf", () => {
|
|
114
|
+
assert.equal(
|
|
115
|
+
labelForMailbox(
|
|
116
|
+
{ fullPath: "INBOX.Projects.Q3", hierarchyDelimiter: "." },
|
|
117
|
+
undefined,
|
|
118
|
+
),
|
|
119
|
+
"Q3",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a flat namespace renders the whole name", () => {
|
|
124
|
+
assert.equal(
|
|
125
|
+
labelForMailbox(
|
|
126
|
+
{ fullPath: "Projects/Q3", hierarchyDelimiter: "" },
|
|
127
|
+
undefined,
|
|
128
|
+
),
|
|
129
|
+
"Projects/Q3",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a slash in a dot-delimited name is part of the name", () => {
|
|
134
|
+
assert.equal(
|
|
135
|
+
labelForMailbox(
|
|
136
|
+
{ fullPath: "INBOX.Reading/Writing", hierarchyDelimiter: "." },
|
|
137
|
+
undefined,
|
|
138
|
+
),
|
|
139
|
+
"Reading/Writing",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
101
144
|
describe("shouldShowUnreadBadgeForRole", () => {
|
|
102
145
|
test("hides the badge for Sent, Drafts, and Trash", () => {
|
|
103
146
|
assert.equal(shouldShowUnreadBadgeForRole("sent"), false);
|
package/src/lib/folder-roles.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type {
|
|
|
2
2
|
RemitImapCanonicalMailboxRole,
|
|
3
3
|
RemitImapFolderAppointment,
|
|
4
4
|
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import {
|
|
6
|
+
type MailboxPath,
|
|
7
|
+
mailboxLeafName,
|
|
8
|
+
} from "@remit/data-ports/mailbox-name";
|
|
5
9
|
import type { NavMailboxRole } from "@remit/ui";
|
|
6
10
|
|
|
7
11
|
/**
|
|
@@ -79,12 +83,6 @@ export function buildMailboxRoleMap(
|
|
|
79
83
|
return byMailbox;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
/** Leaf segment of a provider path (`INBOX/Spam` → `Spam`). */
|
|
83
|
-
export const getMailboxDisplayName = (fullPath: string): string => {
|
|
84
|
-
const parts = fullPath.split("/");
|
|
85
|
-
return parts[parts.length - 1] || fullPath;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
86
|
type Translator = (key: string, fallback: string) => string;
|
|
89
87
|
|
|
90
88
|
/**
|
|
@@ -92,13 +90,13 @@ type Translator = (key: string, fallback: string) => string;
|
|
|
92
90
|
* canonical localized label for the appointed role, else the provider leaf.
|
|
93
91
|
*/
|
|
94
92
|
export function labelForMailbox(
|
|
95
|
-
mailbox:
|
|
93
|
+
mailbox: MailboxPath & { displayNameOverride?: string },
|
|
96
94
|
role: NavMailboxRole | undefined,
|
|
97
95
|
t?: Translator,
|
|
98
96
|
): string {
|
|
99
97
|
const override = mailbox.displayNameOverride?.trim();
|
|
100
98
|
if (override) return override;
|
|
101
|
-
const leaf =
|
|
99
|
+
const leaf = mailboxLeafName(mailbox);
|
|
102
100
|
if (!role || !t) return leaf;
|
|
103
101
|
return t(`sidebar.${role}`, leaf);
|
|
104
102
|
}
|
|
@@ -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
|
-
|
|
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 =
|
|
88
|
+
delay = abortableDelay,
|
|
85
89
|
now = Date.now,
|
|
86
90
|
}: WaitForMailboxSyncedOptions<T>): Promise<T> {
|
|
87
91
|
const deadline = now() + timeoutMs;
|
|
@@ -214,3 +214,22 @@ describe("buildMoveTargets — Outbox locale exclusion (#290)", () => {
|
|
|
214
214
|
assert.deepStrictEqual(ids, ["custom", "inbox", "outboxy", "trash"]);
|
|
215
215
|
});
|
|
216
216
|
});
|
|
217
|
+
|
|
218
|
+
describe("buildMoveTargets — the server’s own delimiter (#877)", () => {
|
|
219
|
+
test("orders and filters by the leaf under a dot-delimited namespace", () => {
|
|
220
|
+
const dotted = (mailboxId: string, fullPath: string) =>
|
|
221
|
+
make({ mailboxId, fullPath, hierarchyDelimiter: "." });
|
|
222
|
+
const result = buildMoveTargets(
|
|
223
|
+
[
|
|
224
|
+
dotted("beta", "INBOX.Projects.Beta"),
|
|
225
|
+
dotted("outbox", "INBOX.Outbox"),
|
|
226
|
+
dotted("alpha", "INBOX.Zoo.Alpha"),
|
|
227
|
+
],
|
|
228
|
+
[],
|
|
229
|
+
);
|
|
230
|
+
assert.deepStrictEqual(
|
|
231
|
+
result.map((mailbox) => mailbox.mailboxId),
|
|
232
|
+
["alpha", "beta"],
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
});
|