@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
|
@@ -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
|
-
|
|
421
|
-
|
|
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
|
-
|
|
498
|
+
await handleMessageDelete(moveEvent, noopLog, 1, deps());
|
|
424
499
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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":
|