@remit/mailbox-service 0.0.96 → 0.0.97
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 +1 -1
- package/src/imapflow-connection.ts +9 -0
- package/src/index.ts +6 -0
- package/src/mailbox-intent.ts +109 -0
- package/src/mailbox-management.test.ts +28 -2
- package/src/mailbox-management.ts +453 -69
- package/src/mailbox-presence.ts +6 -5
- package/src/mailbox-queue.ts +133 -13
- package/src/mailbox-rename-intent.test.ts +271 -25
- package/src/mailbox-rename-subtree.test.ts +494 -106
- package/src/mailbox-sync.ts +1 -27
- package/src/mailbox-upstream.ts +83 -0
|
@@ -3,28 +3,63 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
4
4
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
5
|
import { MailboxManagementService } from "./mailbox-management.js";
|
|
6
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
FolderGoneUpstreamError,
|
|
8
|
+
FolderRenameSettleError,
|
|
9
|
+
} from "./mailbox-upstream.js";
|
|
10
|
+
import type { IImapConnection } from "./types.js";
|
|
7
11
|
|
|
8
|
-
|
|
12
|
+
/**
|
|
13
|
+
* A folder mid-rename: the confirmed path the server still holds, and the
|
|
14
|
+
* target the intent recorded (D2). The settle's row set is exactly the rows
|
|
15
|
+
* carrying such a target, which is what makes it independent of whatever the
|
|
16
|
+
* subtree looks like by the time the RENAME lands (D15).
|
|
17
|
+
*/
|
|
18
|
+
const renaming = (
|
|
19
|
+
mailboxId: string,
|
|
20
|
+
fullPath: string,
|
|
21
|
+
pendingPath: string,
|
|
22
|
+
): MailboxItem =>
|
|
23
|
+
({
|
|
24
|
+
mailboxId,
|
|
25
|
+
accountId: "acc-1",
|
|
26
|
+
fullPath,
|
|
27
|
+
hierarchyDelimiter: "/",
|
|
28
|
+
namespacePrefix: "",
|
|
29
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
30
|
+
pendingPath,
|
|
31
|
+
}) as MailboxItem;
|
|
32
|
+
|
|
33
|
+
const settled = (
|
|
9
34
|
mailboxId: string,
|
|
10
35
|
fullPath: string,
|
|
11
|
-
syncStatus: MailboxItem["syncStatus"],
|
|
36
|
+
syncStatus: MailboxItem["syncStatus"] = MailboxSyncStatus.synced,
|
|
12
37
|
): MailboxItem =>
|
|
13
38
|
({
|
|
14
39
|
mailboxId,
|
|
15
40
|
accountId: "acc-1",
|
|
16
41
|
fullPath,
|
|
17
42
|
hierarchyDelimiter: "/",
|
|
43
|
+
namespacePrefix: "",
|
|
18
44
|
syncStatus,
|
|
19
45
|
}) as MailboxItem;
|
|
20
46
|
|
|
21
|
-
const store = (rows: MailboxItem[],
|
|
47
|
+
const store = (rows: MailboxItem[], vanishBeforeSettle: string[] = []) => {
|
|
22
48
|
const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
|
|
23
49
|
|
|
24
50
|
const repo: Pick<
|
|
25
51
|
IMailboxRepository,
|
|
26
|
-
"update" | "transition" | "
|
|
52
|
+
"get" | "update" | "transition" | "findBySyncStatus"
|
|
27
53
|
> = {
|
|
54
|
+
get: (async (_accountId: string, mailboxId: string) => {
|
|
55
|
+
const found = byId.get(mailboxId as string);
|
|
56
|
+
if (!found) {
|
|
57
|
+
throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
|
|
58
|
+
name: "NotFoundError",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return found;
|
|
62
|
+
}) as IMailboxRepository["get"],
|
|
28
63
|
update: async (_accountId, mailboxId, patch) => {
|
|
29
64
|
const existing = byId.get(mailboxId);
|
|
30
65
|
if (!existing) {
|
|
@@ -46,63 +81,70 @@ const store = (rows: MailboxItem[], vanishAfterSweep: string[] = []) => {
|
|
|
46
81
|
) {
|
|
47
82
|
return null;
|
|
48
83
|
}
|
|
84
|
+
const keepsTarget =
|
|
85
|
+
intent.to === MailboxSyncStatus.pending ||
|
|
86
|
+
intent.to === MailboxSyncStatus.failed;
|
|
49
87
|
const next = {
|
|
50
88
|
...existing,
|
|
51
|
-
...intent.set
|
|
89
|
+
...(intent.set?.fullPath !== undefined
|
|
90
|
+
? { fullPath: intent.set.fullPath }
|
|
91
|
+
: {}),
|
|
52
92
|
syncStatus: intent.to,
|
|
53
93
|
} as MailboxItem;
|
|
94
|
+
if (!keepsTarget) delete (next as { pendingPath?: string }).pendingPath;
|
|
54
95
|
byId.set(mailboxId, next);
|
|
55
96
|
return next;
|
|
56
97
|
},
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
r.fullPath.startsWith(prefix),
|
|
98
|
+
findBySyncStatus: async (_accountId, syncStatus) => {
|
|
99
|
+
const found = [...byId.values()].filter(
|
|
100
|
+
(r) => r.syncStatus === syncStatus,
|
|
61
101
|
);
|
|
62
|
-
for (const mailboxId of
|
|
102
|
+
for (const mailboxId of vanishBeforeSettle) byId.delete(mailboxId);
|
|
63
103
|
return found;
|
|
64
104
|
},
|
|
65
105
|
};
|
|
66
106
|
|
|
67
107
|
return {
|
|
68
108
|
repo: repo as IMailboxRepository,
|
|
69
|
-
|
|
109
|
+
rowOf: (mailboxId: string) => byId.get(mailboxId),
|
|
70
110
|
};
|
|
71
111
|
};
|
|
72
112
|
|
|
73
|
-
const
|
|
113
|
+
const connection = (newPath?: string): IImapConnection =>
|
|
74
114
|
({
|
|
75
|
-
renameMailbox: async () =>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
delimiter: "/",
|
|
81
|
-
attributes: [],
|
|
82
|
-
parentPath: null,
|
|
83
|
-
})),
|
|
115
|
+
renameMailbox: async (_old: string, requested: string) => ({
|
|
116
|
+
path: _old,
|
|
117
|
+
newPath: newPath ?? requested,
|
|
118
|
+
}),
|
|
119
|
+
listMailboxes: async () => [],
|
|
84
120
|
}) as unknown as IImapConnection;
|
|
85
121
|
|
|
86
|
-
const
|
|
122
|
+
const refusing = (
|
|
123
|
+
error: Error,
|
|
124
|
+
listed: string[] | "unreadable" = [],
|
|
125
|
+
): IImapConnection =>
|
|
87
126
|
({
|
|
88
|
-
renameMailbox: async () =>
|
|
127
|
+
renameMailbox: async () => {
|
|
128
|
+
throw error;
|
|
129
|
+
},
|
|
89
130
|
listMailboxes: async () => {
|
|
90
|
-
throw new Error("IMAP connection lost");
|
|
131
|
+
if (listed === "unreadable") throw new Error("IMAP connection lost");
|
|
132
|
+
return listed.map((fullPath) => ({ fullPath, delimiter: "/" }));
|
|
91
133
|
},
|
|
92
134
|
}) as unknown as IImapConnection;
|
|
93
135
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
describe("MailboxManagementService.syncRename —
|
|
101
|
-
it("
|
|
102
|
-
const { repo,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
136
|
+
const nonexistent = (): Error =>
|
|
137
|
+
Object.assign(new Error("Command failed"), {
|
|
138
|
+
serverResponseCode: "NONEXISTENT",
|
|
139
|
+
responseText: "Mailbox doesn't exist: Work",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("MailboxManagementService.syncRename — settling one intent", () => {
|
|
143
|
+
it("adopts the confirmed path on the folder and every row that carried the intent", async () => {
|
|
144
|
+
const { repo, rowOf } = store([
|
|
145
|
+
renaming("mbx-parent", "Work", "Projects"),
|
|
146
|
+
renaming("mbx-child", "Work/2026", "Projects/2026"),
|
|
147
|
+
renaming("mbx-grandchild", "Work/2026/Q1", "Projects/2026/Q1"),
|
|
106
148
|
]);
|
|
107
149
|
const service = new MailboxManagementService(repo);
|
|
108
150
|
|
|
@@ -111,72 +153,141 @@ describe("MailboxManagementService.syncRename — subtree settle", () => {
|
|
|
111
153
|
"mbx-parent",
|
|
112
154
|
"Work",
|
|
113
155
|
"Projects",
|
|
114
|
-
async () =>
|
|
115
|
-
connectionListing(["Projects", "Projects/2026", "Projects/2026/Q1"]),
|
|
156
|
+
async () => connection(),
|
|
116
157
|
);
|
|
117
158
|
|
|
118
159
|
assert.equal(result.success, true);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// the
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
160
|
+
for (const [id, path] of [
|
|
161
|
+
["mbx-parent", "Projects"],
|
|
162
|
+
["mbx-child", "Projects/2026"],
|
|
163
|
+
["mbx-grandchild", "Projects/2026/Q1"],
|
|
164
|
+
] as const) {
|
|
165
|
+
assert.equal(rowOf(id)?.fullPath, path);
|
|
166
|
+
assert.equal(rowOf(id)?.syncStatus, MailboxSyncStatus.synced);
|
|
167
|
+
assert.equal(rowOf(id)?.pendingPath, undefined);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("adopts the path ImapFlow resolved in preference to the requested one", async () => {
|
|
172
|
+
// Not the server echoing a name back — IMAP's RENAME reply carries none.
|
|
173
|
+
// It is ImapFlow's own normalization, with the namespace prefix applied,
|
|
174
|
+
// and adopting it is what stops the row and the server drifting apart.
|
|
175
|
+
const { repo, rowOf } = store([
|
|
176
|
+
renaming("mbx-parent", "Work", "Projects"),
|
|
177
|
+
renaming("mbx-child", "Work/2026", "Projects/2026"),
|
|
135
178
|
]);
|
|
136
179
|
const service = new MailboxManagementService(repo);
|
|
137
180
|
|
|
138
181
|
await service.syncRename(
|
|
139
182
|
"acc-1",
|
|
140
183
|
"mbx-parent",
|
|
184
|
+
"Work",
|
|
141
185
|
"Projects",
|
|
186
|
+
async () => connection("INBOX/Projects"),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
assert.equal(rowOf("mbx-parent")?.fullPath, "INBOX/Projects");
|
|
190
|
+
assert.equal(rowOf("mbx-child")?.fullPath, "INBOX/Projects/2026");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("leaves a folder the sweep inserted under the branch after the intent alone", async () => {
|
|
194
|
+
// D15: the settle applies to the rows that recorded the intent, and those
|
|
195
|
+
// rows identify themselves. Re-resolving the subtree would either throw —
|
|
196
|
+
// poisoning the account's FIFO group — or leave every row pending forever.
|
|
197
|
+
const { repo, rowOf } = store([
|
|
198
|
+
renaming("mbx-parent", "Work", "Projects"),
|
|
199
|
+
settled("mbx-interloper", "Projects/Fresh"),
|
|
200
|
+
]);
|
|
201
|
+
const service = new MailboxManagementService(repo);
|
|
202
|
+
|
|
203
|
+
const result = await service.syncRename(
|
|
204
|
+
"acc-1",
|
|
205
|
+
"mbx-parent",
|
|
206
|
+
"Work",
|
|
207
|
+
"Projects",
|
|
208
|
+
async () => connection(),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
assert.equal(result.success, true);
|
|
212
|
+
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
|
|
213
|
+
assert.equal(rowOf("mbx-interloper")?.fullPath, "Projects/Fresh");
|
|
214
|
+
assert.equal(rowOf("mbx-interloper")?.syncStatus, MailboxSyncStatus.synced);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("settles the rest when a descendant is deleted after the intent", async () => {
|
|
218
|
+
const { repo, rowOf } = store(
|
|
219
|
+
[
|
|
220
|
+
renaming("mbx-parent", "Work", "Projects"),
|
|
221
|
+
renaming("mbx-gone", "Work/Gone", "Projects/Gone"),
|
|
222
|
+
renaming("mbx-child", "Work/2026", "Projects/2026"),
|
|
223
|
+
],
|
|
224
|
+
["mbx-gone"],
|
|
225
|
+
);
|
|
226
|
+
const service = new MailboxManagementService(repo);
|
|
227
|
+
|
|
228
|
+
const result = await service.syncRename(
|
|
229
|
+
"acc-1",
|
|
230
|
+
"mbx-parent",
|
|
142
231
|
"Work",
|
|
143
|
-
|
|
232
|
+
"Projects",
|
|
233
|
+
async () => connection(),
|
|
144
234
|
);
|
|
145
235
|
|
|
146
|
-
assert.equal(
|
|
147
|
-
assert.equal(
|
|
236
|
+
assert.equal(result.success, true);
|
|
237
|
+
assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
|
|
238
|
+
assert.equal(rowOf("mbx-child")?.fullPath, "Projects/2026");
|
|
148
239
|
});
|
|
149
240
|
|
|
150
|
-
it("
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
row("mbx-parent", "Projects", MailboxSyncStatus.pending),
|
|
155
|
-
row("mbx-deleting", "Projects/Old", MailboxSyncStatus.deleting),
|
|
156
|
-
row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
|
|
241
|
+
it("does not touch a folder whose own rename is aimed somewhere else", async () => {
|
|
242
|
+
const { repo, rowOf } = store([
|
|
243
|
+
renaming("mbx-parent", "Work", "Projects"),
|
|
244
|
+
renaming("mbx-other", "Admin", "Beheer"),
|
|
157
245
|
]);
|
|
158
246
|
const service = new MailboxManagementService(repo);
|
|
159
247
|
|
|
160
248
|
await service.syncRename(
|
|
249
|
+
"acc-1",
|
|
250
|
+
"mbx-parent",
|
|
251
|
+
"Work",
|
|
252
|
+
"Projects",
|
|
253
|
+
async () => connection(),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
assert.equal(rowOf("mbx-other")?.fullPath, "Admin");
|
|
257
|
+
assert.equal(rowOf("mbx-other")?.pendingPath, "Beheer");
|
|
258
|
+
assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("issues no RENAME when the row has already settled", async () => {
|
|
262
|
+
const { repo } = store([settled("mbx-parent", "Projects")]);
|
|
263
|
+
const service = new MailboxManagementService(repo);
|
|
264
|
+
let issued = 0;
|
|
265
|
+
|
|
266
|
+
const result = await service.syncRename(
|
|
161
267
|
"acc-1",
|
|
162
268
|
"mbx-parent",
|
|
163
269
|
"Work",
|
|
164
270
|
"Projects",
|
|
165
271
|
async () =>
|
|
166
|
-
|
|
272
|
+
({
|
|
273
|
+
renameMailbox: async () => {
|
|
274
|
+
issued += 1;
|
|
275
|
+
return { path: "Work", newPath: "Projects" };
|
|
276
|
+
},
|
|
277
|
+
}) as unknown as IImapConnection,
|
|
167
278
|
);
|
|
168
279
|
|
|
169
|
-
assert.equal(
|
|
170
|
-
assert.equal(
|
|
280
|
+
assert.equal(result.success, true);
|
|
281
|
+
assert.equal(issued, 0, "a redelivery must not re-issue the rename");
|
|
171
282
|
});
|
|
172
283
|
|
|
173
|
-
it("
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
]);
|
|
284
|
+
it("issues no RENAME when the row is pending for a different target", async () => {
|
|
285
|
+
// A redelivered MAILBOX_RENAME against a folder a second rename has since
|
|
286
|
+
// claimed. `pending` alone passes; the recorded target is what refuses it,
|
|
287
|
+
// and re-issuing from a path that has already moved is what that prevents.
|
|
288
|
+
const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Beheer")]);
|
|
179
289
|
const service = new MailboxManagementService(repo);
|
|
290
|
+
let issued = 0;
|
|
180
291
|
|
|
181
292
|
await service.syncRename(
|
|
182
293
|
"acc-1",
|
|
@@ -184,26 +295,85 @@ describe("MailboxManagementService.syncRename — subtree settle", () => {
|
|
|
184
295
|
"Work",
|
|
185
296
|
"Projects",
|
|
186
297
|
async () =>
|
|
187
|
-
|
|
298
|
+
({
|
|
299
|
+
renameMailbox: async () => {
|
|
300
|
+
issued += 1;
|
|
301
|
+
return { path: "Work", newPath: "Projects" };
|
|
302
|
+
},
|
|
303
|
+
}) as unknown as IImapConnection,
|
|
188
304
|
);
|
|
189
305
|
|
|
190
|
-
assert.equal(
|
|
191
|
-
assert.equal(
|
|
306
|
+
assert.equal(issued, 0);
|
|
307
|
+
assert.equal(rowOf("mbx-parent")?.pendingPath, "Beheer");
|
|
192
308
|
});
|
|
309
|
+
});
|
|
193
310
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
311
|
+
describe("MailboxManagementService.syncRename — rows it must not claim", () => {
|
|
312
|
+
it("leaves a row a different rename recorded under the same branch alone", async () => {
|
|
313
|
+
// Two renames aimed under one path. Matching on the recorded target alone
|
|
314
|
+
// stamps `Other` onto a path this rename never asked for, leaves its own
|
|
315
|
+
// rename with nothing left to settle, and hands the sweep two rows
|
|
316
|
+
// claiming one path to reap and re-insert.
|
|
317
|
+
const { repo, rowOf } = store([
|
|
318
|
+
renaming("mbx-parent", "Work", "Projects"),
|
|
319
|
+
renaming("mbx-other", "Other", "Projects/Old"),
|
|
320
|
+
]);
|
|
321
|
+
const service = new MailboxManagementService(repo);
|
|
322
|
+
|
|
323
|
+
await service.syncRename(
|
|
324
|
+
"acc-1",
|
|
325
|
+
"mbx-parent",
|
|
326
|
+
"Work",
|
|
327
|
+
"Projects",
|
|
328
|
+
async () => connection(),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
|
|
332
|
+
assert.equal(rowOf("mbx-other")?.fullPath, "Other");
|
|
333
|
+
assert.equal(rowOf("mbx-other")?.pendingPath, "Projects/Old");
|
|
334
|
+
assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("leaves a descendant whose recorded target is not this rename's rebase", async () => {
|
|
338
|
+
const { repo, rowOf } = store([
|
|
339
|
+
renaming("mbx-parent", "Work", "Projects"),
|
|
340
|
+
renaming("mbx-child", "Work/2026", "Projects/Archief"),
|
|
341
|
+
]);
|
|
342
|
+
const service = new MailboxManagementService(repo);
|
|
343
|
+
|
|
344
|
+
await service.syncRename(
|
|
345
|
+
"acc-1",
|
|
346
|
+
"mbx-parent",
|
|
347
|
+
"Work",
|
|
348
|
+
"Projects",
|
|
349
|
+
async () => connection(),
|
|
206
350
|
);
|
|
351
|
+
|
|
352
|
+
assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
|
|
353
|
+
assert.equal(rowOf("mbx-child")?.fullPath, "Work/2026");
|
|
354
|
+
assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.pending);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("fails only the rows this rename recorded", async () => {
|
|
358
|
+
const { repo, rowOf } = store([
|
|
359
|
+
renaming("mbx-parent", "Work", "Projects"),
|
|
360
|
+
renaming("mbx-other", "Other", "Projects/Old"),
|
|
361
|
+
]);
|
|
362
|
+
const service = new MailboxManagementService(repo);
|
|
363
|
+
|
|
364
|
+
await service.failRename("acc-1", "mbx-parent", "Work", "Projects");
|
|
365
|
+
|
|
366
|
+
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.failed);
|
|
367
|
+
assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe("MailboxManagementService.syncRename — a rename that already landed", () => {
|
|
372
|
+
it("settles rather than deleting when NONEXISTENT means the source has moved", async () => {
|
|
373
|
+
// A redelivery after a lost settle re-issues RENAME from a path that has
|
|
374
|
+
// moved. Reading that NONEXISTENT as an upstream delete drops the folder
|
|
375
|
+
// and its mail while the folder is alive at the target.
|
|
376
|
+
const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Projects")]);
|
|
207
377
|
const service = new MailboxManagementService(repo);
|
|
208
378
|
|
|
209
379
|
const result = await service.syncRename(
|
|
@@ -211,24 +381,133 @@ describe("MailboxManagementService.syncRename — subtree settle", () => {
|
|
|
211
381
|
"mbx-parent",
|
|
212
382
|
"Work",
|
|
213
383
|
"Projects",
|
|
214
|
-
async () =>
|
|
215
|
-
connectionListing(["Projects", "Projects/Gone", "Projects/2026"]),
|
|
384
|
+
async () => refusing(nonexistent(), ["Projects"]),
|
|
216
385
|
);
|
|
217
386
|
|
|
218
387
|
assert.equal(result.success, true);
|
|
219
|
-
assert.equal(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
388
|
+
assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
|
|
389
|
+
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("reports the folder as gone, carrying what the server said, when neither path is listed", async () => {
|
|
393
|
+
const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
|
|
394
|
+
const service = new MailboxManagementService(repo);
|
|
395
|
+
|
|
396
|
+
await assert.rejects(
|
|
397
|
+
service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
|
|
398
|
+
refusing(nonexistent(), ["Andere"]),
|
|
399
|
+
),
|
|
400
|
+
(error: unknown) =>
|
|
401
|
+
error instanceof FolderGoneUpstreamError &&
|
|
402
|
+
(error.cause as { serverResponseCode?: string })?.serverResponseCode ===
|
|
403
|
+
"NONEXISTENT",
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("raises a settle failure as its own kind, never as a refused rename", async () => {
|
|
408
|
+
// The server executed the rename. Marking the rows failed here would offer
|
|
409
|
+
// a retry of work already done, on a `fullPath` the server no longer holds.
|
|
410
|
+
const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Projects")]);
|
|
411
|
+
const broken = {
|
|
412
|
+
...repo,
|
|
413
|
+
transition: async () => {
|
|
414
|
+
throw new Error("database is locked");
|
|
415
|
+
},
|
|
416
|
+
} as unknown as IMailboxRepository;
|
|
417
|
+
const service = new MailboxManagementService(broken);
|
|
418
|
+
|
|
419
|
+
await assert.rejects(
|
|
420
|
+
service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
|
|
421
|
+
connection(),
|
|
422
|
+
),
|
|
423
|
+
(error: unknown) => error instanceof FolderRenameSettleError,
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.pending);
|
|
427
|
+
assert.equal(
|
|
428
|
+
rowOf("mbx-parent")?.pendingPath,
|
|
429
|
+
"Projects",
|
|
430
|
+
"the intent stands, so the redelivery finishes the settle",
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
describe("MailboxManagementService.syncRename — a settle that dies part-way", () => {
|
|
436
|
+
/**
|
|
437
|
+
* The named folder's row is the redelivery's only witness: the guard reads
|
|
438
|
+
* it and nothing else. Settling it before its descendants would ack a run
|
|
439
|
+
* that stopped half way, and those rows would stay `pending` forever —
|
|
440
|
+
* skipped by message sync, and with no route out, because an intent may only
|
|
441
|
+
* be recorded from `synced` or `failed`.
|
|
442
|
+
*/
|
|
443
|
+
it("leaves the named folder carrying the intent when a descendant's write fails", async () => {
|
|
444
|
+
const { repo, rowOf } = store([
|
|
445
|
+
renaming("mbx-parent", "Work", "Projects"),
|
|
446
|
+
renaming("mbx-child", "Work/2026", "Projects/2026"),
|
|
231
447
|
]);
|
|
448
|
+
let broken = true;
|
|
449
|
+
const flaky = {
|
|
450
|
+
...repo,
|
|
451
|
+
transition: async (
|
|
452
|
+
accountId: string,
|
|
453
|
+
mailboxId: string,
|
|
454
|
+
intent: Parameters<IMailboxRepository["transition"]>[2],
|
|
455
|
+
) => {
|
|
456
|
+
if (broken && mailboxId === "mbx-child") {
|
|
457
|
+
throw new Error("database is locked");
|
|
458
|
+
}
|
|
459
|
+
return repo.transition(accountId, mailboxId, intent);
|
|
460
|
+
},
|
|
461
|
+
} as unknown as IMailboxRepository;
|
|
462
|
+
const service = new MailboxManagementService(flaky);
|
|
463
|
+
|
|
464
|
+
await assert.rejects(
|
|
465
|
+
service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
|
|
466
|
+
connection(),
|
|
467
|
+
),
|
|
468
|
+
(error: unknown) => error instanceof FolderRenameSettleError,
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.pending);
|
|
472
|
+
assert.equal(rowOf("mbx-parent")?.pendingPath, "Projects");
|
|
473
|
+
assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.pending);
|
|
474
|
+
|
|
475
|
+
// The redelivery re-enters against an intent still standing and finishes.
|
|
476
|
+
broken = false;
|
|
477
|
+
const redelivered = new MailboxManagementService(flaky);
|
|
478
|
+
const result = await redelivered.syncRename(
|
|
479
|
+
"acc-1",
|
|
480
|
+
"mbx-parent",
|
|
481
|
+
"Work",
|
|
482
|
+
"Projects",
|
|
483
|
+
async () => connection(),
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
assert.equal(result.success, true);
|
|
487
|
+
assert.equal(rowOf("mbx-parent")?.fullPath, "Projects");
|
|
488
|
+
assert.equal(rowOf("mbx-child")?.fullPath, "Projects/2026");
|
|
489
|
+
assert.equal(rowOf("mbx-child")?.syncStatus, MailboxSyncStatus.synced);
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
describe("MailboxManagementService.syncRename — reading NONEXISTENT", () => {
|
|
494
|
+
const prefixed = (): MailboxItem =>
|
|
495
|
+
({
|
|
496
|
+
mailboxId: "mbx-parent",
|
|
497
|
+
accountId: "acc-1",
|
|
498
|
+
fullPath: "Work",
|
|
499
|
+
hierarchyDelimiter: ".",
|
|
500
|
+
namespacePrefix: "INBOX.",
|
|
501
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
502
|
+
pendingPath: "Projects",
|
|
503
|
+
}) as MailboxItem;
|
|
504
|
+
|
|
505
|
+
it("reads the target through the account's namespace prefix", async () => {
|
|
506
|
+
// The requested string and the path the server keeps are not the same:
|
|
507
|
+
// a Dovecot INBOX namespace stores `Projects` as `INBOX.Projects`.
|
|
508
|
+
// Comparing the raw request against the listing reads a folder that is
|
|
509
|
+
// plainly there as absent — and that answer deletes the user's mail.
|
|
510
|
+
const { repo, rowOf } = store([prefixed()]);
|
|
232
511
|
const service = new MailboxManagementService(repo);
|
|
233
512
|
|
|
234
513
|
const result = await service.syncRename(
|
|
@@ -236,11 +515,120 @@ describe("MailboxManagementService.syncRename — subtree settle", () => {
|
|
|
236
515
|
"mbx-parent",
|
|
237
516
|
"Work",
|
|
238
517
|
"Projects",
|
|
239
|
-
async () =>
|
|
518
|
+
async () => refusing(nonexistent(), ["INBOX.Projects"]),
|
|
240
519
|
);
|
|
241
520
|
|
|
242
521
|
assert.equal(result.success, true);
|
|
243
|
-
assert.equal(
|
|
244
|
-
|
|
522
|
+
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.synced);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("raises the confirmed kind only when the listing holds neither path", async () => {
|
|
526
|
+
const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
|
|
527
|
+
const service = new MailboxManagementService(repo);
|
|
528
|
+
|
|
529
|
+
await assert.rejects(
|
|
530
|
+
service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
|
|
531
|
+
refusing(nonexistent(), ["Andere"]),
|
|
532
|
+
),
|
|
533
|
+
(error: unknown) => error instanceof FolderGoneUpstreamError,
|
|
534
|
+
);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("refuses the rename rather than confirming a delete when the source is still listed", async () => {
|
|
538
|
+
const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
|
|
539
|
+
const service = new MailboxManagementService(repo);
|
|
540
|
+
|
|
541
|
+
await assert.rejects(
|
|
542
|
+
service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
|
|
543
|
+
refusing(nonexistent(), ["Work"]),
|
|
544
|
+
),
|
|
545
|
+
(error: unknown) =>
|
|
546
|
+
!(error instanceof FolderGoneUpstreamError) &&
|
|
547
|
+
(error as { serverResponseCode?: string }).serverResponseCode ===
|
|
548
|
+
"NONEXISTENT",
|
|
549
|
+
);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("refuses the rename when the listing cannot be read at all", async () => {
|
|
553
|
+
// A probe that answers nothing decides nothing. A refused rename costs a
|
|
554
|
+
// retry; reading an unclear answer as a delete costs the folder's mail.
|
|
555
|
+
const { repo } = store([renaming("mbx-parent", "Work", "Projects")]);
|
|
556
|
+
const service = new MailboxManagementService(repo);
|
|
557
|
+
|
|
558
|
+
await assert.rejects(
|
|
559
|
+
service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
|
|
560
|
+
refusing(nonexistent(), "unreadable"),
|
|
561
|
+
),
|
|
562
|
+
(error: unknown) => !(error instanceof FolderGoneUpstreamError),
|
|
563
|
+
);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
describe("MailboxManagementService.failRename", () => {
|
|
568
|
+
it("marks every intent-carrying row failed, keeping its path and its target", async () => {
|
|
569
|
+
const { repo, rowOf } = store([
|
|
570
|
+
renaming("mbx-parent", "Work", "Projects"),
|
|
571
|
+
renaming("mbx-child", "Work/2026", "Projects/2026"),
|
|
572
|
+
]);
|
|
573
|
+
const service = new MailboxManagementService(repo);
|
|
574
|
+
|
|
575
|
+
await service.failRename("acc-1", "mbx-parent", "Work", "Projects");
|
|
576
|
+
|
|
577
|
+
for (const [id, path, target] of [
|
|
578
|
+
["mbx-parent", "Work", "Projects"],
|
|
579
|
+
["mbx-child", "Work/2026", "Projects/2026"],
|
|
580
|
+
] as const) {
|
|
581
|
+
assert.equal(rowOf(id)?.syncStatus, MailboxSyncStatus.failed);
|
|
582
|
+
assert.equal(rowOf(id)?.fullPath, path, "nothing is restored (D2)");
|
|
583
|
+
assert.equal(
|
|
584
|
+
rowOf(id)?.pendingPath,
|
|
585
|
+
target,
|
|
586
|
+
"the target is kept so the client can name it and offer a retry",
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("raises a NotFoundError when the folder row itself is gone", async () => {
|
|
592
|
+
const { repo } = store([]);
|
|
593
|
+
const service = new MailboxManagementService(repo);
|
|
594
|
+
|
|
595
|
+
await assert.rejects(
|
|
596
|
+
service.failRename("acc-1", "mbx-parent", "Work", "Projects"),
|
|
597
|
+
(error: unknown) => (error as Error).name === "NotFoundError",
|
|
598
|
+
);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("records nothing, quietly, when the row is there but carries no intent", async () => {
|
|
602
|
+
// Never a NotFoundError: the caller reads that as the user having deleted
|
|
603
|
+
// the folder and acks the message, which would swallow the IMAP failure
|
|
604
|
+
// that brought us here and leave the subtree `pending` with no retry.
|
|
605
|
+
const { repo } = store([settled("mbx-parent", "Work")]);
|
|
606
|
+
const service = new MailboxManagementService(repo);
|
|
607
|
+
|
|
608
|
+
await assert.doesNotReject(
|
|
609
|
+
service.failRename("acc-1", "mbx-parent", "Work", "Projects"),
|
|
610
|
+
);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("settles a rename ImapFlow never issued as a refusal, not a TypeError", async () => {
|
|
614
|
+
// `mailboxRename` resolves `undefined` when the connection is not
|
|
615
|
+
// AUTHENTICATED or SELECTED. The settle reads the resolved path, so an
|
|
616
|
+
// unguarded dereference would raise a TypeError the rename cannot classify.
|
|
617
|
+
const { repo, rowOf } = store([renaming("mbx-parent", "Work", "Projects")]);
|
|
618
|
+
const service = new MailboxManagementService(repo);
|
|
619
|
+
const notReady = new Error(
|
|
620
|
+
'Rename of "Work" was not issued: the IMAP connection is not ready',
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
await assert.rejects(
|
|
624
|
+
service.syncRename("acc-1", "mbx-parent", "Work", "Projects", async () =>
|
|
625
|
+
refusing(notReady),
|
|
626
|
+
),
|
|
627
|
+
/not ready/,
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
await service.failRename("acc-1", "mbx-parent", "Work", "Projects");
|
|
631
|
+
assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.failed);
|
|
632
|
+
assert.equal(rowOf("mbx-parent")?.fullPath, "Work");
|
|
245
633
|
});
|
|
246
634
|
});
|