@remit/imap-worker 0.0.74 → 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.74",
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,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");
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");
273
392
  });
274
393
 
275
- 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();
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");
289
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");
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,7 +473,126 @@ 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);
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");
309
596
  });
310
597
  });
311
598
 
@@ -443,7 +730,9 @@ describe("processMailboxManagement — a tagged NO the server means as success (
443
730
  assert.equal(lastSettle().to, "failed");
444
731
  });
445
732
 
446
- 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();
447
736
  h.connection.renameMailbox = async () => {
448
737
  throw Object.assign(new Error("Command failed"), {
449
738
  serverResponseCode: "NONEXISTENT",
@@ -454,10 +743,14 @@ describe("processMailboxManagement — a tagged NO the server means as success (
454
743
  await assert.doesNotReject(
455
744
  processMailboxManagement(renameEvent, noopLogger, deps()),
456
745
  );
457
- 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
+ ]);
458
750
  });
459
751
 
460
- 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();
461
754
  h.connection.renameMailbox = async () => {
462
755
  throw Object.assign(new Error("Command failed"), {
463
756
  serverResponseCode: "SERVERBUG",
@@ -1,10 +1,15 @@
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
8
  EVERY_MAILBOX_STATE,
9
+ FolderGoneUpstreamError,
10
+ FolderRenameSettleError,
11
+ isMailboxAbsentUpstream,
12
+ isMailboxPresentUpstream,
8
13
  MailboxManagementService,
9
14
  } from "@remit/mailbox-service";
10
15
  import { isAccountDeleted } from "../account-check.js";
@@ -32,72 +37,50 @@ const defaultDeps: MailboxManagementDeps = {
32
37
  createConnectionScope: createConnectionScopeWithCredentials,
33
38
  };
34
39
 
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
- /**
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.
55
- */
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
40
  /**
67
41
  * Write the outcome of a folder operation back, as the conditional write that
68
42
  * is the only way to write a folder's state
69
43
  * (docs/architecture/folder-rename-and-delete.md D3).
70
44
  *
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.
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.
77
50
  */
78
51
  const recordOutcome = async (
79
52
  mailboxService: Pick<IMailboxRepository, "transition">,
80
53
  accountId: string,
81
54
  mailboxId: string,
82
55
  to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
83
- confirmedPath?: string,
84
56
  ): Promise<void> => {
85
57
  const written = await mailboxService.transition(accountId, mailboxId, {
86
58
  from: EVERY_MAILBOX_STATE,
87
59
  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 } : {},
60
+ set: {},
92
61
  });
93
62
  if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
94
63
  };
95
64
 
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));
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}`);
101
84
  };
102
85
 
103
86
  /**
@@ -110,12 +93,13 @@ const isMailboxPresentUpstream = (error: unknown): boolean => {
110
93
  * never an unrelated missing entity that should have been retried.
111
94
  *
112
95
  * `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.
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.
119
103
  */
120
104
 
121
105
  /**
@@ -192,14 +176,14 @@ const handleCreate = async (
192
176
  { accountId, mailboxId, path },
193
177
  "Mailbox already exists, marking as synced",
194
178
  );
195
- await recordOutcome(
179
+ await recordCreateOutcome(
196
180
  mailboxService,
197
181
  accountId,
198
182
  mailboxId,
199
183
  MailboxSyncStatus.synced,
200
184
  );
201
185
  } else {
202
- await recordOutcome(
186
+ await recordCreateOutcome(
203
187
  mailboxService,
204
188
  accountId,
205
189
  mailboxId,
@@ -246,6 +230,7 @@ const handleRename = async (
246
230
 
247
231
  const {
248
232
  account: accountService,
233
+ accountSetting: accountSettingService,
249
234
  mailbox: mailboxService,
250
235
  secrets,
251
236
  } = await getClient();
@@ -286,38 +271,91 @@ const handleRename = async (
286
271
  newPath,
287
272
  scope.getConnection,
288
273
  )
289
- .then((result) => {
290
- if (result.success) {
291
- log.info(
292
- { accountId, mailboxId, oldPath, newPath },
293
- "Mailbox renamed on IMAP",
294
- );
295
- } else {
274
+ .then(async (result) => {
275
+ if (!result.success) {
296
276
  log.error(
297
277
  { accountId, mailboxId, oldPath, newPath, error: result.error },
298
278
  "Failed to rename mailbox on IMAP",
299
279
  );
280
+ return;
300
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
+ });
301
312
  })
302
313
  .catch(async (error) => {
303
- // If source not found, delete local mailbox
304
- if (isMailboxAbsentUpstream(error)) {
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",
323
+ );
324
+ throw error;
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) {
305
337
  log.info(
306
- { accountId, mailboxId, oldPath },
307
- "Source mailbox not found, deleting local",
338
+ { accountId, mailboxId, oldPath, intent: "rename" },
339
+ "Source mailbox not found, deleting local folder and its mail",
308
340
  );
309
- await mailboxService.delete(accountId, mailboxId);
310
- } else {
311
- // Rollback local rename by restoring old path
312
- await recordOutcome(
313
- mailboxService,
341
+ await managementService.abandonRenameSubtree(
314
342
  accountId,
315
343
  mailboxId,
316
- MailboxSyncStatus.failed,
317
344
  oldPath,
345
+ newPath,
318
346
  );
319
- throw error;
347
+ return;
320
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;
321
359
  })
322
360
  .finally(() => scope.disconnect());
323
361
  } catch (error) {