@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
|
@@ -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:
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|