@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
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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("
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
"
|
|
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
|
|
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
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
it("
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
|
147
|
-
*
|
|
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<
|
|
189
|
+
): Promise<TrashMoveConfirmation> => {
|
|
158
190
|
await sourceConnection.openBox(sourceMailboxPath, true);
|
|
159
|
-
if (!(await isMessageGoneFromOpenMailbox(sourceConnection, uid)))
|
|
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
|
|
196
|
+
if (!message) return { outcome: "row-gone" };
|
|
197
|
+
if (!message.messageIdHeader) return { outcome: "unprobeable" };
|
|
163
198
|
|
|
164
|
-
|
|
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
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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":
|