@remit/imap-worker 0.0.69 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/imap-worker",
3
- "version": "0.0.69",
3
+ "version": "0.0.70",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -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
- for (const message of localMessages) {
254
- if (!expunged.has(message.uid)) continue;
255
- deletedCount += 1;
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 the SEARCH did not name is still marked for a deletion
272
- // that will never come: mail another client already emptied, mail
273
- // that reached Trash after the SEARCH, or a redelivery finishing a
274
- // partial sweep. All three must come back rather than sit invisible.
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) => !expunged.has(message.uid)),
298
+ localMessages.filter((message) => !sweptIds.has(message.messageId)),
277
299
  );
278
300
 
279
301
  log.info(