@remit/imap-worker 0.0.68 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/imap-worker",
3
- "version": "0.0.68",
3
+ "version": "0.0.69",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -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" };