@remit/imap-worker 0.0.86 → 0.0.88

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.
Files changed (71) hide show
  1. package/README.md +3 -1
  2. package/package.json +10 -2
  3. package/src/account-check.ts +1 -1
  4. package/src/body-sync-gate.test.ts +1 -1
  5. package/src/body-sync-gate.ts +1 -1
  6. package/src/e2e-processor-shim.ts +6 -21
  7. package/src/emit.ts +1 -1
  8. package/src/failure-kind.ts +1 -1
  9. package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
  10. package/src/handlers/append-sent-message.ts +3 -24
  11. package/src/handlers/delete-account-objects.test.ts +1 -1
  12. package/src/handlers/delete-account-objects.ts +1 -1
  13. package/src/handlers/empty-trash.sqlite.test.ts +209 -0
  14. package/src/handlers/empty-trash.ts +3 -24
  15. package/src/handlers/flag-push.ts +11 -18
  16. package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
  17. package/src/handlers/mailbox-management.ts +12 -48
  18. package/src/handlers/message-copy.sqlite.test.ts +298 -0
  19. package/src/handlers/message-copy.ts +31 -49
  20. package/src/handlers/message-delete-terminal.ts +12 -10
  21. package/src/handlers/message-delete.sqlite.test.ts +291 -0
  22. package/src/handlers/message-delete.ts +26 -32
  23. package/src/handlers/message-move-terminal.ts +12 -10
  24. package/src/handlers/message-move.sqlite.test.ts +271 -0
  25. package/src/handlers/message-move.test.ts +76 -863
  26. package/src/handlers/message-move.ts +49 -39
  27. package/src/handlers/placement-move-push.test.ts +96 -329
  28. package/src/handlers/placement-move-push.ts +75 -29
  29. package/src/handlers/placement-push.sqlite.test.ts +298 -0
  30. package/src/handlers/probe-message-id.sqlite.test.ts +194 -0
  31. package/src/handlers/probe-message-id.ts +15 -0
  32. package/src/handlers/sync-mailboxes.test.ts +0 -1
  33. package/src/handlers/sync-mailboxes.ts +1 -1
  34. package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
  35. package/src/handlers/sync-message-body.test.ts +3 -299
  36. package/src/handlers/sync-message-body.ts +21 -11
  37. package/src/handlers/sync-messages.sqlite.test.ts +183 -0
  38. package/src/handlers/sync-messages.ts +27 -33
  39. package/src/index.ts +5 -3
  40. package/src/mail-sync-selection.sqlite.test.ts +21 -21
  41. package/src/poller.ts +5 -4
  42. package/src/processor.test.ts +1 -1
  43. package/src/processor.ts +1 -1
  44. package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
  45. package/src/scheduler/attachment-sweep.ts +1 -1
  46. package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
  47. package/src/scheduler/calendar-subscriptions.ts +6 -6
  48. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
  49. package/src/scheduler/handler.ts +12 -1
  50. package/src/scheduler/loop.test.ts +2 -0
  51. package/src/scheduler/run-tick.sqlite.test.ts +199 -0
  52. package/src/scheduler/run-tick.ts +39 -1
  53. package/src/scheduler/runner.ts +21 -1
  54. package/src/scheduler/stranded-placement-redrive.ts +265 -0
  55. package/src/stranded-placement.sqlite.test.ts +795 -0
  56. package/src/test-helpers/test-world.ts +398 -0
  57. package/src/thread-join.sqlite.test.ts +106 -0
  58. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +146 -15
  59. package/src/with-oauth-lifecycle.ts +1 -1
  60. package/src/handlers/append-sent-message.test.ts +0 -529
  61. package/src/handlers/empty-trash.test.ts +0 -728
  62. package/src/handlers/flag-push-placement-race.test.ts +0 -385
  63. package/src/handlers/flag-push.test.ts +0 -325
  64. package/src/handlers/mailbox-management.test.ts +0 -850
  65. package/src/handlers/message-copy.test.ts +0 -654
  66. package/src/handlers/message-delete.test.ts +0 -1869
  67. package/src/handlers/message-move-terminal.test.ts +0 -412
  68. package/src/handlers/sync-messages.test.ts +0 -569
  69. package/src/scheduler/attachment-sweep.test.ts +0 -169
  70. package/src/scheduler/run-tick.test.ts +0 -478
  71. 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
- });