@remit/imap-worker 0.0.75 → 0.0.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/imap-worker",
3
- "version": "0.0.75",
3
+ "version": "0.0.76",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -596,37 +596,57 @@ describe("processMailboxManagement — the seventh state (#363, D3)", () => {
596
596
  });
597
597
  });
598
598
 
599
+ /** The delete intent a MAILBOX_DELETE is settled against. */
600
+ const recordDeleteIntent = (): void => {
601
+ h.rows.set(
602
+ "mbx-1",
603
+ row({ mailboxId: "mbx-1", fullPath: "Archive", syncStatus: "deleting" }),
604
+ );
605
+ };
606
+
599
607
  describe("processMailboxManagement — MAILBOX_DELETE", () => {
600
608
  beforeEach(() => {
601
609
  h = fresh();
610
+ recordDeleteIntent();
602
611
  });
603
612
 
604
- it("deletes on the server and drops the local row", async () => {
613
+ it("deletes on the server and takes the folder's mail with it", async () => {
605
614
  await processMailboxManagement(deleteEvent, noopLogger, deps());
606
615
 
607
616
  assert.equal(called("connection.deleteMailbox")[0]?.args[0], "Archive");
608
- assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
617
+ assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
618
+ "acc-1",
619
+ "mbx-1",
620
+ ]);
621
+ assert.equal(
622
+ called("mailbox.delete").length,
623
+ 0,
624
+ "the bare row delete is what orphaned the folder's mail",
625
+ );
609
626
  });
610
627
 
611
- it("drops the local row when the folder is already gone on the server", async () => {
628
+ it("removes the folder's mail when the folder is already gone on the server", async () => {
612
629
  h.connection.deleteMailbox = async () => {
613
630
  throw new Error("Mailbox not found");
614
631
  };
615
632
 
616
633
  await processMailboxManagement(deleteEvent, noopLogger, deps());
617
634
 
618
- assert.equal(called("mailbox.delete").length, 1);
635
+ assert.equal(called("mailbox.deleteMailboxWithMail").length, 1);
619
636
  });
620
637
 
621
- it("restores the mailbox and swallows the error when the server refuses to delete INBOX", async () => {
638
+ it("marks the delete failed rather than synced when the server refuses INBOX", async () => {
639
+ // The API refuses a delete of INBOX, so this backstop is unreachable from
640
+ // it. Reached another way, the honest outcome is `failed`: the folder was
641
+ // never deleted, and nothing was undone.
622
642
  h.connection.deleteMailbox = async () => {
623
643
  throw new Error("Cannot delete INBOX");
624
644
  };
625
645
 
626
646
  await processMailboxManagement(deleteEvent, noopLogger, deps());
627
647
 
628
- assert.equal(lastSettle().to, "synced");
629
- assert.equal(called("mailbox.delete").length, 0);
648
+ assert.equal(lastSettle().to, "failed");
649
+ assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
630
650
  });
631
651
 
632
652
  it("marks the mailbox failed and rethrows on any other delete error", async () => {
@@ -642,14 +662,21 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
642
662
  assert.equal(lastSettle().to, "failed");
643
663
  });
644
664
 
645
- it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
646
- h.connection.deleteMailbox = async () => {
647
- throw new Error("server exploded");
648
- };
665
+ it("issues no DELETE when the row is not deleting", async () => {
666
+ h.rows.set("mbx-1", row({ mailboxId: "mbx-1", syncStatus: "synced" }));
667
+
668
+ await processMailboxManagement(deleteEvent, noopLogger, deps());
669
+
670
+ assert.equal(called("connection.deleteMailbox").length, 0);
671
+ assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
672
+ });
673
+
674
+ it("acks terminally without connecting when the folder row is gone", async () => {
649
675
  h.mailboxRowGone = true;
650
676
 
651
677
  await processMailboxManagement(deleteEvent, noopLogger, deps());
652
678
 
679
+ assert.equal(called("connection.deleteMailbox").length, 0);
653
680
  assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
654
681
  });
655
682
  });
@@ -665,7 +692,8 @@ describe("processMailboxManagement — a tagged NO the server means as success (
665
692
  * only the message treated an already-absent folder as a failure: the row was
666
693
  * marked failed and the event left poisoning the account's queue.
667
694
  */
668
- it("treats it as the delete already having happened, and drops the local row", async () => {
695
+ it("treats it as the delete already having happened, mail and all", async () => {
696
+ recordDeleteIntent();
669
697
  h.connection.deleteMailbox = async () => {
670
698
  throw Object.assign(new Error("Command failed"), {
671
699
  serverResponseCode: "NONEXISTENT",
@@ -676,11 +704,15 @@ describe("processMailboxManagement — a tagged NO the server means as success (
676
704
  await assert.doesNotReject(
677
705
  processMailboxManagement(deleteEvent, noopLogger, deps()),
678
706
  );
679
- assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
707
+ assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
708
+ "acc-1",
709
+ "mbx-1",
710
+ ]);
680
711
  assert.equal(called("mailbox.transition").length, 0);
681
712
  });
682
713
 
683
714
  it("still marks failed and rethrows when the server fails for any other reason", async () => {
715
+ recordDeleteIntent();
684
716
  h.connection.deleteMailbox = async () => {
685
717
  throw Object.assign(new Error("Command failed"), {
686
718
  serverResponseCode: "SERVERBUG",
@@ -5,7 +5,6 @@ import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
5
5
  import { MailboxSyncStatus } from "@remit/domain-enums";
6
6
  import type { Logger } from "@remit/logger-lambda";
7
7
  import {
8
- EVERY_MAILBOX_STATE,
9
8
  FolderGoneUpstreamError,
10
9
  FolderRenameSettleError,
11
10
  isMailboxAbsentUpstream,
@@ -37,31 +36,6 @@ const defaultDeps: MailboxManagementDeps = {
37
36
  createConnectionScope: createConnectionScopeWithCredentials,
38
37
  };
39
38
 
40
- /**
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).
44
- *
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.
50
- */
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}`);
63
- };
64
-
65
39
  /**
66
40
  * The outcome of a create, written against the state a create in flight is:
67
41
  * `pending` with no recorded rename target (D3, D10). Without the absence
@@ -438,37 +412,32 @@ const handleDelete = async (
438
412
  }
439
413
  })
440
414
  .catch(async (error) => {
441
- // If mailbox not found, it's already deleted (idempotent)
415
+ // The folder is already gone from the server, so the delete has
416
+ // happened. Its mail still goes with it (D8), which is the whole
417
+ // point of the settle.
442
418
  if (isMailboxAbsentUpstream(error)) {
443
419
  log.info(
444
- { accountId, mailboxId, path },
445
- "Mailbox not found on IMAP, deleting local",
446
- );
447
- await mailboxService.delete(accountId, mailboxId);
448
- } else if (
449
- error instanceof Error &&
450
- error.message.includes("Cannot delete INBOX")
451
- ) {
452
- // Restore the mailbox
453
- await recordOutcome(
454
- mailboxService,
455
- accountId,
456
- mailboxId,
457
- MailboxSyncStatus.synced,
458
- );
459
- log.error(
460
- { accountId, mailboxId, path },
461
- "Cannot delete INBOX, restoring mailbox",
420
+ { accountId, mailboxId, path, intent: "delete" },
421
+ "Mailbox not found on IMAP, removing the local folder and its mail",
462
422
  );
463
- // Don't rethrow - this is an expected error
423
+ await managementService.settleDelete(accountId, mailboxId);
464
424
  } else {
465
- // Restore the mailbox on other errors
466
- await recordOutcome(
467
- mailboxService,
468
- accountId,
469
- mailboxId,
470
- MailboxSyncStatus.failed,
471
- );
425
+ // T9, INBOX included. The API refuses a delete of INBOX, so the
426
+ // worker's backstop is unreachable from it; if something else
427
+ // reaches it, the honest outcome is `failed`, not `synced` —
428
+ // the folder was never deleted and nothing was undone.
429
+ await managementService.failDelete(accountId, mailboxId);
430
+ if (
431
+ error instanceof Error &&
432
+ error.message.includes("Cannot delete INBOX")
433
+ ) {
434
+ log.error(
435
+ { accountId, mailboxId, path, intent: "delete" },
436
+ "Cannot delete INBOX",
437
+ );
438
+ // Don't rethrow — no retry can make this succeed.
439
+ return;
440
+ }
472
441
  throw error;
473
442
  }
474
443
  })