@remit/imap-worker 0.0.86 → 0.0.87
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/README.md +3 -1
- package/package.json +10 -2
- package/src/account-check.ts +1 -1
- package/src/body-sync-gate.test.ts +1 -1
- package/src/body-sync-gate.ts +1 -1
- package/src/e2e-processor-shim.ts +6 -21
- package/src/emit.ts +1 -1
- package/src/failure-kind.ts +1 -1
- package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
- package/src/handlers/append-sent-message.ts +3 -24
- package/src/handlers/delete-account-objects.test.ts +1 -1
- package/src/handlers/delete-account-objects.ts +1 -1
- package/src/handlers/empty-trash.sqlite.test.ts +209 -0
- package/src/handlers/empty-trash.ts +3 -24
- package/src/handlers/flag-push.ts +11 -18
- package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
- package/src/handlers/mailbox-management.ts +12 -48
- package/src/handlers/message-copy.sqlite.test.ts +298 -0
- package/src/handlers/message-copy.ts +31 -49
- package/src/handlers/message-delete-terminal.ts +12 -10
- package/src/handlers/message-delete.sqlite.test.ts +291 -0
- package/src/handlers/message-delete.ts +26 -32
- package/src/handlers/message-move-terminal.ts +12 -10
- package/src/handlers/message-move.sqlite.test.ts +271 -0
- package/src/handlers/message-move.test.ts +76 -863
- package/src/handlers/message-move.ts +49 -39
- package/src/handlers/placement-move-push.test.ts +96 -329
- package/src/handlers/placement-move-push.ts +75 -29
- package/src/handlers/placement-push.sqlite.test.ts +298 -0
- package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
- package/src/handlers/probe-message-id.ts +15 -0
- package/src/handlers/sync-mailboxes.test.ts +0 -1
- package/src/handlers/sync-mailboxes.ts +1 -1
- package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
- package/src/handlers/sync-message-body.test.ts +3 -299
- package/src/handlers/sync-message-body.ts +21 -11
- package/src/handlers/sync-messages.sqlite.test.ts +183 -0
- package/src/handlers/sync-messages.ts +27 -33
- package/src/index.ts +5 -3
- package/src/mail-sync-selection.sqlite.test.ts +16 -18
- package/src/poller.ts +5 -4
- package/src/processor.test.ts +1 -1
- package/src/processor.ts +1 -1
- package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
- package/src/scheduler/attachment-sweep.ts +1 -1
- package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
- package/src/scheduler/calendar-subscriptions.ts +6 -6
- package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
- package/src/scheduler/handler.ts +12 -1
- package/src/scheduler/loop.test.ts +2 -0
- package/src/scheduler/run-tick.sqlite.test.ts +192 -0
- package/src/scheduler/run-tick.ts +39 -1
- package/src/scheduler/runner.ts +21 -1
- package/src/scheduler/stranded-placement-redrive.ts +265 -0
- package/src/stranded-placement.sqlite.test.ts +795 -0
- package/src/test-helpers/test-world.ts +398 -0
- package/src/thread-join.sqlite.test.ts +115 -0
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
- package/src/with-oauth-lifecycle.ts +1 -1
- package/src/handlers/append-sent-message.test.ts +0 -529
- package/src/handlers/empty-trash.test.ts +0 -728
- package/src/handlers/flag-push-placement-race.test.ts +0 -385
- package/src/handlers/flag-push.test.ts +0 -325
- package/src/handlers/mailbox-management.test.ts +0 -850
- package/src/handlers/message-copy.test.ts +0 -654
- package/src/handlers/message-delete.test.ts +0 -1869
- package/src/handlers/message-move-terminal.test.ts +0 -412
- package/src/handlers/sync-messages.test.ts +0 -569
- package/src/scheduler/attachment-sweep.test.ts +0 -169
- package/src/scheduler/run-tick.test.ts +0 -478
- package/src/with-oauth-lifecycle.test.ts +0 -318
|
@@ -1,728 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { beforeEach, describe, it } from "node:test";
|
|
3
|
-
import type { RoleResolution } from "@remit/data-ports/folder-role";
|
|
4
|
-
import { noopLogger } from "@remit/logger-lambda/noop-logger";
|
|
5
|
-
import type { EmptyTrashEvent } from "../events.js";
|
|
6
|
-
import { type EmptyTrashDeps, handleEmptyTrash } from "./empty-trash.js";
|
|
7
|
-
|
|
8
|
-
interface Call {
|
|
9
|
-
method: string;
|
|
10
|
-
args: unknown[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
interface Connection {
|
|
14
|
-
openBox: (
|
|
15
|
-
path: string,
|
|
16
|
-
readOnly?: boolean,
|
|
17
|
-
) => Promise<{ uidvalidity: number }>;
|
|
18
|
-
search: (criteria: string[]) => Promise<number[]>;
|
|
19
|
-
deleteMessages: (uids: number[]) => Promise<void>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface LocalMessage {
|
|
23
|
-
messageId: string;
|
|
24
|
-
uid: number;
|
|
25
|
-
status: string;
|
|
26
|
-
syncStatus?: string;
|
|
27
|
-
mailboxId?: string;
|
|
28
|
-
originalMailboxId?: string;
|
|
29
|
-
originalUid?: number;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
type TrashMailbox = { mailboxId: string; fullPath: string };
|
|
33
|
-
|
|
34
|
-
interface Harness {
|
|
35
|
-
calls: Call[];
|
|
36
|
-
account: {
|
|
37
|
-
accountId: string;
|
|
38
|
-
accountConfigId: string;
|
|
39
|
-
deletedAt?: number;
|
|
40
|
-
} | null;
|
|
41
|
-
mailbox: {
|
|
42
|
-
mailboxId: string;
|
|
43
|
-
fullPath: string;
|
|
44
|
-
uidValidity: number;
|
|
45
|
-
cursorState?: string;
|
|
46
|
-
};
|
|
47
|
-
mailboxError?: Error;
|
|
48
|
-
trashResolution: RoleResolution<TrashMailbox>;
|
|
49
|
-
connection: Connection;
|
|
50
|
-
localMessages: LocalMessage[];
|
|
51
|
-
threadMessage: boolean;
|
|
52
|
-
messagesWithoutListingRow: string[];
|
|
53
|
-
/** Message ids whose placement another lane has changed under the sweep. */
|
|
54
|
-
transitionsLost: string[];
|
|
55
|
-
getConnectionCount: number;
|
|
56
|
-
disconnectCount: number;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
let h: Harness;
|
|
60
|
-
|
|
61
|
-
const record =
|
|
62
|
-
(method: string) =>
|
|
63
|
-
async (...args: unknown[]) => {
|
|
64
|
-
h.calls.push({ method, args });
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const buildConnection = (): Connection => ({
|
|
68
|
-
openBox: async () => ({ uidvalidity: 1 }),
|
|
69
|
-
search: async () => [10, 11],
|
|
70
|
-
deleteMessages: record(
|
|
71
|
-
"connection.deleteMessages",
|
|
72
|
-
) as Connection["deleteMessages"],
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
const deleting = (messageId: string, uid: number): LocalMessage => ({
|
|
76
|
-
messageId,
|
|
77
|
-
uid,
|
|
78
|
-
status: "deleting",
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
/** Mid-move into Trash: the folder is written, the uid is still the source's. */
|
|
82
|
-
const movingIntoTrash = (messageId: string, uid: number): LocalMessage => ({
|
|
83
|
-
messageId,
|
|
84
|
-
uid,
|
|
85
|
-
status: "moving",
|
|
86
|
-
syncStatus: "pending",
|
|
87
|
-
mailboxId: "trash-mbx",
|
|
88
|
-
originalMailboxId: "inbox-mbx",
|
|
89
|
-
originalUid: uid,
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
/** The same row between a failed attempt and its redelivery. */
|
|
93
|
-
const retryingIntoTrash = (messageId: string, uid: number): LocalMessage => ({
|
|
94
|
-
...movingIntoTrash(messageId, uid),
|
|
95
|
-
syncStatus: "failed",
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* A row whose move into Trash SETTLED, on a database written before #1217
|
|
100
|
-
* taught `updateUid` to clear `originalUid`. Two folders count uids
|
|
101
|
-
* independently, so Trash handing back the source's own number is ordinary —
|
|
102
|
-
* and the pair it leaves is indistinguishable from a move still in flight.
|
|
103
|
-
*/
|
|
104
|
-
const settledWithStaleOriginalUid = (
|
|
105
|
-
messageId: string,
|
|
106
|
-
uid: number,
|
|
107
|
-
): LocalMessage => ({
|
|
108
|
-
messageId,
|
|
109
|
-
uid,
|
|
110
|
-
status: "deleting",
|
|
111
|
-
syncStatus: "pending",
|
|
112
|
-
mailboxId: "trash-mbx",
|
|
113
|
-
originalMailboxId: "inbox-mbx",
|
|
114
|
-
originalUid: uid,
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
/** The same row after an empty marked the folder, `status` overwritten. */
|
|
118
|
-
const markedMidMove = (messageId: string, uid: number): LocalMessage => ({
|
|
119
|
-
...movingIntoTrash(messageId, uid),
|
|
120
|
-
status: "deleting",
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
const fresh = (): Harness => ({
|
|
124
|
-
calls: [],
|
|
125
|
-
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
126
|
-
mailbox: {
|
|
127
|
-
mailboxId: "trash-mbx",
|
|
128
|
-
fullPath: "Trash",
|
|
129
|
-
uidValidity: 1,
|
|
130
|
-
cursorState: undefined,
|
|
131
|
-
},
|
|
132
|
-
trashResolution: {
|
|
133
|
-
kind: "flagged",
|
|
134
|
-
mailbox: { mailboxId: "trash-mbx", fullPath: "Trash" },
|
|
135
|
-
},
|
|
136
|
-
connection: buildConnection(),
|
|
137
|
-
localMessages: [deleting("msg-1", 10), deleting("msg-2", 11)],
|
|
138
|
-
threadMessage: true,
|
|
139
|
-
messagesWithoutListingRow: [],
|
|
140
|
-
transitionsLost: [],
|
|
141
|
-
getConnectionCount: 0,
|
|
142
|
-
disconnectCount: 0,
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
// Empty Trash only flips `isDeleted` on the listing row, so its presence is how
|
|
146
|
-
// the revert tells its own marks from a permanent delete's (which removes them
|
|
147
|
-
// up front).
|
|
148
|
-
const listingRow = (messageId: string) =>
|
|
149
|
-
h.messagesWithoutListingRow.includes(messageId) || !h.threadMessage
|
|
150
|
-
? null
|
|
151
|
-
: {
|
|
152
|
-
accountConfigId: "cfg-1",
|
|
153
|
-
threadMessageId: `tm-${messageId}`,
|
|
154
|
-
sentDate: 1_700_000_000_000,
|
|
155
|
-
mailboxId: "trash-mbx",
|
|
156
|
-
isRead: false,
|
|
157
|
-
isDeleted: true,
|
|
158
|
-
hasStars: false,
|
|
159
|
-
hasAttachment: false,
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
const deps = (): EmptyTrashDeps =>
|
|
163
|
-
({
|
|
164
|
-
getClient: async () => ({
|
|
165
|
-
account: {
|
|
166
|
-
get: async (accountId: string) => {
|
|
167
|
-
h.calls.push({ method: "account.get", args: [accountId] });
|
|
168
|
-
return h.account;
|
|
169
|
-
},
|
|
170
|
-
},
|
|
171
|
-
message: {
|
|
172
|
-
listAllByMailbox: async () => h.localMessages,
|
|
173
|
-
delete: record("message.delete"),
|
|
174
|
-
update: record("message.update"),
|
|
175
|
-
transitionPlacement: async (
|
|
176
|
-
messageId: string,
|
|
177
|
-
expected: unknown,
|
|
178
|
-
next: unknown,
|
|
179
|
-
) => {
|
|
180
|
-
const won = !h.transitionsLost.includes(messageId);
|
|
181
|
-
h.calls.push({
|
|
182
|
-
method: "message.transitionPlacement",
|
|
183
|
-
args: [messageId, expected, next, won],
|
|
184
|
-
});
|
|
185
|
-
return won ? { messageId } : undefined;
|
|
186
|
-
},
|
|
187
|
-
},
|
|
188
|
-
threadMessage: {
|
|
189
|
-
findByMessageId: async (_cfg: string, messageId: string) =>
|
|
190
|
-
listingRow(messageId),
|
|
191
|
-
findAllByMessageId: async (_cfg: string, messageId: string) => {
|
|
192
|
-
const row = listingRow(messageId);
|
|
193
|
-
return row ? [row] : [];
|
|
194
|
-
},
|
|
195
|
-
delete: record("threadMessage.delete"),
|
|
196
|
-
update: record("threadMessage.update"),
|
|
197
|
-
},
|
|
198
|
-
mailbox: {
|
|
199
|
-
get: async () => {
|
|
200
|
-
if (h.mailboxError) throw h.mailboxError;
|
|
201
|
-
return h.mailbox;
|
|
202
|
-
},
|
|
203
|
-
update: record("mailbox.update"),
|
|
204
|
-
},
|
|
205
|
-
mailboxSpecialUse: {
|
|
206
|
-
resolveTrashRole: async () => h.trashResolution,
|
|
207
|
-
},
|
|
208
|
-
secrets: {},
|
|
209
|
-
}),
|
|
210
|
-
buildLifecycleDeps: () => ({}),
|
|
211
|
-
withOAuthLifecycle: async (
|
|
212
|
-
_deps: unknown,
|
|
213
|
-
_account: unknown,
|
|
214
|
-
_log: unknown,
|
|
215
|
-
cb: (credentials: unknown) => Promise<void>,
|
|
216
|
-
) => cb({}),
|
|
217
|
-
createConnectionScope: () => ({
|
|
218
|
-
getConnection: async () => {
|
|
219
|
-
h.getConnectionCount += 1;
|
|
220
|
-
return h.connection;
|
|
221
|
-
},
|
|
222
|
-
disconnect: async () => {
|
|
223
|
-
h.disconnectCount += 1;
|
|
224
|
-
},
|
|
225
|
-
}),
|
|
226
|
-
}) as unknown as EmptyTrashDeps;
|
|
227
|
-
|
|
228
|
-
const event: EmptyTrashEvent = {
|
|
229
|
-
type: "EMPTY_TRASH",
|
|
230
|
-
schemaVersion: 2,
|
|
231
|
-
accountId: "acc-1",
|
|
232
|
-
trashMailboxId: "trash-mbx",
|
|
233
|
-
trashMailboxPath: "Trash",
|
|
234
|
-
trashUidValidity: 1,
|
|
235
|
-
} as EmptyTrashEvent;
|
|
236
|
-
|
|
237
|
-
const called = (method: string): Call[] =>
|
|
238
|
-
h.calls.filter((c) => c.method === method);
|
|
239
|
-
|
|
240
|
-
// Only a transition that WON reverted anything; a lost predicate is an offered
|
|
241
|
-
// write that the row refused.
|
|
242
|
-
const revertedMessageIds = (): string[] =>
|
|
243
|
-
called("message.transitionPlacement")
|
|
244
|
-
.filter(
|
|
245
|
-
(c) =>
|
|
246
|
-
c.args[3] === true &&
|
|
247
|
-
(c.args[2] as { status?: string; syncStatus?: string }).status ===
|
|
248
|
-
"active" &&
|
|
249
|
-
(c.args[2] as { syncStatus?: string }).syncStatus === "synced",
|
|
250
|
-
)
|
|
251
|
-
.map((c) => c.args[0] as string);
|
|
252
|
-
|
|
253
|
-
const undeletedThreadMessageIds = (): string[] =>
|
|
254
|
-
called("threadMessage.update")
|
|
255
|
-
.filter((c) => (c.args[2] as { isDeleted?: boolean }).isDeleted === false)
|
|
256
|
-
.map((c) => c.args[1] as string);
|
|
257
|
-
|
|
258
|
-
describe("handleEmptyTrash", () => {
|
|
259
|
-
beforeEach(() => {
|
|
260
|
-
h = fresh();
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("expunges every server uid and both local rows for each trashed message", async () => {
|
|
264
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
265
|
-
|
|
266
|
-
assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10, 11]]);
|
|
267
|
-
assert.deepEqual(
|
|
268
|
-
called("message.delete").map((c) => c.args[0]),
|
|
269
|
-
["msg-1", "msg-2"],
|
|
270
|
-
);
|
|
271
|
-
assert.equal(called("threadMessage.delete").length, 2);
|
|
272
|
-
assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it("keeps the local row for a uid the expunge never covered", async () => {
|
|
276
|
-
// Mail that reached Trash after the SEARCH — or a move the unordered dev
|
|
277
|
-
// queue let outrun this event — is still on the server, so deleting its
|
|
278
|
-
// rows would hide mail the user can still see in another client.
|
|
279
|
-
h.localMessages = [
|
|
280
|
-
deleting("msg-1", 10),
|
|
281
|
-
deleting("msg-2", 11),
|
|
282
|
-
{ messageId: "msg-late", uid: 12, status: "active" },
|
|
283
|
-
];
|
|
284
|
-
|
|
285
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
286
|
-
|
|
287
|
-
assert.deepEqual(
|
|
288
|
-
called("message.delete").map((c) => c.args[0]),
|
|
289
|
-
["msg-1", "msg-2"],
|
|
290
|
-
);
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
it("hands every row back when another client emptied the trash first", async () => {
|
|
294
|
-
// Apple Mail got there first, so the SEARCH is empty and this expunge
|
|
295
|
-
// covers nothing. Leaving the rows `deleting` hides mail that no longer
|
|
296
|
-
// exists anywhere, with nothing left to clear the mark.
|
|
297
|
-
h.connection.search = async () => [];
|
|
298
|
-
|
|
299
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
300
|
-
|
|
301
|
-
assert.equal(called("connection.deleteMessages").length, 0);
|
|
302
|
-
assert.equal(called("message.delete").length, 0);
|
|
303
|
-
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
304
|
-
assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-1", "tm-msg-2"]);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it("hands back what a partial sweep left when the event is redelivered", async () => {
|
|
308
|
-
// The first attempt expunged and cleaned up msg-1, then died before
|
|
309
|
-
// msg-2. On redelivery the server has nothing left to find, and msg-2
|
|
310
|
-
// would otherwise sit marked for a deletion that will never come.
|
|
311
|
-
h.localMessages = [deleting("msg-2", 11)];
|
|
312
|
-
h.connection.search = async () => [];
|
|
313
|
-
|
|
314
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
315
|
-
|
|
316
|
-
assert.equal(called("message.delete").length, 0);
|
|
317
|
-
assert.deepEqual(revertedMessageIds(), ["msg-2"]);
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
it("leaves a row whose listing rows another operation already removed", async () => {
|
|
321
|
-
// A permanent delete inside Trash removes its listing rows up front and
|
|
322
|
-
// marks the Message `deleting`. Reverting it here would resurrect a row
|
|
323
|
-
// that operation is about to remove.
|
|
324
|
-
h.localMessages = [deleting("msg-1", 10), deleting("msg-expunging", 12)];
|
|
325
|
-
h.messagesWithoutListingRow = ["msg-expunging"];
|
|
326
|
-
h.connection.search = async () => [];
|
|
327
|
-
|
|
328
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
329
|
-
|
|
330
|
-
assert.deepEqual(revertedMessageIds(), ["msg-1"]);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it("deletes the message even when it has no thread row", async () => {
|
|
334
|
-
h.threadMessage = false;
|
|
335
|
-
|
|
336
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
337
|
-
|
|
338
|
-
assert.equal(called("message.delete").length, 2);
|
|
339
|
-
assert.equal(called("threadMessage.delete").length, 0);
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it("returns early without connecting when the account is soft-deleted", async () => {
|
|
343
|
-
h.account = {
|
|
344
|
-
accountId: "acc-1",
|
|
345
|
-
accountConfigId: "cfg-1",
|
|
346
|
-
deletedAt: Date.now(),
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
350
|
-
|
|
351
|
-
assert.equal(h.getConnectionCount, 0);
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
it("throws when the account no longer exists", async () => {
|
|
355
|
-
h.account = null;
|
|
356
|
-
|
|
357
|
-
await assert.rejects(
|
|
358
|
-
handleEmptyTrash(event, noopLogger, deps()),
|
|
359
|
-
/not found/,
|
|
360
|
-
);
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
it("acks terminally without connecting when the Trash mailbox was deleted", async () => {
|
|
364
|
-
h.mailboxError = Object.assign(new Error("Mailbox not found: trash-mbx"), {
|
|
365
|
-
name: "NotFoundError",
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
369
|
-
|
|
370
|
-
assert.equal(h.getConnectionCount, 0);
|
|
371
|
-
assert.equal(called("message.delete").length, 0);
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
it("abandons and reverts an event minted under an unknown contract", async () => {
|
|
375
|
-
const unversioned = {
|
|
376
|
-
type: "EMPTY_TRASH",
|
|
377
|
-
accountId: "acc-1",
|
|
378
|
-
trashMailboxId: "trash-mbx",
|
|
379
|
-
trashMailboxPath: "Trash",
|
|
380
|
-
} as unknown as EmptyTrashEvent;
|
|
381
|
-
|
|
382
|
-
await handleEmptyTrash(unversioned, noopLogger, deps());
|
|
383
|
-
|
|
384
|
-
assert.equal(h.getConnectionCount, 0, "no connection is ever opened");
|
|
385
|
-
assert.equal(called("connection.deleteMessages").length, 0);
|
|
386
|
-
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
387
|
-
assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-1", "tm-msg-2"]);
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
it("abandons when the Trash role now names a different folder", async () => {
|
|
391
|
-
h.trashResolution = {
|
|
392
|
-
kind: "appointed",
|
|
393
|
-
mailbox: { mailboxId: "other-mbx", fullPath: "INBOX/Bak" },
|
|
394
|
-
};
|
|
395
|
-
|
|
396
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
397
|
-
|
|
398
|
-
assert.equal(h.getConnectionCount, 0);
|
|
399
|
-
assert.equal(called("connection.deleteMessages").length, 0);
|
|
400
|
-
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it("abandons when the Trash role no longer rests on confirmed evidence", async () => {
|
|
404
|
-
h.trashResolution = {
|
|
405
|
-
kind: "proposed",
|
|
406
|
-
mailbox: { mailboxId: "trash-mbx", fullPath: "Trash" },
|
|
407
|
-
};
|
|
408
|
-
|
|
409
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
410
|
-
|
|
411
|
-
assert.equal(called("connection.deleteMessages").length, 0);
|
|
412
|
-
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
it("refuses the expunge when the served UIDVALIDITY is not the one consented to", async () => {
|
|
416
|
-
// The path was reused: a third-party client renamed Trash away and made a
|
|
417
|
-
// fresh one. Same path, different folder, and nobody consented to empty it.
|
|
418
|
-
h.connection.openBox = async () => ({ uidvalidity: 77 });
|
|
419
|
-
h.mailbox = { mailboxId: "trash-mbx", fullPath: "Trash", uidValidity: 77 };
|
|
420
|
-
|
|
421
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
422
|
-
|
|
423
|
-
assert.equal(called("connection.deleteMessages").length, 0);
|
|
424
|
-
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
425
|
-
assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-1", "tm-msg-2"]);
|
|
426
|
-
assert.equal(h.disconnectCount, 1);
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
it("reverts only the rows this empty marked, never a freshly synced one", async () => {
|
|
430
|
-
h.localMessages = [
|
|
431
|
-
deleting("msg-1", 10),
|
|
432
|
-
{ messageId: "msg-arrived", uid: 12, status: "active" },
|
|
433
|
-
];
|
|
434
|
-
h.connection.openBox = async () => ({ uidvalidity: 77 });
|
|
435
|
-
h.mailbox = { mailboxId: "trash-mbx", fullPath: "Trash", uidValidity: 77 };
|
|
436
|
-
|
|
437
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
438
|
-
|
|
439
|
-
assert.deepEqual(revertedMessageIds(), ["msg-1"]);
|
|
440
|
-
assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-1"]);
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
it("reverts the marks when openBox trips a UIDVALIDITY mismatch", async () => {
|
|
444
|
-
// The event is acked and nothing re-issues it, so leaving the folder
|
|
445
|
-
// marked `deleting` hides healthy mail until the user notices.
|
|
446
|
-
h.connection.openBox = async () => ({ uidvalidity: 999 });
|
|
447
|
-
|
|
448
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
449
|
-
|
|
450
|
-
assert.equal(
|
|
451
|
-
(called("mailbox.update")[0]?.args[2] as { cursorState?: string })
|
|
452
|
-
?.cursorState,
|
|
453
|
-
"cursor_invalid",
|
|
454
|
-
);
|
|
455
|
-
assert.equal(called("message.delete").length, 0);
|
|
456
|
-
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
457
|
-
assert.equal(h.disconnectCount, 1);
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
it("reverts the marks without connecting when the cursor is rebuilding", async () => {
|
|
461
|
-
h.mailbox = {
|
|
462
|
-
mailboxId: "trash-mbx",
|
|
463
|
-
fullPath: "Trash",
|
|
464
|
-
uidValidity: 1,
|
|
465
|
-
cursorState: "rebuilding",
|
|
466
|
-
};
|
|
467
|
-
|
|
468
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
469
|
-
|
|
470
|
-
assert.equal(h.getConnectionCount, 0);
|
|
471
|
-
assert.equal(called("message.delete").length, 0);
|
|
472
|
-
assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
it("rethrows an unclassified IMAP error so the event is retried", async () => {
|
|
476
|
-
h.connection.search = async () => {
|
|
477
|
-
throw new Error("server exploded");
|
|
478
|
-
};
|
|
479
|
-
|
|
480
|
-
await assert.rejects(
|
|
481
|
-
handleEmptyTrash(event, noopLogger, deps()),
|
|
482
|
-
/server exploded/,
|
|
483
|
-
);
|
|
484
|
-
|
|
485
|
-
assert.equal(h.disconnectCount, 1);
|
|
486
|
-
});
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* Issue #1217. The sweep matched every row the Trash listing returned against
|
|
491
|
-
* the expunged uids. A row whose move into Trash has not settled carries the
|
|
492
|
-
* SOURCE folder's uid, so that match answers for whatever Trash holds at that
|
|
493
|
-
* uid — a different message, whose Message and thread rows the sweep then
|
|
494
|
-
* deletes while the mail it named is still sitting in the folder it never left.
|
|
495
|
-
*/
|
|
496
|
-
describe("handleEmptyTrash and an unsettled placement", () => {
|
|
497
|
-
beforeEach(() => {
|
|
498
|
-
h = fresh();
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
it("deletes no row of a message whose move into Trash has not settled", async () => {
|
|
502
|
-
h.localMessages = [
|
|
503
|
-
deleting("msg-1", 10),
|
|
504
|
-
movingIntoTrash("msg-moving", 10),
|
|
505
|
-
];
|
|
506
|
-
|
|
507
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
508
|
-
|
|
509
|
-
assert.deepEqual(
|
|
510
|
-
called("message.delete").map((c) => c.args[0]),
|
|
511
|
-
["msg-1"],
|
|
512
|
-
"uid 10 is msg-1's; the in-flight row only borrows it from the inbox",
|
|
513
|
-
);
|
|
514
|
-
assert.equal(called("threadMessage.delete").length, 1);
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
it("leaves a row whose move is mid-retry rather than sweeping it", async () => {
|
|
518
|
-
// `syncStatus: failed` under `status: moving` is an ordinary transient
|
|
519
|
-
// attempt about to be redelivered (`data-ports/message-settlement.ts`),
|
|
520
|
-
// not a give-up. Either way the pair is a lie while it stands, and the
|
|
521
|
-
// uid is somebody else's.
|
|
522
|
-
h.localMessages = [retryingIntoTrash("msg-retrying", 11)];
|
|
523
|
-
|
|
524
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
525
|
-
|
|
526
|
-
assert.equal(called("message.delete").length, 0);
|
|
527
|
-
assert.equal(called("threadMessage.delete").length, 0);
|
|
528
|
-
assert.deepEqual(revertedMessageIds(), []);
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
it("hands back, rather than binds, a mid-move row an earlier mark rewrote", async () => {
|
|
532
|
-
// Defence in depth for the shape `emptyTrash` used to write: the mark
|
|
533
|
-
// overwrote `status` and the row reached here reading settled while its
|
|
534
|
-
// uid still belonged to the inbox. The uid is not bound — but the mark is
|
|
535
|
-
// cleared, because nothing else is coming to clear it and a row left
|
|
536
|
-
// `deleting` is hidden from every listing for good.
|
|
537
|
-
h.localMessages = [deleting("msg-1", 10), markedMidMove("msg-marked", 11)];
|
|
538
|
-
|
|
539
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
540
|
-
|
|
541
|
-
assert.deepEqual(
|
|
542
|
-
called("message.delete").map((c) => c.args[0]),
|
|
543
|
-
["msg-1"],
|
|
544
|
-
);
|
|
545
|
-
assert.deepEqual(revertedMessageIds(), ["msg-marked"]);
|
|
546
|
-
assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-marked"]);
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
it("hands nothing back for a row another lane settled after the sweep read it", async () => {
|
|
550
|
-
// The hand-back is a transition off the row as this sweep read it
|
|
551
|
-
// (imap-mutations R3). PLACEMENT_MOVE_PUSH rides a standard queue that
|
|
552
|
-
// this account's FIFO group does not order, so it can settle a row
|
|
553
|
-
// mid-sweep; writing `active` + `synced` over that would declare a
|
|
554
|
-
// mutation settled that nobody confirmed, and take the listing row with
|
|
555
|
-
// it.
|
|
556
|
-
h.localMessages = [deleting("msg-1", 10), markedMidMove("msg-raced", 11)];
|
|
557
|
-
h.transitionsLost = ["msg-raced"];
|
|
558
|
-
|
|
559
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
560
|
-
|
|
561
|
-
assert.deepEqual(
|
|
562
|
-
called("message.transitionPlacement").map((c) => c.args[0]),
|
|
563
|
-
["msg-raced"],
|
|
564
|
-
"the predicate was still offered — the row just lost it",
|
|
565
|
-
);
|
|
566
|
-
assert.deepEqual(revertedMessageIds(), []);
|
|
567
|
-
assert.deepEqual(
|
|
568
|
-
undeletedThreadMessageIds(),
|
|
569
|
-
[],
|
|
570
|
-
"a lost predicate writes nothing at all, listing rows included",
|
|
571
|
-
);
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
it("predicates the hand-back on the placement the sweep actually read", async () => {
|
|
575
|
-
h.localMessages = [markedMidMove("msg-marked", 11)];
|
|
576
|
-
|
|
577
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
578
|
-
|
|
579
|
-
const [call] = called("message.transitionPlacement");
|
|
580
|
-
assert.deepEqual(call?.args[1], {
|
|
581
|
-
status: "deleting",
|
|
582
|
-
mailboxId: "trash-mbx",
|
|
583
|
-
uid: 11,
|
|
584
|
-
});
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
// Issue #1230. The exclusion used to run after `deleteMessages`, so a row
|
|
588
|
-
// the sweep refused had already lost its server copy: mail that no longer
|
|
589
|
-
// exists, visible in Trash, with nothing able to clear it — pressing Empty
|
|
590
|
-
// Trash again skips it too, because the server no longer lists the uid.
|
|
591
|
-
it("never expunges a uid whose row it will refuse to remove", async () => {
|
|
592
|
-
h.localMessages = [
|
|
593
|
-
deleting("msg-1", 10),
|
|
594
|
-
settledWithStaleOriginalUid("msg-stale", 11),
|
|
595
|
-
];
|
|
596
|
-
|
|
597
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
598
|
-
|
|
599
|
-
assert.deepEqual(
|
|
600
|
-
called("connection.deleteMessages")[0]?.args,
|
|
601
|
-
[[10]],
|
|
602
|
-
"uid 11 survives on the server, because its row survives locally",
|
|
603
|
-
);
|
|
604
|
-
assert.deepEqual(
|
|
605
|
-
called("message.delete").map((c) => c.args[0]),
|
|
606
|
-
["msg-1"],
|
|
607
|
-
);
|
|
608
|
-
assert.deepEqual(
|
|
609
|
-
revertedMessageIds(),
|
|
610
|
-
["msg-stale"],
|
|
611
|
-
"and the refused row is handed back rather than left marked",
|
|
612
|
-
);
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
it("issues no expunge at all when every uid in the folder is refused", async () => {
|
|
616
|
-
h.localMessages = [
|
|
617
|
-
settledWithStaleOriginalUid("msg-stale-a", 10),
|
|
618
|
-
settledWithStaleOriginalUid("msg-stale-b", 11),
|
|
619
|
-
];
|
|
620
|
-
|
|
621
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
622
|
-
|
|
623
|
-
assert.equal(called("connection.deleteMessages").length, 0);
|
|
624
|
-
assert.equal(called("message.delete").length, 0);
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
it("sweeps a settled row whose Trash uid matches the one it left behind", async () => {
|
|
628
|
-
// Two folders count uids independently, so a move can land on the same
|
|
629
|
-
// number it started from. `updateUid` drops `originalUid` when it settles,
|
|
630
|
-
// which is what keeps this row apart from one still mid-move — without it
|
|
631
|
-
// the sweep would expunge the message on the server and then refuse its
|
|
632
|
-
// rows, leaving mail that no longer exists hidden in Trash for good.
|
|
633
|
-
h.localMessages = [
|
|
634
|
-
{
|
|
635
|
-
messageId: "msg-collided",
|
|
636
|
-
uid: 10,
|
|
637
|
-
status: "deleting",
|
|
638
|
-
syncStatus: "pending",
|
|
639
|
-
mailboxId: "trash-mbx",
|
|
640
|
-
originalMailboxId: "inbox-mbx",
|
|
641
|
-
},
|
|
642
|
-
];
|
|
643
|
-
|
|
644
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
645
|
-
|
|
646
|
-
assert.deepEqual(
|
|
647
|
-
called("message.delete").map((c) => c.args[0]),
|
|
648
|
-
["msg-collided"],
|
|
649
|
-
);
|
|
650
|
-
assert.deepEqual(revertedMessageIds(), []);
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
it("sweeps a settled row that reached Trash by a move", async () => {
|
|
654
|
-
// The confirmed pair: `updateUid` wrote Trash's own uid over the
|
|
655
|
-
// source's, leaving `originalUid` behind as history. Refusing this would
|
|
656
|
-
// leave the folder marked for a deletion that already happened.
|
|
657
|
-
h.localMessages = [
|
|
658
|
-
{
|
|
659
|
-
messageId: "msg-settled",
|
|
660
|
-
uid: 11,
|
|
661
|
-
status: "deleting",
|
|
662
|
-
syncStatus: "pending",
|
|
663
|
-
mailboxId: "trash-mbx",
|
|
664
|
-
originalMailboxId: "inbox-mbx",
|
|
665
|
-
originalUid: 42,
|
|
666
|
-
},
|
|
667
|
-
];
|
|
668
|
-
|
|
669
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
670
|
-
|
|
671
|
-
assert.deepEqual(
|
|
672
|
-
called("message.delete").map((c) => c.args[0]),
|
|
673
|
-
["msg-settled"],
|
|
674
|
-
);
|
|
675
|
-
});
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
describe("handleEmptyTrash — the Trash path comes from its mailbox row", () => {
|
|
679
|
-
beforeEach(() => {
|
|
680
|
-
h = fresh();
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
const openedPaths = (): string[] => {
|
|
684
|
-
const opened: string[] = [];
|
|
685
|
-
h.connection.openBox = async (path: string) => {
|
|
686
|
-
opened.push(path);
|
|
687
|
-
return { uidvalidity: 1 };
|
|
688
|
-
};
|
|
689
|
-
return opened;
|
|
690
|
-
};
|
|
691
|
-
|
|
692
|
-
it("runs an empty enqueued with the version 2 payload that carries the folder path", async () => {
|
|
693
|
-
const opened = openedPaths();
|
|
694
|
-
|
|
695
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
696
|
-
|
|
697
|
-
assert.deepEqual(opened, ["Trash"]);
|
|
698
|
-
assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10, 11]]);
|
|
699
|
-
});
|
|
700
|
-
|
|
701
|
-
it("runs an empty enqueued with the pathless payload", async () => {
|
|
702
|
-
const opened = openedPaths();
|
|
703
|
-
const pathless: EmptyTrashEvent = {
|
|
704
|
-
type: "EMPTY_TRASH",
|
|
705
|
-
schemaVersion: 3,
|
|
706
|
-
eventId: "evt-1",
|
|
707
|
-
timestamp: 1,
|
|
708
|
-
accountId: "acc-1",
|
|
709
|
-
trashMailboxId: "trash-mbx",
|
|
710
|
-
trashUidValidity: 1,
|
|
711
|
-
};
|
|
712
|
-
|
|
713
|
-
await handleEmptyTrash(pathless, noopLogger, deps());
|
|
714
|
-
|
|
715
|
-
assert.deepEqual(opened, ["Trash"]);
|
|
716
|
-
assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10, 11]]);
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
it("empties Trash under its current path when the folder was renamed after enqueue", async () => {
|
|
720
|
-
const opened = openedPaths();
|
|
721
|
-
h.mailbox = { ...h.mailbox, fullPath: "Deleted Items" };
|
|
722
|
-
|
|
723
|
-
await handleEmptyTrash(event, noopLogger, deps());
|
|
724
|
-
|
|
725
|
-
assert.deepEqual(opened, ["Deleted Items"]);
|
|
726
|
-
assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10, 11]]);
|
|
727
|
-
});
|
|
728
|
-
});
|