@remit/imap-worker 0.0.51 → 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 +122 -9
- package/src/handlers/message-delete.ts +90 -45
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
|
|
@@ -221,7 +261,7 @@ interface Harness {
|
|
|
221
261
|
connection: Connection;
|
|
222
262
|
threadMessageUpdateError?: Error;
|
|
223
263
|
threadMessage: Record<string, unknown> | null;
|
|
224
|
-
allThreadMessages:
|
|
264
|
+
allThreadMessages: Record<string, unknown>[];
|
|
225
265
|
getConnectionCount: number;
|
|
226
266
|
disconnectCount: number;
|
|
227
267
|
}
|
|
@@ -271,8 +311,8 @@ const fresh = (): Harness => ({
|
|
|
271
311
|
threadMessageId: "tm-1",
|
|
272
312
|
},
|
|
273
313
|
allThreadMessages: [
|
|
274
|
-
{ accountConfigId: "cfg-1", threadMessageId: "tm-1" },
|
|
275
|
-
{ accountConfigId: "cfg-1", threadMessageId: "tm-2" },
|
|
314
|
+
{ ...baseThreadMessage, accountConfigId: "cfg-1", threadMessageId: "tm-1" },
|
|
315
|
+
{ ...baseThreadMessage, accountConfigId: "cfg-1", threadMessageId: "tm-2" },
|
|
276
316
|
],
|
|
277
317
|
getConnectionCount: 0,
|
|
278
318
|
disconnectCount: 0,
|
|
@@ -330,6 +370,7 @@ const deps = (): MessageDeleteDeps =>
|
|
|
330
370
|
|
|
331
371
|
const moveEvent: MessageDeleteEvent = {
|
|
332
372
|
type: "MESSAGE_DELETE",
|
|
373
|
+
schemaVersion: 2,
|
|
333
374
|
accountId: "acc-1",
|
|
334
375
|
messageId: "msg-1",
|
|
335
376
|
mailboxId: "src-mbx",
|
|
@@ -342,6 +383,7 @@ const moveEvent: MessageDeleteEvent = {
|
|
|
342
383
|
|
|
343
384
|
const permanentEvent: MessageDeleteEvent = {
|
|
344
385
|
type: "MESSAGE_DELETE",
|
|
386
|
+
schemaVersion: 2,
|
|
345
387
|
accountId: "acc-1",
|
|
346
388
|
messageId: "msg-1",
|
|
347
389
|
mailboxId: "src-mbx",
|
|
@@ -578,18 +620,89 @@ describe("handleMessageDelete", () => {
|
|
|
578
620
|
);
|
|
579
621
|
});
|
|
580
622
|
|
|
581
|
-
it("
|
|
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.
|
|
582
627
|
h.connection.moveMessages = async () => {
|
|
583
628
|
throw new Error("TRYCREATE: no such mailbox");
|
|
584
629
|
};
|
|
585
630
|
|
|
586
|
-
await
|
|
587
|
-
|
|
588
|
-
|
|
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"],
|
|
589
683
|
);
|
|
684
|
+
});
|
|
590
685
|
|
|
591
|
-
|
|
592
|
-
|
|
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
|
+
);
|
|
593
706
|
});
|
|
594
707
|
|
|
595
708
|
it("marks failed and rethrows on an unclassified IMAP error", async () => {
|
|
@@ -3,6 +3,7 @@ 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 {
|
|
@@ -109,6 +110,18 @@ export const buildThreadMessageMoveRevert = (
|
|
|
109
110
|
composites: currentComposites(threadMessage),
|
|
110
111
|
});
|
|
111
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
|
+
|
|
112
125
|
export interface MessageDeleteDeps {
|
|
113
126
|
getClient: typeof getClient;
|
|
114
127
|
buildLifecycleDeps: typeof buildLifecycleDeps;
|
|
@@ -172,6 +185,75 @@ export const handleMessageDelete = async (
|
|
|
172
185
|
return;
|
|
173
186
|
}
|
|
174
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
|
+
|
|
175
257
|
await withOAuthLifecycle(
|
|
176
258
|
buildLifecycleDeps(secrets, accountService),
|
|
177
259
|
account,
|
|
@@ -223,43 +305,6 @@ export const handleMessageDelete = async (
|
|
|
223
305
|
);
|
|
224
306
|
await connection.openBox(mailboxPath, false);
|
|
225
307
|
|
|
226
|
-
// Only an operation that explicitly says so destroys mail. The
|
|
227
|
-
// event is `JSON.parse`d and cast in the queue handler with no
|
|
228
|
-
// validation, so a missing, misspelled or future `operation` must
|
|
229
|
-
// abandon the delete — the "anything that is not move_to_trash is
|
|
230
|
-
// an expunge" inference is the same one that destroyed mail in the
|
|
231
|
-
// service, and an unrecoverable EXPUNGE is not a default.
|
|
232
|
-
const abandonDelete = async (
|
|
233
|
-
reason: string,
|
|
234
|
-
alert: string,
|
|
235
|
-
): Promise<void> => {
|
|
236
|
-
log.error(
|
|
237
|
-
{ alert, accountId, messageId, uid, mailboxPath, operation },
|
|
238
|
-
reason,
|
|
239
|
-
);
|
|
240
|
-
await messageService.updateUid(messageId, uid, mailboxId);
|
|
241
|
-
await messageService.update(messageId, {
|
|
242
|
-
status: MessageStatus.active,
|
|
243
|
-
syncStatus: MessageSyncStatus.failed,
|
|
244
|
-
});
|
|
245
|
-
const threadMessage = await threadMessageService.findByMessageId(
|
|
246
|
-
account.accountConfigId,
|
|
247
|
-
messageId,
|
|
248
|
-
);
|
|
249
|
-
if (!threadMessage) return;
|
|
250
|
-
const args = buildThreadMessageMoveRevert(
|
|
251
|
-
threadMessage,
|
|
252
|
-
uid,
|
|
253
|
-
mailboxId,
|
|
254
|
-
);
|
|
255
|
-
await threadMessageService.update(
|
|
256
|
-
threadMessage.accountConfigId,
|
|
257
|
-
threadMessage.threadMessageId,
|
|
258
|
-
args.set,
|
|
259
|
-
{ composites: args.composites },
|
|
260
|
-
);
|
|
261
|
-
};
|
|
262
|
-
|
|
263
308
|
if (
|
|
264
309
|
operation !== "move_to_trash" &&
|
|
265
310
|
operation !== "permanent_delete"
|
|
@@ -398,16 +443,16 @@ export const handleMessageDelete = async (
|
|
|
398
443
|
return;
|
|
399
444
|
}
|
|
400
445
|
|
|
401
|
-
// 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.
|
|
402
450
|
if (errorMessage.includes("TRYCREATE") && destinationMailboxPath) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
"
|
|
451
|
+
await abandonDelete(
|
|
452
|
+
"Refused to delete: the destination mailbox does not exist on the server",
|
|
453
|
+
"message_delete_destination_missing",
|
|
406
454
|
);
|
|
407
|
-
|
|
408
|
-
await connection.createMailbox(destinationMailboxPath);
|
|
409
|
-
// Re-throw to let the event be retried
|
|
410
|
-
throw error;
|
|
455
|
+
return;
|
|
411
456
|
}
|
|
412
457
|
|
|
413
458
|
// Mark as failed for other errors
|