@remit/imap-worker 0.0.50 → 0.0.51

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.50",
3
+ "version": "0.0.51",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -205,6 +205,8 @@ interface Connection {
205
205
  ) => Promise<{ uidMap: Map<number, number> }>;
206
206
  deleteMessages: (uids: number[]) => Promise<void>;
207
207
  createMailbox: (path: string) => Promise<void>;
208
+ fetchMessages: (uids: number[]) => Promise<{ uid: number }[]>;
209
+ search: (criteria: unknown[]) => Promise<number[]>;
208
210
  }
209
211
 
210
212
  interface Harness {
@@ -217,6 +219,7 @@ interface Harness {
217
219
  mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
218
220
  mailboxError?: Error;
219
221
  connection: Connection;
222
+ threadMessageUpdateError?: Error;
220
223
  threadMessage: Record<string, unknown> | null;
221
224
  allThreadMessages: { accountConfigId: string; threadMessageId: string }[];
222
225
  getConnectionCount: number;
@@ -240,8 +243,23 @@ const buildConnection = (): Connection => ({
240
243
  createMailbox: record(
241
244
  "connection.createMailbox",
242
245
  ) as Connection["createMailbox"],
246
+ // The source box still holds the uid unless a case says otherwise, so the
247
+ // presence probe only reports "gone" where a test made it true.
248
+ fetchMessages: async (uids: number[]) => uids.map((uid) => ({ uid })),
249
+ search: async (...args: unknown[]) => {
250
+ h.calls.push({ method: "connection.search", args });
251
+ return [10];
252
+ },
243
253
  });
244
254
 
255
+ const sourceNoLongerHoldsTheUid = (): void => {
256
+ h.connection.fetchMessages = async () => [];
257
+ h.connection.search = async (...args: unknown[]) => {
258
+ h.calls.push({ method: "connection.search", args });
259
+ return [];
260
+ };
261
+ };
262
+
245
263
  const fresh = (): Harness => ({
246
264
  calls: [],
247
265
  account: { accountId: "acc-1", accountConfigId: "cfg-1" },
@@ -277,7 +295,10 @@ const deps = (): MessageDeleteDeps =>
277
295
  threadMessage: {
278
296
  findByMessageId: async () => h.threadMessage,
279
297
  findAllByMessageId: async () => h.allThreadMessages,
280
- update: record("threadMessage.update"),
298
+ update: async (...args: unknown[]) => {
299
+ h.calls.push({ method: "threadMessage.update", args });
300
+ if (h.threadMessageUpdateError) throw h.threadMessageUpdateError;
301
+ },
281
302
  delete: record("threadMessage.delete"),
282
303
  },
283
304
  mailbox: {
@@ -440,10 +461,13 @@ describe("handleMessageDelete", () => {
440
461
  );
441
462
  });
442
463
 
443
- it("cleans up locally and swallows the error when the message is already gone on IMAP", async () => {
464
+ // Regression on #212: a permanent delete the server has already applied must
465
+ // still reconcile the local rows, now that the text alone no longer decides.
466
+ it("cleans up locally and swallows the error when the message is confirmed gone on IMAP", async () => {
444
467
  h.connection.deleteMessages = async () => {
445
468
  throw new Error("NONEXISTENT uid");
446
469
  };
470
+ sourceNoLongerHoldsTheUid();
447
471
 
448
472
  await handleMessageDelete(permanentEvent, noopLog, deps());
449
473
 
@@ -451,6 +475,109 @@ describe("handleMessageDelete", () => {
451
475
  assert.equal(called("threadMessage.delete").length, 2);
452
476
  });
453
477
 
478
+ // Issue #845. The three cases below are the same bug from three directions:
479
+ // "not found" in an error string is never on its own a reason to destroy the
480
+ // only local record of live mail.
481
+
482
+ it("keeps the rows when a move-to-trash fails after the MOVE landed", async () => {
483
+ // The MOVE succeeded; the ThreadMessage write behind it did not. The
484
+ // message is sitting in Trash, and the NotFoundError names a local row,
485
+ // not a mail-server one.
486
+ h.threadMessageUpdateError = Object.assign(
487
+ new Error("Thread message not found"),
488
+ { name: "NotFoundError" },
489
+ );
490
+ sourceNoLongerHoldsTheUid();
491
+
492
+ await assert.rejects(
493
+ handleMessageDelete(moveEvent, noopLog, deps()),
494
+ /not found/,
495
+ );
496
+
497
+ assert.equal(
498
+ called("message.delete").length,
499
+ 0,
500
+ "the message is in Trash; deleting its row loses the only handle on it",
501
+ );
502
+ assert.equal(called("threadMessage.delete").length, 0);
503
+ assert.equal(
504
+ called("connection.search").length,
505
+ 0,
506
+ "a move-to-trash never probes: absence from the source is its success signature",
507
+ );
508
+ assert.equal(
509
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
510
+ ?.syncStatus,
511
+ "failed",
512
+ );
513
+ });
514
+
515
+ it("keeps the rows on a NONEXISTENT move-to-trash whose source still holds the uid", async () => {
516
+ h.connection.moveMessages = async () => {
517
+ throw new Error("NONEXISTENT: mailbox does not exist");
518
+ };
519
+
520
+ await assert.rejects(
521
+ handleMessageDelete(moveEvent, noopLog, deps()),
522
+ /NONEXISTENT/,
523
+ );
524
+
525
+ assert.equal(called("message.delete").length, 0);
526
+ assert.equal(called("threadMessage.delete").length, 0);
527
+ assert.equal(
528
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
529
+ ?.syncStatus,
530
+ "failed",
531
+ );
532
+ });
533
+
534
+ it("keeps the rows when a permanent delete's source still holds the uid", async () => {
535
+ h.connection.deleteMessages = async () => {
536
+ throw new Error("NONEXISTENT uid");
537
+ };
538
+ // imapflow drops rows on back-to-back FETCHes (#408); the SEARCH is what
539
+ // the verdict rests on, and it still lists the uid.
540
+ h.connection.fetchMessages = async () => [];
541
+
542
+ await assert.rejects(
543
+ handleMessageDelete(permanentEvent, noopLog, deps()),
544
+ /NONEXISTENT/,
545
+ );
546
+
547
+ assert.equal(
548
+ called("connection.search").length,
549
+ 1,
550
+ "the error text only selects a candidate; the source box decides",
551
+ );
552
+ assert.equal(called("message.delete").length, 0);
553
+ assert.equal(called("threadMessage.delete").length, 0);
554
+ });
555
+
556
+ it("keeps the rows and rethrows the original error when the probe cannot answer", async () => {
557
+ // The SELECT is what failed, so there is no open box left for the probe
558
+ // to ask. The IMAP error must still be the one that rejects, and the row
559
+ // must not be stranded mid-delete for the record to carry to the DLQ.
560
+ h.connection.openBox = async () => {
561
+ throw new Error("NONEXISTENT mailbox does not exist");
562
+ };
563
+ h.connection.fetchMessages = async () => {
564
+ throw new Error("No mailbox selected");
565
+ };
566
+
567
+ await assert.rejects(
568
+ handleMessageDelete(permanentEvent, noopLog, deps()),
569
+ /NONEXISTENT mailbox does not exist/,
570
+ );
571
+
572
+ assert.equal(called("message.delete").length, 0);
573
+ assert.equal(called("threadMessage.delete").length, 0);
574
+ assert.equal(
575
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
576
+ ?.syncStatus,
577
+ "failed",
578
+ );
579
+ });
580
+
454
581
  it("creates the trash mailbox and rethrows on TRYCREATE", async () => {
455
582
  h.connection.moveMessages = async () => {
456
583
  throw new Error("TRYCREATE: no such mailbox");
@@ -8,6 +8,7 @@ import type { Logger } from "@remit/logger-lambda";
8
8
  import {
9
9
  guardConnectionCursor,
10
10
  isCursorRebuildNeeded,
11
+ isMessageGoneFromOpenMailbox,
11
12
  MailboxCursorPausedError,
12
13
  } from "@remit/mailbox-service";
13
14
  import { isAccountDeleted } from "../account-check.js";
@@ -359,14 +360,32 @@ export const handleMessageDelete = async (
359
360
  const errorMessage =
360
361
  error instanceof Error ? error.message : String(error);
361
362
 
362
- // Message not found on IMAP - already deleted (idempotent)
363
- if (
364
- errorMessage.includes("not found") ||
365
- errorMessage.includes("NONEXISTENT")
366
- ) {
363
+ // Reconcile a permanent delete the server already applied (#212).
364
+ // The error text only nominates a candidate — the source box
365
+ // decides, since a landed move-to-trash reports that same text from
366
+ // a LOCAL lookup (#845) — and a probe that cannot answer counts as
367
+ // not-confirmed, leaving the original error to the rethrow below.
368
+ const isGoneFromSource =
369
+ operation === "permanent_delete" &&
370
+ (errorMessage.includes("not found") ||
371
+ errorMessage.includes("NONEXISTENT")) &&
372
+ (await scope
373
+ .getConnection()
374
+ .then((connection) =>
375
+ isMessageGoneFromOpenMailbox(connection, uid),
376
+ )
377
+ .catch((probeError: unknown) => {
378
+ log.warn(
379
+ { messageId, uid, mailboxPath, probeError },
380
+ "Could not confirm whether the message is gone; keeping local rows",
381
+ );
382
+ return false;
383
+ }));
384
+
385
+ if (isGoneFromSource) {
367
386
  log.info(
368
387
  { messageId, uid },
369
- "Message not found on IMAP, cleaning up local",
388
+ "Message confirmed gone from IMAP, cleaning up local",
370
389
  );
371
390
  // Clean up local entities. Multi-mailbox copies are handled by the
372
391
  // helper so we don't leave orphan rows (see issue #212).