@remit/imap-worker 0.0.53 → 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.53",
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
 
@@ -260,6 +295,8 @@ interface Harness {
260
295
  mailboxError?: Error;
261
296
  connection: Connection;
262
297
  threadMessageUpdateError?: Error;
298
+ messageRow: { messageIdHeader?: string } | undefined;
299
+ destinationSearchUids: number[];
263
300
  threadMessage: Record<string, unknown> | null;
264
301
  allThreadMessages: Record<string, unknown>[];
265
302
  getConnectionCount: number;
@@ -274,6 +311,16 @@ const record =
274
311
  h.calls.push({ method, args });
275
312
  };
276
313
 
314
+ const MESSAGE_ID_HEADER = "<trashed-message@example.com>";
315
+
316
+ // The source-presence probe (`isMessageGoneFromOpenMailbox`) and the
317
+ // destination probe both go through `connection.search`; only the criterion
318
+ // form tells them apart.
319
+ const isMessageIdSearch = (criteria: unknown): boolean =>
320
+ Array.isArray(criteria) &&
321
+ Array.isArray(criteria[0]) &&
322
+ criteria[0][0] === "HEADER";
323
+
277
324
  const buildConnection = (): Connection => ({
278
325
  openBox: async () => ({ uidvalidity: 1 }),
279
326
  moveMessages: async () => ({ uidMap: new Map([[10, 20]]) }),
@@ -288,7 +335,7 @@ const buildConnection = (): Connection => ({
288
335
  fetchMessages: async (uids: number[]) => uids.map((uid) => ({ uid })),
289
336
  search: async (...args: unknown[]) => {
290
337
  h.calls.push({ method: "connection.search", args });
291
- return [10];
338
+ return isMessageIdSearch(args[0]) ? h.destinationSearchUids : [10];
292
339
  },
293
340
  });
294
341
 
@@ -296,7 +343,7 @@ const sourceNoLongerHoldsTheUid = (): void => {
296
343
  h.connection.fetchMessages = async () => [];
297
344
  h.connection.search = async (...args: unknown[]) => {
298
345
  h.calls.push({ method: "connection.search", args });
299
- return [];
346
+ return isMessageIdSearch(args[0]) ? h.destinationSearchUids : [];
300
347
  };
301
348
  };
302
349
 
@@ -304,6 +351,8 @@ const fresh = (): Harness => ({
304
351
  calls: [],
305
352
  account: { accountId: "acc-1", accountConfigId: "cfg-1" },
306
353
  mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
354
+ messageRow: { messageIdHeader: MESSAGE_ID_HEADER },
355
+ destinationSearchUids: [],
307
356
  connection: buildConnection(),
308
357
  threadMessage: {
309
358
  ...baseThreadMessage,
@@ -328,6 +377,10 @@ const deps = (): MessageDeleteDeps =>
328
377
  },
329
378
  },
330
379
  message: {
380
+ get: async (messageIds: string[]) => {
381
+ h.calls.push({ method: "message.get", args: [messageIds] });
382
+ return h.messageRow ? [h.messageRow] : [];
383
+ },
331
384
  updateUid: record("message.updateUid"),
332
385
  update: record("message.update"),
333
386
  delete: record("message.delete"),
@@ -340,6 +393,7 @@ const deps = (): MessageDeleteDeps =>
340
393
  if (h.threadMessageUpdateError) throw h.threadMessageUpdateError;
341
394
  },
342
395
  delete: record("threadMessage.delete"),
396
+ deleteMany: record("threadMessage.deleteMany"),
343
397
  },
344
398
  mailbox: {
345
399
  get: async () => {
@@ -357,6 +411,7 @@ const deps = (): MessageDeleteDeps =>
357
411
  _log: unknown,
358
412
  cb: (credentials: unknown) => Promise<void>,
359
413
  ) => cb({}),
414
+ emitEvent: record("emitEvent"),
360
415
  createConnectionScope: () => ({
361
416
  getConnection: async () => {
362
417
  h.getConnectionCount += 1;
@@ -395,13 +450,26 @@ const permanentEvent: MessageDeleteEvent = {
395
450
  const called = (method: string): Call[] =>
396
451
  h.calls.filter((c) => c.method === method);
397
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
+
398
465
  describe("handleMessageDelete", () => {
399
466
  beforeEach(() => {
400
467
  h = fresh();
468
+ resetMetrics();
401
469
  });
402
470
 
403
471
  it("moves to trash, rewrites the uid, and flips the thread row to deleted", async () => {
404
- await handleMessageDelete(moveEvent, noopLog, deps());
472
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
405
473
 
406
474
  assert.deepEqual(called("message.updateUid")[0]?.args, [
407
475
  "msg-1",
@@ -417,17 +485,308 @@ describe("handleMessageDelete", () => {
417
485
  assert.equal(h.disconnectCount, 1);
418
486
  });
419
487
 
420
- it("marks the message failed when the MOVE returns no new uid", async () => {
421
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
488
+ // UIDPLUS is an extension: a server without it answers a perfectly
489
+ // successful MOVE with no COPYUID entry. An empty uidMap is therefore
490
+ // UNCONFIRMED, and the destination is asked by Message-ID before any
491
+ // verdict — the rule every sibling handler already carries. Issue #979.
492
+ describe("no COPYUID entry on the move to trash", () => {
493
+ it("settles the row on the probed uid when the message left the source and is at the destination (non-UIDPLUS server, genuine success)", async () => {
494
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
495
+ sourceNoLongerHoldsTheUid();
496
+ h.destinationSearchUids = [77];
422
497
 
423
- await handleMessageDelete(moveEvent, noopLog, deps());
498
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
424
499
 
425
- assert.equal(called("message.updateUid").length, 0);
426
- assert.equal(
427
- (called("message.update")[0]?.args[1] as { syncStatus?: string })
428
- ?.syncStatus,
429
- "failed",
430
- );
500
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
501
+ "msg-1",
502
+ 77,
503
+ "trash-mbx",
504
+ ]);
505
+ assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
506
+ uid: 77,
507
+ mailboxId: "trash-mbx",
508
+ isDeleted: true,
509
+ });
510
+ assert.equal(
511
+ called("message.update").length,
512
+ 0,
513
+ "a settled move never marks the row failed",
514
+ );
515
+ });
516
+
517
+ it("probes the DESTINATION mailbox, read-only, by Message-ID", async () => {
518
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
519
+ sourceNoLongerHoldsTheUid();
520
+ h.destinationSearchUids = [77];
521
+ const opened: unknown[][] = [];
522
+ h.connection.openBox = (async (...args: unknown[]) => {
523
+ opened.push(args);
524
+ return { uidvalidity: 1 };
525
+ }) as Connection["openBox"];
526
+
527
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
528
+
529
+ assert.deepEqual(
530
+ opened,
531
+ [
532
+ ["INBOX", false],
533
+ ["INBOX", true],
534
+ ["Trash", true],
535
+ ],
536
+ "the source is re-asked read-only, then the destination is EXAMINEd — neither probe may SELECT a box writable",
537
+ );
538
+ assert.deepEqual(called("connection.search").at(-1)?.args[0], [
539
+ ["HEADER", "Message-ID", MESSAGE_ID_HEADER],
540
+ ]);
541
+ });
542
+
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 () => {
549
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
550
+ sourceNoLongerHoldsTheUid();
551
+ h.destinationSearchUids = [];
552
+
553
+ await assert.rejects(
554
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
555
+ /unconfirmed/,
556
+ );
557
+
558
+ assert.equal(called("message.updateUid").length, 0);
559
+ assert.equal(
560
+ called("message.delete").length,
561
+ 0,
562
+ "an unconfirmed move must never delete the local row inside the budget",
563
+ );
564
+ assert.equal(
565
+ called("threadMessage.deleteMany").length,
566
+ 0,
567
+ "an unconfirmed move must never delete the listing rows inside the budget",
568
+ );
569
+ assert.equal(
570
+ called("threadMessage.update").length,
571
+ 0,
572
+ "an unconfirmed move must never revert the listing row to the source",
573
+ );
574
+ assert.equal(
575
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
576
+ ?.syncStatus,
577
+ "failed",
578
+ );
579
+ assert.equal(h.disconnectCount, 1);
580
+ });
581
+
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 () => {
610
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
611
+ sourceNoLongerHoldsTheUid();
612
+ h.messageRow = {};
613
+
614
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
615
+
616
+ assert.equal(
617
+ called("connection.search").filter((c) =>
618
+ JSON.stringify(c.args).includes("HEADER"),
619
+ ).length,
620
+ 0,
621
+ );
622
+ assert.equal(called("message.updateUid").length, 0);
623
+ assert.equal(
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,
632
+ );
633
+ });
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
+
651
+ // `searchMailboxByMessageId` returns the LOWEST matching uid, and one
652
+ // Message-ID can have several server copies in one account while
653
+ // `deriveMessageId` gives them one local row. A source that still holds
654
+ // the uid proves the MOVE did not happen, so any hit at the destination
655
+ // is a different copy.
656
+ it("never takes a destination hit while the source still holds the uid (duplicate Message-ID)", async () => {
657
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
658
+ h.destinationSearchUids = [100];
659
+
660
+ await assert.rejects(
661
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
662
+ /unconfirmed/,
663
+ );
664
+
665
+ assert.equal(
666
+ called("message.updateUid").length,
667
+ 0,
668
+ "an earlier copy's uid must never settle this row",
669
+ );
670
+ assert.equal(called("threadMessage.update").length, 0);
671
+ assert.equal(called("message.delete").length, 0);
672
+ assert.equal(
673
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
674
+ ?.syncStatus,
675
+ "failed",
676
+ );
677
+ });
678
+
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 () => {
768
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
769
+ h.connection.openBox = (async (_path: string, readOnly?: boolean) => {
770
+ if (readOnly) throw new Error("NO [SERVERBUG] EXAMINE failed");
771
+ return { uidvalidity: 1 };
772
+ }) as Connection["openBox"];
773
+
774
+ await assert.rejects(
775
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
776
+ /unconfirmed/,
777
+ );
778
+
779
+ assert.equal(called("message.updateUid").length, 0);
780
+ assert.equal(called("message.delete").length, 0);
781
+ assert.equal(called("threadMessage.deleteMany").length, 0);
782
+ assert.equal(called("threadMessage.update").length, 0);
783
+ assert.equal(
784
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
785
+ ?.syncStatus,
786
+ "failed",
787
+ );
788
+ assert.equal(h.disconnectCount, 1);
789
+ });
431
790
  });
432
791
 
433
792
  // The queue handler `JSON.parse`s the body and casts it to WorkerEvent with
@@ -444,7 +803,7 @@ describe("handleMessageDelete", () => {
444
803
  operation,
445
804
  } as unknown as MessageDeleteEvent;
446
805
 
447
- await handleMessageDelete(malformed, noopLog, deps());
806
+ await handleMessageDelete(malformed, noopLog, 1, deps());
448
807
 
449
808
  assert.equal(
450
809
  called("connection.deleteMessages").length,
@@ -481,7 +840,7 @@ describe("handleMessageDelete", () => {
481
840
  destinationMailboxPath: undefined,
482
841
  } as MessageDeleteEvent;
483
842
 
484
- await handleMessageDelete(destinationless, noopLog, deps());
843
+ await handleMessageDelete(destinationless, noopLog, 1, deps());
485
844
 
486
845
  assert.equal(called("connection.deleteMessages").length, 0);
487
846
  assert.equal(called("message.delete").length, 0);
@@ -492,7 +851,7 @@ describe("handleMessageDelete", () => {
492
851
  });
493
852
 
494
853
  it("expunges on the server and removes every thread row before the message row", async () => {
495
- await handleMessageDelete(permanentEvent, noopLog, deps());
854
+ await handleMessageDelete(permanentEvent, noopLog, 1, deps());
496
855
 
497
856
  assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10]]);
498
857
  assert.equal(called("threadMessage.delete").length, 2);
@@ -511,7 +870,7 @@ describe("handleMessageDelete", () => {
511
870
  };
512
871
  sourceNoLongerHoldsTheUid();
513
872
 
514
- await handleMessageDelete(permanentEvent, noopLog, deps());
873
+ await handleMessageDelete(permanentEvent, noopLog, 1, deps());
515
874
 
516
875
  assert.equal(called("message.delete").length, 1);
517
876
  assert.equal(called("threadMessage.delete").length, 2);
@@ -532,7 +891,7 @@ describe("handleMessageDelete", () => {
532
891
  sourceNoLongerHoldsTheUid();
533
892
 
534
893
  await assert.rejects(
535
- handleMessageDelete(moveEvent, noopLog, deps()),
894
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
536
895
  /not found/,
537
896
  );
538
897
 
@@ -560,7 +919,7 @@ describe("handleMessageDelete", () => {
560
919
  };
561
920
 
562
921
  await assert.rejects(
563
- handleMessageDelete(moveEvent, noopLog, deps()),
922
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
564
923
  /NONEXISTENT/,
565
924
  );
566
925
 
@@ -582,7 +941,7 @@ describe("handleMessageDelete", () => {
582
941
  h.connection.fetchMessages = async () => [];
583
942
 
584
943
  await assert.rejects(
585
- handleMessageDelete(permanentEvent, noopLog, deps()),
944
+ handleMessageDelete(permanentEvent, noopLog, 1, deps()),
586
945
  /NONEXISTENT/,
587
946
  );
588
947
 
@@ -607,7 +966,7 @@ describe("handleMessageDelete", () => {
607
966
  };
608
967
 
609
968
  await assert.rejects(
610
- handleMessageDelete(permanentEvent, noopLog, deps()),
969
+ handleMessageDelete(permanentEvent, noopLog, 1, deps()),
611
970
  /NONEXISTENT mailbox does not exist/,
612
971
  );
613
972
 
@@ -628,7 +987,7 @@ describe("handleMessageDelete", () => {
628
987
  throw new Error("TRYCREATE: no such mailbox");
629
988
  };
630
989
 
631
- await handleMessageDelete(moveEvent, noopLog, deps());
990
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
632
991
 
633
992
  assert.equal(called("connection.createMailbox").length, 0);
634
993
  assert.deepEqual(called("message.updateUid")[0]?.args, [
@@ -653,7 +1012,7 @@ describe("handleMessageDelete", () => {
653
1012
  schemaVersion: undefined,
654
1013
  } as unknown as MessageDeleteEvent;
655
1014
 
656
- await handleMessageDelete(unversioned, noopLog, deps());
1015
+ await handleMessageDelete(unversioned, noopLog, 1, deps());
657
1016
 
658
1017
  assert.equal(h.getConnectionCount, 0);
659
1018
  assert.equal(called("connection.deleteMessages").length, 0);
@@ -675,7 +1034,7 @@ describe("handleMessageDelete", () => {
675
1034
  schemaVersion: undefined,
676
1035
  } as unknown as MessageDeleteEvent;
677
1036
 
678
- await handleMessageDelete(unversioned, noopLog, deps());
1037
+ await handleMessageDelete(unversioned, noopLog, 1, deps());
679
1038
 
680
1039
  assert.deepEqual(
681
1040
  called("threadMessage.update").map((c) => c.args[1]),
@@ -694,7 +1053,7 @@ describe("handleMessageDelete", () => {
694
1053
  schemaVersion: undefined,
695
1054
  } as unknown as MessageDeleteEvent;
696
1055
 
697
- await handleMessageDelete(unversioned, noopLog, deps());
1056
+ await handleMessageDelete(unversioned, noopLog, 1, deps());
698
1057
 
699
1058
  assert.equal(called("connection.deleteMessages").length, 0);
700
1059
  assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
@@ -711,7 +1070,7 @@ describe("handleMessageDelete", () => {
711
1070
  };
712
1071
 
713
1072
  await assert.rejects(
714
- handleMessageDelete(moveEvent, noopLog, deps()),
1073
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
715
1074
  /server exploded/,
716
1075
  );
717
1076
 
@@ -725,7 +1084,7 @@ describe("handleMessageDelete", () => {
725
1084
  it("pauses quietly when openBox trips a UIDVALIDITY mismatch", async () => {
726
1085
  h.connection.openBox = async () => ({ uidvalidity: 999 });
727
1086
 
728
- await handleMessageDelete(moveEvent, noopLog, deps());
1087
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
729
1088
 
730
1089
  assert.equal(
731
1090
  (called("mailbox.update")[0]?.args[2] as { cursorState?: string })
@@ -742,7 +1101,7 @@ describe("handleMessageDelete", () => {
742
1101
  cursorState: "rebuilding",
743
1102
  };
744
1103
 
745
- await handleMessageDelete(moveEvent, noopLog, deps());
1104
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
746
1105
 
747
1106
  assert.equal(h.getConnectionCount, 0);
748
1107
  });
@@ -752,7 +1111,7 @@ describe("handleMessageDelete", () => {
752
1111
  name: "NotFoundError",
753
1112
  });
754
1113
 
755
- await handleMessageDelete(moveEvent, noopLog, deps());
1114
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
756
1115
 
757
1116
  assert.equal(h.getConnectionCount, 0);
758
1117
  assert.equal(called("message.updateUid").length, 0);
@@ -766,7 +1125,7 @@ describe("handleMessageDelete", () => {
766
1125
  deletedAt: Date.now(),
767
1126
  };
768
1127
 
769
- await handleMessageDelete(moveEvent, noopLog, deps());
1128
+ await handleMessageDelete(moveEvent, noopLog, 1, deps());
770
1129
 
771
1130
  assert.equal(h.getConnectionCount, 0);
772
1131
  });
@@ -775,7 +1134,7 @@ describe("handleMessageDelete", () => {
775
1134
  h.account = null;
776
1135
 
777
1136
  await assert.rejects(
778
- handleMessageDelete(moveEvent, noopLog, deps()),
1137
+ handleMessageDelete(moveEvent, noopLog, 1, deps()),
779
1138
  /not found/,
780
1139
  );
781
1140
  });
@@ -1,23 +1,50 @@
1
1
  import { getClient } from "@remit/backend/client";
2
2
  import type {
3
+ IMessageRepository,
3
4
  IThreadMessageRepository,
4
5
  ThreadMessageItem,
5
6
  } from "@remit/data-ports";
6
7
  import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
7
8
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
8
9
  import type { Logger } from "@remit/logger-lambda";
10
+ import { recordImapFailure } from "@remit/logger-lambda";
9
11
  import {
10
12
  guardConnectionCursor,
13
+ type IImapConnection,
11
14
  isCursorRebuildNeeded,
12
15
  isMessageGoneFromOpenMailbox,
13
16
  MailboxCursorPausedError,
14
17
  } from "@remit/mailbox-service";
15
18
  import { isAccountDeleted } from "../account-check.js";
16
19
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
20
+ import { emitEvent } from "../emit.js";
17
21
  import type { MessageDeleteEvent } from "../events.js";
18
22
  import { isNotFoundError } from "../is-not-found.js";
19
23
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
20
24
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.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();
21
48
 
22
49
  /**
23
50
  * Delete every ThreadMessage row that points at this messageId.
@@ -122,11 +149,69 @@ export const buildThreadMessageUndelete = (
122
149
  composites: currentComposites(threadMessage),
123
150
  });
124
151
 
152
+ /**
153
+ * Settle a move to Trash the server left unconfirmed, by asking it two
154
+ * read-only questions instead of one. Both handles wrap the SAME connection but
155
+ * are scoped to their own mailbox: a `guardConnectionCursor` wrap binds its
156
+ * checks to the ONE mailbox snapshot it was built with, so the destination must
157
+ * never be opened through the source's guard.
158
+ *
159
+ * The source is asked first, and a source that still holds the uid ends it: the
160
+ * MOVE did not happen, so nothing at the destination can be this message.
161
+ * Skipping that question is not safe, because `searchMailboxByMessageId`
162
+ * returns the LOWEST matching uid rather than the one that just arrived, and
163
+ * one Message-ID can have several server copies in one account (a sieve
164
+ * `fileinto` + `keep`, a multi-label store, a resend) while
165
+ * `deriveMessageId` is folder-independent and gives them one local row. An
166
+ * ungated probe can hand back an earlier copy's uid, and Empty Trash then
167
+ * expunges by that uid. It also closes the second half of #912: an empty
168
+ * `uidMap` can mean the MOVE matched nothing at all.
169
+ *
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.
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
+
181
+ const confirmTrashMoveUid = async (
182
+ sourceConnection: IImapConnection,
183
+ destinationConnection: IImapConnection,
184
+ messageService: Pick<IMessageRepository, "get">,
185
+ messageId: string,
186
+ sourceMailboxPath: string,
187
+ destinationMailboxPath: string,
188
+ uid: number,
189
+ ): Promise<TrashMoveConfirmation> => {
190
+ await sourceConnection.openBox(sourceMailboxPath, true);
191
+ if (!(await isMessageGoneFromOpenMailbox(sourceConnection, uid))) {
192
+ return { outcome: "still-at-source" };
193
+ }
194
+
195
+ const [message] = await messageService.get([messageId]);
196
+ if (!message) return { outcome: "row-gone" };
197
+ if (!message.messageIdHeader) return { outcome: "unprobeable" };
198
+
199
+ const probedUid = await searchMailboxByMessageId(
200
+ destinationConnection,
201
+ destinationMailboxPath,
202
+ message.messageIdHeader,
203
+ );
204
+ return probedUid === null
205
+ ? { outcome: "unconfirmed" }
206
+ : { outcome: "confirmed", uid: probedUid };
207
+ };
208
+
125
209
  export interface MessageDeleteDeps {
126
210
  getClient: typeof getClient;
127
211
  buildLifecycleDeps: typeof buildLifecycleDeps;
128
212
  withOAuthLifecycle: typeof withOAuthLifecycle;
129
213
  createConnectionScope: typeof createConnectionScopeWithCredentials;
214
+ emitEvent: typeof emitEvent;
130
215
  }
131
216
 
132
217
  const defaultDeps: MessageDeleteDeps = {
@@ -134,15 +219,22 @@ const defaultDeps: MessageDeleteDeps = {
134
219
  buildLifecycleDeps,
135
220
  withOAuthLifecycle,
136
221
  createConnectionScope: createConnectionScopeWithCredentials,
222
+ emitEvent,
137
223
  };
138
224
 
139
225
  /**
140
226
  * Handle MESSAGE_DELETE events.
141
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).
142
233
  */
143
234
  export const handleMessageDelete = async (
144
235
  event: MessageDeleteEvent,
145
236
  log: Logger,
237
+ receiveCount = 1,
146
238
  deps: MessageDeleteDeps = defaultDeps,
147
239
  ): Promise<void> => {
148
240
  const {
@@ -150,6 +242,7 @@ export const handleMessageDelete = async (
150
242
  buildLifecycleDeps,
151
243
  withOAuthLifecycle,
152
244
  createConnectionScope: createConnectionScopeWithCredentials,
245
+ emitEvent,
153
246
  } = deps;
154
247
 
155
248
  const {
@@ -246,6 +339,80 @@ export const handleMessageDelete = async (
246
339
  }
247
340
  };
248
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
+
249
416
  if (!isCurrentSchemaVersion(event.schemaVersion)) {
250
417
  await abandonDelete(
251
418
  "Refused to delete: event was minted under an unknown contract",
@@ -324,15 +491,50 @@ export const handleMessageDelete = async (
324
491
  "Refused to delete: move to trash carries no destination mailbox",
325
492
  "message_delete_missing_destination",
326
493
  );
327
- } else if (operation === "move_to_trash" && destinationMailboxPath) {
494
+ } else if (
495
+ operation === "move_to_trash" &&
496
+ destinationMailboxPath &&
497
+ destinationMailboxId
498
+ ) {
328
499
  // Move to Trash
329
500
  const result = await connection.moveMessages(
330
501
  [uid],
331
502
  destinationMailboxPath,
332
503
  );
333
- const newUid = result.uidMap.get(uid);
334
504
 
335
- if (newUid && destinationMailboxId) {
505
+ // UIDPLUS is an extension. A server without it answers a
506
+ // perfectly successful MOVE with no COPYUID entry, so an empty
507
+ // map is UNCONFIRMED, never evidence the move failed: the server
508
+ // is asked before any verdict, exactly as `handleMessageMove` and
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;
336
538
  // Update message with new UID in Trash
337
539
  await messageService.updateUid(
338
540
  messageId,
@@ -360,15 +562,14 @@ export const handleMessageDelete = async (
360
562
  }
361
563
 
362
564
  log.info({ messageId, newUid }, "Message moved to trash");
363
- } else {
364
- log.error(
365
- { messageId, uid },
366
- "Failed to get new UID after move to trash",
367
- );
368
- await messageService.update(messageId, {
369
- syncStatus: MessageSyncStatus.failed,
370
- });
565
+ return;
371
566
  }
567
+
568
+ await settleUnconfirmedTrashMove(
569
+ confirmation,
570
+ account.accountConfigId,
571
+ scope.getConnection,
572
+ );
372
573
  } else {
373
574
  // Permanent delete — reached only by `operation === "permanent_delete"`.
374
575
  await connection.deleteMessages([uid]);
@@ -455,11 +656,28 @@ export const handleMessageDelete = async (
455
656
  return;
456
657
  }
457
658
 
458
- // Mark as failed for other errors
459
- await messageService.update(messageId, {
460
- syncStatus: MessageSyncStatus.failed,
461
- });
462
- 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
+ );
463
681
  })
464
682
  .finally(() => scope.disconnect());
465
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":