@remit/imap-worker 0.0.45 → 0.0.46

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.45",
3
+ "version": "0.0.46",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -367,6 +367,67 @@ describe("handleMessageDelete", () => {
367
367
  );
368
368
  });
369
369
 
370
+ // The queue handler `JSON.parse`s the body and casts it to WorkerEvent with
371
+ // no validation, so an event whose `operation` is missing, misspelled or
372
+ // from a newer producer reaches here. It must never be read as an expunge.
373
+ for (const [name, operation] of [
374
+ ["missing", undefined],
375
+ ["unknown", "trash"],
376
+ ["empty", ""],
377
+ ] as const) {
378
+ it(`abandons the delete and hands the row back when operation is ${name}`, async () => {
379
+ const malformed = {
380
+ ...moveEvent,
381
+ operation,
382
+ } as unknown as MessageDeleteEvent;
383
+
384
+ await handleMessageDelete(malformed, noopLog, deps());
385
+
386
+ assert.equal(
387
+ called("connection.deleteMessages").length,
388
+ 0,
389
+ "nothing may be expunged for an operation nobody wrote",
390
+ );
391
+ assert.equal(called("message.delete").length, 0);
392
+ assert.equal(called("threadMessage.delete").length, 0);
393
+
394
+ // The row goes back to the mailbox the server still holds it in, so
395
+ // the failure is visible as the message reappearing rather than as an
396
+ // invisible syncStatus on a row that claims Trash.
397
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
398
+ "msg-1",
399
+ 10,
400
+ "src-mbx",
401
+ ]);
402
+ assert.deepEqual(called("message.update")[0]?.args[1], {
403
+ status: "active",
404
+ syncStatus: "failed",
405
+ });
406
+ assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
407
+ uid: 10,
408
+ mailboxId: "src-mbx",
409
+ isDeleted: false,
410
+ });
411
+ });
412
+ }
413
+
414
+ it("refuses to expunge a move-to-trash that names no destination", async () => {
415
+ const destinationless = {
416
+ ...moveEvent,
417
+ destinationMailboxId: undefined,
418
+ destinationMailboxPath: undefined,
419
+ } as MessageDeleteEvent;
420
+
421
+ await handleMessageDelete(destinationless, noopLog, deps());
422
+
423
+ assert.equal(called("connection.deleteMessages").length, 0);
424
+ assert.equal(called("message.delete").length, 0);
425
+ assert.deepEqual(called("message.update")[0]?.args[1], {
426
+ status: "active",
427
+ syncStatus: "failed",
428
+ });
429
+ });
430
+
370
431
  it("expunges on the server and removes every thread row before the message row", async () => {
371
432
  await handleMessageDelete(permanentEvent, noopLog, deps());
372
433
 
@@ -3,7 +3,7 @@ import type {
3
3
  IThreadMessageRepository,
4
4
  ThreadMessageItem,
5
5
  } from "@remit/data-ports";
6
- import { MessageSyncStatus } from "@remit/domain-enums";
6
+ import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
7
7
  import type { Logger } from "@remit/logger-lambda";
8
8
  import {
9
9
  guardConnectionCursor,
@@ -56,16 +56,27 @@ export const deleteAllThreadMessagesForMessage = async (
56
56
  * the caller silently drops the update. Same root cause as PR #186 fixed for
57
57
  * `flag-queue.ts`.
58
58
  */
59
+ type ThreadMessageRowState = Pick<
60
+ ThreadMessageItem,
61
+ | "sentDate"
62
+ | "mailboxId"
63
+ | "isRead"
64
+ | "isDeleted"
65
+ | "hasStars"
66
+ | "hasAttachment"
67
+ >;
68
+
69
+ const currentComposites = (threadMessage: ThreadMessageRowState) => ({
70
+ sentDate: threadMessage.sentDate,
71
+ mailboxId: threadMessage.mailboxId,
72
+ isRead: threadMessage.isRead,
73
+ isDeleted: threadMessage.isDeleted,
74
+ hasStars: threadMessage.hasStars,
75
+ hasAttachment: threadMessage.hasAttachment,
76
+ });
77
+
59
78
  export const buildThreadMessageTrashUpdate = (
60
- threadMessage: Pick<
61
- ThreadMessageItem,
62
- | "sentDate"
63
- | "mailboxId"
64
- | "isRead"
65
- | "isDeleted"
66
- | "hasStars"
67
- | "hasAttachment"
68
- >,
79
+ threadMessage: ThreadMessageRowState,
69
80
  newUid: number,
70
81
  destinationMailboxId: string,
71
82
  ) => ({
@@ -74,14 +85,27 @@ export const buildThreadMessageTrashUpdate = (
74
85
  mailboxId: destinationMailboxId,
75
86
  isDeleted: true,
76
87
  },
77
- composites: {
78
- sentDate: threadMessage.sentDate,
79
- mailboxId: threadMessage.mailboxId,
80
- isRead: threadMessage.isRead,
81
- isDeleted: threadMessage.isDeleted,
82
- hasStars: threadMessage.hasStars,
83
- hasAttachment: threadMessage.hasAttachment,
88
+ composites: currentComposites(threadMessage),
89
+ });
90
+
91
+ /**
92
+ * The inverse payload: put the thread row back where the message still is on
93
+ * the server. The delete was recorded optimistically, so a delete abandoned
94
+ * before any IMAP write has to hand the row back rather than leave it claiming
95
+ * Trash — an invisible `failed` on a row the user cannot see is the shape of
96
+ * the incident this whole change is about.
97
+ */
98
+ export const buildThreadMessageMoveRevert = (
99
+ threadMessage: ThreadMessageRowState,
100
+ sourceUid: number,
101
+ sourceMailboxId: string,
102
+ ) => ({
103
+ set: {
104
+ uid: sourceUid,
105
+ mailboxId: sourceMailboxId,
106
+ isDeleted: false,
84
107
  },
108
+ composites: currentComposites(threadMessage),
85
109
  });
86
110
 
87
111
  export interface MessageDeleteDeps {
@@ -198,7 +222,63 @@ export const handleMessageDelete = async (
198
222
  );
199
223
  await connection.openBox(mailboxPath, false);
200
224
 
201
- if (operation === "move_to_trash" && destinationMailboxPath) {
225
+ // Only an operation that explicitly says so destroys mail. The
226
+ // event is `JSON.parse`d and cast in the queue handler with no
227
+ // validation, so a missing, misspelled or future `operation` must
228
+ // abandon the delete — the "anything that is not move_to_trash is
229
+ // an expunge" inference is the same one that destroyed mail in the
230
+ // service, and an unrecoverable EXPUNGE is not a default.
231
+ const abandonDelete = async (
232
+ reason: string,
233
+ alert: string,
234
+ ): Promise<void> => {
235
+ log.error(
236
+ { alert, accountId, messageId, uid, mailboxPath, operation },
237
+ reason,
238
+ );
239
+ await messageService.updateUid(messageId, uid, mailboxId);
240
+ await messageService.update(messageId, {
241
+ status: MessageStatus.active,
242
+ syncStatus: MessageSyncStatus.failed,
243
+ });
244
+ const threadMessage = await threadMessageService.findByMessageId(
245
+ account.accountConfigId,
246
+ messageId,
247
+ );
248
+ if (!threadMessage) return;
249
+ const args = buildThreadMessageMoveRevert(
250
+ threadMessage,
251
+ uid,
252
+ mailboxId,
253
+ );
254
+ await threadMessageService.update(
255
+ threadMessage.accountConfigId,
256
+ threadMessage.threadMessageId,
257
+ args.set,
258
+ { composites: args.composites },
259
+ );
260
+ };
261
+
262
+ if (
263
+ operation !== "move_to_trash" &&
264
+ operation !== "permanent_delete"
265
+ ) {
266
+ await abandonDelete(
267
+ "Refused to delete: event carries an unrecognized operation",
268
+ "message_delete_unknown_operation",
269
+ );
270
+ } else if (
271
+ operation === "move_to_trash" &&
272
+ (!destinationMailboxPath || !destinationMailboxId)
273
+ ) {
274
+ // Defence in depth. `MessageMoveService` always sets both fields,
275
+ // so nothing mints such an event today; the guard exists so that
276
+ // a future producer that forgets one cannot reach the expunge.
277
+ await abandonDelete(
278
+ "Refused to delete: move to trash carries no destination mailbox",
279
+ "message_delete_missing_destination",
280
+ );
281
+ } else if (operation === "move_to_trash" && destinationMailboxPath) {
202
282
  // Move to Trash
203
283
  const result = await connection.moveMessages(
204
284
  [uid],
@@ -244,7 +324,7 @@ export const handleMessageDelete = async (
244
324
  });
245
325
  }
246
326
  } else {
247
- // Permanent delete
327
+ // Permanent delete — reached only by `operation === "permanent_delete"`.
248
328
  await connection.deleteMessages([uid]);
249
329
 
250
330
  // Delete ThreadMessage rows BEFORE the Message row to collapse the