@remit/imap-worker 0.0.73 → 0.0.75

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.73",
3
+ "version": "0.0.75",
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,17 +55,14 @@ interface Harness {
41
55
  deletedAt?: number;
42
56
  } | null;
43
57
  connection: Connection;
58
+ rows: Map<string, Row>;
44
59
  mailboxUpdateError?: Error;
60
+ mailboxRowGone?: boolean;
45
61
  disconnectCount: number;
46
62
  }
47
63
 
48
64
  let h: Harness;
49
65
 
50
- const notFoundError = (): Error =>
51
- Object.assign(new Error("Mailbox not found: mbx-1"), {
52
- name: "NotFoundError",
53
- });
54
-
55
66
  const record =
56
67
  (method: string) =>
57
68
  async (...args: unknown[]) => {
@@ -71,14 +82,37 @@ const buildConnection = (): Connection => ({
71
82
  messages: { total: 3 },
72
83
  }),
73
84
  closeBox: record("connection.closeBox"),
74
- 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
+ },
75
92
  deleteMailbox: record("connection.deleteMailbox"),
76
93
  });
77
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
+
78
109
  const fresh = (): Harness => ({
79
110
  calls: [],
80
111
  account: { accountId: "acc-1", accountConfigId: "cfg-1" },
81
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" })]]),
82
116
  disconnectCount: 0,
83
117
  });
84
118
 
@@ -91,16 +125,80 @@ const deps = (): MailboxManagementDeps =>
91
125
  return h.account;
92
126
  },
93
127
  },
128
+ accountSetting: {
129
+ get: async () => undefined,
130
+ upsert: record("accountSetting.upsert"),
131
+ delete: record("accountSetting.delete"),
132
+ },
94
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
+ },
95
146
  update: async (...args: unknown[]) => {
96
147
  h.calls.push({ method: "mailbox.update", args });
97
148
  if (h.mailboxUpdateError) throw h.mailboxUpdateError;
98
149
  },
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.
154
+ transition: async (...args: unknown[]) => {
155
+ h.calls.push({ method: "mailbox.transition", args });
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;
185
+ },
99
186
  findByPathPrefix: async (...args: unknown[]) => {
100
187
  h.calls.push({ method: "mailbox.findByPathPrefix", args });
101
188
  return [];
102
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
+ },
103
200
  delete: record("mailbox.delete"),
201
+ deleteMailboxWithMail: record("mailbox.deleteMailboxWithMail"),
104
202
  },
105
203
  secrets: {},
106
204
  }),
@@ -147,6 +245,17 @@ const called = (method: string): Call[] =>
147
245
  const lastUpdate = (): Record<string, unknown> =>
148
246
  (called("mailbox.update").at(-1)?.args[2] ?? {}) as Record<string, unknown>;
149
247
 
248
+ const lastSettle = (): {
249
+ to?: string;
250
+ set?: Record<string, unknown>;
251
+ wherePendingPath?: string | null;
252
+ } =>
253
+ (called("mailbox.transition").at(-1)?.args[2] ?? {}) as {
254
+ to?: string;
255
+ set?: Record<string, unknown>;
256
+ wherePendingPath?: string | null;
257
+ };
258
+
150
259
  describe("processMailboxManagement — MAILBOX_CREATE", () => {
151
260
  beforeEach(() => {
152
261
  h = fresh();
@@ -160,8 +269,8 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
160
269
  uidValidity: 7,
161
270
  uidNext: 42,
162
271
  messageCount: 3,
163
- syncStatus: "synced",
164
272
  });
273
+ assert.equal(lastSettle().to, "synced");
165
274
  assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
166
275
  });
167
276
 
@@ -183,7 +292,8 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
183
292
 
184
293
  await processMailboxManagement(createEvent, noopLogger, deps());
185
294
 
186
- assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
295
+ assert.equal(called("mailbox.update").length, 0);
296
+ assert.equal(lastSettle().to, "synced");
187
297
  });
188
298
 
189
299
  it("treats an already-existing folder as success rather than a failure", async () => {
@@ -193,7 +303,7 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
193
303
 
194
304
  await processMailboxManagement(createEvent, noopLogger, deps());
195
305
 
196
- assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
306
+ assert.equal(lastSettle().to, "synced");
197
307
  });
198
308
 
199
309
  it("marks the mailbox failed and rethrows on any other create error", async () => {
@@ -206,14 +316,14 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
206
316
  /server exploded/,
207
317
  );
208
318
 
209
- assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
319
+ assert.equal(lastSettle().to, "failed");
210
320
  assert.equal(h.disconnectCount, 1);
211
321
  });
212
322
 
213
323
  it("acks terminally without rethrowing when the mailbox row was deleted mid-create (#289)", async () => {
214
- // The status write-back throws NotFoundError because the row is gone; the
215
- // create is moot and must not poison the account's FIFO.
216
- h.mailboxUpdateError = notFoundError();
324
+ // The settle matches no row because the row is gone; the create is moot
325
+ // and must not poison the account's FIFO.
326
+ h.mailboxRowGone = true;
217
327
 
218
328
  await processMailboxManagement(createEvent, noopLogger, deps());
219
329
 
@@ -242,47 +352,117 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
242
352
  });
243
353
  });
244
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
+
245
366
  describe("processMailboxManagement — MAILBOX_RENAME", () => {
246
367
  beforeEach(() => {
247
368
  h = fresh();
369
+ recordRenameIntent();
248
370
  });
249
371
 
250
- it("renames on the server and clears the pending oldPath", async () => {
372
+ it("renames on the server and settles the row onto the confirmed path", async () => {
251
373
  await processMailboxManagement(renameEvent, noopLogger, deps());
252
374
 
253
375
  assert.deepEqual(called("connection.renameMailbox")[0]?.args, [
254
376
  "Archive",
255
377
  "Archive 2024",
256
378
  ]);
257
- assert.deepEqual(lastUpdate(), {
258
- oldPath: undefined,
259
- syncStatus: "synced",
260
- });
379
+ assert.equal(lastSettle().to, "synced");
380
+ assert.equal(lastSettle().set?.fullPath, "Archive 2024");
381
+ });
382
+
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");
261
392
  });
262
393
 
263
- it("drops the local row when the source folder is gone on the server", async () => {
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();
264
399
  h.connection.renameMailbox = async () => {
265
400
  throw new Error("Mailbox not found");
266
401
  };
267
402
 
268
403
  await processMailboxManagement(renameEvent, noopLogger, deps());
269
404
 
270
- assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
271
- assert.equal(called("mailbox.update").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);
272
410
  });
273
411
 
274
- 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();
275
425
  h.connection.renameMailbox = async () => {
276
- 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");
277
443
  };
278
- h.mailboxUpdateError = notFoundError();
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 () => {
455
+ h.mailboxRowGone = true;
279
456
 
280
457
  await processMailboxManagement(renameEvent, noopLogger, deps());
281
458
 
459
+ assert.equal(called("connection.renameMailbox").length, 0);
282
460
  assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
283
461
  });
284
462
 
285
- 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.
286
466
  h.connection.renameMailbox = async () => {
287
467
  throw new Error("server exploded");
288
468
  };
@@ -292,11 +472,127 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
292
472
  /server exploded/,
293
473
  );
294
474
 
295
- assert.deepEqual(lastUpdate(), {
296
- fullPath: "Archive",
297
- oldPath: undefined,
298
- syncStatus: "failed",
299
- });
475
+ assert.equal(lastSettle().to, "failed");
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);
555
+ });
556
+ });
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");
300
596
  });
301
597
  });
302
598
 
@@ -329,7 +625,7 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
329
625
 
330
626
  await processMailboxManagement(deleteEvent, noopLogger, deps());
331
627
 
332
- assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
628
+ assert.equal(lastSettle().to, "synced");
333
629
  assert.equal(called("mailbox.delete").length, 0);
334
630
  });
335
631
 
@@ -343,14 +639,14 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
343
639
  /server exploded/,
344
640
  );
345
641
 
346
- assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
642
+ assert.equal(lastSettle().to, "failed");
347
643
  });
348
644
 
349
645
  it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
350
646
  h.connection.deleteMailbox = async () => {
351
647
  throw new Error("server exploded");
352
648
  };
353
- h.mailboxUpdateError = notFoundError();
649
+ h.mailboxRowGone = true;
354
650
 
355
651
  await processMailboxManagement(deleteEvent, noopLogger, deps());
356
652
 
@@ -381,7 +677,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
381
677
  processMailboxManagement(deleteEvent, noopLogger, deps()),
382
678
  );
383
679
  assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
384
- assert.equal(called("mailbox.update").length, 0);
680
+ assert.equal(called("mailbox.transition").length, 0);
385
681
  });
386
682
 
387
683
  it("still marks failed and rethrows when the server fails for any other reason", async () => {
@@ -396,7 +692,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
396
692
  processMailboxManagement(deleteEvent, noopLogger, deps()),
397
693
  /Command failed/,
398
694
  );
399
- assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
695
+ assert.equal(lastSettle().to, "failed");
400
696
  });
401
697
 
402
698
  /**
@@ -416,7 +712,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
416
712
  await assert.doesNotReject(
417
713
  processMailboxManagement(createEvent, noopLogger, deps()),
418
714
  );
419
- assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
715
+ assert.equal(lastSettle().to, "synced");
420
716
  });
421
717
 
422
718
  it("still marks failed and rethrows when a CREATE fails for any other reason", async () => {
@@ -431,10 +727,12 @@ describe("processMailboxManagement — a tagged NO the server means as success (
431
727
  processMailboxManagement(createEvent, noopLogger, deps()),
432
728
  /Command failed/,
433
729
  );
434
- assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
730
+ assert.equal(lastSettle().to, "failed");
435
731
  });
436
732
 
437
- it("reads NONEXISTENT on a RENAME as the source folder being gone", async () => {
733
+ it("reads NONEXISTENT on a RENAME as the source folder being gone, mail and all", async () => {
734
+ recordRenameIntent();
735
+ listsNeitherPath();
438
736
  h.connection.renameMailbox = async () => {
439
737
  throw Object.assign(new Error("Command failed"), {
440
738
  serverResponseCode: "NONEXISTENT",
@@ -445,10 +743,14 @@ describe("processMailboxManagement — a tagged NO the server means as success (
445
743
  await assert.doesNotReject(
446
744
  processMailboxManagement(renameEvent, noopLogger, deps()),
447
745
  );
448
- assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
746
+ assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
747
+ "acc-1",
748
+ "mbx-1",
749
+ ]);
449
750
  });
450
751
 
451
- it("still rolls back and rethrows when a RENAME fails for any other reason", async () => {
752
+ it("still refuses the intent and rethrows when a RENAME fails for any other reason", async () => {
753
+ recordRenameIntent();
452
754
  h.connection.renameMailbox = async () => {
453
755
  throw Object.assign(new Error("Command failed"), {
454
756
  serverResponseCode: "SERVERBUG",
@@ -460,6 +762,6 @@ describe("processMailboxManagement — a tagged NO the server means as success (
460
762
  processMailboxManagement(renameEvent, noopLogger, deps()),
461
763
  /Command failed/,
462
764
  );
463
- assert.equal(lastUpdate()?.syncStatus, "failed");
765
+ assert.equal(lastSettle().to, "failed");
464
766
  });
465
767
  });
@@ -1,8 +1,17 @@
1
1
  import { getClient } from "@remit/backend/client";
2
- import { isNotFoundError } from "@remit/data-ports/errors";
2
+ import { refreshFolderAppointmentLabels } from "@remit/backend/folder-role-labels";
3
+ import type { IMailboxRepository } from "@remit/data-ports";
4
+ import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
3
5
  import { MailboxSyncStatus } from "@remit/domain-enums";
4
6
  import type { Logger } from "@remit/logger-lambda";
5
- import { MailboxManagementService } from "@remit/mailbox-service";
7
+ import {
8
+ EVERY_MAILBOX_STATE,
9
+ FolderGoneUpstreamError,
10
+ FolderRenameSettleError,
11
+ isMailboxAbsentUpstream,
12
+ isMailboxPresentUpstream,
13
+ MailboxManagementService,
14
+ } from "@remit/mailbox-service";
6
15
  import { isAccountDeleted } from "../account-check.js";
7
16
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
8
17
  import type {
@@ -28,42 +37,50 @@ const defaultDeps: MailboxManagementDeps = {
28
37
  createConnectionScope: createConnectionScopeWithCredentials,
29
38
  };
30
39
 
31
- const stringField = (value: unknown, key: string): string => {
32
- if (!(value instanceof Object)) return "";
33
- const field = Reflect.get(value, key);
34
- return typeof field === "string" ? field : "";
35
- };
36
-
37
40
  /**
38
- * Read a tagged-NO outcome out of an IMAP failure.
41
+ * Write the outcome of a folder operation back, as the conditional write that
42
+ * is the only way to write a folder's state
43
+ * (docs/architecture/folder-rename-and-delete.md D3).
39
44
  *
40
- * The two outcomes below are each a folder operation finding the server already
41
- * in the state it was asked for — the operation having happened, not failing.
42
- * Reading them as failures marks the row `failed` and rethrows, and since folder
43
- * management shares the account's per-account FIFO group with mailbox sync, that
44
- * un-acked rethrow holds back every later sync for the account.
45
- *
46
- * Both places the server can say so are read. `message` carries it when the
47
- * client raises the error itself; RFC 5530's response code and its text carry it
48
- * when the server does. ImapFlow surfaces a tagged NO as a bare "Command failed"
49
- * with the code on the error, which is why matching the message alone never
50
- * caught a real Dovecot answer.
45
+ * The from-set is every state because that is what this write-back decides
46
+ * against today — it is the unconditional write it replaces, moved onto the
47
+ * door. #362 narrows the delete's to the state its intent recorded. A null
48
+ * means the row is gone, which is the NotFoundError the whole-chain guards
49
+ * below classify as the user having deleted the folder mid-sync.
51
50
  */
52
- const saidByServer = (error: Error): string =>
53
- `${error.message} ${stringField(error, "responseText")}`;
54
-
55
- /** The folder is not on the server: a delete has nothing left to do. */
56
- const isMailboxAbsentUpstream = (error: unknown): boolean => {
57
- if (!(error instanceof Error)) return false;
58
- if (stringField(error, "serverResponseCode") === "NONEXISTENT") return true;
59
- return /not found|does ?n.?t exist/i.test(saidByServer(error));
51
+ const recordOutcome = async (
52
+ mailboxService: Pick<IMailboxRepository, "transition">,
53
+ accountId: string,
54
+ mailboxId: string,
55
+ to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
56
+ ): Promise<void> => {
57
+ const written = await mailboxService.transition(accountId, mailboxId, {
58
+ from: EVERY_MAILBOX_STATE,
59
+ to,
60
+ set: {},
61
+ });
62
+ if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
60
63
  };
61
64
 
62
- /** The folder is already on the server: a create has nothing left to do. */
63
- const isMailboxPresentUpstream = (error: unknown): boolean => {
64
- if (!(error instanceof Error)) return false;
65
- if (stringField(error, "serverResponseCode") === "ALREADYEXISTS") return true;
66
- return /already exists/i.test(saidByServer(error));
65
+ /**
66
+ * The outcome of a create, written against the state a create in flight is:
67
+ * `pending` with no recorded rename target (D3, D10). Without the absence
68
+ * check, a create redelivered after a lost acknowledgement settles a row a
69
+ * rename has since claimed and kills the rename silently.
70
+ */
71
+ const recordCreateOutcome = async (
72
+ mailboxService: Pick<IMailboxRepository, "transition">,
73
+ accountId: string,
74
+ mailboxId: string,
75
+ to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
76
+ ): Promise<void> => {
77
+ const written = await mailboxService.transition(accountId, mailboxId, {
78
+ from: [MailboxSyncStatus.pending],
79
+ wherePendingPath: null,
80
+ to,
81
+ set: {},
82
+ });
83
+ if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
67
84
  };
68
85
 
69
86
  /**
@@ -76,12 +93,13 @@ const isMailboxPresentUpstream = (error: unknown): boolean => {
76
93
  * never an unrelated missing entity that should have been retried.
77
94
  *
78
95
  * `syncCreate` and `syncDelete` hold to that by touching nothing but the target
79
- * row. `syncRename` also writes the renamed subtree's descendant rows, and keeps
80
- * the invariant by absorbing their NotFoundErrors itself: a descendant deleted
81
- * mid-settle never reaches these catches. Any sync method that reads or writes a
82
- * second entity owes the same, or these catches must be narrowed (match the
83
- * mailboxId / re-check existence) before a NotFoundError from elsewhere is
84
- * silently acked.
96
+ * row. `syncRename` also writes every other row that recorded the same intent,
97
+ * and keeps the invariant by treating a lost predicate as an outcome rather than
98
+ * an error: a descendant deleted mid-settle is skipped, never raised. The
99
+ * appointment-label refresh writes `account_setting` rows, whose reads answer
100
+ * absent rather than throwing. Any sync method that reads or writes a second
101
+ * entity owes the same, or these catches must be narrowed (match the mailboxId /
102
+ * re-check existence) before a NotFoundError from elsewhere is silently acked.
85
103
  */
86
104
 
87
105
  /**
@@ -158,13 +176,19 @@ const handleCreate = async (
158
176
  { accountId, mailboxId, path },
159
177
  "Mailbox already exists, marking as synced",
160
178
  );
161
- await mailboxService.update(accountId, mailboxId, {
162
- syncStatus: MailboxSyncStatus.synced,
163
- });
179
+ await recordCreateOutcome(
180
+ mailboxService,
181
+ accountId,
182
+ mailboxId,
183
+ MailboxSyncStatus.synced,
184
+ );
164
185
  } else {
165
- await mailboxService.update(accountId, mailboxId, {
166
- syncStatus: MailboxSyncStatus.failed,
167
- });
186
+ await recordCreateOutcome(
187
+ mailboxService,
188
+ accountId,
189
+ mailboxId,
190
+ MailboxSyncStatus.failed,
191
+ );
168
192
  throw error;
169
193
  }
170
194
  })
@@ -206,6 +230,7 @@ const handleRename = async (
206
230
 
207
231
  const {
208
232
  account: accountService,
233
+ accountSetting: accountSettingService,
209
234
  mailbox: mailboxService,
210
235
  secrets,
211
236
  } = await getClient();
@@ -246,36 +271,91 @@ const handleRename = async (
246
271
  newPath,
247
272
  scope.getConnection,
248
273
  )
249
- .then((result) => {
250
- if (result.success) {
251
- log.info(
252
- { accountId, mailboxId, oldPath, newPath },
253
- "Mailbox renamed on IMAP",
254
- );
255
- } else {
274
+ .then(async (result) => {
275
+ if (!result.success) {
256
276
  log.error(
257
277
  { accountId, mailboxId, oldPath, newPath, error: result.error },
258
278
  "Failed to rename mailbox on IMAP",
259
279
  );
280
+ return;
260
281
  }
282
+ log.info(
283
+ { accountId, mailboxId, oldPath, newPath },
284
+ "Mailbox renamed on IMAP",
285
+ );
286
+ if (!result.renamed) return;
287
+ // The path recorded beside each role appointment (#887) moves with
288
+ // the settle, because under D2 that is the first moment the new
289
+ // path is one the server holds.
290
+ //
291
+ // A label is display-only and this runs after the server rename
292
+ // landed, so letting it fail would take the job down the failure
293
+ // path and mark a completed rename refused. Logged and dropped;
294
+ // the next appointment write records the folder's current path
295
+ // anyway.
296
+ await refreshFolderAppointmentLabels(
297
+ accountSettingService,
298
+ account.accountConfigId,
299
+ accountId,
300
+ {
301
+ mailboxId,
302
+ oldPath: result.renamed.oldPath,
303
+ newPath: result.renamed.newPath,
304
+ },
305
+ result.renamed.delimiter,
306
+ ).catch((error: unknown) => {
307
+ log.error(
308
+ { accountId, mailboxId, oldPath, newPath, error },
309
+ "Renamed folder settled, but its role-appointment labels did not move",
310
+ );
311
+ });
261
312
  })
262
313
  .catch(async (error) => {
263
- // If source not found, delete local mailbox
264
- if (isMailboxAbsentUpstream(error)) {
265
- log.info(
266
- { accountId, mailboxId, oldPath },
267
- "Source mailbox not found, deleting local",
314
+ // The server executed the rename and only the local settle did not
315
+ // finish. That is not a refused rename: marking the rows `failed`
316
+ // would offer a retry of work already done and leave `fullPath`
317
+ // naming a path the server no longer holds. Rethrow, so the
318
+ // redelivery finishes the settle against an intent still standing.
319
+ if (error instanceof FolderRenameSettleError) {
320
+ log.error(
321
+ { accountId, mailboxId, oldPath, newPath, error },
322
+ "Rename landed on the server; the settle will finish on redelivery",
268
323
  );
269
- await mailboxService.delete(accountId, mailboxId);
270
- } else {
271
- // Rollback local rename by restoring old path
272
- await mailboxService.update(accountId, mailboxId, {
273
- fullPath: oldPath,
274
- oldPath: undefined,
275
- syncStatus: MailboxSyncStatus.failed,
276
- });
277
324
  throw error;
278
325
  }
326
+ // The folder the rename was to move is confirmed gone: the
327
+ // server refused the RENAME as non-existent and its own listing
328
+ // held neither path. The rename decides that, once, where the
329
+ // evidence is — re-reading the response code here would make
330
+ // every `NONEXISTENT` this code cannot classify destroy the
331
+ // folder's mail. Removing the root row alone is the orphaning
332
+ // bug: every descendant recorded the same intent (D6) and would
333
+ // be left `pending` at a path that does not exist, with its mail
334
+ // stranded out of every reader and still in the search index
335
+ // (D8).
336
+ if (error instanceof FolderGoneUpstreamError) {
337
+ log.info(
338
+ { accountId, mailboxId, oldPath, intent: "rename" },
339
+ "Source mailbox not found, deleting local folder and its mail",
340
+ );
341
+ await managementService.abandonRenameSubtree(
342
+ accountId,
343
+ mailboxId,
344
+ oldPath,
345
+ newPath,
346
+ );
347
+ return;
348
+ }
349
+ // T6. Nothing is restored — `fullPath` was never written, and
350
+ // each intent-carrying row keeps its target so the client can
351
+ // name what the rename was aiming at and offer a retry.
352
+ await managementService.failRename(
353
+ accountId,
354
+ mailboxId,
355
+ oldPath,
356
+ newPath,
357
+ );
358
+ throw error;
279
359
  })
280
360
  .finally(() => scope.disconnect());
281
361
  } catch (error) {
@@ -370,9 +450,12 @@ const handleDelete = async (
370
450
  error.message.includes("Cannot delete INBOX")
371
451
  ) {
372
452
  // Restore the mailbox
373
- await mailboxService.update(accountId, mailboxId, {
374
- syncStatus: MailboxSyncStatus.synced,
375
- });
453
+ await recordOutcome(
454
+ mailboxService,
455
+ accountId,
456
+ mailboxId,
457
+ MailboxSyncStatus.synced,
458
+ );
376
459
  log.error(
377
460
  { accountId, mailboxId, path },
378
461
  "Cannot delete INBOX, restoring mailbox",
@@ -380,9 +463,12 @@ const handleDelete = async (
380
463
  // Don't rethrow - this is an expected error
381
464
  } else {
382
465
  // Restore the mailbox on other errors
383
- await mailboxService.update(accountId, mailboxId, {
384
- syncStatus: MailboxSyncStatus.failed,
385
- });
466
+ await recordOutcome(
467
+ mailboxService,
468
+ accountId,
469
+ mailboxId,
470
+ MailboxSyncStatus.failed,
471
+ );
386
472
  throw error;
387
473
  }
388
474
  })
@@ -6,7 +6,7 @@ import {
6
6
  BodySyncService,
7
7
  guardConnectionCursor,
8
8
  isCursorRebuildNeeded,
9
- isFolderOffServer,
9
+ isFolderMutationInFlight,
10
10
  MailboxCursorPausedError,
11
11
  PlacementMoveService,
12
12
  QuarantineService,
@@ -179,10 +179,10 @@ export const syncMessageBody = async (
179
179
  // A folder still `pending` is terminal for the same reason: the batch
180
180
  // was cut before the folder reached the server, so there is nothing
181
181
  // there to fetch from and no retry that changes it.
182
- if (!mailbox || isFolderOffServer(mailbox)) {
182
+ if (!mailbox || isFolderMutationInFlight(mailbox)) {
183
183
  log.warn(
184
184
  { accountId, mailboxId, eventId: event.eventId },
185
- "Skipping SYNC_MESSAGE_BODY: the server does not hold this folder",
185
+ "Skipping SYNC_MESSAGE_BODY: a folder mutation is in flight",
186
186
  );
187
187
  return;
188
188
  }
@@ -320,7 +320,7 @@ describe("syncMessages — terminal handling of events for a deleted mailbox (is
320
320
  "a deleted mailbox must short-circuit before the OAuth/connection lifecycle",
321
321
  );
322
322
  const skip = warns.find((w) =>
323
- w.msg.includes("the server does not hold this folder"),
323
+ w.msg.includes("a folder mutation is in flight"),
324
324
  );
325
325
  assert.ok(skip, "expected a WARN naming the skipped deleted mailbox");
326
326
  assert.equal(skip.fields.accountId, "acc-1");
@@ -371,9 +371,7 @@ describe("syncMessages — terminal handling of events for a deleted mailbox (is
371
371
 
372
372
  assert.equal(harness.lifecycleCalls, 0);
373
373
  assert.ok(
374
- warns.find((w) =>
375
- w.msg.includes("the server does not hold this folder"),
376
- ),
374
+ warns.find((w) => w.msg.includes("a folder mutation is in flight")),
377
375
  );
378
376
  });
379
377
  }
@@ -19,7 +19,7 @@ import { type Logger, recordImapFailure } from "@remit/logger-lambda";
19
19
  import { RefreshTokenError } from "@remit/mail-oauth-service";
20
20
  import {
21
21
  createManagedConnectionFactory,
22
- isMailboxNotOnServer,
22
+ isMailboxMutationInFlight,
23
23
  MailConnectionError,
24
24
  type MailCredentials,
25
25
  MessageSyncService,
@@ -133,17 +133,23 @@ export const syncMessages = async (
133
133
  return;
134
134
  }
135
135
 
136
- // A SYNC_MESSAGES trigger can address a folder the server does not hold: the
137
- // fan-out enqueues one for a folder whose own create has not landed yet, and
138
- // a `hasMore` next-batch or a periodic tick outlives a folder the user
139
- // deleted. Such an event can never succeed on retry, and the account's
140
- // per-group FIFO ordering lets that one poison message stall the
141
- // whole account's message pipeline for the full visibility window (issue
142
- // #287, #339). A folder the server does not hold — not yet created, or being
143
- // deleted — is an expected terminal outcome, not an infra failure: ack the
144
- // event with a WARN.
136
+ // A SYNC_MESSAGES trigger can address a folder a mutation still owns: the
137
+ // fan-out enqueues one for a folder whose own create has not landed yet, a
138
+ // `hasMore` next-batch or a periodic tick outlives a folder the user
139
+ // deleted, and a rename in flight leaves the folder on the server but under
140
+ // the worker that is moving it. Such an event can never succeed on retry,
141
+ // and the account's per-group FIFO ordering lets that one poison message
142
+ // stall the whole account's message pipeline for the full visibility window
143
+ // (issue #287, #339). A folder with a create, rename or delete outstanding
144
+ // is an expected terminal outcome, not an infra failure: ack the event with
145
+ // a WARN. The rename case is conservative rather than necessary — syncing it
146
+ // would be safe — and keeps the guard a single expression (D12).
145
147
  if (
146
- await isMailboxNotOnServer(mailboxService, event.accountId, event.mailboxId)
148
+ await isMailboxMutationInFlight(
149
+ mailboxService,
150
+ event.accountId,
151
+ event.mailboxId,
152
+ )
147
153
  ) {
148
154
  log.warn(
149
155
  {
@@ -152,7 +158,7 @@ export const syncMessages = async (
152
158
  eventId: event.eventId,
153
159
  event: event.type,
154
160
  },
155
- "Skipping SYNC_MESSAGES: the server does not hold this folder",
161
+ "Skipping SYNC_MESSAGES: a folder mutation is in flight",
156
162
  );
157
163
  return;
158
164
  }
@@ -214,7 +220,7 @@ export const syncMessages = async (
214
220
  // never become the failure that is reported. A read that cannot answer
215
221
  // leaves the round on the loud path below, with the real error and the
216
222
  // state write that goes with it intact.
217
- const folderIsGone = await isMailboxNotOnServer(
223
+ const folderIsGone = await isMailboxMutationInFlight(
218
224
  mailboxService,
219
225
  event.accountId,
220
226
  event.mailboxId,