@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/imap-worker",
3
- "version": "0.0.68",
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(
@@ -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
- it("skips the copy without opening a connection when the cursor is rebuilding", async () => {
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("pauses quietly when openBox trips a UIDVALIDITY mismatch", async () => {
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 { searchMailboxForHighestMessageIdUid } from "./message-move.js";
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" | "updateUid">;
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" };