@remit/mailbox-service 0.0.96 → 0.0.98

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.
@@ -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 type { FlatMailboxInfo, IImapConnection } from "./types.js";
6
+ import {
7
+ FolderGoneUpstreamError,
8
+ FolderRenameSettleError,
9
+ } from "./mailbox-upstream.js";
10
+ import type { IImapConnection } from "./types.js";
7
11
 
8
- const row = (
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[], vanishAfterSweep: string[] = []) => {
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" | "findByPathPrefix"
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
- findByPathPrefix: async (_accountId, pathPrefix, delimiter = "/") => {
58
- const prefix = `${pathPrefix}${delimiter}`;
59
- const found = [...byId.values()].filter((r) =>
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 vanishAfterSweep) byId.delete(mailboxId);
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
- statusOf: (mailboxId: string) => byId.get(mailboxId)?.syncStatus,
109
+ rowOf: (mailboxId: string) => byId.get(mailboxId),
70
110
  };
71
111
  };
72
112
 
73
- const connectionListing = (paths: string[]): IImapConnection =>
113
+ const connection = (newPath?: string): IImapConnection =>
74
114
  ({
75
- renameMailbox: async () => undefined,
76
- listMailboxes: async (): Promise<FlatMailboxInfo[]> =>
77
- paths.map((fullPath) => ({
78
- fullPath,
79
- name: fullPath.split("/").pop() ?? fullPath,
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 connectionWithoutListing = (): IImapConnection =>
122
+ const refusing = (
123
+ error: Error,
124
+ listed: string[] | "unreadable" = [],
125
+ ): IImapConnection =>
87
126
  ({
88
- renameMailbox: async () => undefined,
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
- * The local rename writes the new path across the whole subtree and marks every
96
- * row pending, so a reconcile in that window cannot reap them (#290). These
97
- * fixtures are that state at the moment the worker picks the event up: the
98
- * parent and its descendants already carry their new paths.
99
- */
100
- describe("MailboxManagementService.syncRename — subtree settle", () => {
101
- it("settles every descendant the rename carried, not just the renamed row", async () => {
102
- const { repo, statusOf } = store([
103
- row("mbx-parent", "Projects", MailboxSyncStatus.pending),
104
- row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
105
- row("mbx-grandchild", "Projects/2026/Q1", MailboxSyncStatus.pending),
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
- assert.equal(statusOf("mbx-parent"), MailboxSyncStatus.synced);
120
- assert.equal(statusOf("mbx-child"), MailboxSyncStatus.synced);
121
- assert.equal(statusOf("mbx-grandchild"), MailboxSyncStatus.synced);
122
- });
123
-
124
- it("leaves a descendant behind a rename of its own pending", async () => {
125
- // Two renames in one per-account FIFO group: `Projects` → `Work` moves the
126
- // child row to `Work/2026`, then `Work/2026` → `Work/Archive` moves it
127
- // again before the first RENAME is worked. When the first one lands the
128
- // server holds `Work/2026`; `Work/Archive` exists only locally, and
129
- // stripping its pending marker would let the reconcile sweep reap the row
130
- // and rebuild it under a fresh mailboxId, dangling every filter bound to
131
- // the old one.
132
- const { repo, statusOf } = store([
133
- row("mbx-parent", "Work", MailboxSyncStatus.pending),
134
- row("mbx-child", "Work/Archive", MailboxSyncStatus.pending),
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
- async () => connectionListing(["Work", "Work/2026"]),
232
+ "Projects",
233
+ async () => connection(),
144
234
  );
145
235
 
146
- assert.equal(statusOf("mbx-parent"), MailboxSyncStatus.synced);
147
- assert.equal(statusOf("mbx-child"), MailboxSyncStatus.pending);
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("leaves a descendant the user has asked to delete on its way out", async () => {
151
- // A delete requested after the local rename wrote the subtree pending: the
152
- // folder is still on the server, and settling it would undo the request.
153
- const { repo, statusOf } = store([
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
- connectionListing(["Projects", "Projects/Old", "Projects/2026"]),
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(statusOf("mbx-deleting"), MailboxSyncStatus.deleting);
170
- assert.equal(statusOf("mbx-child"), MailboxSyncStatus.synced);
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("settles inside the renamed subtree without reaching a lookalike sibling", async () => {
174
- const { repo, statusOf } = store([
175
- row("mbx-parent", "Projects", MailboxSyncStatus.pending),
176
- row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
177
- row("mbx-lookalike", "Projectsy/2026", MailboxSyncStatus.pending),
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
- connectionListing(["Projects", "Projects/2026", "Projectsy/2026"]),
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(statusOf("mbx-child"), MailboxSyncStatus.synced);
191
- assert.equal(statusOf("mbx-lookalike"), MailboxSyncStatus.pending);
306
+ assert.equal(issued, 0);
307
+ assert.equal(rowOf("mbx-parent")?.pendingPath, "Beheer");
192
308
  });
309
+ });
193
310
 
194
- it("reports the rename as done when a descendant row vanishes mid-settle", async () => {
195
- // The settle is repair work, not the mutation. A descendant deleted while
196
- // it runs has nothing left to settle, and must not roll a completed server
197
- // rename back to failed — the handler's catch treats any throw from here
198
- // as an IMAP failure and restores the old path.
199
- const { repo, statusOf } = store(
200
- [
201
- row("mbx-parent", "Projects", MailboxSyncStatus.pending),
202
- row("mbx-gone", "Projects/Gone", MailboxSyncStatus.pending),
203
- row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
204
- ],
205
- ["mbx-gone"],
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(statusOf("mbx-child"), MailboxSyncStatus.synced);
220
- });
221
-
222
- it("reports the rename as done when the listing it settles against fails", async () => {
223
- // The listing runs after the server rename has landed. Letting it fail the
224
- // rename would send the caller down its IMAP-failure path, restoring a path
225
- // the server no longer has and marking the row failed — which drops the
226
- // pending marker that keeps the reconcile sweep from reaping it. A
227
- // descendant left pending is the safe outcome.
228
- const { repo, statusOf } = store([
229
- row("mbx-parent", "Projects", MailboxSyncStatus.pending),
230
- row("mbx-child", "Projects/2026", MailboxSyncStatus.pending),
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 () => connectionWithoutListing(),
518
+ async () => refusing(nonexistent(), ["INBOX.Projects"]),
240
519
  );
241
520
 
242
521
  assert.equal(result.success, true);
243
- assert.equal(statusOf("mbx-parent"), MailboxSyncStatus.synced);
244
- assert.equal(statusOf("mbx-child"), MailboxSyncStatus.pending);
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
  });