@remit/imap-worker 0.0.50 → 0.0.52
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 +1 -1
- package/src/events.ts +11 -0
- package/src/handlers/empty-trash.test.ts +208 -24
- package/src/handlers/empty-trash.ts +145 -23
- package/src/handlers/message-delete.test.ts +250 -10
- package/src/handlers/message-delete.ts +115 -51
package/package.json
CHANGED
package/src/events.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { MUTATION_EVENT_SCHEMA_VERSION } from "@remit/data-ports/mutation-events";
|
|
2
|
+
|
|
1
3
|
export interface BaseEvent {
|
|
2
4
|
accountId: string;
|
|
3
5
|
eventId: string; // Idempotency key
|
|
@@ -88,6 +90,7 @@ export type MailboxManagementEvent =
|
|
|
88
90
|
*/
|
|
89
91
|
export interface MessageDeleteEvent extends BaseEvent {
|
|
90
92
|
type: "MESSAGE_DELETE";
|
|
93
|
+
schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
|
|
91
94
|
messageId: string;
|
|
92
95
|
mailboxId: string;
|
|
93
96
|
mailboxPath: string;
|
|
@@ -115,8 +118,16 @@ export interface MessageMoveEvent extends BaseEvent {
|
|
|
115
118
|
*/
|
|
116
119
|
export interface EmptyTrashEvent extends BaseEvent {
|
|
117
120
|
type: "EMPTY_TRASH";
|
|
121
|
+
schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
|
|
118
122
|
trashMailboxId: string;
|
|
119
123
|
trashMailboxPath: string;
|
|
124
|
+
/**
|
|
125
|
+
* The Trash folder's UIDVALIDITY at the moment the user consented. The
|
|
126
|
+
* handler compares it against what the SELECT serves: a path reused by a
|
|
127
|
+
* different folder (rename plus recreate) answers with a different value,
|
|
128
|
+
* and this is the only comparison the mutating connection cannot race.
|
|
129
|
+
*/
|
|
130
|
+
trashUidValidity: number;
|
|
120
131
|
}
|
|
121
132
|
|
|
122
133
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { RoleResolution } from "@remit/data-ports/folder-role";
|
|
3
4
|
import type { Logger } from "@remit/logger-lambda";
|
|
4
5
|
import type { EmptyTrashEvent } from "../events.js";
|
|
5
6
|
import { type EmptyTrashDeps, handleEmptyTrash } from "./empty-trash.js";
|
|
@@ -28,6 +29,14 @@ interface Connection {
|
|
|
28
29
|
deleteMessages: (uids: number[]) => Promise<void>;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
interface LocalMessage {
|
|
33
|
+
messageId: string;
|
|
34
|
+
uid: number;
|
|
35
|
+
status: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type TrashMailbox = { mailboxId: string; fullPath: string };
|
|
39
|
+
|
|
31
40
|
interface Harness {
|
|
32
41
|
calls: Call[];
|
|
33
42
|
account: {
|
|
@@ -37,9 +46,11 @@ interface Harness {
|
|
|
37
46
|
} | null;
|
|
38
47
|
mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
|
|
39
48
|
mailboxError?: Error;
|
|
49
|
+
trashResolution: RoleResolution<TrashMailbox>;
|
|
40
50
|
connection: Connection;
|
|
41
|
-
localMessages:
|
|
42
|
-
threadMessage:
|
|
51
|
+
localMessages: LocalMessage[];
|
|
52
|
+
threadMessage: boolean;
|
|
53
|
+
messagesWithoutListingRow: string[];
|
|
43
54
|
getConnectionCount: number;
|
|
44
55
|
disconnectCount: number;
|
|
45
56
|
}
|
|
@@ -60,17 +71,45 @@ const buildConnection = (): Connection => ({
|
|
|
60
71
|
) as Connection["deleteMessages"],
|
|
61
72
|
});
|
|
62
73
|
|
|
74
|
+
const deleting = (messageId: string, uid: number): LocalMessage => ({
|
|
75
|
+
messageId,
|
|
76
|
+
uid,
|
|
77
|
+
status: "deleting",
|
|
78
|
+
});
|
|
79
|
+
|
|
63
80
|
const fresh = (): Harness => ({
|
|
64
81
|
calls: [],
|
|
65
82
|
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
66
83
|
mailbox: { mailboxId: "trash-mbx", uidValidity: 1, cursorState: undefined },
|
|
84
|
+
trashResolution: {
|
|
85
|
+
kind: "flagged",
|
|
86
|
+
mailbox: { mailboxId: "trash-mbx", fullPath: "Trash" },
|
|
87
|
+
},
|
|
67
88
|
connection: buildConnection(),
|
|
68
|
-
localMessages: [
|
|
69
|
-
threadMessage:
|
|
89
|
+
localMessages: [deleting("msg-1", 10), deleting("msg-2", 11)],
|
|
90
|
+
threadMessage: true,
|
|
91
|
+
messagesWithoutListingRow: [],
|
|
70
92
|
getConnectionCount: 0,
|
|
71
93
|
disconnectCount: 0,
|
|
72
94
|
});
|
|
73
95
|
|
|
96
|
+
// Empty Trash only flips `isDeleted` on the listing row, so its presence is how
|
|
97
|
+
// the revert tells its own marks from a permanent delete's (which removes them
|
|
98
|
+
// up front).
|
|
99
|
+
const listingRow = (messageId: string) =>
|
|
100
|
+
h.messagesWithoutListingRow.includes(messageId) || !h.threadMessage
|
|
101
|
+
? null
|
|
102
|
+
: {
|
|
103
|
+
accountConfigId: "cfg-1",
|
|
104
|
+
threadMessageId: `tm-${messageId}`,
|
|
105
|
+
sentDate: 1_700_000_000_000,
|
|
106
|
+
mailboxId: "trash-mbx",
|
|
107
|
+
isRead: false,
|
|
108
|
+
isDeleted: true,
|
|
109
|
+
hasStars: false,
|
|
110
|
+
hasAttachment: false,
|
|
111
|
+
};
|
|
112
|
+
|
|
74
113
|
const deps = (): EmptyTrashDeps =>
|
|
75
114
|
({
|
|
76
115
|
getClient: async () => ({
|
|
@@ -83,10 +122,17 @@ const deps = (): EmptyTrashDeps =>
|
|
|
83
122
|
message: {
|
|
84
123
|
listAllByMailbox: async () => h.localMessages,
|
|
85
124
|
delete: record("message.delete"),
|
|
125
|
+
update: record("message.update"),
|
|
86
126
|
},
|
|
87
127
|
threadMessage: {
|
|
88
|
-
findByMessageId: async () =>
|
|
128
|
+
findByMessageId: async (_cfg: string, messageId: string) =>
|
|
129
|
+
listingRow(messageId),
|
|
130
|
+
findAllByMessageId: async (_cfg: string, messageId: string) => {
|
|
131
|
+
const row = listingRow(messageId);
|
|
132
|
+
return row ? [row] : [];
|
|
133
|
+
},
|
|
89
134
|
delete: record("threadMessage.delete"),
|
|
135
|
+
update: record("threadMessage.update"),
|
|
90
136
|
},
|
|
91
137
|
mailbox: {
|
|
92
138
|
get: async () => {
|
|
@@ -95,6 +141,9 @@ const deps = (): EmptyTrashDeps =>
|
|
|
95
141
|
},
|
|
96
142
|
update: record("mailbox.update"),
|
|
97
143
|
},
|
|
144
|
+
mailboxSpecialUse: {
|
|
145
|
+
resolveTrashRole: async () => h.trashResolution,
|
|
146
|
+
},
|
|
98
147
|
secrets: {},
|
|
99
148
|
}),
|
|
100
149
|
buildLifecycleDeps: () => ({}),
|
|
@@ -117,14 +166,31 @@ const deps = (): EmptyTrashDeps =>
|
|
|
117
166
|
|
|
118
167
|
const event: EmptyTrashEvent = {
|
|
119
168
|
type: "EMPTY_TRASH",
|
|
169
|
+
schemaVersion: 2,
|
|
120
170
|
accountId: "acc-1",
|
|
121
171
|
trashMailboxId: "trash-mbx",
|
|
122
172
|
trashMailboxPath: "Trash",
|
|
173
|
+
trashUidValidity: 1,
|
|
123
174
|
} as EmptyTrashEvent;
|
|
124
175
|
|
|
125
176
|
const called = (method: string): Call[] =>
|
|
126
177
|
h.calls.filter((c) => c.method === method);
|
|
127
178
|
|
|
179
|
+
const revertedMessageIds = (): string[] =>
|
|
180
|
+
called("message.update")
|
|
181
|
+
.filter(
|
|
182
|
+
(c) =>
|
|
183
|
+
(c.args[1] as { status?: string; syncStatus?: string }).status ===
|
|
184
|
+
"active" &&
|
|
185
|
+
(c.args[1] as { syncStatus?: string }).syncStatus === "synced",
|
|
186
|
+
)
|
|
187
|
+
.map((c) => c.args[0] as string);
|
|
188
|
+
|
|
189
|
+
const undeletedThreadMessageIds = (): string[] =>
|
|
190
|
+
called("threadMessage.update")
|
|
191
|
+
.filter((c) => (c.args[2] as { isDeleted?: boolean }).isDeleted === false)
|
|
192
|
+
.map((c) => c.args[1] as string);
|
|
193
|
+
|
|
128
194
|
describe("handleEmptyTrash", () => {
|
|
129
195
|
beforeEach(() => {
|
|
130
196
|
h = fresh();
|
|
@@ -142,21 +208,66 @@ describe("handleEmptyTrash", () => {
|
|
|
142
208
|
assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
|
|
143
209
|
});
|
|
144
210
|
|
|
145
|
-
it("
|
|
211
|
+
it("keeps the local row for a uid the expunge never covered", async () => {
|
|
212
|
+
// Mail that reached Trash after the SEARCH — or a move the unordered dev
|
|
213
|
+
// queue let outrun this event — is still on the server, so deleting its
|
|
214
|
+
// rows would hide mail the user can still see in another client.
|
|
215
|
+
h.localMessages = [
|
|
216
|
+
deleting("msg-1", 10),
|
|
217
|
+
deleting("msg-2", 11),
|
|
218
|
+
{ messageId: "msg-late", uid: 12, status: "active" },
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
await handleEmptyTrash(event, noopLog, deps());
|
|
222
|
+
|
|
223
|
+
assert.deepEqual(
|
|
224
|
+
called("message.delete").map((c) => c.args[0]),
|
|
225
|
+
["msg-1", "msg-2"],
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("hands every row back when another client emptied the trash first", async () => {
|
|
230
|
+
// Apple Mail got there first, so the SEARCH is empty and this expunge
|
|
231
|
+
// covers nothing. Leaving the rows `deleting` hides mail that no longer
|
|
232
|
+
// exists anywhere, with nothing left to clear the mark.
|
|
146
233
|
h.connection.search = async () => [];
|
|
147
234
|
|
|
148
235
|
await handleEmptyTrash(event, noopLog, deps());
|
|
149
236
|
|
|
150
237
|
assert.equal(called("connection.deleteMessages").length, 0);
|
|
151
|
-
assert.equal(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
238
|
+
assert.equal(called("message.delete").length, 0);
|
|
239
|
+
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
240
|
+
assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-1", "tm-msg-2"]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("hands back what a partial sweep left when the event is redelivered", async () => {
|
|
244
|
+
// The first attempt expunged and cleaned up msg-1, then died before
|
|
245
|
+
// msg-2. On redelivery the server has nothing left to find, and msg-2
|
|
246
|
+
// would otherwise sit marked for a deletion that will never come.
|
|
247
|
+
h.localMessages = [deleting("msg-2", 11)];
|
|
248
|
+
h.connection.search = async () => [];
|
|
249
|
+
|
|
250
|
+
await handleEmptyTrash(event, noopLog, deps());
|
|
251
|
+
|
|
252
|
+
assert.equal(called("message.delete").length, 0);
|
|
253
|
+
assert.deepEqual(revertedMessageIds(), ["msg-2"]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("leaves a row whose listing rows another operation already removed", async () => {
|
|
257
|
+
// A permanent delete inside Trash removes its listing rows up front and
|
|
258
|
+
// marks the Message `deleting`. Reverting it here would resurrect a row
|
|
259
|
+
// that operation is about to remove.
|
|
260
|
+
h.localMessages = [deleting("msg-1", 10), deleting("msg-expunging", 12)];
|
|
261
|
+
h.messagesWithoutListingRow = ["msg-expunging"];
|
|
262
|
+
h.connection.search = async () => [];
|
|
263
|
+
|
|
264
|
+
await handleEmptyTrash(event, noopLog, deps());
|
|
265
|
+
|
|
266
|
+
assert.deepEqual(revertedMessageIds(), ["msg-1"]);
|
|
156
267
|
});
|
|
157
268
|
|
|
158
269
|
it("deletes the message even when it has no thread row", async () => {
|
|
159
|
-
h.threadMessage =
|
|
270
|
+
h.threadMessage = false;
|
|
160
271
|
|
|
161
272
|
await handleEmptyTrash(event, noopLog, deps());
|
|
162
273
|
|
|
@@ -182,12 +293,10 @@ describe("handleEmptyTrash", () => {
|
|
|
182
293
|
await assert.rejects(handleEmptyTrash(event, noopLog, deps()), /not found/);
|
|
183
294
|
});
|
|
184
295
|
|
|
185
|
-
it("
|
|
186
|
-
h.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
cursorState: "rebuilding",
|
|
190
|
-
};
|
|
296
|
+
it("acks terminally without connecting when the Trash mailbox was deleted", async () => {
|
|
297
|
+
h.mailboxError = Object.assign(new Error("Mailbox not found: trash-mbx"), {
|
|
298
|
+
name: "NotFoundError",
|
|
299
|
+
});
|
|
191
300
|
|
|
192
301
|
await handleEmptyTrash(event, noopLog, deps());
|
|
193
302
|
|
|
@@ -195,18 +304,78 @@ describe("handleEmptyTrash", () => {
|
|
|
195
304
|
assert.equal(called("message.delete").length, 0);
|
|
196
305
|
});
|
|
197
306
|
|
|
198
|
-
it("
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
307
|
+
it("abandons and reverts an event minted under an unknown contract", async () => {
|
|
308
|
+
const unversioned = {
|
|
309
|
+
type: "EMPTY_TRASH",
|
|
310
|
+
accountId: "acc-1",
|
|
311
|
+
trashMailboxId: "trash-mbx",
|
|
312
|
+
trashMailboxPath: "Trash",
|
|
313
|
+
} as unknown as EmptyTrashEvent;
|
|
314
|
+
|
|
315
|
+
await handleEmptyTrash(unversioned, noopLog, deps());
|
|
316
|
+
|
|
317
|
+
assert.equal(h.getConnectionCount, 0, "no connection is ever opened");
|
|
318
|
+
assert.equal(called("connection.deleteMessages").length, 0);
|
|
319
|
+
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
320
|
+
assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-1", "tm-msg-2"]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("abandons when the Trash role now names a different folder", async () => {
|
|
324
|
+
h.trashResolution = {
|
|
325
|
+
kind: "appointed",
|
|
326
|
+
mailbox: { mailboxId: "other-mbx", fullPath: "INBOX/Bak" },
|
|
327
|
+
};
|
|
202
328
|
|
|
203
329
|
await handleEmptyTrash(event, noopLog, deps());
|
|
204
330
|
|
|
205
331
|
assert.equal(h.getConnectionCount, 0);
|
|
206
|
-
assert.equal(called("
|
|
332
|
+
assert.equal(called("connection.deleteMessages").length, 0);
|
|
333
|
+
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
207
334
|
});
|
|
208
335
|
|
|
209
|
-
it("
|
|
336
|
+
it("abandons when the Trash role no longer rests on confirmed evidence", async () => {
|
|
337
|
+
h.trashResolution = {
|
|
338
|
+
kind: "proposed",
|
|
339
|
+
mailbox: { mailboxId: "trash-mbx", fullPath: "Trash" },
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
await handleEmptyTrash(event, noopLog, deps());
|
|
343
|
+
|
|
344
|
+
assert.equal(called("connection.deleteMessages").length, 0);
|
|
345
|
+
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("refuses the expunge when the served UIDVALIDITY is not the one consented to", async () => {
|
|
349
|
+
// The path was reused: a third-party client renamed Trash away and made a
|
|
350
|
+
// fresh one. Same path, different folder, and nobody consented to empty it.
|
|
351
|
+
h.connection.openBox = async () => ({ uidvalidity: 77 });
|
|
352
|
+
h.mailbox = { mailboxId: "trash-mbx", uidValidity: 77 };
|
|
353
|
+
|
|
354
|
+
await handleEmptyTrash(event, noopLog, deps());
|
|
355
|
+
|
|
356
|
+
assert.equal(called("connection.deleteMessages").length, 0);
|
|
357
|
+
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
358
|
+
assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-1", "tm-msg-2"]);
|
|
359
|
+
assert.equal(h.disconnectCount, 1);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("reverts only the rows this empty marked, never a freshly synced one", async () => {
|
|
363
|
+
h.localMessages = [
|
|
364
|
+
deleting("msg-1", 10),
|
|
365
|
+
{ messageId: "msg-arrived", uid: 12, status: "active" },
|
|
366
|
+
];
|
|
367
|
+
h.connection.openBox = async () => ({ uidvalidity: 77 });
|
|
368
|
+
h.mailbox = { mailboxId: "trash-mbx", uidValidity: 77 };
|
|
369
|
+
|
|
370
|
+
await handleEmptyTrash(event, noopLog, deps());
|
|
371
|
+
|
|
372
|
+
assert.deepEqual(revertedMessageIds(), ["msg-1"]);
|
|
373
|
+
assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-1"]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("reverts the marks when openBox trips a UIDVALIDITY mismatch", async () => {
|
|
377
|
+
// The event is acked and nothing re-issues it, so leaving the folder
|
|
378
|
+
// marked `deleting` hides healthy mail until the user notices.
|
|
210
379
|
h.connection.openBox = async () => ({ uidvalidity: 999 });
|
|
211
380
|
|
|
212
381
|
await handleEmptyTrash(event, noopLog, deps());
|
|
@@ -217,9 +386,24 @@ describe("handleEmptyTrash", () => {
|
|
|
217
386
|
"cursor_invalid",
|
|
218
387
|
);
|
|
219
388
|
assert.equal(called("message.delete").length, 0);
|
|
389
|
+
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
220
390
|
assert.equal(h.disconnectCount, 1);
|
|
221
391
|
});
|
|
222
392
|
|
|
393
|
+
it("reverts the marks without connecting when the cursor is rebuilding", async () => {
|
|
394
|
+
h.mailbox = {
|
|
395
|
+
mailboxId: "trash-mbx",
|
|
396
|
+
uidValidity: 1,
|
|
397
|
+
cursorState: "rebuilding",
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
await handleEmptyTrash(event, noopLog, deps());
|
|
401
|
+
|
|
402
|
+
assert.equal(h.getConnectionCount, 0);
|
|
403
|
+
assert.equal(called("message.delete").length, 0);
|
|
404
|
+
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
405
|
+
});
|
|
406
|
+
|
|
223
407
|
it("rethrows an unclassified IMAP error so the event is retried", async () => {
|
|
224
408
|
h.connection.search = async () => {
|
|
225
409
|
throw new Error("server exploded");
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
+
import type { MessageItem } from "@remit/data-ports";
|
|
3
|
+
import { trashMailboxAt } from "@remit/data-ports/folder-role";
|
|
4
|
+
import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
|
|
5
|
+
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
2
6
|
import type { Logger } from "@remit/logger-lambda";
|
|
3
7
|
import {
|
|
4
8
|
guardConnectionCursor,
|
|
@@ -11,6 +15,7 @@ import type { EmptyTrashEvent } from "../events.js";
|
|
|
11
15
|
import { isNotFoundError } from "../is-not-found.js";
|
|
12
16
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
13
17
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
18
|
+
import { buildThreadMessageUndelete } from "./message-delete.js";
|
|
14
19
|
|
|
15
20
|
export interface EmptyTrashDeps {
|
|
16
21
|
getClient: typeof getClient;
|
|
@@ -29,6 +34,13 @@ const defaultDeps: EmptyTrashDeps = {
|
|
|
29
34
|
/**
|
|
30
35
|
* Handle EMPTY_TRASH events.
|
|
31
36
|
* Permanently deletes all messages in the Trash mailbox.
|
|
37
|
+
*
|
|
38
|
+
* The only unrecoverable mutation reader issues, so the folder's identity is
|
|
39
|
+
* confirmed here, on the connection that does the expunging: the role still
|
|
40
|
+
* names this mailbox on confirmed evidence, and the UIDVALIDITY the SELECT
|
|
41
|
+
* serves still matches the one the user consented to. Every refusal reverts
|
|
42
|
+
* the optimistic local marks and acks — a throw would stall the account's FIFO
|
|
43
|
+
* group behind an event no retry can fix (issues #287, #289, #290).
|
|
32
44
|
*/
|
|
33
45
|
export const handleEmptyTrash = async (
|
|
34
46
|
event: EmptyTrashEvent,
|
|
@@ -47,6 +59,7 @@ export const handleEmptyTrash = async (
|
|
|
47
59
|
message: messageService,
|
|
48
60
|
threadMessage: threadMessageService,
|
|
49
61
|
mailbox: mailboxService,
|
|
62
|
+
mailboxSpecialUse: mailboxSpecialUseService,
|
|
50
63
|
secrets,
|
|
51
64
|
} = await getClient();
|
|
52
65
|
|
|
@@ -66,6 +79,92 @@ export const handleEmptyTrash = async (
|
|
|
66
79
|
return;
|
|
67
80
|
}
|
|
68
81
|
|
|
82
|
+
// `emptyTrash` marks every row in the folder `deleting` + `isDeleted` before
|
|
83
|
+
// enqueueing, so any row this expunge does not carry through is hidden from
|
|
84
|
+
// every listing with nothing else to unhide it: no sync clears it, a repeat
|
|
85
|
+
// Empty Trash skips it again, and listings filter `isDeleted`. Handing it
|
|
86
|
+
// back is right whichever way it got left behind — mail already gone from
|
|
87
|
+
// the server becomes visible and the next cursor rebuild reconciles it, and
|
|
88
|
+
// an unsettled move is rewritten by its own MESSAGE_DELETE. Not `failed`:
|
|
89
|
+
// the mail is intact, and saying otherwise about a whole folder is a lie.
|
|
90
|
+
const handBackMarkedRows = async (
|
|
91
|
+
rows: readonly { messageId: string; status: MessageItem["status"] }[],
|
|
92
|
+
): Promise<number> => {
|
|
93
|
+
let revertedCount = 0;
|
|
94
|
+
for (const message of rows) {
|
|
95
|
+
if (message.status !== MessageStatus.deleting) continue;
|
|
96
|
+
|
|
97
|
+
// Empty Trash only flips `isDeleted` on the listing rows; a permanent
|
|
98
|
+
// delete removes them up front. So a `deleting` row with no listing row
|
|
99
|
+
// was marked by some other in-flight operation, and reverting the
|
|
100
|
+
// Message under it would resurrect what that one is about to remove.
|
|
101
|
+
const threadMessages = await threadMessageService.findAllByMessageId(
|
|
102
|
+
account.accountConfigId,
|
|
103
|
+
message.messageId,
|
|
104
|
+
);
|
|
105
|
+
if (threadMessages.length === 0) continue;
|
|
106
|
+
|
|
107
|
+
await messageService.update(message.messageId, {
|
|
108
|
+
status: MessageStatus.active,
|
|
109
|
+
syncStatus: MessageSyncStatus.synced,
|
|
110
|
+
});
|
|
111
|
+
for (const threadMessage of threadMessages) {
|
|
112
|
+
const args = buildThreadMessageUndelete(threadMessage);
|
|
113
|
+
await threadMessageService.update(
|
|
114
|
+
threadMessage.accountConfigId,
|
|
115
|
+
threadMessage.threadMessageId,
|
|
116
|
+
args.set,
|
|
117
|
+
{ composites: args.composites },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
revertedCount += 1;
|
|
121
|
+
}
|
|
122
|
+
return revertedCount;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const abandonEmptyTrash = async (
|
|
126
|
+
reason: string,
|
|
127
|
+
alert: string,
|
|
128
|
+
context: Record<string, unknown> = {},
|
|
129
|
+
): Promise<void> => {
|
|
130
|
+
log.error(
|
|
131
|
+
{ alert, accountId, trashMailboxId, trashMailboxPath, ...context },
|
|
132
|
+
reason,
|
|
133
|
+
);
|
|
134
|
+
await handBackMarkedRows(
|
|
135
|
+
await messageService.listAllByMailbox(trashMailboxId),
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
if (!isCurrentSchemaVersion(event.schemaVersion)) {
|
|
140
|
+
await abandonEmptyTrash(
|
|
141
|
+
"Refused to empty trash: event was minted under an unknown contract",
|
|
142
|
+
"empty_trash_unknown_schema_version",
|
|
143
|
+
{ schemaVersion: event.schemaVersion },
|
|
144
|
+
);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The role can be re-appointed between consent and this run, and a queued
|
|
149
|
+
// event then expunges the folder the user just stopped using as Trash.
|
|
150
|
+
// Confirmed evidence only: what the user appointed, or what the server
|
|
151
|
+
// flagged.
|
|
152
|
+
const resolution = await mailboxSpecialUseService.resolveTrashRole(accountId);
|
|
153
|
+
const trashGate = trashMailboxAt(resolution, "confirmed");
|
|
154
|
+
if (!trashGate.allowed || trashGate.mailbox.mailboxId !== trashMailboxId) {
|
|
155
|
+
await abandonEmptyTrash(
|
|
156
|
+
"Refused to empty trash: this account's Trash is no longer that folder",
|
|
157
|
+
"empty_trash_role_moved",
|
|
158
|
+
{
|
|
159
|
+
resolvedKind: resolution.kind,
|
|
160
|
+
resolvedMailboxId: trashGate.allowed
|
|
161
|
+
? trashGate.mailbox.mailboxId
|
|
162
|
+
: undefined,
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
69
168
|
await withOAuthLifecycle(
|
|
70
169
|
buildLifecycleDeps(secrets, accountService),
|
|
71
170
|
account,
|
|
@@ -90,13 +189,14 @@ export const handleEmptyTrash = async (
|
|
|
90
189
|
return;
|
|
91
190
|
}
|
|
92
191
|
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
192
|
+
// A paused cursor is not a wait here: this return acks the event and
|
|
193
|
+
// nothing re-issues it, so the marks have to come back or the folder
|
|
194
|
+
// stays hidden forever. The user retries.
|
|
96
195
|
if (isCursorRebuildNeeded(mailbox.cursorState)) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
"
|
|
196
|
+
await abandonEmptyTrash(
|
|
197
|
+
"Abandoned empty trash: mailbox cursor is not normal",
|
|
198
|
+
"empty_trash_cursor_paused",
|
|
199
|
+
{ cursorState: mailbox.cursorState },
|
|
100
200
|
);
|
|
101
201
|
return;
|
|
102
202
|
}
|
|
@@ -108,21 +208,34 @@ export const handleEmptyTrash = async (
|
|
|
108
208
|
.then(async (rawConnection) => {
|
|
109
209
|
// Guard at the openBox choke point (epic #1281 invariants 3 & 5):
|
|
110
210
|
// a fresh mismatch trips the mailbox and throws once the SELECT
|
|
111
|
-
// reveals it.
|
|
112
|
-
// up once the mailbox returns to normal.
|
|
211
|
+
// reveals it.
|
|
113
212
|
const connection = guardConnectionCursor(
|
|
114
213
|
rawConnection,
|
|
115
214
|
{ mailboxService },
|
|
116
215
|
accountId,
|
|
117
216
|
mailbox,
|
|
118
217
|
);
|
|
119
|
-
await connection.openBox(trashMailboxPath, false);
|
|
218
|
+
const boxStatus = await connection.openBox(trashMailboxPath, false);
|
|
219
|
+
|
|
220
|
+
// The path is not the folder. A third-party client that renames
|
|
221
|
+
// Trash and creates a fresh one leaves this event pointing at a
|
|
222
|
+
// path now served by a folder the user never consented to empty;
|
|
223
|
+
// UIDVALIDITY is what tells them apart (RFC 9051 2.3.1.1).
|
|
224
|
+
if (boxStatus.uidvalidity !== event.trashUidValidity) {
|
|
225
|
+
await abandonEmptyTrash(
|
|
226
|
+
"Refused to empty trash: the folder at this path is not the one the user emptied",
|
|
227
|
+
"empty_trash_uidvalidity_mismatch",
|
|
228
|
+
{
|
|
229
|
+
servedUidValidity: boxStatus.uidvalidity,
|
|
230
|
+
consentedUidValidity: event.trashUidValidity,
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
120
235
|
|
|
121
|
-
// Search for all messages in Trash
|
|
122
236
|
const uids = await connection.search(["ALL"]);
|
|
123
237
|
|
|
124
238
|
if (uids.length > 0) {
|
|
125
|
-
// Delete all messages on IMAP
|
|
126
239
|
await connection.deleteMessages(uids);
|
|
127
240
|
log.info(
|
|
128
241
|
{ count: uids.length },
|
|
@@ -130,15 +243,19 @@ export const handleEmptyTrash = async (
|
|
|
130
243
|
);
|
|
131
244
|
}
|
|
132
245
|
|
|
133
|
-
//
|
|
246
|
+
// Local cleanup follows the expunge, uid by uid. What was expunged
|
|
247
|
+
// is a fact this connection observed, and only those rows go.
|
|
248
|
+
const expunged = new Set(uids);
|
|
134
249
|
const localMessages =
|
|
135
250
|
await messageService.listAllByMailbox(trashMailboxId);
|
|
251
|
+
let deletedCount = 0;
|
|
136
252
|
|
|
137
253
|
for (const message of localMessages) {
|
|
138
|
-
|
|
254
|
+
if (!expunged.has(message.uid)) continue;
|
|
255
|
+
deletedCount += 1;
|
|
256
|
+
|
|
139
257
|
await messageService.delete(message.messageId);
|
|
140
258
|
|
|
141
|
-
// Delete the ThreadMessage entity
|
|
142
259
|
const threadMessage = await threadMessageService.findByMessageId(
|
|
143
260
|
account.accountConfigId,
|
|
144
261
|
message.messageId,
|
|
@@ -151,20 +268,25 @@ export const handleEmptyTrash = async (
|
|
|
151
268
|
}
|
|
152
269
|
}
|
|
153
270
|
|
|
271
|
+
// Everything the SEARCH did not name is still marked for a deletion
|
|
272
|
+
// that will never come: mail another client already emptied, mail
|
|
273
|
+
// that reached Trash after the SEARCH, or a redelivery finishing a
|
|
274
|
+
// partial sweep. All three must come back rather than sit invisible.
|
|
275
|
+
const revertedCount = await handBackMarkedRows(
|
|
276
|
+
localMessages.filter((message) => !expunged.has(message.uid)),
|
|
277
|
+
);
|
|
278
|
+
|
|
154
279
|
log.info(
|
|
155
|
-
{ accountId, deletedCount
|
|
280
|
+
{ accountId, deletedCount, revertedCount },
|
|
156
281
|
"Trash emptied successfully",
|
|
157
282
|
);
|
|
158
283
|
})
|
|
159
|
-
.catch((error: unknown) => {
|
|
284
|
+
.catch(async (error: unknown) => {
|
|
160
285
|
if (error instanceof MailboxCursorPausedError) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
cursorState: error.state,
|
|
166
|
-
},
|
|
167
|
-
"Mailbox cursor not normal; pausing empty-trash this round",
|
|
286
|
+
await abandonEmptyTrash(
|
|
287
|
+
"Abandoned empty trash: mailbox cursor is not normal",
|
|
288
|
+
"empty_trash_cursor_paused",
|
|
289
|
+
{ cursorState: error.state },
|
|
168
290
|
);
|
|
169
291
|
return;
|
|
170
292
|
}
|
|
@@ -5,6 +5,7 @@ import type { Logger } from "@remit/logger-lambda";
|
|
|
5
5
|
import type { MessageDeleteEvent } from "../events.js";
|
|
6
6
|
import {
|
|
7
7
|
buildThreadMessageTrashUpdate,
|
|
8
|
+
buildThreadMessageUndelete,
|
|
8
9
|
deleteAllThreadMessagesForMessage,
|
|
9
10
|
handleMessageDelete,
|
|
10
11
|
type MessageDeleteDeps,
|
|
@@ -113,6 +114,45 @@ describe("buildThreadMessageTrashUpdate", () => {
|
|
|
113
114
|
});
|
|
114
115
|
});
|
|
115
116
|
|
|
117
|
+
describe("buildThreadMessageUndelete", () => {
|
|
118
|
+
// Every `set` payload here is hand-written, so the three builders can drift
|
|
119
|
+
// apart silently. Undelete is the exact inverse of the trash update's
|
|
120
|
+
// deletion mark, and it must move nothing else: the mail is still in Trash
|
|
121
|
+
// at the uid the row already carries.
|
|
122
|
+
|
|
123
|
+
it("clears only the deletion mark that buildThreadMessageTrashUpdate set", () => {
|
|
124
|
+
const trashed = buildThreadMessageTrashUpdate(
|
|
125
|
+
baseThreadMessage,
|
|
126
|
+
42,
|
|
127
|
+
trashMailboxId,
|
|
128
|
+
);
|
|
129
|
+
const undelete = buildThreadMessageUndelete(baseThreadMessage);
|
|
130
|
+
|
|
131
|
+
assert.strictEqual(trashed.set.isDeleted, true);
|
|
132
|
+
assert.strictEqual(undelete.set.isDeleted, false);
|
|
133
|
+
assert.deepStrictEqual(
|
|
134
|
+
Object.keys(undelete.set),
|
|
135
|
+
["isDeleted"],
|
|
136
|
+
"undelete must not move the row's uid or mailbox",
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("carries the CURRENT row state in composites, like every other builder", () => {
|
|
141
|
+
const undelete = buildThreadMessageUndelete(baseThreadMessage);
|
|
142
|
+
|
|
143
|
+
assert.deepStrictEqual(
|
|
144
|
+
undelete.composites,
|
|
145
|
+
buildThreadMessageTrashUpdate(baseThreadMessage, 42, trashMailboxId)
|
|
146
|
+
.composites,
|
|
147
|
+
);
|
|
148
|
+
assert.strictEqual(
|
|
149
|
+
undelete.composites.isDeleted,
|
|
150
|
+
baseThreadMessage.isDeleted,
|
|
151
|
+
"composites hold the state to check against, never the new value",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
116
156
|
describe("deleteAllThreadMessagesForMessage (#212)", () => {
|
|
117
157
|
// Regression for the multi-mailbox cleanup gap in #212. A single Message
|
|
118
158
|
// can have ThreadMessage rows in multiple mailboxes (e.g. INBOX + a label
|
|
@@ -205,6 +245,8 @@ interface Connection {
|
|
|
205
245
|
) => Promise<{ uidMap: Map<number, number> }>;
|
|
206
246
|
deleteMessages: (uids: number[]) => Promise<void>;
|
|
207
247
|
createMailbox: (path: string) => Promise<void>;
|
|
248
|
+
fetchMessages: (uids: number[]) => Promise<{ uid: number }[]>;
|
|
249
|
+
search: (criteria: unknown[]) => Promise<number[]>;
|
|
208
250
|
}
|
|
209
251
|
|
|
210
252
|
interface Harness {
|
|
@@ -217,8 +259,9 @@ interface Harness {
|
|
|
217
259
|
mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
|
|
218
260
|
mailboxError?: Error;
|
|
219
261
|
connection: Connection;
|
|
262
|
+
threadMessageUpdateError?: Error;
|
|
220
263
|
threadMessage: Record<string, unknown> | null;
|
|
221
|
-
allThreadMessages:
|
|
264
|
+
allThreadMessages: Record<string, unknown>[];
|
|
222
265
|
getConnectionCount: number;
|
|
223
266
|
disconnectCount: number;
|
|
224
267
|
}
|
|
@@ -240,8 +283,23 @@ const buildConnection = (): Connection => ({
|
|
|
240
283
|
createMailbox: record(
|
|
241
284
|
"connection.createMailbox",
|
|
242
285
|
) as Connection["createMailbox"],
|
|
286
|
+
// The source box still holds the uid unless a case says otherwise, so the
|
|
287
|
+
// presence probe only reports "gone" where a test made it true.
|
|
288
|
+
fetchMessages: async (uids: number[]) => uids.map((uid) => ({ uid })),
|
|
289
|
+
search: async (...args: unknown[]) => {
|
|
290
|
+
h.calls.push({ method: "connection.search", args });
|
|
291
|
+
return [10];
|
|
292
|
+
},
|
|
243
293
|
});
|
|
244
294
|
|
|
295
|
+
const sourceNoLongerHoldsTheUid = (): void => {
|
|
296
|
+
h.connection.fetchMessages = async () => [];
|
|
297
|
+
h.connection.search = async (...args: unknown[]) => {
|
|
298
|
+
h.calls.push({ method: "connection.search", args });
|
|
299
|
+
return [];
|
|
300
|
+
};
|
|
301
|
+
};
|
|
302
|
+
|
|
245
303
|
const fresh = (): Harness => ({
|
|
246
304
|
calls: [],
|
|
247
305
|
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
@@ -253,8 +311,8 @@ const fresh = (): Harness => ({
|
|
|
253
311
|
threadMessageId: "tm-1",
|
|
254
312
|
},
|
|
255
313
|
allThreadMessages: [
|
|
256
|
-
{ accountConfigId: "cfg-1", threadMessageId: "tm-1" },
|
|
257
|
-
{ accountConfigId: "cfg-1", threadMessageId: "tm-2" },
|
|
314
|
+
{ ...baseThreadMessage, accountConfigId: "cfg-1", threadMessageId: "tm-1" },
|
|
315
|
+
{ ...baseThreadMessage, accountConfigId: "cfg-1", threadMessageId: "tm-2" },
|
|
258
316
|
],
|
|
259
317
|
getConnectionCount: 0,
|
|
260
318
|
disconnectCount: 0,
|
|
@@ -277,7 +335,10 @@ const deps = (): MessageDeleteDeps =>
|
|
|
277
335
|
threadMessage: {
|
|
278
336
|
findByMessageId: async () => h.threadMessage,
|
|
279
337
|
findAllByMessageId: async () => h.allThreadMessages,
|
|
280
|
-
update:
|
|
338
|
+
update: async (...args: unknown[]) => {
|
|
339
|
+
h.calls.push({ method: "threadMessage.update", args });
|
|
340
|
+
if (h.threadMessageUpdateError) throw h.threadMessageUpdateError;
|
|
341
|
+
},
|
|
281
342
|
delete: record("threadMessage.delete"),
|
|
282
343
|
},
|
|
283
344
|
mailbox: {
|
|
@@ -309,6 +370,7 @@ const deps = (): MessageDeleteDeps =>
|
|
|
309
370
|
|
|
310
371
|
const moveEvent: MessageDeleteEvent = {
|
|
311
372
|
type: "MESSAGE_DELETE",
|
|
373
|
+
schemaVersion: 2,
|
|
312
374
|
accountId: "acc-1",
|
|
313
375
|
messageId: "msg-1",
|
|
314
376
|
mailboxId: "src-mbx",
|
|
@@ -321,6 +383,7 @@ const moveEvent: MessageDeleteEvent = {
|
|
|
321
383
|
|
|
322
384
|
const permanentEvent: MessageDeleteEvent = {
|
|
323
385
|
type: "MESSAGE_DELETE",
|
|
386
|
+
schemaVersion: 2,
|
|
324
387
|
accountId: "acc-1",
|
|
325
388
|
messageId: "msg-1",
|
|
326
389
|
mailboxId: "src-mbx",
|
|
@@ -440,10 +503,13 @@ describe("handleMessageDelete", () => {
|
|
|
440
503
|
);
|
|
441
504
|
});
|
|
442
505
|
|
|
443
|
-
|
|
506
|
+
// Regression on #212: a permanent delete the server has already applied must
|
|
507
|
+
// still reconcile the local rows, now that the text alone no longer decides.
|
|
508
|
+
it("cleans up locally and swallows the error when the message is confirmed gone on IMAP", async () => {
|
|
444
509
|
h.connection.deleteMessages = async () => {
|
|
445
510
|
throw new Error("NONEXISTENT uid");
|
|
446
511
|
};
|
|
512
|
+
sourceNoLongerHoldsTheUid();
|
|
447
513
|
|
|
448
514
|
await handleMessageDelete(permanentEvent, noopLog, deps());
|
|
449
515
|
|
|
@@ -451,18 +517,192 @@ describe("handleMessageDelete", () => {
|
|
|
451
517
|
assert.equal(called("threadMessage.delete").length, 2);
|
|
452
518
|
});
|
|
453
519
|
|
|
454
|
-
|
|
520
|
+
// Issue #845. The three cases below are the same bug from three directions:
|
|
521
|
+
// "not found" in an error string is never on its own a reason to destroy the
|
|
522
|
+
// only local record of live mail.
|
|
523
|
+
|
|
524
|
+
it("keeps the rows when a move-to-trash fails after the MOVE landed", async () => {
|
|
525
|
+
// The MOVE succeeded; the ThreadMessage write behind it did not. The
|
|
526
|
+
// message is sitting in Trash, and the NotFoundError names a local row,
|
|
527
|
+
// not a mail-server one.
|
|
528
|
+
h.threadMessageUpdateError = Object.assign(
|
|
529
|
+
new Error("Thread message not found"),
|
|
530
|
+
{ name: "NotFoundError" },
|
|
531
|
+
);
|
|
532
|
+
sourceNoLongerHoldsTheUid();
|
|
533
|
+
|
|
534
|
+
await assert.rejects(
|
|
535
|
+
handleMessageDelete(moveEvent, noopLog, deps()),
|
|
536
|
+
/not found/,
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
assert.equal(
|
|
540
|
+
called("message.delete").length,
|
|
541
|
+
0,
|
|
542
|
+
"the message is in Trash; deleting its row loses the only handle on it",
|
|
543
|
+
);
|
|
544
|
+
assert.equal(called("threadMessage.delete").length, 0);
|
|
545
|
+
assert.equal(
|
|
546
|
+
called("connection.search").length,
|
|
547
|
+
0,
|
|
548
|
+
"a move-to-trash never probes: absence from the source is its success signature",
|
|
549
|
+
);
|
|
550
|
+
assert.equal(
|
|
551
|
+
(called("message.update")[0]?.args[1] as { syncStatus?: string })
|
|
552
|
+
?.syncStatus,
|
|
553
|
+
"failed",
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("keeps the rows on a NONEXISTENT move-to-trash whose source still holds the uid", async () => {
|
|
455
558
|
h.connection.moveMessages = async () => {
|
|
456
|
-
throw new Error("
|
|
559
|
+
throw new Error("NONEXISTENT: mailbox does not exist");
|
|
457
560
|
};
|
|
458
561
|
|
|
459
562
|
await assert.rejects(
|
|
460
563
|
handleMessageDelete(moveEvent, noopLog, deps()),
|
|
461
|
-
/
|
|
564
|
+
/NONEXISTENT/,
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
assert.equal(called("message.delete").length, 0);
|
|
568
|
+
assert.equal(called("threadMessage.delete").length, 0);
|
|
569
|
+
assert.equal(
|
|
570
|
+
(called("message.update")[0]?.args[1] as { syncStatus?: string })
|
|
571
|
+
?.syncStatus,
|
|
572
|
+
"failed",
|
|
573
|
+
);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("keeps the rows when a permanent delete's source still holds the uid", async () => {
|
|
577
|
+
h.connection.deleteMessages = async () => {
|
|
578
|
+
throw new Error("NONEXISTENT uid");
|
|
579
|
+
};
|
|
580
|
+
// imapflow drops rows on back-to-back FETCHes (#408); the SEARCH is what
|
|
581
|
+
// the verdict rests on, and it still lists the uid.
|
|
582
|
+
h.connection.fetchMessages = async () => [];
|
|
583
|
+
|
|
584
|
+
await assert.rejects(
|
|
585
|
+
handleMessageDelete(permanentEvent, noopLog, deps()),
|
|
586
|
+
/NONEXISTENT/,
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
assert.equal(
|
|
590
|
+
called("connection.search").length,
|
|
591
|
+
1,
|
|
592
|
+
"the error text only selects a candidate; the source box decides",
|
|
593
|
+
);
|
|
594
|
+
assert.equal(called("message.delete").length, 0);
|
|
595
|
+
assert.equal(called("threadMessage.delete").length, 0);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("keeps the rows and rethrows the original error when the probe cannot answer", async () => {
|
|
599
|
+
// The SELECT is what failed, so there is no open box left for the probe
|
|
600
|
+
// to ask. The IMAP error must still be the one that rejects, and the row
|
|
601
|
+
// must not be stranded mid-delete for the record to carry to the DLQ.
|
|
602
|
+
h.connection.openBox = async () => {
|
|
603
|
+
throw new Error("NONEXISTENT mailbox does not exist");
|
|
604
|
+
};
|
|
605
|
+
h.connection.fetchMessages = async () => {
|
|
606
|
+
throw new Error("No mailbox selected");
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
await assert.rejects(
|
|
610
|
+
handleMessageDelete(permanentEvent, noopLog, deps()),
|
|
611
|
+
/NONEXISTENT mailbox does not exist/,
|
|
462
612
|
);
|
|
463
613
|
|
|
464
|
-
assert.equal(called("
|
|
465
|
-
assert.equal(
|
|
614
|
+
assert.equal(called("message.delete").length, 0);
|
|
615
|
+
assert.equal(called("threadMessage.delete").length, 0);
|
|
616
|
+
assert.equal(
|
|
617
|
+
(called("message.update")[0]?.args[1] as { syncStatus?: string })
|
|
618
|
+
?.syncStatus,
|
|
619
|
+
"failed",
|
|
620
|
+
);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("abandons rather than creating the destination on TRYCREATE", async () => {
|
|
624
|
+
// Creating it resurrects an empty `Trash` beside the real one, and the
|
|
625
|
+
// name-hint rule then has two folders to choose between. The picker
|
|
626
|
+
// offers the ones the account actually has.
|
|
627
|
+
h.connection.moveMessages = async () => {
|
|
628
|
+
throw new Error("TRYCREATE: no such mailbox");
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
await handleMessageDelete(moveEvent, noopLog, deps());
|
|
632
|
+
|
|
633
|
+
assert.equal(called("connection.createMailbox").length, 0);
|
|
634
|
+
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
635
|
+
"msg-1",
|
|
636
|
+
10,
|
|
637
|
+
"src-mbx",
|
|
638
|
+
]);
|
|
639
|
+
assert.deepEqual(called("message.update")[0]?.args[1], {
|
|
640
|
+
status: "active",
|
|
641
|
+
syncStatus: "failed",
|
|
642
|
+
});
|
|
643
|
+
assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
|
|
644
|
+
uid: 10,
|
|
645
|
+
mailboxId: "src-mbx",
|
|
646
|
+
isDeleted: false,
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("abandons an event minted under an unknown contract, before connecting", async () => {
|
|
651
|
+
const unversioned = {
|
|
652
|
+
...moveEvent,
|
|
653
|
+
schemaVersion: undefined,
|
|
654
|
+
} as unknown as MessageDeleteEvent;
|
|
655
|
+
|
|
656
|
+
await handleMessageDelete(unversioned, noopLog, deps());
|
|
657
|
+
|
|
658
|
+
assert.equal(h.getConnectionCount, 0);
|
|
659
|
+
assert.equal(called("connection.deleteMessages").length, 0);
|
|
660
|
+
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
661
|
+
"msg-1",
|
|
662
|
+
10,
|
|
663
|
+
"src-mbx",
|
|
664
|
+
]);
|
|
665
|
+
assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
|
|
666
|
+
uid: 10,
|
|
667
|
+
mailboxId: "src-mbx",
|
|
668
|
+
isDeleted: false,
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("hands back every listing row a message has, not just the first", async () => {
|
|
673
|
+
const unversioned = {
|
|
674
|
+
...moveEvent,
|
|
675
|
+
schemaVersion: undefined,
|
|
676
|
+
} as unknown as MessageDeleteEvent;
|
|
677
|
+
|
|
678
|
+
await handleMessageDelete(unversioned, noopLog, deps());
|
|
679
|
+
|
|
680
|
+
assert.deepEqual(
|
|
681
|
+
called("threadMessage.update").map((c) => c.args[1]),
|
|
682
|
+
["tm-1", "tm-2"],
|
|
683
|
+
);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("finishes the removal when an abandoned delete has no listing rows left", async () => {
|
|
687
|
+
// A permanent delete removes them before it enqueues, and they cannot be
|
|
688
|
+
// rebuilt here. Restoring the Message alone leaves mail nothing can list,
|
|
689
|
+
// which is worse than either consistent state; the server copy survives
|
|
690
|
+
// and a full sync brings it back.
|
|
691
|
+
h.allThreadMessages = [];
|
|
692
|
+
const unversioned = {
|
|
693
|
+
...permanentEvent,
|
|
694
|
+
schemaVersion: undefined,
|
|
695
|
+
} as unknown as MessageDeleteEvent;
|
|
696
|
+
|
|
697
|
+
await handleMessageDelete(unversioned, noopLog, deps());
|
|
698
|
+
|
|
699
|
+
assert.equal(called("connection.deleteMessages").length, 0);
|
|
700
|
+
assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
|
|
701
|
+
assert.equal(
|
|
702
|
+
called("message.update").length,
|
|
703
|
+
0,
|
|
704
|
+
"never leave a Message row no listing can reach",
|
|
705
|
+
);
|
|
466
706
|
});
|
|
467
707
|
|
|
468
708
|
it("marks failed and rethrows on an unclassified IMAP error", async () => {
|
|
@@ -3,11 +3,13 @@ import type {
|
|
|
3
3
|
IThreadMessageRepository,
|
|
4
4
|
ThreadMessageItem,
|
|
5
5
|
} from "@remit/data-ports";
|
|
6
|
+
import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
|
|
6
7
|
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
7
8
|
import type { Logger } from "@remit/logger-lambda";
|
|
8
9
|
import {
|
|
9
10
|
guardConnectionCursor,
|
|
10
11
|
isCursorRebuildNeeded,
|
|
12
|
+
isMessageGoneFromOpenMailbox,
|
|
11
13
|
MailboxCursorPausedError,
|
|
12
14
|
} from "@remit/mailbox-service";
|
|
13
15
|
import { isAccountDeleted } from "../account-check.js";
|
|
@@ -108,6 +110,18 @@ export const buildThreadMessageMoveRevert = (
|
|
|
108
110
|
composites: currentComposites(threadMessage),
|
|
109
111
|
});
|
|
110
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Hand a row back after an abandoned expunge. The mail never left Trash, so
|
|
115
|
+
* only the deletion mark reverts — the uid and mailbox on the row are still
|
|
116
|
+
* where the server has it.
|
|
117
|
+
*/
|
|
118
|
+
export const buildThreadMessageUndelete = (
|
|
119
|
+
threadMessage: ThreadMessageRowState,
|
|
120
|
+
) => ({
|
|
121
|
+
set: { isDeleted: false },
|
|
122
|
+
composites: currentComposites(threadMessage),
|
|
123
|
+
});
|
|
124
|
+
|
|
111
125
|
export interface MessageDeleteDeps {
|
|
112
126
|
getClient: typeof getClient;
|
|
113
127
|
buildLifecycleDeps: typeof buildLifecycleDeps;
|
|
@@ -171,6 +185,75 @@ export const handleMessageDelete = async (
|
|
|
171
185
|
return;
|
|
172
186
|
}
|
|
173
187
|
|
|
188
|
+
// Only an operation that explicitly says so destroys mail. The event is
|
|
189
|
+
// `JSON.parse`d and cast in the queue handler with no validation, so a
|
|
190
|
+
// missing, misspelled or future field must abandon the delete — the
|
|
191
|
+
// "anything that is not move_to_trash is an expunge" inference is the same
|
|
192
|
+
// one that destroyed mail in the service, and an unrecoverable EXPUNGE is
|
|
193
|
+
// not a default. Abandoning hands the row back where the server still has
|
|
194
|
+
// it: an invisible `failed` on a row the user cannot see is the shape of
|
|
195
|
+
// the incident this whole change is about.
|
|
196
|
+
const abandonDelete = async (
|
|
197
|
+
reason: string,
|
|
198
|
+
alert: string,
|
|
199
|
+
): Promise<void> => {
|
|
200
|
+
log.error(
|
|
201
|
+
{ alert, accountId, messageId, uid, mailboxPath, operation },
|
|
202
|
+
reason,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const threadMessages = await threadMessageService.findAllByMessageId(
|
|
206
|
+
account.accountConfigId,
|
|
207
|
+
messageId,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// A permanent delete removes the listing rows before it enqueues, and
|
|
211
|
+
// they cannot be rebuilt from here — the row is denormalized off an
|
|
212
|
+
// envelope only the sync path shapes. Restoring the Message alone would
|
|
213
|
+
// leave mail nothing can list, which is the silent vanish rather than a
|
|
214
|
+
// visible failure, so the local removal finishes instead. The server copy
|
|
215
|
+
// survives (nothing was expunged) and a full sync of the mailbox brings
|
|
216
|
+
// it back. Reachable only through the rollout window, where a v1 event
|
|
217
|
+
// carries no `schemaVersion`.
|
|
218
|
+
if (threadMessages.length === 0) {
|
|
219
|
+
log.error(
|
|
220
|
+
{
|
|
221
|
+
alert: "message_delete_abandoned_after_local_cleanup",
|
|
222
|
+
accountId,
|
|
223
|
+
messageId,
|
|
224
|
+
uid,
|
|
225
|
+
mailboxPath,
|
|
226
|
+
},
|
|
227
|
+
"Abandoned delete had no listing rows left to restore; the server copy was not expunged",
|
|
228
|
+
);
|
|
229
|
+
await messageService.delete(messageId);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await messageService.updateUid(messageId, uid, mailboxId);
|
|
234
|
+
await messageService.update(messageId, {
|
|
235
|
+
status: MessageStatus.active,
|
|
236
|
+
syncStatus: MessageSyncStatus.failed,
|
|
237
|
+
});
|
|
238
|
+
for (const threadMessage of threadMessages) {
|
|
239
|
+
const args = buildThreadMessageMoveRevert(threadMessage, uid, mailboxId);
|
|
240
|
+
await threadMessageService.update(
|
|
241
|
+
threadMessage.accountConfigId,
|
|
242
|
+
threadMessage.threadMessageId,
|
|
243
|
+
args.set,
|
|
244
|
+
{ composites: args.composites },
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
if (!isCurrentSchemaVersion(event.schemaVersion)) {
|
|
250
|
+
await abandonDelete(
|
|
251
|
+
"Refused to delete: event was minted under an unknown contract",
|
|
252
|
+
"message_delete_unknown_schema_version",
|
|
253
|
+
);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
174
257
|
await withOAuthLifecycle(
|
|
175
258
|
buildLifecycleDeps(secrets, accountService),
|
|
176
259
|
account,
|
|
@@ -222,43 +305,6 @@ export const handleMessageDelete = async (
|
|
|
222
305
|
);
|
|
223
306
|
await connection.openBox(mailboxPath, false);
|
|
224
307
|
|
|
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
308
|
if (
|
|
263
309
|
operation !== "move_to_trash" &&
|
|
264
310
|
operation !== "permanent_delete"
|
|
@@ -359,14 +405,32 @@ export const handleMessageDelete = async (
|
|
|
359
405
|
const errorMessage =
|
|
360
406
|
error instanceof Error ? error.message : String(error);
|
|
361
407
|
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
408
|
+
// Reconcile a permanent delete the server already applied (#212).
|
|
409
|
+
// The error text only nominates a candidate — the source box
|
|
410
|
+
// decides, since a landed move-to-trash reports that same text from
|
|
411
|
+
// a LOCAL lookup (#845) — and a probe that cannot answer counts as
|
|
412
|
+
// not-confirmed, leaving the original error to the rethrow below.
|
|
413
|
+
const isGoneFromSource =
|
|
414
|
+
operation === "permanent_delete" &&
|
|
415
|
+
(errorMessage.includes("not found") ||
|
|
416
|
+
errorMessage.includes("NONEXISTENT")) &&
|
|
417
|
+
(await scope
|
|
418
|
+
.getConnection()
|
|
419
|
+
.then((connection) =>
|
|
420
|
+
isMessageGoneFromOpenMailbox(connection, uid),
|
|
421
|
+
)
|
|
422
|
+
.catch((probeError: unknown) => {
|
|
423
|
+
log.warn(
|
|
424
|
+
{ messageId, uid, mailboxPath, probeError },
|
|
425
|
+
"Could not confirm whether the message is gone; keeping local rows",
|
|
426
|
+
);
|
|
427
|
+
return false;
|
|
428
|
+
}));
|
|
429
|
+
|
|
430
|
+
if (isGoneFromSource) {
|
|
367
431
|
log.info(
|
|
368
432
|
{ messageId, uid },
|
|
369
|
-
"Message
|
|
433
|
+
"Message confirmed gone from IMAP, cleaning up local",
|
|
370
434
|
);
|
|
371
435
|
// Clean up local entities. Multi-mailbox copies are handled by the
|
|
372
436
|
// helper so we don't leave orphan rows (see issue #212).
|
|
@@ -379,16 +443,16 @@ export const handleMessageDelete = async (
|
|
|
379
443
|
return;
|
|
380
444
|
}
|
|
381
445
|
|
|
382
|
-
// TRYCREATE
|
|
446
|
+
// TRYCREATE — the destination this event names is not on the
|
|
447
|
+
// server. Creating it would resurrect an empty `Trash` beside the
|
|
448
|
+
// real one and hand the name-hint rule two folders to choose
|
|
449
|
+
// between; the folder picker offers the ones that exist instead.
|
|
383
450
|
if (errorMessage.includes("TRYCREATE") && destinationMailboxPath) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
"
|
|
451
|
+
await abandonDelete(
|
|
452
|
+
"Refused to delete: the destination mailbox does not exist on the server",
|
|
453
|
+
"message_delete_destination_missing",
|
|
387
454
|
);
|
|
388
|
-
|
|
389
|
-
await connection.createMailbox(destinationMailboxPath);
|
|
390
|
-
// Re-throw to let the event be retried
|
|
391
|
-
throw error;
|
|
455
|
+
return;
|
|
392
456
|
}
|
|
393
457
|
|
|
394
458
|
// Mark as failed for other errors
|