@remit/mailbox-service 0.0.95 → 0.0.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,15 @@
1
- import type { IMailboxRepository } from "@remit/data-ports";
1
+ import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
2
2
  import { NotFoundError } from "@remit/data-ports/errors";
3
+ import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
3
4
  import { MailboxSyncStatus } from "@remit/domain-enums";
4
- import { EVERY_MAILBOX_STATE, isNotFoundError } from "./mailbox-presence.js";
5
- import type { IImapConnection } from "./types.js";
5
+ import { recordedByRename } from "./mailbox-intent.js";
6
+ import { isNotFoundError } from "./mailbox-presence.js";
7
+ import {
8
+ FolderGoneUpstreamError,
9
+ FolderRenameSettleError,
10
+ isMailboxAbsentUpstream,
11
+ } from "./mailbox-upstream.js";
12
+ import type { FlatMailboxInfo, IImapConnection } from "./types.js";
6
13
 
7
14
  /**
8
15
  * Input for creating a mailbox
@@ -35,6 +42,12 @@ export interface DeleteMailboxInput {
35
42
  export interface MailboxManagementSyncResult {
36
43
  success: boolean;
37
44
  error?: string;
45
+ /**
46
+ * Where the folder ended up, on a rename the server confirmed. Absent when
47
+ * the recorded intent had already moved on, so no RENAME was issued —
48
+ * which is how a caller tells the two apart without a second read.
49
+ */
50
+ renamed?: { oldPath: string; newPath: string; delimiter: string };
38
51
  }
39
52
 
40
53
  /**
@@ -93,9 +106,38 @@ export const validateMailboxOperation = (
93
106
  operation: "delete" | "rename",
94
107
  path: string,
95
108
  ): void => {
96
- if (path.toUpperCase() === "INBOX" && operation === "delete") {
97
- throw new Error("Cannot delete INBOX");
98
- }
109
+ if (path.toUpperCase() !== "INBOX") return;
110
+ // A RENAME of INBOX moves its mail to the new name and leaves an empty INBOX
111
+ // behind — a bulk move wearing a rename's clothes, which strands the
112
+ // account's inbox appointment and voids the row's UID cursor (D5). The API
113
+ // refuses it; this is the backstop for anything that reaches the worker
114
+ // another way.
115
+ throw new Error(
116
+ operation === "delete" ? "Cannot delete INBOX" : "Cannot rename INBOX",
117
+ );
118
+ };
119
+
120
+ /**
121
+ * Whether the server's own listing holds a path this code named.
122
+ *
123
+ * The requested string and the path the server keeps are not the same thing.
124
+ * Under a namespace prefix a Dovecot INBOX namespace stores `Projects` as
125
+ * `INBOX.Projects`, which is why the settle adopts the path ImapFlow resolved
126
+ * rather than the one that was asked for (D2) — and a probe comparing the raw
127
+ * request against the listing would read a folder that is plainly there as
128
+ * absent. There is no resolved path to adopt on the failure path, so the
129
+ * account's own prefix stands in for the normalization.
130
+ */
131
+ const holdsPath = (
132
+ onServer: ReadonlySet<string>,
133
+ mailbox: Pick<MailboxItem, "namespacePrefix">,
134
+ path: string,
135
+ ): boolean => {
136
+ if (onServer.has(path)) return true;
137
+ const { namespacePrefix } = mailbox;
138
+ if (namespacePrefix.length === 0) return false;
139
+ if (path.startsWith(namespacePrefix)) return false;
140
+ return onServer.has(`${namespacePrefix}${path}`);
99
141
  };
100
142
 
101
143
  /**
@@ -117,27 +159,103 @@ export class MailboxManagementService {
117
159
  }
118
160
 
119
161
  /**
120
- * Write the settled state back, as the conditional write that is now the
121
- * only way to write it at all (D3). The from-set is every state because that
122
- * is what this settle decides against today — it is the unconditional write
123
- * it replaces, moved onto the door; #363 narrows it to the state its intent
124
- * recorded and gives a lost predicate its `already-settled` outcome.
162
+ * Settle a create: `pending` with **no** recorded rename target, which is the
163
+ * state a create in flight is (D3, D10).
164
+ *
165
+ * The absence check is what the whole `wherePendingPath` predicate exists
166
+ * for. A create whose acknowledgement was lost, redelivered against a row a
167
+ * rename has since claimed, passes a `pending`-only guard; the CREATE
168
+ * collides, ImapFlow reports the collision as `{created: false}` rather than
169
+ * an error, and this write then strands the row `synced` with a rename
170
+ * target on it — the seventh combination — leaving the rename's own settle
171
+ * with nothing to match and the RENAME never running.
125
172
  *
126
- * A null means the row is gone, which is what the unconditional write raised
127
- * a NotFoundError for, and what the handlers' #289-class terminal guards
128
- * classify as the user having deleted the folder mid-sync.
173
+ * A null means the row is gone or the intent moved on. The handlers'
174
+ * #289-class terminal guards classify the NotFoundError as the user having
175
+ * deleted the folder mid-sync.
129
176
  */
130
- private settle = async (
177
+ private settleCreate = async (
131
178
  accountId: string,
132
179
  mailboxId: string,
133
180
  confirmedPath?: string,
134
181
  ): Promise<void> => {
135
182
  const settled = await this.mailboxService.transition(accountId, mailboxId, {
136
- from: EVERY_MAILBOX_STATE,
183
+ from: [MailboxSyncStatus.pending],
184
+ wherePendingPath: null,
137
185
  to: MailboxSyncStatus.synced,
138
186
  set: confirmedPath !== undefined ? { fullPath: confirmedPath } : {},
139
187
  });
140
- if (!settled) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
188
+ if (settled) return;
189
+ // Either the row is gone, or a rename claimed it while this create was in
190
+ // flight. Both resolve the job: the folder exists, and the rename is the
191
+ // live intent.
192
+ const current = await this.mailboxService
193
+ .get(accountId, mailboxId)
194
+ .catch((error: unknown) => {
195
+ if (isNotFoundError(error)) return undefined;
196
+ throw error;
197
+ });
198
+ if (!current) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
199
+ this.log.info(
200
+ {
201
+ accountId,
202
+ mailboxId,
203
+ intent: "create",
204
+ from: current.syncStatus,
205
+ outcome: "superseded",
206
+ },
207
+ "Folder create superseded",
208
+ );
209
+ };
210
+
211
+ /**
212
+ * The rows one rename intent recorded, each with the path it settles to.
213
+ *
214
+ * The settle deliberately does not re-resolve the subtree (D15): membership
215
+ * changes between the intent and the settle — the sweep inserts a row,
216
+ * another client deletes a descendant — and an all-or-nothing settle then
217
+ * either throws, poisoning the account's FIFO group (#339), or resolves
218
+ * leaving every row `pending` forever with no route out, because `failed` is
219
+ * only reachable from a settle that ran.
220
+ *
221
+ * So the rows identify themselves, by the pair `recordedByRename` tests: a
222
+ * target at or under the rename's target is not on its own enough, because a
223
+ * second folder renamed under the same branch would be claimed by a rename
224
+ * that never touched it. A row that appeared afterwards carries no such pair
225
+ * and is not touched.
226
+ *
227
+ * `confirmedPath` re-prefixes each recorded target off the path the rename
228
+ * actually resolved to, so a normalization the requested string did not carry
229
+ * moves the whole branch rather than the folder alone.
230
+ *
231
+ * `delimiter` is the renamed folder's own, read from the row the caller
232
+ * already holds — deriving one from whatever the listing happens to contain
233
+ * strands every descendant when that row has gone.
234
+ */
235
+ private intentCarryingRows = async (
236
+ accountId: string,
237
+ oldPath: string,
238
+ target: string,
239
+ confirmed: string,
240
+ delimiter: string,
241
+ ): Promise<{ row: MailboxItem; confirmedPath: string }[]> => {
242
+ const pending = await this.mailboxService.findBySyncStatus(
243
+ accountId,
244
+ MailboxSyncStatus.pending,
245
+ );
246
+ const carrying: { row: MailboxItem; confirmedPath: string }[] = [];
247
+ for (const row of pending) {
248
+ if (!recordedByRename(row, oldPath, target, delimiter)) continue;
249
+ const confirmedPath = rebaseMailboxPath(
250
+ row.pendingPath as string,
251
+ target,
252
+ confirmed,
253
+ delimiter,
254
+ );
255
+ if (confirmedPath === undefined) continue;
256
+ carrying.push({ row, confirmedPath });
257
+ }
258
+ return carrying;
141
259
  };
142
260
 
143
261
  /**
@@ -206,25 +324,30 @@ export class MailboxManagementService {
206
324
  uidNext: status.uidnext,
207
325
  messageCount: status.messages.total,
208
326
  });
209
- await this.settle(accountId, mailboxId, confirmedPath);
327
+ await this.settleCreate(accountId, mailboxId, confirmedPath);
210
328
 
211
329
  await connection.closeBox();
212
330
  } else {
213
331
  // Mark as synced even if we couldn't get full info
214
- await this.settle(accountId, mailboxId, confirmedPath);
332
+ await this.settleCreate(accountId, mailboxId, confirmedPath);
215
333
  }
216
334
 
217
335
  return { success: true };
218
336
  };
219
337
 
220
338
  /**
221
- * Sync a RENAME operation to IMAP.
339
+ * Sync a RENAME operation to IMAP and settle the rows that recorded it (T5).
222
340
  * Called by worker after dequeuing MAILBOX_RENAME event.
223
341
  *
342
+ * Guarded on the intent, not on the event (D10): the row must still be
343
+ * `pending` with this event's target recorded, or the rename resolves with no
344
+ * IMAP call at all. That is what stops a redelivered MAILBOX_RENAME re-issuing
345
+ * a RENAME from a path that has already moved.
346
+ *
224
347
  * @param accountId - Account that owns the mailbox (tenant scope)
225
348
  * @param mailboxId - ID of the mailbox to rename
226
- * @param oldPath - Current path of the mailbox
227
- * @param newPath - New path for the mailbox
349
+ * @param oldPath - Path the intent was recorded against
350
+ * @param newPath - Target the intent recorded
228
351
  * @param getConnection - Factory to get IMAP connection
229
352
  */
230
353
  syncRename = async (
@@ -234,75 +357,336 @@ export class MailboxManagementService {
234
357
  newPath: string,
235
358
  getConnection: () => Promise<IImapConnection>,
236
359
  ): Promise<MailboxManagementSyncResult> => {
360
+ const standing = await this.renameIntentStanding(
361
+ accountId,
362
+ mailboxId,
363
+ newPath,
364
+ );
365
+ if (!standing) return { success: true };
366
+
237
367
  const connection = await getConnection();
368
+ const delimiter = standing.hierarchyDelimiter;
238
369
 
239
- await connection.renameMailbox(oldPath, newPath);
370
+ const confirmed = await this.issueRename(
371
+ connection,
372
+ standing,
373
+ accountId,
374
+ mailboxId,
375
+ oldPath,
376
+ newPath,
377
+ );
240
378
 
241
379
  this.log.info(
242
- { mailboxId, oldPath, newPath },
380
+ { accountId, mailboxId, intent: "rename", oldPath, newPath, confirmed },
243
381
  "Renamed mailbox on IMAP server",
244
382
  );
245
383
 
246
- await this.settle(accountId, mailboxId);
247
-
248
- // The server rename is done and the row records it. The settle is repair
249
- // work on top of that, so nothing it hits — the listing included — may reach
250
- // the caller's failure path, which rolls the row back to a path the server
251
- // no longer has and hands it to the reconcile sweep to reap.
252
- await this.settleRenamedSubtree(accountId, newPath, connection).catch(
253
- (error: unknown) => {
254
- this.log.error(
255
- { mailboxId, newPath, error },
256
- "Renamed subtree left pending",
257
- );
384
+ // The server has executed the rename. Nothing after this point may reach
385
+ // the caller's failure path: `failRename` would mark the rows refused for
386
+ // a rename that landed, and the folder would sit at `failed` offering a
387
+ // retry of something already done. A settle that cannot finish is wrapped
388
+ // so the caller can tell the two apart, and rethrown so SQS redelivers —
389
+ // the rows are still `pending` with their targets, so the redelivery's
390
+ // guard passes and the settle runs again.
391
+ await this.settleRenameIntent(
392
+ accountId,
393
+ mailboxId,
394
+ oldPath,
395
+ newPath,
396
+ confirmed,
397
+ delimiter,
398
+ ).catch((error: unknown) => {
399
+ throw new FolderRenameSettleError(mailboxId, confirmed, error);
400
+ });
401
+
402
+ return {
403
+ success: true,
404
+ renamed: { oldPath, newPath: confirmed, delimiter },
405
+ };
406
+ };
407
+
408
+ /**
409
+ * Issue the RENAME and read back the path it resolved to.
410
+ *
411
+ * A `NONEXISTENT` here has three readings, and only one of them may destroy
412
+ * anything. Either another client deleted the folder; or this rename already
413
+ * landed and only its settle was lost, in which case the folder is alive at
414
+ * the target; or the server is saying something this code cannot classify.
415
+ * The listing separates the first two, and everything it cannot answer is a
416
+ * refused rename (T6) — a rename that failed costs the user a retry, while
417
+ * reading an unclear answer as a delete costs them the folder's mail.
418
+ *
419
+ * So the caller is told which of the two it is by the *kind* of error, never
420
+ * by re-reading the server's code: only `FolderGoneUpstreamError` removes a
421
+ * folder, and it is raised exactly where the listing held neither path.
422
+ */
423
+ private issueRename = async (
424
+ connection: IImapConnection,
425
+ standing: MailboxItem,
426
+ accountId: string,
427
+ mailboxId: string,
428
+ oldPath: string,
429
+ newPath: string,
430
+ ): Promise<string> => {
431
+ const result = await connection
432
+ .renameMailbox(oldPath, newPath)
433
+ .catch(async (error: unknown) => {
434
+ if (!isMailboxAbsentUpstream(error)) throw error;
435
+ const listed = await connection
436
+ .listMailboxes()
437
+ .catch((): FlatMailboxInfo[] | undefined => undefined);
438
+ // A listing that cannot be read answers nothing, so it decides
439
+ // nothing: the rename is refused and the folder is left alone.
440
+ if (!listed) throw error;
441
+
442
+ const onServer = new Set(listed.map((mailbox) => mailbox.fullPath));
443
+ if (holdsPath(onServer, standing, newPath)) {
444
+ this.log.info(
445
+ { accountId, mailboxId, intent: "rename", oldPath, newPath },
446
+ "Source folder gone and the target is on the server: this rename already landed",
447
+ );
448
+ return undefined;
449
+ }
450
+ if (holdsPath(onServer, standing, oldPath)) throw error;
451
+
452
+ throw new FolderGoneUpstreamError(mailboxId, oldPath, error);
453
+ });
454
+
455
+ // ImapFlow's own normalization of the requested path — namespace prefix
456
+ // applied, special names resolved, delimiter joined. IMAP's RENAME reply
457
+ // carries no path, so this is not the server echoing a name back and it
458
+ // cannot detect a server that stored a different one; adopting it is still
459
+ // right, because it fixes the prefix and delimiter drift the requested
460
+ // string carries (D2).
461
+ return typeof result?.newPath === "string" && result.newPath.length > 0
462
+ ? result.newPath
463
+ : newPath;
464
+ };
465
+
466
+ /**
467
+ * Whether the rename this event was enqueued for is still the folder's live
468
+ * intent. `pending` alone cannot answer it — that state covers a create in
469
+ * flight too — so the recorded target is half the predicate, and it is the
470
+ * same predicate the settling write carries, so the guard and the write
471
+ * cannot disagree between the read and the write.
472
+ */
473
+ private renameIntentStanding = async (
474
+ accountId: string,
475
+ mailboxId: string,
476
+ newPath: string,
477
+ ): Promise<MailboxItem | undefined> => {
478
+ const row = await this.mailboxService
479
+ .get(accountId, mailboxId)
480
+ .catch((error: unknown) => {
481
+ if (isNotFoundError(error)) return undefined;
482
+ throw error;
483
+ });
484
+ if (
485
+ row &&
486
+ row.syncStatus === MailboxSyncStatus.pending &&
487
+ row.pendingPath === newPath
488
+ ) {
489
+ return row;
490
+ }
491
+ this.log.info(
492
+ {
493
+ accountId,
494
+ mailboxId,
495
+ intent: "rename",
496
+ from: row?.syncStatus,
497
+ newPath,
498
+ outcome: row ? "superseded" : "already-settled",
258
499
  },
500
+ "Skipping MAILBOX_RENAME: the recorded intent has moved on",
259
501
  );
260
-
261
- return { success: true };
502
+ return undefined;
262
503
  };
263
504
 
264
505
  /**
265
- * IMAP RENAME moves the whole subtree in one command, so the descendants the
266
- * local rename marked pending are on the server the moment the parent's is.
267
- * Nothing else settles them: the reconcile sweep treats pending as in-flight
268
- * and leaves it alone, and a descendant left pending is read as off-server, so
269
- * its sync events are terminally acked and its mail never arrives.
506
+ * T5, per row (D15): each intent-carrying row adopts its own confirmed path,
507
+ * drops the recorded target and settles `synced`, on its own conditional
508
+ * write. A row another client moved in between fails its predicate and is
509
+ * skipped rather than overwritten; a row that is gone is skipped for free.
270
510
  *
271
- * A descendant is settled only where the server's own listing holds its path.
272
- * A local path the server has not materialized is still in flight behind
273
- * another queued operation, and stripping its pending marker would expose the
274
- * row to the reconcile sweep.
511
+ * **The named folder is written last, and that ordering is load-bearing.**
512
+ * The redelivery's guard reads that row and nothing else, so it is the
513
+ * witness for the whole settle: while it still carries the intent, a
514
+ * redelivery re-enters and finishes whatever the last attempt left. Settling
515
+ * it first would ack a run that died part-way through its descendants, and
516
+ * those rows would stay `pending` forever — invisible to message sync, and
517
+ * with no route out, because an intent may only be recorded from `synced` or
518
+ * `failed`, so neither a retry nor a dismissal can reach them.
519
+ */
520
+ private settleRenameIntent = async (
521
+ accountId: string,
522
+ mailboxId: string,
523
+ oldPath: string,
524
+ target: string,
525
+ confirmed: string,
526
+ delimiter: string,
527
+ ): Promise<void> => {
528
+ const carrying = await this.intentCarryingRows(
529
+ accountId,
530
+ oldPath,
531
+ target,
532
+ confirmed,
533
+ delimiter,
534
+ );
535
+ const ordered = [
536
+ ...carrying.filter((entry) => entry.row.mailboxId !== mailboxId),
537
+ ...carrying.filter((entry) => entry.row.mailboxId === mailboxId),
538
+ ];
539
+ for (const { row, confirmedPath } of ordered) {
540
+ const settled = await this.mailboxService.transition(
541
+ accountId,
542
+ row.mailboxId,
543
+ {
544
+ from: [MailboxSyncStatus.pending],
545
+ wherePendingPath: row.pendingPath,
546
+ to: MailboxSyncStatus.synced,
547
+ set: { fullPath: confirmedPath },
548
+ },
549
+ );
550
+ this.log.info(
551
+ {
552
+ accountId,
553
+ mailboxId: row.mailboxId,
554
+ intent: "rename",
555
+ from: MailboxSyncStatus.pending,
556
+ to: MailboxSyncStatus.synced,
557
+ fullPath: confirmedPath,
558
+ outcome: settled ? "settled" : "superseded",
559
+ },
560
+ "Settled renamed folder",
561
+ );
562
+ }
563
+ };
564
+
565
+ /**
566
+ * T6: the rename did not land, so every row that recorded it goes to
567
+ * `failed`, keeping its target so the client can name what the rename was
568
+ * aiming at and offer a retry. Nothing is restored — `fullPath` was never
569
+ * written, which is the whole point of recording the target instead (D2).
275
570
  */
276
- private settleRenamedSubtree = async (
571
+ failRename = async (
277
572
  accountId: string,
573
+ mailboxId: string,
574
+ oldPath: string,
278
575
  newPath: string,
279
- connection: IImapConnection,
280
576
  ): Promise<void> => {
281
- const listed = await connection.listMailboxes();
282
- const onServer = new Set(listed.map((mailbox) => mailbox.fullPath));
283
- const delimiter =
284
- listed.find((mailbox) => mailbox.fullPath === newPath)?.delimiter ??
285
- listed[0]?.delimiter ??
286
- "/";
287
-
288
- const descendants = await this.mailboxService.findByPathPrefix(
577
+ const root = await this.mailboxService
578
+ .get(accountId, mailboxId)
579
+ .catch((error: unknown) => {
580
+ if (isNotFoundError(error)) return undefined;
581
+ throw error;
582
+ });
583
+ if (!root) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
584
+
585
+ const carrying = await this.intentCarryingRows(
289
586
  accountId,
587
+ oldPath,
290
588
  newPath,
291
- delimiter,
589
+ newPath,
590
+ root.hierarchyDelimiter,
292
591
  );
592
+ // The row is there and no row carries the intent, so this refusal has
593
+ // nothing to record. It must not be reported as a missing mailbox: the
594
+ // caller's #289-class guard reads a NotFoundError as the user having
595
+ // deleted the folder and acks the message, which would swallow the IMAP
596
+ // error that brought us here and leave the subtree `pending`. Say so and
597
+ // let the caller rethrow what actually failed.
598
+ if (carrying.length === 0) {
599
+ this.log.error(
600
+ {
601
+ accountId,
602
+ mailboxId,
603
+ intent: "rename",
604
+ from: root.syncStatus,
605
+ oldPath,
606
+ newPath,
607
+ outcome: "superseded",
608
+ },
609
+ "Refused rename recorded nothing: no row carries this intent",
610
+ );
611
+ return;
612
+ }
613
+ for (const { row } of carrying) {
614
+ const failed = await this.mailboxService.transition(
615
+ accountId,
616
+ row.mailboxId,
617
+ {
618
+ from: [MailboxSyncStatus.pending],
619
+ wherePendingPath: row.pendingPath,
620
+ to: MailboxSyncStatus.failed,
621
+ },
622
+ );
623
+ this.log.info(
624
+ {
625
+ accountId,
626
+ mailboxId: row.mailboxId,
627
+ intent: "rename",
628
+ from: MailboxSyncStatus.pending,
629
+ to: MailboxSyncStatus.failed,
630
+ outcome: failed ? "refused" : "superseded",
631
+ },
632
+ "Recorded a refused rename",
633
+ );
634
+ }
635
+ };
293
636
 
294
- for (const descendant of descendants) {
295
- if (!onServer.has(descendant.fullPath)) continue;
296
- // The per-row settle of D15: `pending` is the state that carries the
297
- // intent, and the predicate is on the UPDATE, so a row another client
298
- // moved in between is skipped rather than overwritten. A row that is
299
- // gone is skipped for free — a null is not an error here, which is what
300
- // keeps a descendant deleted mid-settle out of the handler's
301
- // whole-chain NotFoundError guard.
302
- await this.mailboxService.transition(accountId, descendant.mailboxId, {
303
- from: [MailboxSyncStatus.pending],
304
- to: MailboxSyncStatus.synced,
637
+ /**
638
+ * The folder the rename was to move was found gone from the server, and
639
+ * the target was not there either, so it was deleted by another client
640
+ * mid-rename (D8). Every row that recorded this rename's intent — the
641
+ * root and each descendant — goes with its mail, the same walk `failRename`
642
+ * uses: deleting the root alone strands every descendant `pending` with
643
+ * nothing left to revisit it, which is the orphaning bug this closes.
644
+ */
645
+ abandonRenameSubtree = async (
646
+ accountId: string,
647
+ mailboxId: string,
648
+ oldPath: string,
649
+ newPath: string,
650
+ ): Promise<void> => {
651
+ const root = await this.mailboxService
652
+ .get(accountId, mailboxId)
653
+ .catch((error: unknown) => {
654
+ if (isNotFoundError(error)) return undefined;
655
+ throw error;
305
656
  });
657
+ if (!root) {
658
+ this.log.info(
659
+ { accountId, mailboxId, intent: "rename", outcome: "already-settled" },
660
+ "Nothing to abandon: the folder row is already gone",
661
+ );
662
+ return;
663
+ }
664
+
665
+ const carrying = await this.intentCarryingRows(
666
+ accountId,
667
+ oldPath,
668
+ newPath,
669
+ newPath,
670
+ root.hierarchyDelimiter,
671
+ );
672
+ // A removal that throws part-way abandons the rest into a redelivery, and
673
+ // that is safe rather than merely tolerable: `deleteMailboxWithMail` is
674
+ // idempotent — it returns without touching anything when the row is gone,
675
+ // and resumes from whatever is left when it is not, because the mailbox
676
+ // row is removed last. The rows still standing carry the intent, so the
677
+ // next attempt resolves the same set and finishes it.
678
+ for (const { row } of carrying) {
679
+ await this.mailboxService.deleteMailboxWithMail(accountId, row.mailboxId);
680
+ this.log.info(
681
+ {
682
+ accountId,
683
+ mailboxId: row.mailboxId,
684
+ intent: "rename",
685
+ from: MailboxSyncStatus.pending,
686
+ to: "deleted",
687
+ },
688
+ "Source mailbox not found, deleted local folder and its mail",
689
+ );
306
690
  }
307
691
  };
308
692
 
@@ -9,12 +9,13 @@ export const isNotFoundError = (error: unknown): boolean =>
9
9
  * against none of them.
10
10
  *
11
11
  * A transition predicate is supposed to name the state the caller read
12
- * (docs/architecture/folder-rename-and-delete.md D3), and the intent recorders
13
- * and settles that use this one name nothing yet: they are today's
12
+ * (docs/architecture/folder-rename-and-delete.md D3). What still uses this one
13
+ * is the delete path — its intent recorder and its settle are today's
14
14
  * unconditional writes, moved onto the only door that can write a folder state
15
- * at all. Each is narrowed to the from-set its transition-table row allows when
16
- * the intents themselves land (#362, #363), which is also where a loser gets
17
- * its 409.
15
+ * at all, and #362 narrows each to the from-set its transition-table row
16
+ * allows, which is also where its loser gets a 409. The rename path is already
17
+ * narrowed: `INTENT_RECORDABLE_FROM` for the intent, and `pending` plus the
18
+ * recorded target for the settle.
18
19
  */
19
20
  export const EVERY_MAILBOX_STATE = [
20
21
  MailboxSyncStatus.synced,