@remit/imap-worker 0.0.74 → 0.0.76

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/imap-worker",
3
- "version": "0.0.74",
3
+ "version": "0.0.76",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -29,10 +29,24 @@ interface Connection {
29
29
  messages: { total: number };
30
30
  }>;
31
31
  closeBox: () => Promise<void>;
32
- renameMailbox: (oldPath: string, newPath: string) => Promise<void>;
32
+ renameMailbox: (
33
+ oldPath: string,
34
+ newPath: string,
35
+ ) => Promise<{ path: string; newPath: string }>;
33
36
  deleteMailbox: (path: string) => Promise<void>;
34
37
  }
35
38
 
39
+ /** The folder rows the handler reads its guards and its settle row-set from. */
40
+ interface Row {
41
+ mailboxId: string;
42
+ accountId: string;
43
+ fullPath: string;
44
+ hierarchyDelimiter: string;
45
+ namespacePrefix: string;
46
+ syncStatus: string;
47
+ pendingPath?: string;
48
+ }
49
+
36
50
  interface Harness {
37
51
  calls: Call[];
38
52
  account: {
@@ -41,6 +55,7 @@ interface Harness {
41
55
  deletedAt?: number;
42
56
  } | null;
43
57
  connection: Connection;
58
+ rows: Map<string, Row>;
44
59
  mailboxUpdateError?: Error;
45
60
  mailboxRowGone?: boolean;
46
61
  disconnectCount: number;
@@ -48,11 +63,6 @@ interface Harness {
48
63
 
49
64
  let h: Harness;
50
65
 
51
- const notFoundError = (): Error =>
52
- Object.assign(new Error("Mailbox not found: mbx-1"), {
53
- name: "NotFoundError",
54
- });
55
-
56
66
  const record =
57
67
  (method: string) =>
58
68
  async (...args: unknown[]) => {
@@ -72,14 +82,37 @@ const buildConnection = (): Connection => ({
72
82
  messages: { total: 3 },
73
83
  }),
74
84
  closeBox: record("connection.closeBox"),
75
- renameMailbox: record("connection.renameMailbox"),
85
+ renameMailbox: async (oldPath: string, newPath: string) => {
86
+ h.calls.push({
87
+ method: "connection.renameMailbox",
88
+ args: [oldPath, newPath],
89
+ });
90
+ return { path: oldPath, newPath };
91
+ },
76
92
  deleteMailbox: record("connection.deleteMailbox"),
77
93
  });
78
94
 
95
+ const row = (over: Partial<Row> & { mailboxId: string }): Row => ({
96
+ accountId: "acc-1",
97
+ fullPath: "Archive",
98
+ hierarchyDelimiter: "/",
99
+ namespacePrefix: "",
100
+ syncStatus: "pending",
101
+ ...over,
102
+ });
103
+
104
+ /** The server holding neither path is what confirms the folder is gone. */
105
+ const listsNeitherPath = (): void => {
106
+ h.connection.listMailboxes = async () => [{ fullPath: "Iets anders" }];
107
+ };
108
+
79
109
  const fresh = (): Harness => ({
80
110
  calls: [],
81
111
  account: { accountId: "acc-1", accountConfigId: "cfg-1" },
82
112
  connection: buildConnection(),
113
+ // A create in flight: `pending` with no recorded rename target, which is
114
+ // what tells it apart from a rename in flight (D3).
115
+ rows: new Map([["mbx-1", row({ mailboxId: "mbx-1" })]]),
83
116
  disconnectCount: 0,
84
117
  });
85
118
 
@@ -92,23 +125,80 @@ const deps = (): MailboxManagementDeps =>
92
125
  return h.account;
93
126
  },
94
127
  },
128
+ accountSetting: {
129
+ get: async () => undefined,
130
+ upsert: record("accountSetting.upsert"),
131
+ delete: record("accountSetting.delete"),
132
+ },
95
133
  mailbox: {
134
+ get: async (accountId: string, mailboxId: string) => {
135
+ h.calls.push({ method: "mailbox.get", args: [accountId, mailboxId] });
136
+ const found = h.mailboxRowGone
137
+ ? undefined
138
+ : h.rows.get(mailboxId as string);
139
+ if (!found) {
140
+ throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
141
+ name: "NotFoundError",
142
+ });
143
+ }
144
+ return found;
145
+ },
96
146
  update: async (...args: unknown[]) => {
97
147
  h.calls.push({ method: "mailbox.update", args });
98
148
  if (h.mailboxUpdateError) throw h.mailboxUpdateError;
99
149
  },
100
- // The conditional write the folder state now goes through: a row
101
- // that is gone matches nothing, so the loser gets a null rather
102
- // than a throw and the handler raises the NotFoundError itself.
150
+ // The conditional write the folder state now goes through, with the
151
+ // predicate honoured: a row that is gone or that somebody else moved
152
+ // matches nothing, so the loser gets a null rather than a throw and
153
+ // the handler raises the NotFoundError itself.
103
154
  transition: async (...args: unknown[]) => {
104
155
  h.calls.push({ method: "mailbox.transition", args });
105
- return h.mailboxRowGone ? null : {};
156
+ if (h.mailboxRowGone) return null;
157
+ const mailboxId = args[1] as string;
158
+ const intent = args[2] as {
159
+ from: readonly string[];
160
+ wherePendingPath?: string | null;
161
+ to: string;
162
+ set?: { fullPath?: string };
163
+ };
164
+ const current = h.rows.get(mailboxId);
165
+ if (!current) return null;
166
+ if (!intent.from.includes(current.syncStatus)) return null;
167
+ if (
168
+ intent.wherePendingPath !== undefined &&
169
+ (intent.wherePendingPath ?? undefined) !== current.pendingPath
170
+ ) {
171
+ return null;
172
+ }
173
+ const next: Row = {
174
+ ...current,
175
+ ...(intent.set?.fullPath !== undefined
176
+ ? { fullPath: intent.set.fullPath }
177
+ : {}),
178
+ syncStatus: intent.to,
179
+ };
180
+ if (intent.to !== "pending" && intent.to !== "failed") {
181
+ next.pendingPath = undefined;
182
+ }
183
+ h.rows.set(mailboxId, next);
184
+ return next;
106
185
  },
107
186
  findByPathPrefix: async (...args: unknown[]) => {
108
187
  h.calls.push({ method: "mailbox.findByPathPrefix", args });
109
188
  return [];
110
189
  },
190
+ findBySyncStatus: async (accountId: string, syncStatus: string) => {
191
+ h.calls.push({
192
+ method: "mailbox.findBySyncStatus",
193
+ args: [accountId, syncStatus],
194
+ });
195
+ if (h.mailboxRowGone) return [];
196
+ return [...h.rows.values()].filter(
197
+ (r) => r.syncStatus === syncStatus,
198
+ );
199
+ },
111
200
  delete: record("mailbox.delete"),
201
+ deleteMailboxWithMail: record("mailbox.deleteMailboxWithMail"),
112
202
  },
113
203
  secrets: {},
114
204
  }),
@@ -155,10 +245,15 @@ const called = (method: string): Call[] =>
155
245
  const lastUpdate = (): Record<string, unknown> =>
156
246
  (called("mailbox.update").at(-1)?.args[2] ?? {}) as Record<string, unknown>;
157
247
 
158
- const lastSettle = (): { to?: string; set?: Record<string, unknown> } =>
248
+ const lastSettle = (): {
249
+ to?: string;
250
+ set?: Record<string, unknown>;
251
+ wherePendingPath?: string | null;
252
+ } =>
159
253
  (called("mailbox.transition").at(-1)?.args[2] ?? {}) as {
160
254
  to?: string;
161
255
  set?: Record<string, unknown>;
256
+ wherePendingPath?: string | null;
162
257
  };
163
258
 
164
259
  describe("processMailboxManagement — MAILBOX_CREATE", () => {
@@ -257,12 +352,24 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
257
352
  });
258
353
  });
259
354
 
355
+ /**
356
+ * The rename intent a MAILBOX_RENAME is settled against: the folder keeps the
357
+ * path the server holds, and carries the target the intent recorded (D2).
358
+ */
359
+ const recordRenameIntent = (pendingPath = "Archive 2024"): void => {
360
+ h.rows.set(
361
+ "mbx-1",
362
+ row({ mailboxId: "mbx-1", fullPath: "Archive", pendingPath }),
363
+ );
364
+ };
365
+
260
366
  describe("processMailboxManagement — MAILBOX_RENAME", () => {
261
367
  beforeEach(() => {
262
368
  h = fresh();
369
+ recordRenameIntent();
263
370
  });
264
371
 
265
- it("renames on the server and settles the row", async () => {
372
+ it("renames on the server and settles the row onto the confirmed path", async () => {
266
373
  await processMailboxManagement(renameEvent, noopLogger, deps());
267
374
 
268
375
  assert.deepEqual(called("connection.renameMailbox")[0]?.args, [
@@ -270,31 +377,92 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
270
377
  "Archive 2024",
271
378
  ]);
272
379
  assert.equal(lastSettle().to, "synced");
380
+ assert.equal(lastSettle().set?.fullPath, "Archive 2024");
273
381
  });
274
382
 
275
- it("drops the local row when the source folder is gone on the server", async () => {
383
+ it("settles onto the path ImapFlow resolved, not the one that was asked for", async () => {
384
+ h.connection.renameMailbox = async (oldPath: string) => {
385
+ h.calls.push({ method: "connection.renameMailbox", args: [oldPath] });
386
+ return { path: oldPath, newPath: "INBOX/Archive 2024" };
387
+ };
388
+
389
+ await processMailboxManagement(renameEvent, noopLogger, deps());
390
+
391
+ assert.equal(lastSettle().set?.fullPath, "INBOX/Archive 2024");
392
+ });
393
+
394
+ it("takes the folder's mail with it when the source is gone on the server", async () => {
395
+ // The folder was deleted under us. Dropping the row on its own is the
396
+ // orphaning bug: its mail stays keyed to a dead mailboxId, out of every
397
+ // reader and still in the search index (D8).
398
+ listsNeitherPath();
276
399
  h.connection.renameMailbox = async () => {
277
400
  throw new Error("Mailbox not found");
278
401
  };
279
402
 
280
403
  await processMailboxManagement(renameEvent, noopLogger, deps());
281
404
 
282
- assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
283
- assert.equal(called("mailbox.transition").length, 0);
405
+ assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
406
+ "acc-1",
407
+ "mbx-1",
408
+ ]);
409
+ assert.equal(called("mailbox.delete").length, 0);
284
410
  });
285
411
 
286
- it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
412
+ it("takes every descendant's mail with it too, not just the renamed folder's", async () => {
413
+ // The whole subtree recorded this rename's intent (D6); dropping the
414
+ // root alone strands each descendant `pending` at a path the server
415
+ // never held, with nothing left to revisit it.
416
+ h.rows.set(
417
+ "mbx-2",
418
+ row({
419
+ mailboxId: "mbx-2",
420
+ fullPath: "Archive/Sub",
421
+ pendingPath: "Archive 2024/Sub",
422
+ }),
423
+ );
424
+ listsNeitherPath();
287
425
  h.connection.renameMailbox = async () => {
288
- throw new Error("server exploded");
426
+ throw new Error("Mailbox not found");
427
+ };
428
+
429
+ await processMailboxManagement(renameEvent, noopLogger, deps());
430
+
431
+ const deleted = called("mailbox.deleteMailboxWithMail").map(
432
+ (c) => c.args[1],
433
+ );
434
+ assert.deepEqual(new Set(deleted), new Set(["mbx-1", "mbx-2"]));
435
+ });
436
+
437
+ it("refuses the rename rather than deleting when the source is still on the server", async () => {
438
+ // The server said NONEXISTENT and its own listing still holds the folder,
439
+ // so it was talking about something else. Reading that as an upstream
440
+ // delete would destroy the folder's mail over an answer nothing confirms.
441
+ h.connection.renameMailbox = async () => {
442
+ throw new Error("Mailbox not found");
289
443
  };
444
+
445
+ await assert.rejects(
446
+ processMailboxManagement(renameEvent, noopLogger, deps()),
447
+ /Mailbox not found/,
448
+ );
449
+
450
+ assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
451
+ assert.equal(lastSettle().to, "failed");
452
+ });
453
+
454
+ it("acks terminally without connecting when the folder row is gone", async () => {
290
455
  h.mailboxRowGone = true;
291
456
 
292
457
  await processMailboxManagement(renameEvent, noopLogger, deps());
293
458
 
459
+ assert.equal(called("connection.renameMailbox").length, 0);
294
460
  assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
295
461
  });
296
462
 
297
- it("rolls the local path back and rethrows on any other rename error", async () => {
463
+ it("keeps the confirmed path and the target on a refused rename", async () => {
464
+ // T6. `fullPath` was never written, so there is nothing to restore; the
465
+ // target is kept so the client can name what the rename was aiming at.
298
466
  h.connection.renameMailbox = async () => {
299
467
  throw new Error("server exploded");
300
468
  };
@@ -305,41 +473,180 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
305
473
  );
306
474
 
307
475
  assert.equal(lastSettle().to, "failed");
308
- assert.equal(lastSettle().set?.fullPath, "Archive");
476
+ assert.equal(
477
+ lastSettle().set?.fullPath,
478
+ undefined,
479
+ "nothing is restored: the row never left the path the server holds",
480
+ );
481
+ assert.equal(lastSettle().wherePendingPath, "Archive 2024");
482
+ });
483
+
484
+ it("settles instead of deleting when NONEXISTENT means the rename already landed", async () => {
485
+ // A redelivery after a lost settle re-issues RENAME from a path that has
486
+ // moved. Reading that as an upstream delete drops the folder and its mail
487
+ // while the folder is alive at the target.
488
+ h.connection.renameMailbox = async () => {
489
+ throw Object.assign(new Error("Command failed"), {
490
+ serverResponseCode: "NONEXISTENT",
491
+ responseText: "Mailbox doesn't exist: Archive",
492
+ });
493
+ };
494
+ h.connection.listMailboxes = async () => [{ fullPath: "Archive 2024" }];
495
+
496
+ await processMailboxManagement(renameEvent, noopLogger, deps());
497
+
498
+ assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
499
+ assert.equal(lastSettle().to, "synced");
500
+ assert.equal(lastSettle().set?.fullPath, "Archive 2024");
501
+ });
502
+
503
+ it("rethrows a settle failure without marking the rename refused", async () => {
504
+ // The server executed the rename. Marking the rows failed would offer a
505
+ // retry of work already done, on a path the server no longer holds.
506
+ let settles = 0;
507
+ const broken = deps();
508
+ const inner = broken.getClient as unknown as () => Promise<
509
+ Record<string, unknown>
510
+ >;
511
+ broken.getClient = (async () => {
512
+ const client = await inner();
513
+ return {
514
+ ...client,
515
+ mailbox: {
516
+ ...(client.mailbox as Record<string, unknown>),
517
+ transition: async () => {
518
+ settles += 1;
519
+ throw new Error("database is locked");
520
+ },
521
+ },
522
+ };
523
+ }) as unknown as MailboxManagementDeps["getClient"];
524
+
525
+ await assert.rejects(
526
+ processMailboxManagement(renameEvent, noopLogger, broken),
527
+ /local settle did not finish/,
528
+ );
529
+ assert.equal(settles, 1, "the refusal path never ran a second write");
530
+ });
531
+
532
+ it("issues no RENAME when the row has already settled", async () => {
533
+ h.rows.set(
534
+ "mbx-1",
535
+ row({
536
+ mailboxId: "mbx-1",
537
+ fullPath: "Archive 2024",
538
+ syncStatus: "synced",
539
+ }),
540
+ );
541
+
542
+ await processMailboxManagement(renameEvent, noopLogger, deps());
543
+
544
+ assert.equal(called("connection.renameMailbox").length, 0);
545
+ assert.equal(called("mailbox.transition").length, 0);
546
+ });
547
+
548
+ it("issues no RENAME when the row is pending for a different target", async () => {
549
+ h.rows.set("mbx-1", row({ mailboxId: "mbx-1", pendingPath: "Archief" }));
550
+
551
+ await processMailboxManagement(renameEvent, noopLogger, deps());
552
+
553
+ assert.equal(called("connection.renameMailbox").length, 0);
554
+ assert.equal(called("mailbox.transition").length, 0);
309
555
  });
310
556
  });
311
557
 
558
+ describe("processMailboxManagement — the seventh state (#363, D3)", () => {
559
+ beforeEach(() => {
560
+ h = fresh();
561
+ recordRenameIntent();
562
+ });
563
+
564
+ /**
565
+ * A settled create whose acknowledgement was lost, redelivered against a row
566
+ * this rename has since claimed. A `pending`-only guard passes it; CREATE
567
+ * collides and ImapFlow reports `{created: false}`, which #346 correctly
568
+ * reads as success; the settle then writes `synced` with the rename target
569
+ * still on the row — and the rename's own settle no longer matches, so the
570
+ * RENAME never runs and nothing is ever marked failed.
571
+ */
572
+ it("lets a redelivered create settle nothing on a row a rename has claimed", async () => {
573
+ await processMailboxManagement(createEvent, noopLogger, deps());
574
+
575
+ const settle = called("mailbox.transition").at(-1)?.args[2] as {
576
+ wherePendingPath?: string | null;
577
+ to?: string;
578
+ };
579
+ assert.equal(
580
+ settle?.wherePendingPath,
581
+ null,
582
+ "the create settle requires the row to carry no rename target",
583
+ );
584
+ assert.equal(h.rows.get("mbx-1")?.pendingPath, "Archive 2024");
585
+ });
586
+
587
+ it("then settles the rename normally", async () => {
588
+ await processMailboxManagement(createEvent, noopLogger, deps());
589
+ h.calls = [];
590
+
591
+ await processMailboxManagement(renameEvent, noopLogger, deps());
592
+
593
+ assert.equal(called("connection.renameMailbox").length, 1);
594
+ assert.equal(lastSettle().to, "synced");
595
+ assert.equal(lastSettle().set?.fullPath, "Archive 2024");
596
+ });
597
+ });
598
+
599
+ /** The delete intent a MAILBOX_DELETE is settled against. */
600
+ const recordDeleteIntent = (): void => {
601
+ h.rows.set(
602
+ "mbx-1",
603
+ row({ mailboxId: "mbx-1", fullPath: "Archive", syncStatus: "deleting" }),
604
+ );
605
+ };
606
+
312
607
  describe("processMailboxManagement — MAILBOX_DELETE", () => {
313
608
  beforeEach(() => {
314
609
  h = fresh();
610
+ recordDeleteIntent();
315
611
  });
316
612
 
317
- it("deletes on the server and drops the local row", async () => {
613
+ it("deletes on the server and takes the folder's mail with it", async () => {
318
614
  await processMailboxManagement(deleteEvent, noopLogger, deps());
319
615
 
320
616
  assert.equal(called("connection.deleteMailbox")[0]?.args[0], "Archive");
321
- assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
617
+ assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
618
+ "acc-1",
619
+ "mbx-1",
620
+ ]);
621
+ assert.equal(
622
+ called("mailbox.delete").length,
623
+ 0,
624
+ "the bare row delete is what orphaned the folder's mail",
625
+ );
322
626
  });
323
627
 
324
- it("drops the local row when the folder is already gone on the server", async () => {
628
+ it("removes the folder's mail when the folder is already gone on the server", async () => {
325
629
  h.connection.deleteMailbox = async () => {
326
630
  throw new Error("Mailbox not found");
327
631
  };
328
632
 
329
633
  await processMailboxManagement(deleteEvent, noopLogger, deps());
330
634
 
331
- assert.equal(called("mailbox.delete").length, 1);
635
+ assert.equal(called("mailbox.deleteMailboxWithMail").length, 1);
332
636
  });
333
637
 
334
- it("restores the mailbox and swallows the error when the server refuses to delete INBOX", async () => {
638
+ it("marks the delete failed rather than synced when the server refuses INBOX", async () => {
639
+ // The API refuses a delete of INBOX, so this backstop is unreachable from
640
+ // it. Reached another way, the honest outcome is `failed`: the folder was
641
+ // never deleted, and nothing was undone.
335
642
  h.connection.deleteMailbox = async () => {
336
643
  throw new Error("Cannot delete INBOX");
337
644
  };
338
645
 
339
646
  await processMailboxManagement(deleteEvent, noopLogger, deps());
340
647
 
341
- assert.equal(lastSettle().to, "synced");
342
- assert.equal(called("mailbox.delete").length, 0);
648
+ assert.equal(lastSettle().to, "failed");
649
+ assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
343
650
  });
344
651
 
345
652
  it("marks the mailbox failed and rethrows on any other delete error", async () => {
@@ -355,14 +662,21 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
355
662
  assert.equal(lastSettle().to, "failed");
356
663
  });
357
664
 
358
- it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
359
- h.connection.deleteMailbox = async () => {
360
- throw new Error("server exploded");
361
- };
665
+ it("issues no DELETE when the row is not deleting", async () => {
666
+ h.rows.set("mbx-1", row({ mailboxId: "mbx-1", syncStatus: "synced" }));
667
+
668
+ await processMailboxManagement(deleteEvent, noopLogger, deps());
669
+
670
+ assert.equal(called("connection.deleteMailbox").length, 0);
671
+ assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
672
+ });
673
+
674
+ it("acks terminally without connecting when the folder row is gone", async () => {
362
675
  h.mailboxRowGone = true;
363
676
 
364
677
  await processMailboxManagement(deleteEvent, noopLogger, deps());
365
678
 
679
+ assert.equal(called("connection.deleteMailbox").length, 0);
366
680
  assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
367
681
  });
368
682
  });
@@ -378,7 +692,8 @@ describe("processMailboxManagement — a tagged NO the server means as success (
378
692
  * only the message treated an already-absent folder as a failure: the row was
379
693
  * marked failed and the event left poisoning the account's queue.
380
694
  */
381
- it("treats it as the delete already having happened, and drops the local row", async () => {
695
+ it("treats it as the delete already having happened, mail and all", async () => {
696
+ recordDeleteIntent();
382
697
  h.connection.deleteMailbox = async () => {
383
698
  throw Object.assign(new Error("Command failed"), {
384
699
  serverResponseCode: "NONEXISTENT",
@@ -389,11 +704,15 @@ describe("processMailboxManagement — a tagged NO the server means as success (
389
704
  await assert.doesNotReject(
390
705
  processMailboxManagement(deleteEvent, noopLogger, deps()),
391
706
  );
392
- assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
707
+ assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
708
+ "acc-1",
709
+ "mbx-1",
710
+ ]);
393
711
  assert.equal(called("mailbox.transition").length, 0);
394
712
  });
395
713
 
396
714
  it("still marks failed and rethrows when the server fails for any other reason", async () => {
715
+ recordDeleteIntent();
397
716
  h.connection.deleteMailbox = async () => {
398
717
  throw Object.assign(new Error("Command failed"), {
399
718
  serverResponseCode: "SERVERBUG",
@@ -443,7 +762,9 @@ describe("processMailboxManagement — a tagged NO the server means as success (
443
762
  assert.equal(lastSettle().to, "failed");
444
763
  });
445
764
 
446
- it("reads NONEXISTENT on a RENAME as the source folder being gone", async () => {
765
+ it("reads NONEXISTENT on a RENAME as the source folder being gone, mail and all", async () => {
766
+ recordRenameIntent();
767
+ listsNeitherPath();
447
768
  h.connection.renameMailbox = async () => {
448
769
  throw Object.assign(new Error("Command failed"), {
449
770
  serverResponseCode: "NONEXISTENT",
@@ -454,10 +775,14 @@ describe("processMailboxManagement — a tagged NO the server means as success (
454
775
  await assert.doesNotReject(
455
776
  processMailboxManagement(renameEvent, noopLogger, deps()),
456
777
  );
457
- assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
778
+ assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
779
+ "acc-1",
780
+ "mbx-1",
781
+ ]);
458
782
  });
459
783
 
460
- it("still rolls back and rethrows when a RENAME fails for any other reason", async () => {
784
+ it("still refuses the intent and rethrows when a RENAME fails for any other reason", async () => {
785
+ recordRenameIntent();
461
786
  h.connection.renameMailbox = async () => {
462
787
  throw Object.assign(new Error("Command failed"), {
463
788
  serverResponseCode: "SERVERBUG",
@@ -1,10 +1,14 @@
1
1
  import { getClient } from "@remit/backend/client";
2
+ import { refreshFolderAppointmentLabels } from "@remit/backend/folder-role-labels";
2
3
  import type { IMailboxRepository } from "@remit/data-ports";
3
4
  import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
4
5
  import { MailboxSyncStatus } from "@remit/domain-enums";
5
6
  import type { Logger } from "@remit/logger-lambda";
6
7
  import {
7
- EVERY_MAILBOX_STATE,
8
+ FolderGoneUpstreamError,
9
+ FolderRenameSettleError,
10
+ isMailboxAbsentUpstream,
11
+ isMailboxPresentUpstream,
8
12
  MailboxManagementService,
9
13
  } from "@remit/mailbox-service";
10
14
  import { isAccountDeleted } from "../account-check.js";
@@ -32,74 +36,27 @@ const defaultDeps: MailboxManagementDeps = {
32
36
  createConnectionScope: createConnectionScopeWithCredentials,
33
37
  };
34
38
 
35
- const stringField = (value: unknown, key: string): string => {
36
- if (!(value instanceof Object)) return "";
37
- const field = Reflect.get(value, key);
38
- return typeof field === "string" ? field : "";
39
- };
40
-
41
39
  /**
42
- * Read a tagged-NO outcome out of an IMAP failure.
43
- *
44
- * The two outcomes below are each a folder operation finding the server already
45
- * in the state it was asked for — the operation having happened, not failing.
46
- * Reading them as failures marks the row `failed` and rethrows, and since folder
47
- * management shares the account's per-account FIFO group with mailbox sync, that
48
- * un-acked rethrow holds back every later sync for the account.
49
- *
50
- * Both places the server can say so are read. `message` carries it when the
51
- * client raises the error itself; RFC 5530's response code and its text carry it
52
- * when the server does. ImapFlow surfaces a tagged NO as a bare "Command failed"
53
- * with the code on the error, which is why matching the message alone never
54
- * caught a real Dovecot answer.
40
+ * The outcome of a create, written against the state a create in flight is:
41
+ * `pending` with no recorded rename target (D3, D10). Without the absence
42
+ * check, a create redelivered after a lost acknowledgement settles a row a
43
+ * rename has since claimed and kills the rename silently.
55
44
  */
56
- const saidByServer = (error: Error): string =>
57
- `${error.message} ${stringField(error, "responseText")}`;
58
-
59
- /** The folder is not on the server: a delete has nothing left to do. */
60
- const isMailboxAbsentUpstream = (error: unknown): boolean => {
61
- if (!(error instanceof Error)) return false;
62
- if (stringField(error, "serverResponseCode") === "NONEXISTENT") return true;
63
- return /not found|does ?n.?t exist/i.test(saidByServer(error));
64
- };
65
-
66
- /**
67
- * Write the outcome of a folder operation back, as the conditional write that
68
- * is the only way to write a folder's state
69
- * (docs/architecture/folder-rename-and-delete.md D3).
70
- *
71
- * The from-set is every state because that is what these write-backs decide
72
- * against today — they are the unconditional writes they replace, moved onto
73
- * the door. #363 narrows each to the state its intent recorded, and gives a
74
- * lost predicate its `superseded` outcome. A null means the row is gone, which
75
- * is the NotFoundError the whole-chain guards below classify as the user having
76
- * deleted the folder mid-sync.
77
- */
78
- const recordOutcome = async (
45
+ const recordCreateOutcome = async (
79
46
  mailboxService: Pick<IMailboxRepository, "transition">,
80
47
  accountId: string,
81
48
  mailboxId: string,
82
49
  to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
83
- confirmedPath?: string,
84
50
  ): Promise<void> => {
85
51
  const written = await mailboxService.transition(accountId, mailboxId, {
86
- from: EVERY_MAILBOX_STATE,
52
+ from: [MailboxSyncStatus.pending],
53
+ wherePendingPath: null,
87
54
  to,
88
- // `failed` keeps whatever rename target the row carries, so the UI can
89
- // name what the rename was aiming at (T6); every other outcome drops it,
90
- // which the transition does on its own.
91
- set: confirmedPath !== undefined ? { fullPath: confirmedPath } : {},
55
+ set: {},
92
56
  });
93
57
  if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
94
58
  };
95
59
 
96
- /** The folder is already on the server: a create has nothing left to do. */
97
- const isMailboxPresentUpstream = (error: unknown): boolean => {
98
- if (!(error instanceof Error)) return false;
99
- if (stringField(error, "serverResponseCode") === "ALREADYEXISTS") return true;
100
- return /already exists/i.test(saidByServer(error));
101
- };
102
-
103
60
  /**
104
61
  * Pinned invariant for the whole-chain terminal guards below.
105
62
  *
@@ -110,12 +67,13 @@ const isMailboxPresentUpstream = (error: unknown): boolean => {
110
67
  * never an unrelated missing entity that should have been retried.
111
68
  *
112
69
  * `syncCreate` and `syncDelete` hold to that by touching nothing but the target
113
- * row. `syncRename` also writes the renamed subtree's descendant rows, and keeps
114
- * the invariant by absorbing their NotFoundErrors itself: a descendant deleted
115
- * mid-settle never reaches these catches. Any sync method that reads or writes a
116
- * second entity owes the same, or these catches must be narrowed (match the
117
- * mailboxId / re-check existence) before a NotFoundError from elsewhere is
118
- * silently acked.
70
+ * row. `syncRename` also writes every other row that recorded the same intent,
71
+ * and keeps the invariant by treating a lost predicate as an outcome rather than
72
+ * an error: a descendant deleted mid-settle is skipped, never raised. The
73
+ * appointment-label refresh writes `account_setting` rows, whose reads answer
74
+ * absent rather than throwing. Any sync method that reads or writes a second
75
+ * entity owes the same, or these catches must be narrowed (match the mailboxId /
76
+ * re-check existence) before a NotFoundError from elsewhere is silently acked.
119
77
  */
120
78
 
121
79
  /**
@@ -192,14 +150,14 @@ const handleCreate = async (
192
150
  { accountId, mailboxId, path },
193
151
  "Mailbox already exists, marking as synced",
194
152
  );
195
- await recordOutcome(
153
+ await recordCreateOutcome(
196
154
  mailboxService,
197
155
  accountId,
198
156
  mailboxId,
199
157
  MailboxSyncStatus.synced,
200
158
  );
201
159
  } else {
202
- await recordOutcome(
160
+ await recordCreateOutcome(
203
161
  mailboxService,
204
162
  accountId,
205
163
  mailboxId,
@@ -246,6 +204,7 @@ const handleRename = async (
246
204
 
247
205
  const {
248
206
  account: accountService,
207
+ accountSetting: accountSettingService,
249
208
  mailbox: mailboxService,
250
209
  secrets,
251
210
  } = await getClient();
@@ -286,38 +245,91 @@ const handleRename = async (
286
245
  newPath,
287
246
  scope.getConnection,
288
247
  )
289
- .then((result) => {
290
- if (result.success) {
291
- log.info(
292
- { accountId, mailboxId, oldPath, newPath },
293
- "Mailbox renamed on IMAP",
294
- );
295
- } else {
248
+ .then(async (result) => {
249
+ if (!result.success) {
296
250
  log.error(
297
251
  { accountId, mailboxId, oldPath, newPath, error: result.error },
298
252
  "Failed to rename mailbox on IMAP",
299
253
  );
254
+ return;
300
255
  }
256
+ log.info(
257
+ { accountId, mailboxId, oldPath, newPath },
258
+ "Mailbox renamed on IMAP",
259
+ );
260
+ if (!result.renamed) return;
261
+ // The path recorded beside each role appointment (#887) moves with
262
+ // the settle, because under D2 that is the first moment the new
263
+ // path is one the server holds.
264
+ //
265
+ // A label is display-only and this runs after the server rename
266
+ // landed, so letting it fail would take the job down the failure
267
+ // path and mark a completed rename refused. Logged and dropped;
268
+ // the next appointment write records the folder's current path
269
+ // anyway.
270
+ await refreshFolderAppointmentLabels(
271
+ accountSettingService,
272
+ account.accountConfigId,
273
+ accountId,
274
+ {
275
+ mailboxId,
276
+ oldPath: result.renamed.oldPath,
277
+ newPath: result.renamed.newPath,
278
+ },
279
+ result.renamed.delimiter,
280
+ ).catch((error: unknown) => {
281
+ log.error(
282
+ { accountId, mailboxId, oldPath, newPath, error },
283
+ "Renamed folder settled, but its role-appointment labels did not move",
284
+ );
285
+ });
301
286
  })
302
287
  .catch(async (error) => {
303
- // If source not found, delete local mailbox
304
- if (isMailboxAbsentUpstream(error)) {
288
+ // The server executed the rename and only the local settle did not
289
+ // finish. That is not a refused rename: marking the rows `failed`
290
+ // would offer a retry of work already done and leave `fullPath`
291
+ // naming a path the server no longer holds. Rethrow, so the
292
+ // redelivery finishes the settle against an intent still standing.
293
+ if (error instanceof FolderRenameSettleError) {
294
+ log.error(
295
+ { accountId, mailboxId, oldPath, newPath, error },
296
+ "Rename landed on the server; the settle will finish on redelivery",
297
+ );
298
+ throw error;
299
+ }
300
+ // The folder the rename was to move is confirmed gone: the
301
+ // server refused the RENAME as non-existent and its own listing
302
+ // held neither path. The rename decides that, once, where the
303
+ // evidence is — re-reading the response code here would make
304
+ // every `NONEXISTENT` this code cannot classify destroy the
305
+ // folder's mail. Removing the root row alone is the orphaning
306
+ // bug: every descendant recorded the same intent (D6) and would
307
+ // be left `pending` at a path that does not exist, with its mail
308
+ // stranded out of every reader and still in the search index
309
+ // (D8).
310
+ if (error instanceof FolderGoneUpstreamError) {
305
311
  log.info(
306
- { accountId, mailboxId, oldPath },
307
- "Source mailbox not found, deleting local",
312
+ { accountId, mailboxId, oldPath, intent: "rename" },
313
+ "Source mailbox not found, deleting local folder and its mail",
308
314
  );
309
- await mailboxService.delete(accountId, mailboxId);
310
- } else {
311
- // Rollback local rename by restoring old path
312
- await recordOutcome(
313
- mailboxService,
315
+ await managementService.abandonRenameSubtree(
314
316
  accountId,
315
317
  mailboxId,
316
- MailboxSyncStatus.failed,
317
318
  oldPath,
319
+ newPath,
318
320
  );
319
- throw error;
321
+ return;
320
322
  }
323
+ // T6. Nothing is restored — `fullPath` was never written, and
324
+ // each intent-carrying row keeps its target so the client can
325
+ // name what the rename was aiming at and offer a retry.
326
+ await managementService.failRename(
327
+ accountId,
328
+ mailboxId,
329
+ oldPath,
330
+ newPath,
331
+ );
332
+ throw error;
321
333
  })
322
334
  .finally(() => scope.disconnect());
323
335
  } catch (error) {
@@ -400,37 +412,32 @@ const handleDelete = async (
400
412
  }
401
413
  })
402
414
  .catch(async (error) => {
403
- // If mailbox not found, it's already deleted (idempotent)
415
+ // The folder is already gone from the server, so the delete has
416
+ // happened. Its mail still goes with it (D8), which is the whole
417
+ // point of the settle.
404
418
  if (isMailboxAbsentUpstream(error)) {
405
419
  log.info(
406
- { accountId, mailboxId, path },
407
- "Mailbox not found on IMAP, deleting local",
420
+ { accountId, mailboxId, path, intent: "delete" },
421
+ "Mailbox not found on IMAP, removing the local folder and its mail",
408
422
  );
409
- await mailboxService.delete(accountId, mailboxId);
410
- } else if (
411
- error instanceof Error &&
412
- error.message.includes("Cannot delete INBOX")
413
- ) {
414
- // Restore the mailbox
415
- await recordOutcome(
416
- mailboxService,
417
- accountId,
418
- mailboxId,
419
- MailboxSyncStatus.synced,
420
- );
421
- log.error(
422
- { accountId, mailboxId, path },
423
- "Cannot delete INBOX, restoring mailbox",
424
- );
425
- // Don't rethrow - this is an expected error
423
+ await managementService.settleDelete(accountId, mailboxId);
426
424
  } else {
427
- // Restore the mailbox on other errors
428
- await recordOutcome(
429
- mailboxService,
430
- accountId,
431
- mailboxId,
432
- MailboxSyncStatus.failed,
433
- );
425
+ // T9, INBOX included. The API refuses a delete of INBOX, so the
426
+ // worker's backstop is unreachable from it; if something else
427
+ // reaches it, the honest outcome is `failed`, not `synced` —
428
+ // the folder was never deleted and nothing was undone.
429
+ await managementService.failDelete(accountId, mailboxId);
430
+ if (
431
+ error instanceof Error &&
432
+ error.message.includes("Cannot delete INBOX")
433
+ ) {
434
+ log.error(
435
+ { accountId, mailboxId, path, intent: "delete" },
436
+ "Cannot delete INBOX",
437
+ );
438
+ // Don't rethrow — no retry can make this succeed.
439
+ return;
440
+ }
434
441
  throw error;
435
442
  }
436
443
  })