@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
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
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).
|