@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.
@@ -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,