@remit/imap-worker 0.0.68 → 0.0.70
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 +1 -1
- package/src/handlers/empty-trash.test.ts +138 -0
- package/src/handlers/empty-trash.ts +31 -9
- package/src/handlers/message-copy.test.ts +99 -2
- package/src/handlers/message-copy.ts +82 -12
- package/src/handlers/message-delete-terminal.ts +6 -1
- package/src/handlers/message-delete.test.ts +400 -54
- package/src/handlers/message-delete.ts +236 -97
- package/src/handlers/message-move-terminal.test.ts +10 -22
- package/src/handlers/message-move-terminal.ts +6 -1
- package/src/handlers/message-move.test.ts +363 -1
- package/src/handlers/message-move.ts +289 -51
- package/src/handlers/restore-source-placement.ts +66 -20
package/package.json
CHANGED
|
@@ -33,6 +33,10 @@ interface LocalMessage {
|
|
|
33
33
|
messageId: string;
|
|
34
34
|
uid: number;
|
|
35
35
|
status: string;
|
|
36
|
+
syncStatus?: string;
|
|
37
|
+
mailboxId?: string;
|
|
38
|
+
originalMailboxId?: string;
|
|
39
|
+
originalUid?: number;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
type TrashMailbox = { mailboxId: string; fullPath: string };
|
|
@@ -77,6 +81,29 @@ const deleting = (messageId: string, uid: number): LocalMessage => ({
|
|
|
77
81
|
status: "deleting",
|
|
78
82
|
});
|
|
79
83
|
|
|
84
|
+
/** Mid-move into Trash: the folder is written, the uid is still the source's. */
|
|
85
|
+
const movingIntoTrash = (messageId: string, uid: number): LocalMessage => ({
|
|
86
|
+
messageId,
|
|
87
|
+
uid,
|
|
88
|
+
status: "moving",
|
|
89
|
+
syncStatus: "pending",
|
|
90
|
+
mailboxId: "trash-mbx",
|
|
91
|
+
originalMailboxId: "inbox-mbx",
|
|
92
|
+
originalUid: uid,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** The same row between a failed attempt and its redelivery. */
|
|
96
|
+
const retryingIntoTrash = (messageId: string, uid: number): LocalMessage => ({
|
|
97
|
+
...movingIntoTrash(messageId, uid),
|
|
98
|
+
syncStatus: "failed",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** The same row after an empty marked the folder, `status` overwritten. */
|
|
102
|
+
const markedMidMove = (messageId: string, uid: number): LocalMessage => ({
|
|
103
|
+
...movingIntoTrash(messageId, uid),
|
|
104
|
+
status: "deleting",
|
|
105
|
+
});
|
|
106
|
+
|
|
80
107
|
const fresh = (): Harness => ({
|
|
81
108
|
calls: [],
|
|
82
109
|
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
@@ -417,3 +444,114 @@ describe("handleEmptyTrash", () => {
|
|
|
417
444
|
assert.equal(h.disconnectCount, 1);
|
|
418
445
|
});
|
|
419
446
|
});
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Issue #1217. The sweep matched every row the Trash listing returned against
|
|
450
|
+
* the expunged uids. A row whose move into Trash has not settled carries the
|
|
451
|
+
* SOURCE folder's uid, so that match answers for whatever Trash holds at that
|
|
452
|
+
* uid — a different message, whose Message and thread rows the sweep then
|
|
453
|
+
* deletes while the mail it named is still sitting in the folder it never left.
|
|
454
|
+
*/
|
|
455
|
+
describe("handleEmptyTrash and an unsettled placement", () => {
|
|
456
|
+
beforeEach(() => {
|
|
457
|
+
h = fresh();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("deletes no row of a message whose move into Trash has not settled", async () => {
|
|
461
|
+
h.localMessages = [
|
|
462
|
+
deleting("msg-1", 10),
|
|
463
|
+
movingIntoTrash("msg-moving", 10),
|
|
464
|
+
];
|
|
465
|
+
|
|
466
|
+
await handleEmptyTrash(event, noopLog, deps());
|
|
467
|
+
|
|
468
|
+
assert.deepEqual(
|
|
469
|
+
called("message.delete").map((c) => c.args[0]),
|
|
470
|
+
["msg-1"],
|
|
471
|
+
"uid 10 is msg-1's; the in-flight row only borrows it from the inbox",
|
|
472
|
+
);
|
|
473
|
+
assert.equal(called("threadMessage.delete").length, 1);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("leaves a row whose move is mid-retry rather than sweeping it", async () => {
|
|
477
|
+
// `syncStatus: failed` under `status: moving` is an ordinary transient
|
|
478
|
+
// attempt about to be redelivered (`data-ports/message-settlement.ts`),
|
|
479
|
+
// not a give-up. Either way the pair is a lie while it stands, and the
|
|
480
|
+
// uid is somebody else's.
|
|
481
|
+
h.localMessages = [retryingIntoTrash("msg-retrying", 11)];
|
|
482
|
+
|
|
483
|
+
await handleEmptyTrash(event, noopLog, deps());
|
|
484
|
+
|
|
485
|
+
assert.equal(called("message.delete").length, 0);
|
|
486
|
+
assert.equal(called("threadMessage.delete").length, 0);
|
|
487
|
+
assert.deepEqual(revertedMessageIds(), []);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("hands back, rather than binds, a mid-move row an earlier mark rewrote", async () => {
|
|
491
|
+
// Defence in depth for the shape `emptyTrash` used to write: the mark
|
|
492
|
+
// overwrote `status` and the row reached here reading settled while its
|
|
493
|
+
// uid still belonged to the inbox. The uid is not bound — but the mark is
|
|
494
|
+
// cleared, because nothing else is coming to clear it and a row left
|
|
495
|
+
// `deleting` is hidden from every listing for good.
|
|
496
|
+
h.localMessages = [deleting("msg-1", 10), markedMidMove("msg-marked", 11)];
|
|
497
|
+
|
|
498
|
+
await handleEmptyTrash(event, noopLog, deps());
|
|
499
|
+
|
|
500
|
+
assert.deepEqual(
|
|
501
|
+
called("message.delete").map((c) => c.args[0]),
|
|
502
|
+
["msg-1"],
|
|
503
|
+
);
|
|
504
|
+
assert.deepEqual(revertedMessageIds(), ["msg-marked"]);
|
|
505
|
+
assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-marked"]);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("sweeps a settled row whose Trash uid matches the one it left behind", async () => {
|
|
509
|
+
// Two folders count uids independently, so a move can land on the same
|
|
510
|
+
// number it started from. `updateUid` drops `originalUid` when it settles,
|
|
511
|
+
// which is what keeps this row apart from one still mid-move — without it
|
|
512
|
+
// the sweep would expunge the message on the server and then refuse its
|
|
513
|
+
// rows, leaving mail that no longer exists hidden in Trash for good.
|
|
514
|
+
h.localMessages = [
|
|
515
|
+
{
|
|
516
|
+
messageId: "msg-collided",
|
|
517
|
+
uid: 10,
|
|
518
|
+
status: "deleting",
|
|
519
|
+
syncStatus: "pending",
|
|
520
|
+
mailboxId: "trash-mbx",
|
|
521
|
+
originalMailboxId: "inbox-mbx",
|
|
522
|
+
},
|
|
523
|
+
];
|
|
524
|
+
|
|
525
|
+
await handleEmptyTrash(event, noopLog, deps());
|
|
526
|
+
|
|
527
|
+
assert.deepEqual(
|
|
528
|
+
called("message.delete").map((c) => c.args[0]),
|
|
529
|
+
["msg-collided"],
|
|
530
|
+
);
|
|
531
|
+
assert.deepEqual(revertedMessageIds(), []);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("sweeps a settled row that reached Trash by a move", async () => {
|
|
535
|
+
// The confirmed pair: `updateUid` wrote Trash's own uid over the
|
|
536
|
+
// source's, leaving `originalUid` behind as history. Refusing this would
|
|
537
|
+
// leave the folder marked for a deletion that already happened.
|
|
538
|
+
h.localMessages = [
|
|
539
|
+
{
|
|
540
|
+
messageId: "msg-settled",
|
|
541
|
+
uid: 11,
|
|
542
|
+
status: "deleting",
|
|
543
|
+
syncStatus: "pending",
|
|
544
|
+
mailboxId: "trash-mbx",
|
|
545
|
+
originalMailboxId: "inbox-mbx",
|
|
546
|
+
originalUid: 42,
|
|
547
|
+
},
|
|
548
|
+
];
|
|
549
|
+
|
|
550
|
+
await handleEmptyTrash(event, noopLog, deps());
|
|
551
|
+
|
|
552
|
+
assert.deepEqual(
|
|
553
|
+
called("message.delete").map((c) => c.args[0]),
|
|
554
|
+
["msg-settled"],
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
});
|
|
@@ -5,6 +5,7 @@ import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
|
|
|
5
5
|
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
6
6
|
import type { Logger } from "@remit/logger-lambda";
|
|
7
7
|
import {
|
|
8
|
+
carriesForeignUid,
|
|
8
9
|
guardConnectionCursor,
|
|
9
10
|
isCursorRebuildNeeded,
|
|
10
11
|
MailboxCursorPausedError,
|
|
@@ -248,12 +249,29 @@ export const handleEmptyTrash = async (
|
|
|
248
249
|
const expunged = new Set(uids);
|
|
249
250
|
const localMessages =
|
|
250
251
|
await messageService.listAllByMailbox(trashMailboxId);
|
|
251
|
-
let deletedCount = 0;
|
|
252
252
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
// Reconciles, never waits (imap-mutations R2). A row whose move
|
|
254
|
+
// into Trash has not settled names this folder while still
|
|
255
|
+
// carrying the SOURCE folder's uid, so matching it against the
|
|
256
|
+
// expunge answers for whatever Trash held at that uid — another
|
|
257
|
+
// message, deleted here in both its rows. Waiting is not open to
|
|
258
|
+
// this handler the way it is to the API-side mutators: every event
|
|
259
|
+
// of an account shares one FIFO group, so the move that would
|
|
260
|
+
// settle the row cannot run until this returns, and the ceiling
|
|
261
|
+
// would be spent to reach the same answer.
|
|
262
|
+
//
|
|
263
|
+
// `carriesForeignUid`, not the placement binding: the binding reads
|
|
264
|
+
// `status`, and `status` is exactly what an operation marking this
|
|
265
|
+
// folder overwrites. This is the second half of the gate
|
|
266
|
+
// `emptyTrash` applies before it marks anything.
|
|
267
|
+
const swept = localMessages.filter(
|
|
268
|
+
(message) =>
|
|
269
|
+
!carriesForeignUid(message) && expunged.has(message.uid),
|
|
270
|
+
);
|
|
271
|
+
const sweptIds = new Set(swept.map((message) => message.messageId));
|
|
272
|
+
const deletedCount = swept.length;
|
|
256
273
|
|
|
274
|
+
for (const message of swept) {
|
|
257
275
|
await messageService.delete(message.messageId);
|
|
258
276
|
|
|
259
277
|
const threadMessage = await threadMessageService.findByMessageId(
|
|
@@ -268,12 +286,16 @@ export const handleEmptyTrash = async (
|
|
|
268
286
|
}
|
|
269
287
|
}
|
|
270
288
|
|
|
271
|
-
// Everything
|
|
272
|
-
// that will never come: mail another client already
|
|
273
|
-
// that reached Trash after the SEARCH,
|
|
274
|
-
// partial sweep
|
|
289
|
+
// Everything this sweep did not carry through is still marked for a
|
|
290
|
+
// deletion that will never come: mail another client already
|
|
291
|
+
// emptied, mail that reached Trash after the SEARCH, a redelivery
|
|
292
|
+
// finishing a partial sweep, and a row whose uid the sweep would
|
|
293
|
+
// not bind. All of them must come back rather than sit invisible —
|
|
294
|
+
// the last one especially, since nothing else is coming to clear
|
|
295
|
+
// its mark, and a row hidden in a folder it never left is worse
|
|
296
|
+
// than one the next sync re-projects.
|
|
275
297
|
const revertedCount = await handBackMarkedRows(
|
|
276
|
-
localMessages.filter((message) => !
|
|
298
|
+
localMessages.filter((message) => !sweptIds.has(message.messageId)),
|
|
277
299
|
);
|
|
278
300
|
|
|
279
301
|
log.info(
|
|
@@ -157,6 +157,7 @@ const deps = (): MessageCopyDeps =>
|
|
|
157
157
|
_log: unknown,
|
|
158
158
|
cb: (credentials: unknown) => Promise<void>,
|
|
159
159
|
) => cb({}),
|
|
160
|
+
emitEvent: record("emitEvent"),
|
|
160
161
|
createConnectionScope: () => ({
|
|
161
162
|
getConnection: async () => {
|
|
162
163
|
h.getConnectionCount += 1;
|
|
@@ -369,7 +370,12 @@ describe("handleMessageCopy", () => {
|
|
|
369
370
|
assert.equal(called("message.update").length, 0);
|
|
370
371
|
});
|
|
371
372
|
|
|
372
|
-
|
|
373
|
+
// Issue #1203. Both pauses are reached before `copyMessages`, so the COPY
|
|
374
|
+
// was never issued. Acking left the optimistic row at `uid: 0`/`moving`
|
|
375
|
+
// with nothing to re-enqueue it and no folder set for the cursor rebuild to
|
|
376
|
+
// find it in, which is `holdsCopyOf`'s row-existence answer turning into a
|
|
377
|
+
// copy that can never settle.
|
|
378
|
+
it("reconciles the optimistic copy row away when the cursor is rebuilding", async () => {
|
|
373
379
|
h.mailbox = {
|
|
374
380
|
mailboxId: "src-mbx",
|
|
375
381
|
uidValidity: 1,
|
|
@@ -380,9 +386,99 @@ describe("handleMessageCopy", () => {
|
|
|
380
386
|
|
|
381
387
|
assert.equal(h.getConnectionCount, 0);
|
|
382
388
|
assert.equal(called("message.updateUid").length, 0);
|
|
389
|
+
assert.deepEqual(called("message.delete")[0]?.args, ["new-msg"]);
|
|
390
|
+
assert.equal(called("threadMessage.deleteMany").length, 1);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// A redelivery's earlier attempt may have copied: the tagged OK can be lost
|
|
394
|
+
// with the connection. Deleting this row on the pause alone would leave the
|
|
395
|
+
// landed copy with no row of its own — `holdsCopyOf` answers on row
|
|
396
|
+
// existence, so no sync makes a second one — and the next destination sync
|
|
397
|
+
// would repoint the SOURCE row out of the folder the server still holds it
|
|
398
|
+
// in. The pause is on the source; the destination is asked before anything
|
|
399
|
+
// is deleted.
|
|
400
|
+
it("asks the destination before reconciling a redelivered copy onto a paused cursor", async () => {
|
|
401
|
+
h.mailbox = {
|
|
402
|
+
mailboxId: "src-mbx",
|
|
403
|
+
uidValidity: 1,
|
|
404
|
+
cursorState: "rebuilding",
|
|
405
|
+
};
|
|
406
|
+
h.destinationHolds = [42];
|
|
407
|
+
|
|
408
|
+
await handleMessageCopy(event, noopLog, 2, deps());
|
|
409
|
+
|
|
410
|
+
assert.ok(askedTheDestination(), "the destination was asked for the copy");
|
|
411
|
+
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
412
|
+
"new-msg",
|
|
413
|
+
42,
|
|
414
|
+
"dst-mbx",
|
|
415
|
+
]);
|
|
416
|
+
assert.equal(
|
|
417
|
+
called("message.delete").length,
|
|
418
|
+
0,
|
|
419
|
+
"a copy the destination confirms is never reconciled away",
|
|
420
|
+
);
|
|
421
|
+
assert.equal(h.disconnectCount, 1);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("settles a redelivered copy broken when the destination cannot be asked", async () => {
|
|
425
|
+
h.mailbox = {
|
|
426
|
+
mailboxId: "src-mbx",
|
|
427
|
+
uidValidity: 1,
|
|
428
|
+
cursorState: "rebuilding",
|
|
429
|
+
};
|
|
430
|
+
h.copyRow = { ...unsettledCopyRow(), messageIdHeader: undefined };
|
|
431
|
+
|
|
432
|
+
await handleMessageCopy(event, noopLog, 2, deps());
|
|
433
|
+
|
|
434
|
+
assert.equal(
|
|
435
|
+
called("message.delete").length,
|
|
436
|
+
0,
|
|
437
|
+
"silence is never grounds to delete a row that may describe real mail",
|
|
438
|
+
);
|
|
439
|
+
assert.equal(
|
|
440
|
+
(called("message.update")[0]?.args[1] as { status?: string })?.status,
|
|
441
|
+
"deleted",
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// A paused settle is a settle, and this handler had no way to say so: it
|
|
446
|
+
// carried no `emitEvent` at all, so the destination — which is not paused
|
|
447
|
+
// and has no rebuild coming — was never told to re-project. The move and
|
|
448
|
+
// delete handlers have run this resync since #1203.
|
|
449
|
+
it("resyncs both folders after a paused copy settles", async () => {
|
|
450
|
+
h.mailbox = {
|
|
451
|
+
mailboxId: "src-mbx",
|
|
452
|
+
uidValidity: 1,
|
|
453
|
+
cursorState: "rebuilding",
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
await handleMessageCopy(event, noopLog, 1, deps());
|
|
457
|
+
|
|
458
|
+
assert.deepEqual(
|
|
459
|
+
called("emitEvent").map((c) => c.args[0]),
|
|
460
|
+
[
|
|
461
|
+
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
|
|
462
|
+
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "dst-mbx" },
|
|
463
|
+
],
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("reconciles a redelivered copy away when the destination denies holding it", async () => {
|
|
468
|
+
h.mailbox = {
|
|
469
|
+
mailboxId: "src-mbx",
|
|
470
|
+
uidValidity: 1,
|
|
471
|
+
cursorState: "rebuilding",
|
|
472
|
+
};
|
|
473
|
+
h.destinationHolds = [];
|
|
474
|
+
|
|
475
|
+
await handleMessageCopy(event, noopLog, 2, deps());
|
|
476
|
+
|
|
477
|
+
assert.ok(askedTheDestination());
|
|
478
|
+
assert.deepEqual(called("message.delete")[0]?.args, ["new-msg"]);
|
|
383
479
|
});
|
|
384
480
|
|
|
385
|
-
it("
|
|
481
|
+
it("reconciles the optimistic copy row away when openBox trips a UIDVALIDITY mismatch", async () => {
|
|
386
482
|
h.connection.openBox = async () => ({ uidvalidity: 999 });
|
|
387
483
|
|
|
388
484
|
await handleMessageCopy(event, noopLog, 1, deps());
|
|
@@ -394,6 +490,7 @@ describe("handleMessageCopy", () => {
|
|
|
394
490
|
"the mismatch trips the mailbox cursor",
|
|
395
491
|
);
|
|
396
492
|
assert.equal(called("message.updateUid").length, 0);
|
|
493
|
+
assert.deepEqual(called("message.delete")[0]?.args, ["new-msg"]);
|
|
397
494
|
assert.equal(h.disconnectCount, 1);
|
|
398
495
|
});
|
|
399
496
|
|
|
@@ -12,17 +12,22 @@ import {
|
|
|
12
12
|
import { attemptBudget } from "@remit/sqs-client/attempt-budget";
|
|
13
13
|
import { isAccountDeleted } from "../account-check.js";
|
|
14
14
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
15
|
+
import { emitEvent } from "../emit.js";
|
|
15
16
|
import type { MessageCopyEvent } from "../events.js";
|
|
16
17
|
import { isNotFoundError } from "../is-not-found.js";
|
|
17
18
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
18
19
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
19
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
emitMoveResync,
|
|
22
|
+
searchMailboxForHighestMessageIdUid,
|
|
23
|
+
} from "./message-move.js";
|
|
20
24
|
|
|
21
25
|
export interface MessageCopyDeps {
|
|
22
26
|
getClient: typeof getClient;
|
|
23
27
|
buildLifecycleDeps: typeof buildLifecycleDeps;
|
|
24
28
|
withOAuthLifecycle: typeof withOAuthLifecycle;
|
|
25
29
|
createConnectionScope: typeof createConnectionScopeWithCredentials;
|
|
30
|
+
emitEvent: typeof emitEvent;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
const defaultDeps: MessageCopyDeps = {
|
|
@@ -30,6 +35,7 @@ const defaultDeps: MessageCopyDeps = {
|
|
|
30
35
|
buildLifecycleDeps,
|
|
31
36
|
withOAuthLifecycle,
|
|
32
37
|
createConnectionScope: createConnectionScopeWithCredentials,
|
|
38
|
+
emitEvent,
|
|
33
39
|
};
|
|
34
40
|
|
|
35
41
|
export const getMessageCopyMaxAttempts = (
|
|
@@ -83,6 +89,7 @@ export const handleMessageCopy = async (
|
|
|
83
89
|
buildLifecycleDeps,
|
|
84
90
|
withOAuthLifecycle,
|
|
85
91
|
createConnectionScope: createConnectionScopeWithCredentials,
|
|
92
|
+
emitEvent,
|
|
86
93
|
} = deps;
|
|
87
94
|
|
|
88
95
|
const {
|
|
@@ -172,17 +179,6 @@ export const handleMessageCopy = async (
|
|
|
172
179
|
return;
|
|
173
180
|
}
|
|
174
181
|
|
|
175
|
-
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
176
|
-
// paused never even opens a connection. Optimization only — the
|
|
177
|
-
// guardConnectionCursor openBox wrap below is the structural guarantee.
|
|
178
|
-
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
179
|
-
log.info(
|
|
180
|
-
{ accountId, sourceMessageId, mailboxId: sourceMailboxId },
|
|
181
|
-
"Mailbox cursor not normal; pausing outbound copy this round",
|
|
182
|
-
);
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
182
|
// The copy row carries the source's Message-ID header, so the
|
|
187
183
|
// destination can be asked where this copy is. Always on the UNGUARDED
|
|
188
184
|
// handle: a `guardConnectionCursor` wrap is bound to the SOURCE mailbox
|
|
@@ -317,6 +313,78 @@ export const handleMessageCopy = async (
|
|
|
317
313
|
|
|
318
314
|
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
319
315
|
|
|
316
|
+
// A paused cursor settles rather than acks: acking leaves the optimistic
|
|
317
|
+
// row at `uid: 0`/`moving` with nothing to re-enqueue it and no folder
|
|
318
|
+
// set for the cursor rebuild to find it in (issue #1203). Like every
|
|
319
|
+
// other verdict on this row, it reconciles rather than waits (R2,
|
|
320
|
+
// docs/architecture/imap-mutations.md) — the settle below IS the
|
|
321
|
+
// reconciliation path, since the destination is not paused and no
|
|
322
|
+
// rebuild is coming for it.
|
|
323
|
+
//
|
|
324
|
+
// What may be concluded from the pause depends on what was issued, and
|
|
325
|
+
// `receiveCount` alone does not say: `commandIssued` is what this round
|
|
326
|
+
// did, a redelivery is what an earlier one may have done and lost with
|
|
327
|
+
// its connection. Where neither says the server may have been asked, the
|
|
328
|
+
// row records something the server was never told about and reconciling
|
|
329
|
+
// it away is exact. Otherwise this row may be the only record real mail
|
|
330
|
+
// will ever have, and deleting it would let the next destination sync
|
|
331
|
+
// repoint the SOURCE row out of the folder the server still holds it in.
|
|
332
|
+
// The pause is on the source and says nothing about the destination,
|
|
333
|
+
// which is probed on the unguarded handle anyway, so the destination is
|
|
334
|
+
// asked before anything is deleted and silence settles broken.
|
|
335
|
+
//
|
|
336
|
+
// A settle is a settle, so both folders re-read their counts from IMAP,
|
|
337
|
+
// the same resync the move and delete handlers run: the destination is
|
|
338
|
+
// not paused, so nothing else is coming to re-project it.
|
|
339
|
+
const settlePausedCopy = async (
|
|
340
|
+
commandIssued: boolean,
|
|
341
|
+
): Promise<void> => {
|
|
342
|
+
const mayHaveIssued = commandIssued || receiveCount > 1;
|
|
343
|
+
|
|
344
|
+
if (!mayHaveIssued) {
|
|
345
|
+
await settleNeverLanded(
|
|
346
|
+
"source mailbox cursor paused before the copy",
|
|
347
|
+
);
|
|
348
|
+
} else {
|
|
349
|
+
const probe = await probeDestination(await scope.getConnection());
|
|
350
|
+
if (probe.kind === "confirmed") {
|
|
351
|
+
await settleCopied(probe.uid);
|
|
352
|
+
} else if (probe.kind === "absent") {
|
|
353
|
+
await settleNeverLanded(
|
|
354
|
+
"redelivered onto a paused source cursor, absent from destination",
|
|
355
|
+
);
|
|
356
|
+
} else {
|
|
357
|
+
await settleBroken(
|
|
358
|
+
"redelivered onto a paused source cursor with no Message-ID to ask the destination with",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
await emitMoveResync(emitEvent, {
|
|
364
|
+
accountId,
|
|
365
|
+
sourceMailboxId,
|
|
366
|
+
destinationMailboxId,
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
|
|
371
|
+
// paused never even opens a connection on a first delivery. Optimization
|
|
372
|
+
// only — the guardConnectionCursor openBox wrap below is the structural
|
|
373
|
+
// guarantee.
|
|
374
|
+
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
375
|
+
log.info(
|
|
376
|
+
{ accountId, sourceMessageId, mailboxId: sourceMailboxId },
|
|
377
|
+
"Mailbox cursor not normal; pausing outbound copy this round",
|
|
378
|
+
);
|
|
379
|
+
await settlePausedCopy(false).finally(() => scope.disconnect());
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Flipped the instant the COPY leaves, so every settle downstream of it
|
|
384
|
+
// knows the server was asked. IMAP COPY is not idempotent, so this is
|
|
385
|
+
// the flag that must never be inferred.
|
|
386
|
+
let commandIssued = false;
|
|
387
|
+
|
|
320
388
|
await scope
|
|
321
389
|
.getConnection()
|
|
322
390
|
.then(async (rawConnection) => {
|
|
@@ -354,6 +422,7 @@ export const handleMessageCopy = async (
|
|
|
354
422
|
await connection.openBox(sourceMailboxPath, true);
|
|
355
423
|
|
|
356
424
|
// Execute IMAP COPY
|
|
425
|
+
commandIssued = true;
|
|
357
426
|
const result = await connection.copyMessages(
|
|
358
427
|
[uid],
|
|
359
428
|
destinationMailboxPath,
|
|
@@ -395,6 +464,7 @@ export const handleMessageCopy = async (
|
|
|
395
464
|
},
|
|
396
465
|
"Mailbox cursor not normal; pausing outbound copy this round",
|
|
397
466
|
);
|
|
467
|
+
await settlePausedCopy(commandIssued);
|
|
398
468
|
return;
|
|
399
469
|
}
|
|
400
470
|
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
IMessageRepository,
|
|
3
3
|
IThreadMessageRepository,
|
|
4
4
|
} from "@remit/data-ports";
|
|
5
|
+
import { MessageSyncStatus } from "@remit/domain-enums";
|
|
5
6
|
import {
|
|
6
7
|
type IImapConnection,
|
|
7
8
|
isMessageGoneFromOpenMailbox,
|
|
@@ -17,7 +18,7 @@ export interface MessageDeleteTerminalLogger {
|
|
|
17
18
|
|
|
18
19
|
export interface ResolveExhaustedMessageDeleteDeps
|
|
19
20
|
extends StaleMessageReconcileDeps {
|
|
20
|
-
messageService: Pick<IMessageRepository, "delete" | "
|
|
21
|
+
messageService: Pick<IMessageRepository, "delete" | "updateForMove">;
|
|
21
22
|
threadMessageService: Pick<
|
|
22
23
|
IThreadMessageRepository,
|
|
23
24
|
"findAllByMessageId" | "deleteMany" | "update"
|
|
@@ -130,6 +131,10 @@ export const resolveExhaustedMessageDeleteFailure = async (
|
|
|
130
131
|
messageId,
|
|
131
132
|
sourceMailboxId,
|
|
132
133
|
uid,
|
|
134
|
+
// The probe above just confirmed the pair, so the row is a faithful
|
|
135
|
+
// projection of the source again. The delete's own failure is the alert,
|
|
136
|
+
// not a `failed` left on a row nothing is still trying to delete.
|
|
137
|
+
syncStatus: MessageSyncStatus.synced,
|
|
133
138
|
});
|
|
134
139
|
|
|
135
140
|
return { outcome: "broken" };
|