@remit/imap-worker 0.0.54 → 0.0.55

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.54",
3
+ "version": "0.0.55",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -0,0 +1,119 @@
1
+ import type { IMessageRepository } from "@remit/data-ports";
2
+ import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
3
+ import {
4
+ type IImapConnection,
5
+ isMessageGoneFromOpenMailbox,
6
+ reconcileStaleMessage,
7
+ type StaleMessageReconcileDeps,
8
+ } from "@remit/mailbox-service";
9
+
10
+ export interface MessageDeleteTerminalLogger {
11
+ info(obj: Record<string, unknown>, msg: string): void;
12
+ error(obj: Record<string, unknown>, msg: string): void;
13
+ }
14
+
15
+ export interface ResolveExhaustedMessageDeleteDeps
16
+ extends StaleMessageReconcileDeps {
17
+ messageService: Pick<IMessageRepository, "delete" | "update">;
18
+ log: MessageDeleteTerminalLogger;
19
+ }
20
+
21
+ export interface ResolveExhaustedMessageDeleteInput {
22
+ accountId: string;
23
+ accountConfigId: string;
24
+ messageId: string;
25
+ uid: number;
26
+ sourceMailboxPath: string;
27
+ getConnection: () => Promise<IImapConnection>;
28
+ }
29
+
30
+ export type MessageDeleteTerminalOutcome = "reconciled" | "broken";
31
+
32
+ export interface ResolveExhaustedMessageDeleteResult {
33
+ outcome: MessageDeleteTerminalOutcome;
34
+ }
35
+
36
+ /**
37
+ * Resolve a MESSAGE_DELETE failure that has exhausted its redelivery budget
38
+ * into exactly one of two terminal outcomes, mirroring
39
+ * `resolveExhaustedMessageMoveFailure` for the same failure taxonomy (issue
40
+ * #655) — no third, softer outcome.
41
+ *
42
+ * 1. RECONCILED (expected) — the message no longer exists at the delete's
43
+ * source on IMAP, confirmed by {@link isMessageGoneFromOpenMailbox} rather
44
+ * than by a FETCH coming back empty. The move to Trash landed server-side,
45
+ * or a foreign client moved or expunged the message; from here those are
46
+ * indistinguishable and have the same answer. The stale rows are deleted via
47
+ * {@link reconcileStaleMessage} and the caller resyncs the affected folders,
48
+ * so whichever folder actually holds the message re-projects it with the
49
+ * server's own UID. Metric only, no alarm — routine.
50
+ * 2. BROKEN — the message is still at the source, so the delete never took
51
+ * effect, but it keeps failing: broken code or a broken account, not a
52
+ * transient blip. The row's mailbox and uid are left exactly as they stand,
53
+ * because reverting the optimistic move on this ambiguity is what PR #652
54
+ * was pulled for.
55
+ *
56
+ * `status` does settle, to `active`. It is not a claim about where the
57
+ * message is — only that this row is no longer mid-mutation. Leaving it
58
+ * `moving` makes `isPlacementUnsettled` true forever, and every later delete
59
+ * of that message then waits on a mutation that has already terminated, so
60
+ * the user is left with mail they cannot delete.
61
+ *
62
+ * A server that cannot be reached at exhaustion time never reaches either
63
+ * verdict: the probe throws and the record dead-letters with the row untouched.
64
+ * Absence is only ever concluded from an answer the server gave.
65
+ */
66
+ export const resolveExhaustedMessageDeleteFailure = async (
67
+ deps: ResolveExhaustedMessageDeleteDeps,
68
+ input: ResolveExhaustedMessageDeleteInput,
69
+ ): Promise<ResolveExhaustedMessageDeleteResult> => {
70
+ const {
71
+ accountId,
72
+ accountConfigId,
73
+ messageId,
74
+ uid,
75
+ sourceMailboxPath,
76
+ getConnection,
77
+ } = input;
78
+
79
+ const connection = await getConnection();
80
+ await connection.openBox(sourceMailboxPath, true);
81
+
82
+ if (await isMessageGoneFromOpenMailbox(connection, uid)) {
83
+ const { threadMessagesDeleted } = await reconcileStaleMessage(
84
+ deps,
85
+ accountConfigId,
86
+ messageId,
87
+ );
88
+ deps.log.info(
89
+ {
90
+ metric: "message_delete_stale_row_reconciled",
91
+ accountId,
92
+ accountConfigId,
93
+ messageId,
94
+ uid,
95
+ sourceMailboxPath,
96
+ threadMessagesDeleted,
97
+ },
98
+ "Message no longer at its delete source after retry exhaustion (the delete landed server-side, or an external delete or move); stale row reconciled",
99
+ );
100
+ return { outcome: "reconciled" };
101
+ }
102
+
103
+ await deps.messageService.update(messageId, {
104
+ status: MessageStatus.active,
105
+ syncStatus: MessageSyncStatus.failed,
106
+ });
107
+ deps.log.error(
108
+ {
109
+ alert: "message_delete_failed",
110
+ accountId,
111
+ accountConfigId,
112
+ messageId,
113
+ uid,
114
+ sourceMailboxPath,
115
+ },
116
+ "Delete could not be pushed to IMAP after retry exhaustion; the message is still at its source — row settled out of `moving` and left in place for operator investigation",
117
+ );
118
+ return { outcome: "broken" };
119
+ };
@@ -2,15 +2,50 @@ import assert from "node:assert";
2
2
  import { beforeEach, describe, it, mock } from "node:test";
3
3
  import type { ThreadMessageItem } from "@remit/data-ports";
4
4
  import type { Logger } from "@remit/logger-lambda";
5
+ import { renderMetrics, resetMetrics } from "@remit/logger-lambda";
5
6
  import type { MessageDeleteEvent } from "../events.js";
6
7
  import {
7
8
  buildThreadMessageTrashUpdate,
8
9
  buildThreadMessageUndelete,
9
10
  deleteAllThreadMessagesForMessage,
11
+ getMessageDeleteMaxAttempts,
10
12
  handleMessageDelete,
13
+ MESSAGE_DELETE_MAX_ATTEMPTS,
11
14
  type MessageDeleteDeps,
12
15
  } from "./message-delete.js";
13
16
 
17
+ describe("getMessageDeleteMaxAttempts — env-derived threshold (#980)", () => {
18
+ it("parses the injected env var", () => {
19
+ assert.equal(
20
+ getMessageDeleteMaxAttempts({ MESSAGE_DELETE_MAX_ATTEMPTS: "3" }),
21
+ 3,
22
+ );
23
+ assert.equal(
24
+ getMessageDeleteMaxAttempts({ MESSAGE_DELETE_MAX_ATTEMPTS: "5" }),
25
+ 5,
26
+ );
27
+ });
28
+
29
+ it("defaults to the mailbox queue's own maxReceiveCount when unset", () => {
30
+ assert.equal(getMessageDeleteMaxAttempts({}), 3);
31
+ });
32
+
33
+ it("defaults on a non-numeric or non-positive value", () => {
34
+ assert.equal(
35
+ getMessageDeleteMaxAttempts({ MESSAGE_DELETE_MAX_ATTEMPTS: "nope" }),
36
+ 3,
37
+ );
38
+ assert.equal(
39
+ getMessageDeleteMaxAttempts({ MESSAGE_DELETE_MAX_ATTEMPTS: "0" }),
40
+ 3,
41
+ );
42
+ });
43
+
44
+ it("MESSAGE_DELETE_MAX_ATTEMPTS is a concrete, positive number at module load", () => {
45
+ assert.ok(MESSAGE_DELETE_MAX_ATTEMPTS > 0);
46
+ });
47
+ });
48
+
14
49
  const sourceMailboxId = "source-mailbox-id-aaaaaaaaa";
15
50
  const trashMailboxId = "trash-mailbox-id-aaaaaaaaa";
16
51
 
@@ -358,6 +393,7 @@ const deps = (): MessageDeleteDeps =>
358
393
  if (h.threadMessageUpdateError) throw h.threadMessageUpdateError;
359
394
  },
360
395
  delete: record("threadMessage.delete"),
396
+ deleteMany: record("threadMessage.deleteMany"),
361
397
  },
362
398
  mailbox: {
363
399
  get: async () => {
@@ -375,6 +411,7 @@ const deps = (): MessageDeleteDeps =>
375
411
  _log: unknown,
376
412
  cb: (credentials: unknown) => Promise<void>,
377
413
  ) => cb({}),
414
+ emitEvent: record("emitEvent"),
378
415
  createConnectionScope: () => ({
379
416
  getConnection: async () => {
380
417
  h.getConnectionCount += 1;
@@ -413,13 +450,26 @@ const permanentEvent: MessageDeleteEvent = {
413
450
  const called = (method: string): Call[] =>
414
451
  h.calls.filter((c) => c.method === method);
415
452
 
453
+ // Label order in the rendered text is prom-client's, not ours.
454
+ const imapFailures = async (operation: string): Promise<number> => {
455
+ const line = (await renderMetrics())
456
+ .split("\n")
457
+ .find(
458
+ (candidate) =>
459
+ candidate.startsWith("remit_imap_failures_total{") &&
460
+ candidate.includes(`operation="${operation}"`),
461
+ );
462
+ return line ? Number(line.slice(line.lastIndexOf(" ") + 1)) : 0;
463
+ };
464
+
416
465
  describe("handleMessageDelete", () => {
417
466
  beforeEach(() => {
418
467
  h = fresh();
468
+ resetMetrics();
419
469
  });
420
470
 
421
471
  it("moves to trash, rewrites the uid, and flips the thread row to deleted", async () => {
422
- await handleMessageDelete(moveEvent, noopLog, deps());
472
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
423
473
 
424
474
  assert.deepEqual(called("message.updateUid")[0]?.args, [
425
475
  "msg-1",
@@ -445,7 +495,7 @@ describe("handleMessageDelete", () => {
445
495
  sourceNoLongerHoldsTheUid();
446
496
  h.destinationSearchUids = [77];
447
497
 
448
- await handleMessageDelete(moveEvent, noopLog, deps());
498
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
449
499
 
450
500
  assert.deepEqual(called("message.updateUid")[0]?.args, [
451
501
  "msg-1",
@@ -474,7 +524,7 @@ describe("handleMessageDelete", () => {
474
524
  return { uidvalidity: 1 };
475
525
  }) as Connection["openBox"];
476
526
 
477
- await handleMessageDelete(moveEvent, noopLog, deps());
527
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
478
528
 
479
529
  assert.deepEqual(
480
530
  opened,
@@ -490,23 +540,31 @@ describe("handleMessageDelete", () => {
490
540
  ]);
491
541
  });
492
542
 
493
- it("leaves local state alone when the probe finds nothing never reverts, never deletes (#655)", async () => {
543
+ // Issue #980. Within the budget the row is marked unsettled and the
544
+ // event is re-thrown so the queue redelivers it, exactly as
545
+ // `handleMessageMove` does; nothing is reverted and nothing is deleted,
546
+ // because a MOVE that ran server-side but dropped before the tagged OK
547
+ // is indistinguishable from one that never ran (#655, PR #652).
548
+ it("throws to redeliver while the budget still has attempts left", async () => {
494
549
  h.connection.moveMessages = async () => ({ uidMap: new Map() });
495
550
  sourceNoLongerHoldsTheUid();
496
551
  h.destinationSearchUids = [];
497
552
 
498
- await handleMessageDelete(moveEvent, noopLog, deps());
553
+ await assert.rejects(
554
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
555
+ /unconfirmed/,
556
+ );
499
557
 
500
558
  assert.equal(called("message.updateUid").length, 0);
501
559
  assert.equal(
502
560
  called("message.delete").length,
503
561
  0,
504
- "an unconfirmed move must never delete the local row",
562
+ "an unconfirmed move must never delete the local row inside the budget",
505
563
  );
506
564
  assert.equal(
507
- called("threadMessage.delete").length,
565
+ called("threadMessage.deleteMany").length,
508
566
  0,
509
- "an unconfirmed move must never delete the listing rows",
567
+ "an unconfirmed move must never delete the listing rows inside the budget",
510
568
  );
511
569
  assert.equal(
512
570
  called("threadMessage.update").length,
@@ -518,14 +576,42 @@ describe("handleMessageDelete", () => {
518
576
  ?.syncStatus,
519
577
  "failed",
520
578
  );
579
+ assert.equal(h.disconnectCount, 1);
521
580
  });
522
581
 
523
- it("does not probe when the row carries no Message-ID header", async () => {
582
+ it("settles once at the ceiling: reconciles the stale rows and resyncs both folders", async () => {
583
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
584
+ sourceNoLongerHoldsTheUid();
585
+ h.destinationSearchUids = [];
586
+
587
+ await handleMessageDelete(moveEvent, noopLog, 3, deps());
588
+
589
+ assert.equal(
590
+ called("threadMessage.deleteMany").length,
591
+ 1,
592
+ "the ceiling settles the row rather than leaving it claiming a uid the server does not have",
593
+ );
594
+ assert.equal(called("message.delete").length, 1);
595
+ assert.deepEqual(
596
+ called("emitEvent").map((c) => c.args[0]),
597
+ [
598
+ { type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
599
+ { type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "trash-mbx" },
600
+ ],
601
+ "both folders re-project from IMAP so the message reappears where it actually is",
602
+ );
603
+ assert.equal(h.disconnectCount, 1);
604
+ });
605
+
606
+ // A message with no Message-ID header on a non-UIDPLUS server can never
607
+ // be confirmed at the destination, so redelivering it only burns the
608
+ // budget on an answer that cannot change.
609
+ it("settles on the first attempt when the row carries no Message-ID header", async () => {
524
610
  h.connection.moveMessages = async () => ({ uidMap: new Map() });
525
611
  sourceNoLongerHoldsTheUid();
526
612
  h.messageRow = {};
527
613
 
528
- await handleMessageDelete(moveEvent, noopLog, deps());
614
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
529
615
 
530
616
  assert.equal(
531
617
  called("connection.search").filter((c) =>
@@ -535,12 +621,33 @@ describe("handleMessageDelete", () => {
535
621
  );
536
622
  assert.equal(called("message.updateUid").length, 0);
537
623
  assert.equal(
538
- (called("message.update")[0]?.args[1] as { syncStatus?: string })
539
- ?.syncStatus,
540
- "failed",
624
+ called("message.delete").length,
625
+ 1,
626
+ "an unprobeable row settles instead of spending the whole budget",
627
+ );
628
+ assert.equal(called("emitEvent").length, 2);
629
+ assert.equal(
630
+ await imapFailures("MESSAGE_DELETE_TRASH_MOVE_UNCONFIRMED"),
631
+ 1,
541
632
  );
542
633
  });
543
634
 
635
+ // The row this event names was already deleted, so there is nothing to
636
+ // settle and nothing to show. Re-throwing would loop a NotFoundError on
637
+ // the account's per-group FIFO and block every later mailbox event.
638
+ it("acks without touching the database when the local row is already gone", async () => {
639
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
640
+ sourceNoLongerHoldsTheUid();
641
+ h.messageRow = undefined;
642
+
643
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
644
+
645
+ assert.equal(called("message.update").length, 0);
646
+ assert.equal(called("message.delete").length, 0);
647
+ assert.equal(called("threadMessage.deleteMany").length, 0);
648
+ assert.equal(called("emitEvent").length, 0);
649
+ });
650
+
544
651
  // `searchMailboxByMessageId` returns the LOWEST matching uid, and one
545
652
  // Message-ID can have several server copies in one account while
546
653
  // `deriveMessageId` gives them one local row. A source that still holds
@@ -550,7 +657,10 @@ describe("handleMessageDelete", () => {
550
657
  h.connection.moveMessages = async () => ({ uidMap: new Map() });
551
658
  h.destinationSearchUids = [100];
552
659
 
553
- await handleMessageDelete(moveEvent, noopLog, deps());
660
+ await assert.rejects(
661
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
662
+ /unconfirmed/,
663
+ );
554
664
 
555
665
  assert.equal(
556
666
  called("message.updateUid").length,
@@ -566,22 +676,109 @@ describe("handleMessageDelete", () => {
566
676
  );
567
677
  });
568
678
 
569
- // The MOVE has already run by the time the probe goes out, so a probe
570
- // that cannot answer counts as not-confirmed. Throwing would redeliver
571
- // on the account's per-group FIFO and re-MOVE a uid the source no longer
572
- // holds, head-of-line blocking every other delete on the account.
573
- it("treats an unanswerable probe as unconfirmed rather than throwing", async () => {
679
+ // A source that still holds the uid at the ceiling means the MOVE never
680
+ // took effect. Local state is left exactly as it stands the row is the
681
+ // only record that this delete is still owed, and a revert races a MOVE
682
+ // that may yet have landed (#652, #655).
683
+ it("leaves a message still at its source untouched at the ceiling and never re-throws", async () => {
684
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
685
+ h.destinationSearchUids = [100];
686
+
687
+ await handleMessageDelete(moveEvent, noopLog, 3, deps());
688
+
689
+ assert.equal(called("message.delete").length, 0);
690
+ assert.equal(called("threadMessage.deleteMany").length, 0);
691
+ assert.equal(called("threadMessage.update").length, 0);
692
+ assert.equal(called("emitEvent").length, 0);
693
+
694
+ // The row's mailbox and uid stay put, but `status` must leave
695
+ // `moving`: `isPlacementUnsettled` reads exactly that value, so a row
696
+ // left mid-mutation makes every later delete of this message wait on
697
+ // a mutation that has already terminated.
698
+ assert.deepEqual(called("message.update").at(-1)?.args[1], {
699
+ status: "active",
700
+ syncStatus: "failed",
701
+ });
702
+ assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
703
+ });
704
+
705
+ // Issue #980, the failure the budget exists for: every redelivery
706
+ // re-MOVEs a uid the source no longer holds and throws identically. The
707
+ // ceiling lives in the error catch, so a throwing `moveMessages` settles
708
+ // there rather than running past the budget into the dead-letter queue.
709
+ describe("a re-MOVE that throws", () => {
710
+ const moveThrows = (): void => {
711
+ h.connection.moveMessages = async () => {
712
+ throw new Error(
713
+ "NO [TRYAGAIN] UID MOVE failed: no matching messages",
714
+ );
715
+ };
716
+ };
717
+
718
+ it("re-throws inside the budget so the queue redelivers", async () => {
719
+ moveThrows();
720
+
721
+ await assert.rejects(
722
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
723
+ /UID MOVE failed/,
724
+ );
725
+
726
+ assert.equal(called("message.delete").length, 0);
727
+ assert.equal(called("threadMessage.deleteMany").length, 0);
728
+ assert.equal(
729
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
730
+ ?.syncStatus,
731
+ "failed",
732
+ );
733
+ });
734
+
735
+ it("settles at the ceiling instead of dead-lettering undiagnosed", async () => {
736
+ moveThrows();
737
+ sourceNoLongerHoldsTheUid();
738
+
739
+ await handleMessageDelete(moveEvent, noopLog, 3, deps());
740
+
741
+ assert.equal(
742
+ called("threadMessage.deleteMany").length,
743
+ 1,
744
+ "the ceiling must diagnose the failure, not hand it to the DLQ",
745
+ );
746
+ assert.equal(called("message.delete").length, 1);
747
+ assert.equal(called("emitEvent").length, 2);
748
+ });
749
+
750
+ it("settles a still-present message at the ceiling out of `moving`", async () => {
751
+ moveThrows();
752
+
753
+ await handleMessageDelete(moveEvent, noopLog, 3, deps());
754
+
755
+ assert.deepEqual(called("message.update").at(-1)?.args[1], {
756
+ status: "active",
757
+ syncStatus: "failed",
758
+ });
759
+ assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
760
+ });
761
+ });
762
+
763
+ // A probe the server refused says nothing either way, so it counts as
764
+ // unconfirmed and spends an attempt. The budget is what makes that safe:
765
+ // without a ceiling the redeliveries would re-MOVE a uid the source no
766
+ // longer holds forever and head-of-line block the account's deletes.
767
+ it("treats an unanswerable probe as unconfirmed and spends an attempt", async () => {
574
768
  h.connection.moveMessages = async () => ({ uidMap: new Map() });
575
769
  h.connection.openBox = (async (_path: string, readOnly?: boolean) => {
576
770
  if (readOnly) throw new Error("NO [SERVERBUG] EXAMINE failed");
577
771
  return { uidvalidity: 1 };
578
772
  }) as Connection["openBox"];
579
773
 
580
- await handleMessageDelete(moveEvent, noopLog, deps());
774
+ await assert.rejects(
775
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
776
+ /unconfirmed/,
777
+ );
581
778
 
582
779
  assert.equal(called("message.updateUid").length, 0);
583
780
  assert.equal(called("message.delete").length, 0);
584
- assert.equal(called("threadMessage.delete").length, 0);
781
+ assert.equal(called("threadMessage.deleteMany").length, 0);
585
782
  assert.equal(called("threadMessage.update").length, 0);
586
783
  assert.equal(
587
784
  (called("message.update")[0]?.args[1] as { syncStatus?: string })
@@ -606,7 +803,7 @@ describe("handleMessageDelete", () => {
606
803
  operation,
607
804
  } as unknown as MessageDeleteEvent;
608
805
 
609
- await handleMessageDelete(malformed, noopLog, deps());
806
+ await handleMessageDelete(malformed, noopLog, 1, deps());
610
807
 
611
808
  assert.equal(
612
809
  called("connection.deleteMessages").length,
@@ -643,7 +840,7 @@ describe("handleMessageDelete", () => {
643
840
  destinationMailboxPath: undefined,
644
841
  } as MessageDeleteEvent;
645
842
 
646
- await handleMessageDelete(destinationless, noopLog, deps());
843
+ await handleMessageDelete(destinationless, noopLog, 1, deps());
647
844
 
648
845
  assert.equal(called("connection.deleteMessages").length, 0);
649
846
  assert.equal(called("message.delete").length, 0);
@@ -654,7 +851,7 @@ describe("handleMessageDelete", () => {
654
851
  });
655
852
 
656
853
  it("expunges on the server and removes every thread row before the message row", async () => {
657
- await handleMessageDelete(permanentEvent, noopLog, deps());
854
+ await handleMessageDelete(permanentEvent, noopLog, 1, deps());
658
855
 
659
856
  assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10]]);
660
857
  assert.equal(called("threadMessage.delete").length, 2);
@@ -673,7 +870,7 @@ describe("handleMessageDelete", () => {
673
870
  };
674
871
  sourceNoLongerHoldsTheUid();
675
872
 
676
- await handleMessageDelete(permanentEvent, noopLog, deps());
873
+ await handleMessageDelete(permanentEvent, noopLog, 1, deps());
677
874
 
678
875
  assert.equal(called("message.delete").length, 1);
679
876
  assert.equal(called("threadMessage.delete").length, 2);
@@ -694,7 +891,7 @@ describe("handleMessageDelete", () => {
694
891
  sourceNoLongerHoldsTheUid();
695
892
 
696
893
  await assert.rejects(
697
- handleMessageDelete(moveEvent, noopLog, deps()),
894
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
698
895
  /not found/,
699
896
  );
700
897
 
@@ -722,7 +919,7 @@ describe("handleMessageDelete", () => {
722
919
  };
723
920
 
724
921
  await assert.rejects(
725
- handleMessageDelete(moveEvent, noopLog, deps()),
922
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
726
923
  /NONEXISTENT/,
727
924
  );
728
925
 
@@ -744,7 +941,7 @@ describe("handleMessageDelete", () => {
744
941
  h.connection.fetchMessages = async () => [];
745
942
 
746
943
  await assert.rejects(
747
- handleMessageDelete(permanentEvent, noopLog, deps()),
944
+ handleMessageDelete(permanentEvent, noopLog, 1, deps()),
748
945
  /NONEXISTENT/,
749
946
  );
750
947
 
@@ -769,7 +966,7 @@ describe("handleMessageDelete", () => {
769
966
  };
770
967
 
771
968
  await assert.rejects(
772
- handleMessageDelete(permanentEvent, noopLog, deps()),
969
+ handleMessageDelete(permanentEvent, noopLog, 1, deps()),
773
970
  /NONEXISTENT mailbox does not exist/,
774
971
  );
775
972
 
@@ -790,7 +987,7 @@ describe("handleMessageDelete", () => {
790
987
  throw new Error("TRYCREATE: no such mailbox");
791
988
  };
792
989
 
793
- await handleMessageDelete(moveEvent, noopLog, deps());
990
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
794
991
 
795
992
  assert.equal(called("connection.createMailbox").length, 0);
796
993
  assert.deepEqual(called("message.updateUid")[0]?.args, [
@@ -815,7 +1012,7 @@ describe("handleMessageDelete", () => {
815
1012
  schemaVersion: undefined,
816
1013
  } as unknown as MessageDeleteEvent;
817
1014
 
818
- await handleMessageDelete(unversioned, noopLog, deps());
1015
+ await handleMessageDelete(unversioned, noopLog, 1, deps());
819
1016
 
820
1017
  assert.equal(h.getConnectionCount, 0);
821
1018
  assert.equal(called("connection.deleteMessages").length, 0);
@@ -837,7 +1034,7 @@ describe("handleMessageDelete", () => {
837
1034
  schemaVersion: undefined,
838
1035
  } as unknown as MessageDeleteEvent;
839
1036
 
840
- await handleMessageDelete(unversioned, noopLog, deps());
1037
+ await handleMessageDelete(unversioned, noopLog, 1, deps());
841
1038
 
842
1039
  assert.deepEqual(
843
1040
  called("threadMessage.update").map((c) => c.args[1]),
@@ -856,7 +1053,7 @@ describe("handleMessageDelete", () => {
856
1053
  schemaVersion: undefined,
857
1054
  } as unknown as MessageDeleteEvent;
858
1055
 
859
- await handleMessageDelete(unversioned, noopLog, deps());
1056
+ await handleMessageDelete(unversioned, noopLog, 1, deps());
860
1057
 
861
1058
  assert.equal(called("connection.deleteMessages").length, 0);
862
1059
  assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
@@ -873,7 +1070,7 @@ describe("handleMessageDelete", () => {
873
1070
  };
874
1071
 
875
1072
  await assert.rejects(
876
- handleMessageDelete(moveEvent, noopLog, deps()),
1073
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
877
1074
  /server exploded/,
878
1075
  );
879
1076
 
@@ -887,7 +1084,7 @@ describe("handleMessageDelete", () => {
887
1084
  it("pauses quietly when openBox trips a UIDVALIDITY mismatch", async () => {
888
1085
  h.connection.openBox = async () => ({ uidvalidity: 999 });
889
1086
 
890
- await handleMessageDelete(moveEvent, noopLog, deps());
1087
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
891
1088
 
892
1089
  assert.equal(
893
1090
  (called("mailbox.update")[0]?.args[2] as { cursorState?: string })
@@ -904,7 +1101,7 @@ describe("handleMessageDelete", () => {
904
1101
  cursorState: "rebuilding",
905
1102
  };
906
1103
 
907
- await handleMessageDelete(moveEvent, noopLog, deps());
1104
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
908
1105
 
909
1106
  assert.equal(h.getConnectionCount, 0);
910
1107
  });
@@ -914,7 +1111,7 @@ describe("handleMessageDelete", () => {
914
1111
  name: "NotFoundError",
915
1112
  });
916
1113
 
917
- await handleMessageDelete(moveEvent, noopLog, deps());
1114
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
918
1115
 
919
1116
  assert.equal(h.getConnectionCount, 0);
920
1117
  assert.equal(called("message.updateUid").length, 0);
@@ -928,7 +1125,7 @@ describe("handleMessageDelete", () => {
928
1125
  deletedAt: Date.now(),
929
1126
  };
930
1127
 
931
- await handleMessageDelete(moveEvent, noopLog, deps());
1128
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
932
1129
 
933
1130
  assert.equal(h.getConnectionCount, 0);
934
1131
  });
@@ -937,7 +1134,7 @@ describe("handleMessageDelete", () => {
937
1134
  h.account = null;
938
1135
 
939
1136
  await assert.rejects(
940
- handleMessageDelete(moveEvent, noopLog, deps()),
1137
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
941
1138
  /not found/,
942
1139
  );
943
1140
  });
@@ -7,6 +7,7 @@ import type {
7
7
  import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
8
8
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
9
9
  import type { Logger } from "@remit/logger-lambda";
10
+ import { recordImapFailure } from "@remit/logger-lambda";
10
11
  import {
11
12
  guardConnectionCursor,
12
13
  type IImapConnection,
@@ -16,11 +17,34 @@ import {
16
17
  } from "@remit/mailbox-service";
17
18
  import { isAccountDeleted } from "../account-check.js";
18
19
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
20
+ import { emitEvent } from "../emit.js";
19
21
  import type { MessageDeleteEvent } from "../events.js";
20
22
  import { isNotFoundError } from "../is-not-found.js";
21
23
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
22
24
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
23
- import { searchMailboxByMessageId } from "./message-move.js";
25
+ import { resolveExhaustedMessageDeleteFailure } from "./message-delete-terminal.js";
26
+ import { emitMoveResync, searchMailboxByMessageId } from "./message-move.js";
27
+
28
+ /**
29
+ * Fallback when `MESSAGE_DELETE_MAX_ATTEMPTS` is unset (local dev, unit tests).
30
+ * Matches the `maxReceiveCount` the redrive policy of the queue `emit.ts`
31
+ * routes MESSAGE_DELETE onto uses (`remit-message-mgmt`,
32
+ * `deploy/vps/queues.json`), same pattern as `MESSAGE_MOVE_MAX_ATTEMPTS`.
33
+ */
34
+ const DEFAULT_MESSAGE_DELETE_MAX_ATTEMPTS = 3;
35
+
36
+ export const getMessageDeleteMaxAttempts = (
37
+ processEnv: NodeJS.ProcessEnv = process.env,
38
+ ): number => {
39
+ const raw = processEnv.MESSAGE_DELETE_MAX_ATTEMPTS;
40
+ if (!raw) return DEFAULT_MESSAGE_DELETE_MAX_ATTEMPTS;
41
+ const parsed = Number.parseInt(raw, 10);
42
+ return Number.isFinite(parsed) && parsed > 0
43
+ ? parsed
44
+ : DEFAULT_MESSAGE_DELETE_MAX_ATTEMPTS;
45
+ };
46
+
47
+ export const MESSAGE_DELETE_MAX_ATTEMPTS = getMessageDeleteMaxAttempts();
24
48
 
25
49
  /**
26
50
  * Delete every ThreadMessage row that points at this messageId.
@@ -143,9 +167,17 @@ export const buildThreadMessageUndelete = (
143
167
  * expunges by that uid. It also closes the second half of #912: an empty
144
168
  * `uidMap` can mean the MOVE matched nothing at all.
145
169
  *
146
- * A row with no `messageIdHeader` has nothing to probe with, so it stays
147
- * unconfirmed rather than guessing.
170
+ * A row with no `messageIdHeader`, and a row that is already deleted, have
171
+ * nothing to probe with; they are distinct verdicts because no redelivery can
172
+ * change either answer.
148
173
  */
174
+ export type TrashMoveConfirmation =
175
+ | { outcome: "confirmed"; uid: number }
176
+ | { outcome: "still-at-source" }
177
+ | { outcome: "row-gone" }
178
+ | { outcome: "unprobeable" }
179
+ | { outcome: "unconfirmed" };
180
+
149
181
  const confirmTrashMoveUid = async (
150
182
  sourceConnection: IImapConnection,
151
183
  destinationConnection: IImapConnection,
@@ -154,18 +186,24 @@ const confirmTrashMoveUid = async (
154
186
  sourceMailboxPath: string,
155
187
  destinationMailboxPath: string,
156
188
  uid: number,
157
- ): Promise<number | null> => {
189
+ ): Promise<TrashMoveConfirmation> => {
158
190
  await sourceConnection.openBox(sourceMailboxPath, true);
159
- if (!(await isMessageGoneFromOpenMailbox(sourceConnection, uid))) return null;
191
+ if (!(await isMessageGoneFromOpenMailbox(sourceConnection, uid))) {
192
+ return { outcome: "still-at-source" };
193
+ }
160
194
 
161
195
  const [message] = await messageService.get([messageId]);
162
- if (!message?.messageIdHeader) return null;
196
+ if (!message) return { outcome: "row-gone" };
197
+ if (!message.messageIdHeader) return { outcome: "unprobeable" };
163
198
 
164
- return searchMailboxByMessageId(
199
+ const probedUid = await searchMailboxByMessageId(
165
200
  destinationConnection,
166
201
  destinationMailboxPath,
167
202
  message.messageIdHeader,
168
203
  );
204
+ return probedUid === null
205
+ ? { outcome: "unconfirmed" }
206
+ : { outcome: "confirmed", uid: probedUid };
169
207
  };
170
208
 
171
209
  export interface MessageDeleteDeps {
@@ -173,6 +211,7 @@ export interface MessageDeleteDeps {
173
211
  buildLifecycleDeps: typeof buildLifecycleDeps;
174
212
  withOAuthLifecycle: typeof withOAuthLifecycle;
175
213
  createConnectionScope: typeof createConnectionScopeWithCredentials;
214
+ emitEvent: typeof emitEvent;
176
215
  }
177
216
 
178
217
  const defaultDeps: MessageDeleteDeps = {
@@ -180,15 +219,22 @@ const defaultDeps: MessageDeleteDeps = {
180
219
  buildLifecycleDeps,
181
220
  withOAuthLifecycle,
182
221
  createConnectionScope: createConnectionScopeWithCredentials,
222
+ emitEvent,
183
223
  };
184
224
 
185
225
  /**
186
226
  * Handle MESSAGE_DELETE events.
187
227
  * Either moves to Trash (IMAP MOVE) or permanently deletes (IMAP DELETE).
228
+ *
229
+ * A failing delete retries on redelivery until `receiveCount` reaches
230
+ * {@link MESSAGE_DELETE_MAX_ATTEMPTS}, at which point
231
+ * {@link resolveExhaustedMessageDeleteFailure} asks IMAP where the message
232
+ * actually is and settles the row into one terminal outcome (issue #980).
188
233
  */
189
234
  export const handleMessageDelete = async (
190
235
  event: MessageDeleteEvent,
191
236
  log: Logger,
237
+ receiveCount = 1,
192
238
  deps: MessageDeleteDeps = defaultDeps,
193
239
  ): Promise<void> => {
194
240
  const {
@@ -196,6 +242,7 @@ export const handleMessageDelete = async (
196
242
  buildLifecycleDeps,
197
243
  withOAuthLifecycle,
198
244
  createConnectionScope: createConnectionScopeWithCredentials,
245
+ emitEvent,
199
246
  } = deps;
200
247
 
201
248
  const {
@@ -292,6 +339,80 @@ export const handleMessageDelete = async (
292
339
  }
293
340
  };
294
341
 
342
+ const settleExhaustedDelete = async (
343
+ accountConfigId: string,
344
+ getConnection: () => Promise<IImapConnection>,
345
+ ): Promise<void> => {
346
+ const { outcome } = await resolveExhaustedMessageDeleteFailure(
347
+ { messageService, threadMessageService, log },
348
+ {
349
+ accountId,
350
+ accountConfigId,
351
+ messageId,
352
+ uid,
353
+ sourceMailboxPath: mailboxPath,
354
+ getConnection,
355
+ },
356
+ );
357
+
358
+ if (outcome === "broken") {
359
+ recordImapFailure("MESSAGE_DELETE_EXHAUSTED", "other");
360
+ return;
361
+ }
362
+
363
+ if (destinationMailboxId) {
364
+ await emitMoveResync(emitEvent, {
365
+ accountId,
366
+ sourceMailboxId: mailboxId,
367
+ destinationMailboxId,
368
+ });
369
+ }
370
+ };
371
+
372
+ /**
373
+ * Decide what an unconfirmed move to Trash does with this delivery. The
374
+ * settle itself is `resolveExhaustedMessageDeleteFailure`, reached through
375
+ * the attempt budget in the catch below — the two verdicts handled here are
376
+ * the ones a redelivery could never answer.
377
+ */
378
+ const settleUnconfirmedTrashMove = async (
379
+ confirmation: Exclude<TrashMoveConfirmation, { outcome: "confirmed" }>,
380
+ accountConfigId: string,
381
+ getConnection: () => Promise<IImapConnection>,
382
+ ): Promise<void> => {
383
+ const context = {
384
+ accountId,
385
+ accountConfigId,
386
+ messageId,
387
+ uid,
388
+ mailboxPath,
389
+ receiveCount,
390
+ confirmation: confirmation.outcome,
391
+ };
392
+
393
+ if (confirmation.outcome === "row-gone") {
394
+ log.warn(
395
+ context,
396
+ "Move to trash unconfirmed and the local row is already gone; nothing left to settle",
397
+ );
398
+ return;
399
+ }
400
+
401
+ if (confirmation.outcome === "unprobeable") {
402
+ recordImapFailure("MESSAGE_DELETE_TRASH_MOVE_UNCONFIRMED", "other");
403
+ log.info(
404
+ context,
405
+ "Move to trash carries no Message-ID header to probe the destination with; settling on the source's answer alone",
406
+ );
407
+ await settleExhaustedDelete(accountConfigId, getConnection);
408
+ return;
409
+ }
410
+
411
+ throw new Error(
412
+ `Move to trash unconfirmed for message ${messageId} (attempt ${receiveCount}/${MESSAGE_DELETE_MAX_ATTEMPTS})`,
413
+ );
414
+ };
415
+
295
416
  if (!isCurrentSchemaVersion(event.schemaVersion)) {
296
417
  await abandonDelete(
297
418
  "Refused to delete: event was minted under an unknown contract",
@@ -385,42 +506,35 @@ export const handleMessageDelete = async (
385
506
  // perfectly successful MOVE with no COPYUID entry, so an empty
386
507
  // map is UNCONFIRMED, never evidence the move failed: the server
387
508
  // is asked before any verdict, exactly as `handleMessageMove` and
388
- // `attemptMove` do. Reading the empty map as a failure left the
389
- // message in Trash under a uid nothing local knew, while the row
390
- // kept the SOURCE folder's uid — which Empty Trash then decides
391
- // by (issues #979, #665).
392
- //
393
- // A probe that cannot answer counts as not-confirmed, never as a
394
- // throw. The MOVE has already run by this point, so throwing here
395
- // would redeliver on the account's per-group FIFO and re-MOVE a
396
- // uid the source no longer holds — head-of-line blocking the
397
- // whole account's deletes over a transient NO, a renamed folder
398
- // or a SEARCH the server refused.
399
- const newUid =
400
- result.uidMap.get(uid) ??
401
- (await confirmTrashMoveUid(
402
- connection,
403
- rawConnection,
404
- messageService,
405
- messageId,
406
- mailboxPath,
407
- destinationMailboxPath,
408
- uid,
409
- ).catch((probeError: unknown) => {
410
- log.warn(
411
- {
412
- messageId,
413
- uid,
414
- mailboxPath,
415
- destinationMailboxPath,
416
- probeError,
417
- },
418
- "Could not confirm the move to trash; keeping local rows",
419
- );
420
- return null;
421
- }));
422
-
423
- if (newUid) {
509
+ // `attemptMove` do (issues #979, #665). A probe the server refused
510
+ // says nothing either way and counts as unconfirmed.
511
+ const copyUid = result.uidMap.get(uid);
512
+ const confirmation: TrashMoveConfirmation = copyUid
513
+ ? { outcome: "confirmed", uid: copyUid }
514
+ : await confirmTrashMoveUid(
515
+ connection,
516
+ rawConnection,
517
+ messageService,
518
+ messageId,
519
+ mailboxPath,
520
+ destinationMailboxPath,
521
+ uid,
522
+ ).catch((probeError: unknown) => {
523
+ log.warn(
524
+ {
525
+ messageId,
526
+ uid,
527
+ mailboxPath,
528
+ destinationMailboxPath,
529
+ probeError,
530
+ },
531
+ "Could not confirm the move to trash; keeping local rows",
532
+ );
533
+ return { outcome: "unconfirmed" } as const;
534
+ });
535
+
536
+ if (confirmation.outcome === "confirmed") {
537
+ const newUid = confirmation.uid;
424
538
  // Update message with new UID in Trash
425
539
  await messageService.updateUid(
426
540
  messageId,
@@ -448,39 +562,14 @@ export const handleMessageDelete = async (
448
562
  }
449
563
 
450
564
  log.info({ messageId, newUid }, "Message moved to trash");
451
- } else {
452
- // Unconfirmed, not failed. A MOVE that ran server-side but
453
- // dropped before the tagged OK is indistinguishable from one
454
- // that never ran, so local state is left exactly as it stands:
455
- // nothing is reverted and nothing is deleted. Reverting on that
456
- // ambiguity is the blind revert #655 recorded when it was
457
- // pulled from PR #652.
458
- //
459
- // This marks and returns rather than throwing. Throwing is the
460
- // shape `handleMessageMove` uses, but it can only carry a
461
- // budget: this handler has no `receiveCount`, no MAX_ATTEMPTS
462
- // and no exhaustion path, so every redelivery on the account's
463
- // per-group FIFO would re-MOVE a uid the source no longer
464
- // holds, fail identically, and stall that account's whole
465
- // delete pipeline (#287, #289, #290) until the queue's
466
- // maxReceiveCount dead-letters it — leaving the row in this
467
- // same state, minus the pipeline. Issue #980 wires the budget;
468
- // the throw belongs with it, not ahead of it.
469
- log.error(
470
- {
471
- alert: "message_delete_trash_move_unconfirmed",
472
- accountId,
473
- messageId,
474
- uid,
475
- mailboxPath,
476
- destinationMailboxPath,
477
- },
478
- "Move to trash unconfirmed: no COPYUID entry, and the server did not confirm the message at the destination; local rows left as they stand",
479
- );
480
- await messageService.update(messageId, {
481
- syncStatus: MessageSyncStatus.failed,
482
- });
565
+ return;
483
566
  }
567
+
568
+ await settleUnconfirmedTrashMove(
569
+ confirmation,
570
+ account.accountConfigId,
571
+ scope.getConnection,
572
+ );
484
573
  } else {
485
574
  // Permanent delete — reached only by `operation === "permanent_delete"`.
486
575
  await connection.deleteMessages([uid]);
@@ -567,11 +656,28 @@ export const handleMessageDelete = async (
567
656
  return;
568
657
  }
569
658
 
570
- // Mark as failed for other errors
571
- await messageService.update(messageId, {
572
- syncStatus: MessageSyncStatus.failed,
573
- });
574
- throw error;
659
+ if (receiveCount < MESSAGE_DELETE_MAX_ATTEMPTS) {
660
+ // Transient failure — connections drop. No alarm; redelivery
661
+ // retries, and `failed` marks the row unsettled meanwhile. It is
662
+ // not a terminal signal: only the resolver below settles anything.
663
+ await messageService.update(messageId, {
664
+ syncStatus: MessageSyncStatus.failed,
665
+ });
666
+ throw error;
667
+ }
668
+
669
+ // Budget exhausted. Settling here is what keeps a throwing
670
+ // `moveMessages` inside the ceiling: re-MOVEing a uid the source no
671
+ // longer holds fails identically on every redelivery, which is the
672
+ // failure this budget exists for.
673
+ await settleExhaustedDelete(
674
+ account.accountConfigId,
675
+ scope.getConnection,
676
+ );
677
+ log.error(
678
+ { accountId, messageId, uid, mailboxPath, error: errorMessage },
679
+ "Delete retry exhausted; settled into a terminal outcome",
680
+ );
575
681
  })
576
682
  .finally(() => scope.disconnect());
577
683
  },
package/src/processor.ts CHANGED
@@ -38,7 +38,7 @@ export const processEvent = async (
38
38
  case "MAILBOX_DELETE":
39
39
  return processMailboxManagement(event, log);
40
40
  case "MESSAGE_DELETE":
41
- return handleMessageDelete(event, log);
41
+ return handleMessageDelete(event, log, receiveCount);
42
42
  case "MESSAGE_MOVE":
43
43
  return handleMessageMove(event, log, receiveCount);
44
44
  case "PLACEMENT_MOVE_PUSH":