@remit/imap-worker 0.0.53 → 0.0.54

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.53",
3
+ "version": "0.0.54",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -260,6 +260,8 @@ interface Harness {
260
260
  mailboxError?: Error;
261
261
  connection: Connection;
262
262
  threadMessageUpdateError?: Error;
263
+ messageRow: { messageIdHeader?: string } | undefined;
264
+ destinationSearchUids: number[];
263
265
  threadMessage: Record<string, unknown> | null;
264
266
  allThreadMessages: Record<string, unknown>[];
265
267
  getConnectionCount: number;
@@ -274,6 +276,16 @@ const record =
274
276
  h.calls.push({ method, args });
275
277
  };
276
278
 
279
+ const MESSAGE_ID_HEADER = "<trashed-message@example.com>";
280
+
281
+ // The source-presence probe (`isMessageGoneFromOpenMailbox`) and the
282
+ // destination probe both go through `connection.search`; only the criterion
283
+ // form tells them apart.
284
+ const isMessageIdSearch = (criteria: unknown): boolean =>
285
+ Array.isArray(criteria) &&
286
+ Array.isArray(criteria[0]) &&
287
+ criteria[0][0] === "HEADER";
288
+
277
289
  const buildConnection = (): Connection => ({
278
290
  openBox: async () => ({ uidvalidity: 1 }),
279
291
  moveMessages: async () => ({ uidMap: new Map([[10, 20]]) }),
@@ -288,7 +300,7 @@ const buildConnection = (): Connection => ({
288
300
  fetchMessages: async (uids: number[]) => uids.map((uid) => ({ uid })),
289
301
  search: async (...args: unknown[]) => {
290
302
  h.calls.push({ method: "connection.search", args });
291
- return [10];
303
+ return isMessageIdSearch(args[0]) ? h.destinationSearchUids : [10];
292
304
  },
293
305
  });
294
306
 
@@ -296,7 +308,7 @@ const sourceNoLongerHoldsTheUid = (): void => {
296
308
  h.connection.fetchMessages = async () => [];
297
309
  h.connection.search = async (...args: unknown[]) => {
298
310
  h.calls.push({ method: "connection.search", args });
299
- return [];
311
+ return isMessageIdSearch(args[0]) ? h.destinationSearchUids : [];
300
312
  };
301
313
  };
302
314
 
@@ -304,6 +316,8 @@ const fresh = (): Harness => ({
304
316
  calls: [],
305
317
  account: { accountId: "acc-1", accountConfigId: "cfg-1" },
306
318
  mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
319
+ messageRow: { messageIdHeader: MESSAGE_ID_HEADER },
320
+ destinationSearchUids: [],
307
321
  connection: buildConnection(),
308
322
  threadMessage: {
309
323
  ...baseThreadMessage,
@@ -328,6 +342,10 @@ const deps = (): MessageDeleteDeps =>
328
342
  },
329
343
  },
330
344
  message: {
345
+ get: async (messageIds: string[]) => {
346
+ h.calls.push({ method: "message.get", args: [messageIds] });
347
+ return h.messageRow ? [h.messageRow] : [];
348
+ },
331
349
  updateUid: record("message.updateUid"),
332
350
  update: record("message.update"),
333
351
  delete: record("message.delete"),
@@ -417,17 +435,161 @@ describe("handleMessageDelete", () => {
417
435
  assert.equal(h.disconnectCount, 1);
418
436
  });
419
437
 
420
- it("marks the message failed when the MOVE returns no new uid", async () => {
421
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
438
+ // UIDPLUS is an extension: a server without it answers a perfectly
439
+ // successful MOVE with no COPYUID entry. An empty uidMap is therefore
440
+ // UNCONFIRMED, and the destination is asked by Message-ID before any
441
+ // verdict — the rule every sibling handler already carries. Issue #979.
442
+ describe("no COPYUID entry on the move to trash", () => {
443
+ it("settles the row on the probed uid when the message left the source and is at the destination (non-UIDPLUS server, genuine success)", async () => {
444
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
445
+ sourceNoLongerHoldsTheUid();
446
+ h.destinationSearchUids = [77];
422
447
 
423
- await handleMessageDelete(moveEvent, noopLog, deps());
448
+ await handleMessageDelete(moveEvent, noopLog, deps());
424
449
 
425
- assert.equal(called("message.updateUid").length, 0);
426
- assert.equal(
427
- (called("message.update")[0]?.args[1] as { syncStatus?: string })
428
- ?.syncStatus,
429
- "failed",
430
- );
450
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
451
+ "msg-1",
452
+ 77,
453
+ "trash-mbx",
454
+ ]);
455
+ assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
456
+ uid: 77,
457
+ mailboxId: "trash-mbx",
458
+ isDeleted: true,
459
+ });
460
+ assert.equal(
461
+ called("message.update").length,
462
+ 0,
463
+ "a settled move never marks the row failed",
464
+ );
465
+ });
466
+
467
+ it("probes the DESTINATION mailbox, read-only, by Message-ID", async () => {
468
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
469
+ sourceNoLongerHoldsTheUid();
470
+ h.destinationSearchUids = [77];
471
+ const opened: unknown[][] = [];
472
+ h.connection.openBox = (async (...args: unknown[]) => {
473
+ opened.push(args);
474
+ return { uidvalidity: 1 };
475
+ }) as Connection["openBox"];
476
+
477
+ await handleMessageDelete(moveEvent, noopLog, deps());
478
+
479
+ assert.deepEqual(
480
+ opened,
481
+ [
482
+ ["INBOX", false],
483
+ ["INBOX", true],
484
+ ["Trash", true],
485
+ ],
486
+ "the source is re-asked read-only, then the destination is EXAMINEd — neither probe may SELECT a box writable",
487
+ );
488
+ assert.deepEqual(called("connection.search").at(-1)?.args[0], [
489
+ ["HEADER", "Message-ID", MESSAGE_ID_HEADER],
490
+ ]);
491
+ });
492
+
493
+ it("leaves local state alone when the probe finds nothing — never reverts, never deletes (#655)", async () => {
494
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
495
+ sourceNoLongerHoldsTheUid();
496
+ h.destinationSearchUids = [];
497
+
498
+ await handleMessageDelete(moveEvent, noopLog, deps());
499
+
500
+ assert.equal(called("message.updateUid").length, 0);
501
+ assert.equal(
502
+ called("message.delete").length,
503
+ 0,
504
+ "an unconfirmed move must never delete the local row",
505
+ );
506
+ assert.equal(
507
+ called("threadMessage.delete").length,
508
+ 0,
509
+ "an unconfirmed move must never delete the listing rows",
510
+ );
511
+ assert.equal(
512
+ called("threadMessage.update").length,
513
+ 0,
514
+ "an unconfirmed move must never revert the listing row to the source",
515
+ );
516
+ assert.equal(
517
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
518
+ ?.syncStatus,
519
+ "failed",
520
+ );
521
+ });
522
+
523
+ it("does not probe when the row carries no Message-ID header", async () => {
524
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
525
+ sourceNoLongerHoldsTheUid();
526
+ h.messageRow = {};
527
+
528
+ await handleMessageDelete(moveEvent, noopLog, deps());
529
+
530
+ assert.equal(
531
+ called("connection.search").filter((c) =>
532
+ JSON.stringify(c.args).includes("HEADER"),
533
+ ).length,
534
+ 0,
535
+ );
536
+ assert.equal(called("message.updateUid").length, 0);
537
+ assert.equal(
538
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
539
+ ?.syncStatus,
540
+ "failed",
541
+ );
542
+ });
543
+
544
+ // `searchMailboxByMessageId` returns the LOWEST matching uid, and one
545
+ // Message-ID can have several server copies in one account while
546
+ // `deriveMessageId` gives them one local row. A source that still holds
547
+ // the uid proves the MOVE did not happen, so any hit at the destination
548
+ // is a different copy.
549
+ it("never takes a destination hit while the source still holds the uid (duplicate Message-ID)", async () => {
550
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
551
+ h.destinationSearchUids = [100];
552
+
553
+ await handleMessageDelete(moveEvent, noopLog, deps());
554
+
555
+ assert.equal(
556
+ called("message.updateUid").length,
557
+ 0,
558
+ "an earlier copy's uid must never settle this row",
559
+ );
560
+ assert.equal(called("threadMessage.update").length, 0);
561
+ assert.equal(called("message.delete").length, 0);
562
+ assert.equal(
563
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
564
+ ?.syncStatus,
565
+ "failed",
566
+ );
567
+ });
568
+
569
+ // The MOVE has already run by the time the probe goes out, so a probe
570
+ // that cannot answer counts as not-confirmed. Throwing would redeliver
571
+ // on the account's per-group FIFO and re-MOVE a uid the source no longer
572
+ // holds, head-of-line blocking every other delete on the account.
573
+ it("treats an unanswerable probe as unconfirmed rather than throwing", async () => {
574
+ h.connection.moveMessages = async () => ({ uidMap: new Map() });
575
+ h.connection.openBox = (async (_path: string, readOnly?: boolean) => {
576
+ if (readOnly) throw new Error("NO [SERVERBUG] EXAMINE failed");
577
+ return { uidvalidity: 1 };
578
+ }) as Connection["openBox"];
579
+
580
+ await handleMessageDelete(moveEvent, noopLog, deps());
581
+
582
+ assert.equal(called("message.updateUid").length, 0);
583
+ assert.equal(called("message.delete").length, 0);
584
+ assert.equal(called("threadMessage.delete").length, 0);
585
+ assert.equal(called("threadMessage.update").length, 0);
586
+ assert.equal(
587
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
588
+ ?.syncStatus,
589
+ "failed",
590
+ );
591
+ assert.equal(h.disconnectCount, 1);
592
+ });
431
593
  });
432
594
 
433
595
  // The queue handler `JSON.parse`s the body and casts it to WorkerEvent with
@@ -1,5 +1,6 @@
1
1
  import { getClient } from "@remit/backend/client";
2
2
  import type {
3
+ IMessageRepository,
3
4
  IThreadMessageRepository,
4
5
  ThreadMessageItem,
5
6
  } from "@remit/data-ports";
@@ -8,6 +9,7 @@ import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
8
9
  import type { Logger } from "@remit/logger-lambda";
9
10
  import {
10
11
  guardConnectionCursor,
12
+ type IImapConnection,
11
13
  isCursorRebuildNeeded,
12
14
  isMessageGoneFromOpenMailbox,
13
15
  MailboxCursorPausedError,
@@ -18,6 +20,7 @@ import type { MessageDeleteEvent } from "../events.js";
18
20
  import { isNotFoundError } from "../is-not-found.js";
19
21
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
20
22
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
23
+ import { searchMailboxByMessageId } from "./message-move.js";
21
24
 
22
25
  /**
23
26
  * Delete every ThreadMessage row that points at this messageId.
@@ -122,6 +125,49 @@ export const buildThreadMessageUndelete = (
122
125
  composites: currentComposites(threadMessage),
123
126
  });
124
127
 
128
+ /**
129
+ * Settle a move to Trash the server left unconfirmed, by asking it two
130
+ * read-only questions instead of one. Both handles wrap the SAME connection but
131
+ * are scoped to their own mailbox: a `guardConnectionCursor` wrap binds its
132
+ * checks to the ONE mailbox snapshot it was built with, so the destination must
133
+ * never be opened through the source's guard.
134
+ *
135
+ * The source is asked first, and a source that still holds the uid ends it: the
136
+ * MOVE did not happen, so nothing at the destination can be this message.
137
+ * Skipping that question is not safe, because `searchMailboxByMessageId`
138
+ * returns the LOWEST matching uid rather than the one that just arrived, and
139
+ * one Message-ID can have several server copies in one account (a sieve
140
+ * `fileinto` + `keep`, a multi-label store, a resend) while
141
+ * `deriveMessageId` is folder-independent and gives them one local row. An
142
+ * ungated probe can hand back an earlier copy's uid, and Empty Trash then
143
+ * expunges by that uid. It also closes the second half of #912: an empty
144
+ * `uidMap` can mean the MOVE matched nothing at all.
145
+ *
146
+ * A row with no `messageIdHeader` has nothing to probe with, so it stays
147
+ * unconfirmed rather than guessing.
148
+ */
149
+ const confirmTrashMoveUid = async (
150
+ sourceConnection: IImapConnection,
151
+ destinationConnection: IImapConnection,
152
+ messageService: Pick<IMessageRepository, "get">,
153
+ messageId: string,
154
+ sourceMailboxPath: string,
155
+ destinationMailboxPath: string,
156
+ uid: number,
157
+ ): Promise<number | null> => {
158
+ await sourceConnection.openBox(sourceMailboxPath, true);
159
+ if (!(await isMessageGoneFromOpenMailbox(sourceConnection, uid))) return null;
160
+
161
+ const [message] = await messageService.get([messageId]);
162
+ if (!message?.messageIdHeader) return null;
163
+
164
+ return searchMailboxByMessageId(
165
+ destinationConnection,
166
+ destinationMailboxPath,
167
+ message.messageIdHeader,
168
+ );
169
+ };
170
+
125
171
  export interface MessageDeleteDeps {
126
172
  getClient: typeof getClient;
127
173
  buildLifecycleDeps: typeof buildLifecycleDeps;
@@ -324,15 +370,57 @@ export const handleMessageDelete = async (
324
370
  "Refused to delete: move to trash carries no destination mailbox",
325
371
  "message_delete_missing_destination",
326
372
  );
327
- } else if (operation === "move_to_trash" && destinationMailboxPath) {
373
+ } else if (
374
+ operation === "move_to_trash" &&
375
+ destinationMailboxPath &&
376
+ destinationMailboxId
377
+ ) {
328
378
  // Move to Trash
329
379
  const result = await connection.moveMessages(
330
380
  [uid],
331
381
  destinationMailboxPath,
332
382
  );
333
- const newUid = result.uidMap.get(uid);
334
383
 
335
- if (newUid && destinationMailboxId) {
384
+ // UIDPLUS is an extension. A server without it answers a
385
+ // perfectly successful MOVE with no COPYUID entry, so an empty
386
+ // map is UNCONFIRMED, never evidence the move failed: the server
387
+ // is asked before any verdict, exactly as `handleMessageMove` and
388
+ // `attemptMove` do. Reading the empty map as a failure left the
389
+ // message in Trash under a uid nothing local knew, while the row
390
+ // kept the SOURCE folder's uid — which Empty Trash then decides
391
+ // by (issues #979, #665).
392
+ //
393
+ // A probe that cannot answer counts as not-confirmed, never as a
394
+ // throw. The MOVE has already run by this point, so throwing here
395
+ // would redeliver on the account's per-group FIFO and re-MOVE a
396
+ // uid the source no longer holds — head-of-line blocking the
397
+ // whole account's deletes over a transient NO, a renamed folder
398
+ // or a SEARCH the server refused.
399
+ const newUid =
400
+ result.uidMap.get(uid) ??
401
+ (await confirmTrashMoveUid(
402
+ connection,
403
+ rawConnection,
404
+ messageService,
405
+ messageId,
406
+ mailboxPath,
407
+ destinationMailboxPath,
408
+ uid,
409
+ ).catch((probeError: unknown) => {
410
+ log.warn(
411
+ {
412
+ messageId,
413
+ uid,
414
+ mailboxPath,
415
+ destinationMailboxPath,
416
+ probeError,
417
+ },
418
+ "Could not confirm the move to trash; keeping local rows",
419
+ );
420
+ return null;
421
+ }));
422
+
423
+ if (newUid) {
336
424
  // Update message with new UID in Trash
337
425
  await messageService.updateUid(
338
426
  messageId,
@@ -361,9 +449,33 @@ export const handleMessageDelete = async (
361
449
 
362
450
  log.info({ messageId, newUid }, "Message moved to trash");
363
451
  } else {
452
+ // Unconfirmed, not failed. A MOVE that ran server-side but
453
+ // dropped before the tagged OK is indistinguishable from one
454
+ // that never ran, so local state is left exactly as it stands:
455
+ // nothing is reverted and nothing is deleted. Reverting on that
456
+ // ambiguity is the blind revert #655 recorded when it was
457
+ // pulled from PR #652.
458
+ //
459
+ // This marks and returns rather than throwing. Throwing is the
460
+ // shape `handleMessageMove` uses, but it can only carry a
461
+ // budget: this handler has no `receiveCount`, no MAX_ATTEMPTS
462
+ // and no exhaustion path, so every redelivery on the account's
463
+ // per-group FIFO would re-MOVE a uid the source no longer
464
+ // holds, fail identically, and stall that account's whole
465
+ // delete pipeline (#287, #289, #290) until the queue's
466
+ // maxReceiveCount dead-letters it — leaving the row in this
467
+ // same state, minus the pipeline. Issue #980 wires the budget;
468
+ // the throw belongs with it, not ahead of it.
364
469
  log.error(
365
- { messageId, uid },
366
- "Failed to get new UID after move to trash",
470
+ {
471
+ alert: "message_delete_trash_move_unconfirmed",
472
+ accountId,
473
+ messageId,
474
+ uid,
475
+ mailboxPath,
476
+ destinationMailboxPath,
477
+ },
478
+ "Move to trash unconfirmed: no COPYUID entry, and the server did not confirm the message at the destination; local rows left as they stand",
367
479
  );
368
480
  await messageService.update(messageId, {
369
481
  syncStatus: MessageSyncStatus.failed,