@remit/imap-worker 0.0.67 → 0.0.69
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/message-copy.test.ts +99 -2
- package/src/handlers/message-copy.ts +82 -12
- package/src/handlers/message-delete-terminal.ts +11 -19
- package/src/handlers/message-delete.test.ts +435 -47
- package/src/handlers/message-delete.ts +259 -89
- package/src/handlers/message-move-terminal.test.ts +163 -18
- package/src/handlers/message-move-terminal.ts +46 -10
- package/src/handlers/message-move.test.ts +363 -1
- package/src/handlers/message-move.ts +339 -60
- package/src/handlers/restore-source-placement.ts +135 -0
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
|
-
import { afterEach, before, describe, it, mock } from "node:test";
|
|
2
|
+
import { afterEach, before, beforeEach, describe, it, mock } from "node:test";
|
|
3
3
|
import { getClient, type RemitClient, setClient } from "@remit/backend/client";
|
|
4
4
|
import type { AccountItem, ThreadMessageItem } from "@remit/data-ports";
|
|
5
5
|
import type { Logger } from "@remit/logger-lambda";
|
|
6
6
|
import type { IImapConnection } from "@remit/mailbox-service";
|
|
7
7
|
import type { MessageMoveEvent } from "../events.js";
|
|
8
|
+
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
9
|
+
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
8
10
|
import {
|
|
9
11
|
buildThreadMessageMoveUpdate,
|
|
10
12
|
emitMoveResync,
|
|
11
13
|
getMessageMoveMaxAttempts,
|
|
12
14
|
handleMessageMove,
|
|
13
15
|
MESSAGE_MOVE_MAX_ATTEMPTS,
|
|
16
|
+
type MessageMoveDeps,
|
|
14
17
|
moveThenResync,
|
|
18
|
+
probePausedPlacement,
|
|
15
19
|
searchMailboxForHighestMessageIdUid,
|
|
16
20
|
} from "./message-move.js";
|
|
17
21
|
|
|
@@ -273,11 +277,86 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
273
277
|
get: async () => [],
|
|
274
278
|
update: async () => undefined,
|
|
275
279
|
updateUid: async () => undefined,
|
|
280
|
+
updateForMove: async () => undefined,
|
|
281
|
+
},
|
|
282
|
+
threadMessage: {
|
|
283
|
+
findAllByMessageId: async () => [],
|
|
284
|
+
findByMessageId: async () => undefined,
|
|
285
|
+
update: async () => undefined,
|
|
276
286
|
},
|
|
277
287
|
secrets: { decrypt: async () => undefined },
|
|
278
288
|
} as unknown as RemitClient);
|
|
279
289
|
});
|
|
280
290
|
|
|
291
|
+
let emitted: unknown[] = [];
|
|
292
|
+
let connectCount = 0;
|
|
293
|
+
let logLines: { level: string; fields: Record<string, unknown> }[] = [];
|
|
294
|
+
|
|
295
|
+
// The level a give-up is logged at is behaviour: a routine pause must not
|
|
296
|
+
// page anyone, and a placement the server never settled must.
|
|
297
|
+
const capturingLogger = (): Logger => {
|
|
298
|
+
const at =
|
|
299
|
+
(level: string) =>
|
|
300
|
+
(fields: Record<string, unknown>): void => {
|
|
301
|
+
logLines.push({ level, fields });
|
|
302
|
+
};
|
|
303
|
+
const log = {
|
|
304
|
+
info: at("info"),
|
|
305
|
+
warn: at("warn"),
|
|
306
|
+
error: at("error"),
|
|
307
|
+
debug: at("debug"),
|
|
308
|
+
fatal: at("fatal"),
|
|
309
|
+
trace: at("trace"),
|
|
310
|
+
child: () => log,
|
|
311
|
+
} as unknown as Logger;
|
|
312
|
+
return log;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// One folder's Message-ID SEARCH answers, keyed by the box last opened.
|
|
316
|
+
// `openBox` on this handle is deliberately unguarded: the paused settle asks
|
|
317
|
+
// on the identity axis, which a UIDVALIDITY change leaves intact.
|
|
318
|
+
const holdingConnection = (
|
|
319
|
+
holdings: Record<string, number[]>,
|
|
320
|
+
): IImapConnection => {
|
|
321
|
+
let opened = "";
|
|
322
|
+
return {
|
|
323
|
+
openBox: async (path: string) => {
|
|
324
|
+
opened = path;
|
|
325
|
+
return {} as never;
|
|
326
|
+
},
|
|
327
|
+
search: async () => holdings[opened] ?? [],
|
|
328
|
+
} as unknown as IImapConnection;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const moveDeps = (connection?: IImapConnection): MessageMoveDeps => ({
|
|
332
|
+
getClient,
|
|
333
|
+
buildLifecycleDeps,
|
|
334
|
+
withOAuthLifecycle,
|
|
335
|
+
createConnectionScope: () => ({
|
|
336
|
+
getConnection: async () => {
|
|
337
|
+
connectCount += 1;
|
|
338
|
+
if (!connection) throw new Error("this case must not connect");
|
|
339
|
+
return connection;
|
|
340
|
+
},
|
|
341
|
+
disconnect: async () => undefined,
|
|
342
|
+
}),
|
|
343
|
+
emitEvent: (async (event: unknown) => {
|
|
344
|
+
emitted.push(event);
|
|
345
|
+
}) as MessageMoveDeps["emitEvent"],
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const pausedSource = () => ({
|
|
349
|
+
mailboxId: "mm-src-zzz",
|
|
350
|
+
uidValidity: 1,
|
|
351
|
+
cursorState: "rebuilding",
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
beforeEach(() => {
|
|
355
|
+
emitted = [];
|
|
356
|
+
connectCount = 0;
|
|
357
|
+
logLines = [];
|
|
358
|
+
});
|
|
359
|
+
|
|
281
360
|
afterEach(() => mock.restoreAll());
|
|
282
361
|
|
|
283
362
|
it("acks without connecting when the source mailbox was deleted", async () => {
|
|
@@ -328,6 +407,206 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
328
407
|
);
|
|
329
408
|
});
|
|
330
409
|
|
|
410
|
+
const arrangePausedMove = async (
|
|
411
|
+
options: { probeable: boolean } = { probeable: true },
|
|
412
|
+
): Promise<{
|
|
413
|
+
updateForMoveCalls: unknown[][];
|
|
414
|
+
updateUidCalls: unknown[][];
|
|
415
|
+
threadUpdateCalls: unknown[][];
|
|
416
|
+
}> => {
|
|
417
|
+
const client = await getClient();
|
|
418
|
+
mock.method(client.account, "get", async () => cappedAccount());
|
|
419
|
+
mock.method(client.secrets, "decrypt", async () => "fake-password");
|
|
420
|
+
mock.method(client.message, "get", async () => [
|
|
421
|
+
{
|
|
422
|
+
...pendingRow(),
|
|
423
|
+
messageIdHeader: options.probeable ? "<moved@example.com>" : undefined,
|
|
424
|
+
},
|
|
425
|
+
]);
|
|
426
|
+
mock.method(client.mailbox, "get", async () => pausedSource());
|
|
427
|
+
const updateForMoveCalls: unknown[][] = [];
|
|
428
|
+
mock.method(client.message, "updateForMove", async (...args: unknown[]) => {
|
|
429
|
+
updateForMoveCalls.push(args);
|
|
430
|
+
});
|
|
431
|
+
const updateUidCalls: unknown[][] = [];
|
|
432
|
+
mock.method(client.message, "updateUid", async (...args: unknown[]) => {
|
|
433
|
+
updateUidCalls.push(args);
|
|
434
|
+
});
|
|
435
|
+
const threadRow = {
|
|
436
|
+
...baseThreadMessage,
|
|
437
|
+
accountConfigId: "mm-cfg-zzz",
|
|
438
|
+
threadMessageId: "mm-tm-zzz",
|
|
439
|
+
mailboxId: "mm-dst-zzz",
|
|
440
|
+
};
|
|
441
|
+
mock.method(client.threadMessage, "findAllByMessageId", async () => [
|
|
442
|
+
threadRow,
|
|
443
|
+
]);
|
|
444
|
+
mock.method(client.threadMessage, "findByMessageId", async () => threadRow);
|
|
445
|
+
const threadUpdateCalls: unknown[][] = [];
|
|
446
|
+
mock.method(client.threadMessage, "update", async (...args: unknown[]) => {
|
|
447
|
+
threadUpdateCalls.push(args);
|
|
448
|
+
});
|
|
449
|
+
return { updateForMoveCalls, updateUidCalls, threadUpdateCalls };
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// Issue #1203. Acking a paused cursor left the row `moving` with `mailboxId`
|
|
453
|
+
// on the destination and `uid` on the source. Nothing re-enqueues a
|
|
454
|
+
// MESSAGE_MOVE, and the cursor rebuild matches rows by
|
|
455
|
+
// `(accountConfigId, mailboxId)`, so a row naming the destination sits in
|
|
456
|
+
// neither folder's set: `placementBindingOf` answered `in_flight` for good
|
|
457
|
+
// and the message became unmovable and undeletable.
|
|
458
|
+
it("hands the row back to its source on a first delivery, without connecting", async () => {
|
|
459
|
+
const { updateForMoveCalls, threadUpdateCalls } = await arrangePausedMove();
|
|
460
|
+
|
|
461
|
+
await handleMessageMove(event, silentLogger, 1, moveDeps());
|
|
462
|
+
|
|
463
|
+
assert.equal(
|
|
464
|
+
connectCount,
|
|
465
|
+
0,
|
|
466
|
+
"a first delivery has provably issued no MOVE, so it needs no answer from the server",
|
|
467
|
+
);
|
|
468
|
+
assert.deepEqual(
|
|
469
|
+
updateForMoveCalls[0],
|
|
470
|
+
[
|
|
471
|
+
"mm-msg-zzz",
|
|
472
|
+
{
|
|
473
|
+
mailboxId: "mm-src-zzz",
|
|
474
|
+
uid: 10,
|
|
475
|
+
status: "active",
|
|
476
|
+
syncStatus: "synced",
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
"the row goes back to the source pair, which is the set the rebuild adjudicates",
|
|
480
|
+
);
|
|
481
|
+
assert.equal(
|
|
482
|
+
(threadUpdateCalls[0]?.[2] as { mailboxId?: string })?.mailboxId,
|
|
483
|
+
"mm-src-zzz",
|
|
484
|
+
"the listing row follows the message back to its source folder",
|
|
485
|
+
);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// A paused settle is a settle, so both folders re-read their counts from
|
|
489
|
+
// IMAP — the same resync every other terminal verdict in this handler runs.
|
|
490
|
+
// Without it the repair depended entirely on someone else arming the
|
|
491
|
+
// mailbox's rebuild.
|
|
492
|
+
it("resyncs both folders after a paused move settles", async () => {
|
|
493
|
+
await arrangePausedMove();
|
|
494
|
+
|
|
495
|
+
await handleMessageMove(event, silentLogger, 1, moveDeps());
|
|
496
|
+
|
|
497
|
+
assert.deepEqual(emitted, [
|
|
498
|
+
{ type: "SYNC_MESSAGES", accountId: acctId, mailboxId: "mm-src-zzz" },
|
|
499
|
+
{ type: "SYNC_MESSAGES", accountId: acctId, mailboxId: "mm-dst-zzz" },
|
|
500
|
+
]);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// Issue #1203, the redelivery half, and the mail-loss edge in it. INBOX was
|
|
504
|
+
// recreated (UIDVALIDITY bumped, which is why the cursor is paused), the
|
|
505
|
+
// MOVE never ran, and INBOX honestly answers "no" for the Message-ID because
|
|
506
|
+
// it no longer holds anything it used to. Archive holds an older copy of the
|
|
507
|
+
// same Message-ID at uid 40 — a sieve `fileinto` + `keep`, a resend — which
|
|
508
|
+
// `deriveMessageId` folds into this one local row. Settling `moved` on 40
|
|
509
|
+
// binds the row to that copy, and the next permanent delete expunges it:
|
|
510
|
+
// Archive is not paused, so no rebuild is ever coming to adjudicate there.
|
|
511
|
+
it("never settles a redelivered paused move as moved off a destination sighting", async () => {
|
|
512
|
+
const { updateForMoveCalls, updateUidCalls } = await arrangePausedMove();
|
|
513
|
+
|
|
514
|
+
await handleMessageMove(
|
|
515
|
+
event,
|
|
516
|
+
capturingLogger(),
|
|
517
|
+
2,
|
|
518
|
+
moveDeps(holdingConnection({ INBOX: [], Archive: [40] })),
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
assert.equal(
|
|
522
|
+
updateUidCalls.length,
|
|
523
|
+
0,
|
|
524
|
+
"a sighting off a source whose uid axis has moved is not proof this move ran",
|
|
525
|
+
);
|
|
526
|
+
// The source pair is the set its own cursor rebuild walks and adjudicates
|
|
527
|
+
// by Message-ID, so handing the row back there is the reconcile path.
|
|
528
|
+
assert.deepEqual(updateForMoveCalls[0]?.[1], {
|
|
529
|
+
mailboxId: "mm-src-zzz",
|
|
530
|
+
uid: 10,
|
|
531
|
+
status: "active",
|
|
532
|
+
syncStatus: "failed",
|
|
533
|
+
});
|
|
534
|
+
assert.equal(
|
|
535
|
+
logLines.filter(
|
|
536
|
+
(line) =>
|
|
537
|
+
line.fields.alert === "message_move_paused_placement_unproven",
|
|
538
|
+
).length,
|
|
539
|
+
1,
|
|
540
|
+
"a placement the server never settled is an operator's to adjudicate",
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// The taxonomy `message-copy.ts` already applies: an evidence-free
|
|
545
|
+
// redelivery is broken, not settled. With no Message-ID header neither
|
|
546
|
+
// folder was asked, and silence is not an answer to write `synced` on.
|
|
547
|
+
it("settles a redelivered paused move broken when neither folder can be asked", async () => {
|
|
548
|
+
const { updateForMoveCalls } = await arrangePausedMove({
|
|
549
|
+
probeable: false,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
await handleMessageMove(
|
|
553
|
+
event,
|
|
554
|
+
capturingLogger(),
|
|
555
|
+
2,
|
|
556
|
+
moveDeps(holdingConnection({})),
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
assert.equal(
|
|
560
|
+
(updateForMoveCalls[0]?.[1] as { syncStatus?: string })?.syncStatus,
|
|
561
|
+
"failed",
|
|
562
|
+
);
|
|
563
|
+
assert.equal(
|
|
564
|
+
logLines.filter(
|
|
565
|
+
(line) =>
|
|
566
|
+
line.fields.alert === "message_move_paused_placement_unproven",
|
|
567
|
+
).length,
|
|
568
|
+
1,
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// The user's move was silently dropped. Nothing throws, nothing alerts and
|
|
573
|
+
// the row reads settled, so the only trace it ever leaves is this metric —
|
|
574
|
+
// the counterpart of copy's `message_copy_not_landed`.
|
|
575
|
+
it("counts a paused move the server was never told about", async () => {
|
|
576
|
+
await arrangePausedMove();
|
|
577
|
+
|
|
578
|
+
await handleMessageMove(event, capturingLogger(), 1, moveDeps());
|
|
579
|
+
|
|
580
|
+
const dropped = logLines.filter(
|
|
581
|
+
(line) => line.fields.metric === "message_move_dropped_on_pause",
|
|
582
|
+
);
|
|
583
|
+
assert.equal(dropped.length, 1);
|
|
584
|
+
assert.equal(dropped[0]?.level, "warn");
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Issue #1122 on the same path. The destination already held an older copy
|
|
588
|
+
// of this Message-ID and the MOVE never ran, so the destination hit is not
|
|
589
|
+
// this message. The source is asked first for exactly that reason, and its
|
|
590
|
+
// answer ends it.
|
|
591
|
+
it("never binds a redelivered paused move to an older copy while the source still answers", async () => {
|
|
592
|
+
const { updateForMoveCalls, updateUidCalls } = await arrangePausedMove();
|
|
593
|
+
|
|
594
|
+
await handleMessageMove(
|
|
595
|
+
event,
|
|
596
|
+
silentLogger,
|
|
597
|
+
2,
|
|
598
|
+
moveDeps(holdingConnection({ INBOX: [4], Archive: [12] })),
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
assert.equal(updateUidCalls.length, 0);
|
|
602
|
+
assert.deepEqual(updateForMoveCalls[0]?.[1], {
|
|
603
|
+
mailboxId: "mm-src-zzz",
|
|
604
|
+
uid: 10,
|
|
605
|
+
status: "active",
|
|
606
|
+
syncStatus: "synced",
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
331
610
|
it("acks without connecting when the message row is already gone", async () => {
|
|
332
611
|
const client = await getClient();
|
|
333
612
|
mock.method(client.account, "get", async () => cappedAccount());
|
|
@@ -343,6 +622,89 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
343
622
|
});
|
|
344
623
|
});
|
|
345
624
|
|
|
625
|
+
describe("probePausedPlacement — which folder a paused mutation left the message in (#1203)", () => {
|
|
626
|
+
const buildConnection = (
|
|
627
|
+
holdings: Record<string, number[]>,
|
|
628
|
+
): { connection: IImapConnection; opened: string[] } => {
|
|
629
|
+
const opened: string[] = [];
|
|
630
|
+
let current = "";
|
|
631
|
+
return {
|
|
632
|
+
opened,
|
|
633
|
+
connection: {
|
|
634
|
+
openBox: async (path: string) => {
|
|
635
|
+
opened.push(path);
|
|
636
|
+
current = path;
|
|
637
|
+
return {} as never;
|
|
638
|
+
},
|
|
639
|
+
search: async () => holdings[current] ?? [],
|
|
640
|
+
} as unknown as IImapConnection,
|
|
641
|
+
};
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const probe = (holdings: Record<string, number[]>) => {
|
|
645
|
+
const { connection, opened } = buildConnection(holdings);
|
|
646
|
+
return {
|
|
647
|
+
opened,
|
|
648
|
+
result: probePausedPlacement(connection, {
|
|
649
|
+
messageIdHeader: "<paused@example.com>",
|
|
650
|
+
sourceMailboxPath: "INBOX",
|
|
651
|
+
destinationMailboxPath: "Archive",
|
|
652
|
+
}),
|
|
653
|
+
};
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
it("stops at the source and never asks the destination when the source still holds it", async () => {
|
|
657
|
+
const { opened, result } = probe({ INBOX: [4], Archive: [12] });
|
|
658
|
+
|
|
659
|
+
assert.deepStrictEqual(await result, { kind: "at-source" });
|
|
660
|
+
assert.deepStrictEqual(
|
|
661
|
+
opened,
|
|
662
|
+
["INBOX"],
|
|
663
|
+
"a source hit ends it: with the mutation unrun, every destination hit is an older copy (#1122)",
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("answers the destination's highest matching uid once the source has let go", async () => {
|
|
668
|
+
const { result } = probe({ INBOX: [], Archive: [12, 77] });
|
|
669
|
+
|
|
670
|
+
assert.deepStrictEqual(await result, { kind: "at-destination", uid: 77 });
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("answers gone when neither folder holds it", async () => {
|
|
674
|
+
assert.deepStrictEqual(await probe({}).result, { kind: "gone" });
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("answers gone, not at-source, when there is no destination to ask", async () => {
|
|
678
|
+
const { connection, opened } = buildConnection({ INBOX: [] });
|
|
679
|
+
|
|
680
|
+
assert.deepStrictEqual(
|
|
681
|
+
await probePausedPlacement(connection, {
|
|
682
|
+
messageIdHeader: "<paused@example.com>",
|
|
683
|
+
sourceMailboxPath: "INBOX",
|
|
684
|
+
destinationMailboxPath: undefined,
|
|
685
|
+
}),
|
|
686
|
+
{ kind: "gone" },
|
|
687
|
+
);
|
|
688
|
+
assert.deepStrictEqual(opened, ["INBOX"]);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// A row with no Message-ID header cannot be asked about, and the server's
|
|
692
|
+
// silence is not an answer. `unprobeable` keeps that distinct from `gone`.
|
|
693
|
+
it("asks nothing at all when the row carries no Message-ID header", async () => {
|
|
694
|
+
const { connection, opened } = buildConnection({ INBOX: [4] });
|
|
695
|
+
|
|
696
|
+
assert.deepStrictEqual(
|
|
697
|
+
await probePausedPlacement(connection, {
|
|
698
|
+
messageIdHeader: undefined,
|
|
699
|
+
sourceMailboxPath: "INBOX",
|
|
700
|
+
destinationMailboxPath: "Archive",
|
|
701
|
+
}),
|
|
702
|
+
{ kind: "unprobeable" },
|
|
703
|
+
);
|
|
704
|
+
assert.deepStrictEqual(opened, []);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
346
708
|
describe("searchMailboxForHighestMessageIdUid — the probe that binds a move to a UID (#912)", () => {
|
|
347
709
|
const isMessageIdCriterion = (
|
|
348
710
|
criterion: unknown,
|