@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,1869 +0,0 @@
1
- import assert from "node:assert";
2
- import { beforeEach, describe, it, mock } from "node:test";
3
- import type { ThreadMessageItem } from "@remit/data-ports";
4
- import type { Logger } from "@remit/logger-lambda";
5
- import { renderMetrics, resetMetrics } from "@remit/logger-lambda";
6
- import { noopLogger } from "@remit/logger-lambda/noop-logger";
7
- import {
8
- buildThreadMessageTrashUpdate,
9
- buildThreadMessageUndelete,
10
- } from "@remit/mailbox-service";
11
- import type { MessageDeleteEvent } from "../events.js";
12
- import {
13
- deleteAllThreadMessagesForMessage,
14
- getMessageDeleteMaxAttempts,
15
- handleMessageDelete,
16
- MESSAGE_DELETE_MAX_ATTEMPTS,
17
- type MessageDeleteDeps,
18
- } from "./message-delete.js";
19
-
20
- describe("getMessageDeleteMaxAttempts — env-derived threshold (#980)", () => {
21
- it("parses the injected env var", () => {
22
- assert.equal(
23
- getMessageDeleteMaxAttempts({ MESSAGE_DELETE_MAX_ATTEMPTS: "3" }),
24
- 3,
25
- );
26
- assert.equal(
27
- getMessageDeleteMaxAttempts({ MESSAGE_DELETE_MAX_ATTEMPTS: "5" }),
28
- 5,
29
- );
30
- });
31
-
32
- it("defaults to the mailbox queue's own maxReceiveCount when unset", () => {
33
- assert.equal(getMessageDeleteMaxAttempts({}), 3);
34
- });
35
-
36
- it("defaults on a non-numeric or non-positive value", () => {
37
- assert.equal(
38
- getMessageDeleteMaxAttempts({ MESSAGE_DELETE_MAX_ATTEMPTS: "nope" }),
39
- 3,
40
- );
41
- assert.equal(
42
- getMessageDeleteMaxAttempts({ MESSAGE_DELETE_MAX_ATTEMPTS: "0" }),
43
- 3,
44
- );
45
- });
46
-
47
- it("MESSAGE_DELETE_MAX_ATTEMPTS is a concrete, positive number at module load", () => {
48
- assert.ok(MESSAGE_DELETE_MAX_ATTEMPTS > 0);
49
- });
50
- });
51
-
52
- const sourceMailboxId = "source-mailbox-id-aaaaaaaaa";
53
- const trashMailboxId = "trash-mailbox-id-aaaaaaaaa";
54
-
55
- const baseThreadMessage = {
56
- sentDate: 1700000000000,
57
- mailboxId: sourceMailboxId,
58
- isRead: true,
59
- isDeleted: false,
60
- hasStars: true,
61
- hasAttachment: false,
62
- } satisfies Pick<
63
- ThreadMessageItem,
64
- | "sentDate"
65
- | "mailboxId"
66
- | "isRead"
67
- | "isDeleted"
68
- | "hasStars"
69
- | "hasAttachment"
70
- >;
71
-
72
- describe("buildThreadMessageTrashUpdate", () => {
73
- // Regression for the same composites-direction landmine PR #186 fixed in
74
- // `flag-queue.ts`. The CURRENT row state must go in `composites`; the NEW
75
- // values must go in `set`. Flipping any of these silently drops the
76
- // move-to-trash update on the ThreadMessage row — IMAP shows the message in
77
- // Trash but the local thread-list still shows it in the source mailbox.
78
-
79
- it("set carries the NEW uid, destination mailboxId, and isDeleted=true", () => {
80
- const args = buildThreadMessageTrashUpdate(
81
- baseThreadMessage,
82
- 42,
83
- trashMailboxId,
84
- );
85
-
86
- assert.strictEqual(args.set.uid, 42);
87
- assert.strictEqual(
88
- args.set.mailboxId,
89
- trashMailboxId,
90
- "set.mailboxId must be the NEW trash mailbox",
91
- );
92
- assert.strictEqual(
93
- args.set.isDeleted,
94
- true,
95
- "set.isDeleted must be true (move-to-trash marks the row deleted)",
96
- );
97
- });
98
-
99
- it("composites.mailboxId is the CURRENT (source) mailboxId, not Trash", () => {
100
- const args = buildThreadMessageTrashUpdate(
101
- baseThreadMessage,
102
- 42,
103
- trashMailboxId,
104
- );
105
-
106
- assert.strictEqual(
107
- args.composites.mailboxId,
108
- sourceMailboxId,
109
- "composites.mailboxId must be the CURRENT source mailbox; passing Trash breaks the conditional check",
110
- );
111
- assert.notStrictEqual(
112
- args.composites.mailboxId,
113
- trashMailboxId,
114
- "composites.mailboxId must NOT match the new trash mailbox id",
115
- );
116
- });
117
-
118
- it("composites.isDeleted is the CURRENT value, not the new true", () => {
119
- const args = buildThreadMessageTrashUpdate(
120
- baseThreadMessage,
121
- 42,
122
- trashMailboxId,
123
- );
124
-
125
- assert.strictEqual(
126
- args.composites.isDeleted,
127
- false,
128
- "composites.isDeleted must be the CURRENT value (false), not the new value (true)",
129
- );
130
- });
131
-
132
- it("composites mirrors every CURRENT indexed attribute on the threadMessage", () => {
133
- const tm = {
134
- sentDate: 1700000000123,
135
- mailboxId: sourceMailboxId,
136
- isRead: false,
137
- isDeleted: false,
138
- hasStars: true,
139
- hasAttachment: true,
140
- };
141
-
142
- const args = buildThreadMessageTrashUpdate(tm, 99, trashMailboxId);
143
-
144
- assert.deepStrictEqual(args.composites, {
145
- sentDate: tm.sentDate,
146
- mailboxId: tm.mailboxId,
147
- isRead: tm.isRead,
148
- isDeleted: tm.isDeleted,
149
- hasStars: tm.hasStars,
150
- hasAttachment: tm.hasAttachment,
151
- });
152
- });
153
- });
154
-
155
- describe("buildThreadMessageUndelete", () => {
156
- // Every `set` payload here is hand-written, so the three builders can drift
157
- // apart silently. Undelete is the exact inverse of the trash update's
158
- // deletion mark, and it must move nothing else: the mail is still in Trash
159
- // at the uid the row already carries.
160
-
161
- it("clears only the deletion mark that buildThreadMessageTrashUpdate set", () => {
162
- const trashed = buildThreadMessageTrashUpdate(
163
- baseThreadMessage,
164
- 42,
165
- trashMailboxId,
166
- );
167
- const undelete = buildThreadMessageUndelete(baseThreadMessage);
168
-
169
- assert.strictEqual(trashed.set.isDeleted, true);
170
- assert.strictEqual(undelete.set.isDeleted, false);
171
- assert.deepStrictEqual(
172
- Object.keys(undelete.set),
173
- ["isDeleted"],
174
- "undelete must not move the row's uid or mailbox",
175
- );
176
- });
177
-
178
- it("carries the CURRENT row state in composites, like every other builder", () => {
179
- const undelete = buildThreadMessageUndelete(baseThreadMessage);
180
-
181
- assert.deepStrictEqual(
182
- undelete.composites,
183
- buildThreadMessageTrashUpdate(baseThreadMessage, 42, trashMailboxId)
184
- .composites,
185
- );
186
- assert.strictEqual(
187
- undelete.composites.isDeleted,
188
- baseThreadMessage.isDeleted,
189
- "composites hold the state to check against, never the new value",
190
- );
191
- });
192
- });
193
-
194
- describe("deleteAllThreadMessagesForMessage (#212)", () => {
195
- // Regression for the multi-mailbox cleanup gap in #212. A single Message
196
- // can have ThreadMessage rows in multiple mailboxes (e.g. INBOX + a label
197
- // folder copy). The pre-fix code used `findByMessageId` (single row) and
198
- // left orphan rows behind that then leaked into other mailbox listings.
199
-
200
- const baseRow = (
201
- threadMessageId: string,
202
- mailboxId: string,
203
- ): Pick<
204
- ThreadMessageItem,
205
- "accountConfigId" | "threadMessageId" | "mailboxId"
206
- > => ({
207
- accountConfigId: "alice-config-aaaaaaaaaa",
208
- threadMessageId,
209
- mailboxId,
210
- });
211
-
212
- it("deletes every ThreadMessage row returned by findAllByMessageId", async () => {
213
- const rows = [
214
- baseRow("alice-tm-1-aaaaaaaaaa", "alice-inbox-aaaaaaaaa"),
215
- baseRow("alice-tm-2-aaaaaaaaaa", "alice-label-aaaaaaaaa"),
216
- ];
217
-
218
- const findAllByMessageId = mock.fn(async () => rows);
219
- const deleteRow = mock.fn(async () => undefined);
220
-
221
- const count = await deleteAllThreadMessagesForMessage(
222
- {
223
- findAllByMessageId,
224
- delete: deleteRow,
225
- } as unknown as Parameters<typeof deleteAllThreadMessagesForMessage>[0],
226
- "alice-config-aaaaaaaaaa",
227
- "alice-msg-multi-aaaaaaaa",
228
- );
229
-
230
- assert.equal(count, 2);
231
- assert.equal(deleteRow.mock.calls.length, 2);
232
- assert.deepEqual(deleteRow.mock.calls[0].arguments, [
233
- "alice-config-aaaaaaaaaa",
234
- "alice-tm-1-aaaaaaaaaa",
235
- ]);
236
- assert.deepEqual(deleteRow.mock.calls[1].arguments, [
237
- "alice-config-aaaaaaaaaa",
238
- "alice-tm-2-aaaaaaaaaa",
239
- ]);
240
- });
241
-
242
- it("returns zero when no ThreadMessage rows exist", async () => {
243
- const findAllByMessageId = mock.fn(async () => []);
244
- const deleteRow = mock.fn(async () => undefined);
245
-
246
- const count = await deleteAllThreadMessagesForMessage(
247
- {
248
- findAllByMessageId,
249
- delete: deleteRow,
250
- } as unknown as Parameters<typeof deleteAllThreadMessagesForMessage>[0],
251
- "alice-config-aaaaaaaaaa",
252
- "alice-msg-missing-aaaaaa",
253
- );
254
-
255
- assert.equal(count, 0);
256
- assert.equal(deleteRow.mock.calls.length, 0);
257
- });
258
- });
259
-
260
- interface Call {
261
- method: string;
262
- args: unknown[];
263
- }
264
-
265
- interface LogLine {
266
- level: string;
267
- fields: Record<string, unknown>;
268
- }
269
-
270
- interface Connection {
271
- openBox: (
272
- path: string,
273
- readOnly?: boolean,
274
- ) => Promise<{ uidvalidity: number }>;
275
- moveMessages: (
276
- uids: number[],
277
- dest: string,
278
- ) => Promise<{ uidMap: Map<number, number> }>;
279
- deleteMessages: (uids: number[]) => Promise<void>;
280
- createMailbox: (path: string) => Promise<void>;
281
- fetchMessages: (uids: number[]) => Promise<{ uid: number }[]>;
282
- search: (criteria: unknown[]) => Promise<number[]>;
283
- }
284
-
285
- interface Harness {
286
- calls: Call[];
287
- account: {
288
- accountId: string;
289
- accountConfigId: string;
290
- deletedAt?: number;
291
- } | null;
292
- mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
293
- mailboxError?: Error;
294
- folderPaths: Record<string, string>;
295
- goneMailboxIds: string[];
296
- connection: Connection;
297
- threadMessageUpdateError?: Error;
298
- messageRow: { messageIdHeader?: string; status?: string } | undefined;
299
- /** The row was settled by another lane, so every transition loses. */
300
- transitionLost?: boolean;
301
- destinationSearchUids: number[];
302
- // A Message-ID SEARCH is answered by whichever folder was opened last, so a
303
- // case can put the same header in both. Default: the source still holds it,
304
- // the destination does not.
305
- sourceSearchUids: number[];
306
- openBoxPath: string;
307
- threadMessage: Record<string, unknown> | null;
308
- allThreadMessages: Record<string, unknown>[];
309
- getConnectionCount: number;
310
- disconnectCount: number;
311
- // Rejections handed to `getConnection`, one per call, oldest first. A
312
- // dropped connection is how the guarded openBox gets skipped on a delivery
313
- // that still goes on to settle the row.
314
- connectionErrors: Error[];
315
- logs: LogLine[];
316
- }
317
-
318
- // The level a give-up is logged at is behaviour: a routine pause must not page
319
- // anyone, and the local rows going while the server copy survives must stay
320
- // readable whichever level it lands on.
321
- const capturingLog = (): Logger => {
322
- const at =
323
- (level: string) =>
324
- (fields: Record<string, unknown>): void => {
325
- h.logs.push({ level, fields });
326
- };
327
- const log = {
328
- info: at("info"),
329
- warn: at("warn"),
330
- error: at("error"),
331
- debug: at("debug"),
332
- fatal: at("fatal"),
333
- trace: at("trace"),
334
- child: () => log,
335
- } as unknown as Logger;
336
- return log;
337
- };
338
-
339
- const loggedWith = (key: string, value: string): LogLine[] =>
340
- h.logs.filter((line) => line.fields[key] === value);
341
-
342
- let h: Harness;
343
-
344
- const record =
345
- (method: string) =>
346
- async (...args: unknown[]) => {
347
- h.calls.push({ method, args });
348
- };
349
-
350
- const MESSAGE_ID_HEADER = "<trashed-message@example.com>";
351
-
352
- // The source-presence probe (`isMessageGoneFromOpenMailbox`) and the
353
- // destination probe both go through `connection.search`; only the criterion
354
- // form tells them apart.
355
- const isMessageIdSearch = (criteria: unknown): boolean =>
356
- Array.isArray(criteria) &&
357
- Array.isArray(criteria[0]) &&
358
- criteria[0][0] === "HEADER";
359
-
360
- const buildConnection = (): Connection => ({
361
- openBox: async (path: string) => {
362
- h.openBoxPath = path;
363
- return { uidvalidity: 1 };
364
- },
365
- moveMessages: async () => ({ uidMap: new Map([[10, 20]]) }),
366
- deleteMessages: record(
367
- "connection.deleteMessages",
368
- ) as Connection["deleteMessages"],
369
- createMailbox: record(
370
- "connection.createMailbox",
371
- ) as Connection["createMailbox"],
372
- // The source box still holds the uid unless a case says otherwise, so the
373
- // presence probe only reports "gone" where a test made it true.
374
- fetchMessages: async (uids: number[]) => {
375
- h.calls.push({ method: "connection.fetchMessages", args: [uids] });
376
- return uids.map((uid) => ({ uid }));
377
- },
378
- search: async (...args: unknown[]) => {
379
- h.calls.push({ method: "connection.search", args });
380
- if (!isMessageIdSearch(args[0])) return [10];
381
- return h.openBoxPath === "INBOX"
382
- ? h.sourceSearchUids
383
- : h.destinationSearchUids;
384
- },
385
- });
386
-
387
- const sourceNoLongerHoldsTheUid = (): void => {
388
- h.sourceSearchUids = [];
389
- h.connection.fetchMessages = async () => [];
390
- h.connection.search = async (...args: unknown[]) => {
391
- h.calls.push({ method: "connection.search", args });
392
- if (!isMessageIdSearch(args[0])) return [];
393
- return h.openBoxPath === "INBOX"
394
- ? h.sourceSearchUids
395
- : h.destinationSearchUids;
396
- };
397
- };
398
-
399
- const fresh = (): Harness => ({
400
- calls: [],
401
- account: { accountId: "acc-1", accountConfigId: "cfg-1" },
402
- mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
403
- folderPaths: { "src-mbx": "INBOX", "trash-mbx": "Trash" },
404
- goneMailboxIds: [],
405
- // What the API writes before it enqueues: a move to Trash records `moving`,
406
- // a permanent delete records `deleting`, and the handler's guard asks one
407
- // question of both — is a mutation still outstanding on this row.
408
- messageRow: { messageIdHeader: MESSAGE_ID_HEADER, status: "moving" },
409
- destinationSearchUids: [],
410
- sourceSearchUids: [10],
411
- openBoxPath: "INBOX",
412
- connection: buildConnection(),
413
- threadMessage: {
414
- ...baseThreadMessage,
415
- accountConfigId: "cfg-1",
416
- threadMessageId: "tm-1",
417
- },
418
- allThreadMessages: [
419
- { ...baseThreadMessage, accountConfigId: "cfg-1", threadMessageId: "tm-1" },
420
- { ...baseThreadMessage, accountConfigId: "cfg-1", threadMessageId: "tm-2" },
421
- ],
422
- getConnectionCount: 0,
423
- disconnectCount: 0,
424
- connectionErrors: [],
425
- logs: [],
426
- });
427
-
428
- const deps = (): MessageDeleteDeps =>
429
- ({
430
- getClient: async () => ({
431
- account: {
432
- get: async (accountId: string) => {
433
- h.calls.push({ method: "account.get", args: [accountId] });
434
- return h.account;
435
- },
436
- },
437
- message: {
438
- get: async (messageIds: string[]) => {
439
- h.calls.push({ method: "message.get", args: [messageIds] });
440
- return h.messageRow ? [h.messageRow] : [];
441
- },
442
- updateUid: record("message.updateUid"),
443
- transitionPlacement: async (...args: unknown[]) => {
444
- h.calls.push({ method: "message.transitionPlacement", args });
445
- return h.transitionLost ? undefined : { messageId: "msg-1" };
446
- },
447
- update: record("message.update"),
448
- delete: record("message.delete"),
449
- },
450
- threadMessage: {
451
- findByMessageId: async () => h.threadMessage,
452
- findAllByMessageId: async (...args: unknown[]) => {
453
- h.calls.push({ method: "threadMessage.findAllByMessageId", args });
454
- return h.allThreadMessages;
455
- },
456
- update: async (...args: unknown[]) => {
457
- h.calls.push({ method: "threadMessage.update", args });
458
- if (h.threadMessageUpdateError) throw h.threadMessageUpdateError;
459
- },
460
- delete: record("threadMessage.delete"),
461
- deleteMany: record("threadMessage.deleteMany"),
462
- },
463
- mailbox: {
464
- get: async (_accountId: string, mailboxId: string) => {
465
- if (h.mailboxError) throw h.mailboxError;
466
- if (h.goneMailboxIds.includes(mailboxId)) {
467
- throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
468
- name: "NotFoundError",
469
- });
470
- }
471
- const row =
472
- mailboxId === h.mailbox.mailboxId
473
- ? h.mailbox
474
- : { mailboxId, uidValidity: 1, cursorState: "normal" };
475
- return { ...row, fullPath: h.folderPaths[mailboxId] };
476
- },
477
- update: record("mailbox.update"),
478
- },
479
- secrets: {},
480
- }),
481
- buildLifecycleDeps: () => ({}),
482
- withOAuthLifecycle: async (
483
- _deps: unknown,
484
- _account: unknown,
485
- _log: unknown,
486
- cb: (credentials: unknown) => Promise<void>,
487
- ) => cb({}),
488
- emitEvent: record("emitEvent"),
489
- createConnectionScope: () => ({
490
- getConnection: async () => {
491
- h.getConnectionCount += 1;
492
- const failure = h.connectionErrors.shift();
493
- if (failure) throw failure;
494
- return h.connection;
495
- },
496
- disconnect: async () => {
497
- h.disconnectCount += 1;
498
- },
499
- }),
500
- }) as unknown as MessageDeleteDeps;
501
-
502
- const moveEvent: MessageDeleteEvent = {
503
- type: "MESSAGE_DELETE",
504
- schemaVersion: 2,
505
- accountId: "acc-1",
506
- messageId: "msg-1",
507
- mailboxId: "src-mbx",
508
- mailboxPath: "INBOX",
509
- uid: 10,
510
- operation: "move_to_trash",
511
- destinationMailboxId: "trash-mbx",
512
- destinationMailboxPath: "Trash",
513
- } as MessageDeleteEvent;
514
-
515
- const permanentEvent: MessageDeleteEvent = {
516
- type: "MESSAGE_DELETE",
517
- schemaVersion: 2,
518
- accountId: "acc-1",
519
- messageId: "msg-1",
520
- mailboxId: "src-mbx",
521
- mailboxPath: "INBOX",
522
- uid: 10,
523
- operation: "permanent_delete",
524
- } as MessageDeleteEvent;
525
-
526
- /**
527
- * A permanent delete's row carries `deleting`, which is the state its own guard
528
- * asks for — the move to Trash's is `moving`, and the default fixture is that.
529
- * Called at the point of the handler call so the row is right for the event.
530
- */
531
- const permanent = (): MessageDeleteEvent => {
532
- if (h.messageRow) h.messageRow.status = "deleting";
533
- return permanentEvent;
534
- };
535
-
536
- const called = (method: string): Call[] =>
537
- h.calls.filter((c) => c.method === method);
538
-
539
- /**
540
- * The transient attempt marker: the one transition that writes nothing but the
541
- * attempt's own sync status, predicated on this delete still being outstanding.
542
- * Told apart from the hand-back transitions, which write a whole placement.
543
- */
544
- /**
545
- * The hand-back transitions: the ones that write a whole placement back onto
546
- * the row. Told apart from the attempt marker, which writes only a sync status,
547
- * and from the claim that precedes a removal, which writes only a status.
548
- */
549
- const restoreCalls = (): Call[] =>
550
- called("message.transitionPlacement").filter(
551
- (candidate) =>
552
- (candidate.args[2] as { mailboxId?: string }).mailboxId !== undefined,
553
- );
554
-
555
- const attemptMarkerSyncStatus = (): string | undefined => {
556
- const call = called("message.transitionPlacement").find(
557
- (candidate) => Object.keys(candidate.args[2] as object).length === 1,
558
- );
559
- return (call?.args[2] as { syncStatus?: string })?.syncStatus;
560
- };
561
-
562
- // The one write that puts a given-up delete back on its source pair. It clears
563
- // `moving` in the same write as the pair, which is what keeps a later delete
564
- // from waiting on a mutation that has terminated (#1005), and it is a
565
- // transition rather than a plain update so a row another lane already settled
566
- // keeps its own placement (imap-mutations R3).
567
- const restoredToSource = (syncStatus: string): unknown[] => [
568
- "msg-1",
569
- { status: ["moving", "deleting"] },
570
- {
571
- mailboxId: "src-mbx",
572
- uid: 10,
573
- status: "active",
574
- syncStatus,
575
- // A give-up names the mutation it gave up on, so the reading pane can
576
- // offer the way out for THAT one (#1229). `none` rides along with every
577
- // other sync status, so the field never outlives its gate.
578
- abandonedMutation: syncStatus === "abandoned" ? "delete" : "none",
579
- },
580
- ];
581
-
582
- // Label order in the rendered text is prom-client's, not ours.
583
- const imapFailures = async (operation: string): Promise<number> => {
584
- const line = (await renderMetrics())
585
- .split("\n")
586
- .find(
587
- (candidate) =>
588
- candidate.startsWith("remit_imap_failures_total{") &&
589
- candidate.includes(`operation="${operation}"`),
590
- );
591
- return line ? Number(line.slice(line.lastIndexOf(" ") + 1)) : 0;
592
- };
593
-
594
- describe("handleMessageDelete", () => {
595
- beforeEach(() => {
596
- h = fresh();
597
- resetMetrics();
598
- });
599
-
600
- it("moves to trash, rewrites the uid, and flips the thread row to deleted", async () => {
601
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
602
-
603
- assert.deepEqual(called("message.updateUid")[0]?.args, [
604
- "msg-1",
605
- 20,
606
- "trash-mbx",
607
- ]);
608
- const update = called("threadMessage.update")[0];
609
- assert.deepEqual(update?.args[2], {
610
- uid: 20,
611
- mailboxId: "trash-mbx",
612
- isDeleted: true,
613
- });
614
- assert.equal(h.disconnectCount, 1);
615
- });
616
-
617
- // UIDPLUS is an extension: a server without it answers a perfectly
618
- // successful MOVE with no COPYUID entry. An empty uidMap is therefore
619
- // UNCONFIRMED, and the destination is asked by Message-ID before any
620
- // verdict — the rule every sibling handler already carries. Issue #979.
621
- describe("no COPYUID entry on the move to trash", () => {
622
- it("settles the row on the probed uid when the message left the source and is at the destination (non-UIDPLUS server, genuine success)", async () => {
623
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
624
- sourceNoLongerHoldsTheUid();
625
- h.destinationSearchUids = [77];
626
-
627
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
628
-
629
- assert.deepEqual(called("message.updateUid")[0]?.args, [
630
- "msg-1",
631
- 77,
632
- "trash-mbx",
633
- ]);
634
- assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
635
- uid: 77,
636
- mailboxId: "trash-mbx",
637
- isDeleted: true,
638
- });
639
- assert.equal(
640
- called("message.update").length,
641
- 0,
642
- "a settled move never marks the row failed",
643
- );
644
- });
645
-
646
- // Issue #1122. Trash already holds an older copy of this Message-ID (a
647
- // resend, a sieve `fileinto` + `keep`, an earlier delete of another
648
- // folder's copy). Settling on uid 12 points the row at that copy and
649
- // orphans the one this MOVE just delivered: Empty Trash then expunges by
650
- // the wrong uid and the fresh copy survives, unreachable.
651
- it("settles on the copy this move delivered, not on an older copy already in Trash", async () => {
652
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
653
- sourceNoLongerHoldsTheUid();
654
- h.destinationSearchUids = [12, 77];
655
-
656
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
657
-
658
- assert.deepEqual(called("message.updateUid")[0]?.args, [
659
- "msg-1",
660
- 77,
661
- "trash-mbx",
662
- ]);
663
- assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
664
- uid: 77,
665
- mailboxId: "trash-mbx",
666
- isDeleted: true,
667
- });
668
- });
669
-
670
- it("probes the DESTINATION mailbox, read-only, by Message-ID", async () => {
671
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
672
- sourceNoLongerHoldsTheUid();
673
- h.destinationSearchUids = [77];
674
- const opened: unknown[][] = [];
675
- h.connection.openBox = (async (...args: unknown[]) => {
676
- opened.push(args);
677
- h.openBoxPath = args[0] as string;
678
- return { uidvalidity: 1 };
679
- }) as Connection["openBox"];
680
-
681
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
682
-
683
- assert.deepEqual(
684
- opened,
685
- [
686
- ["INBOX", false],
687
- ["INBOX", true],
688
- ["Trash", true],
689
- ],
690
- "the source is re-asked read-only, then the destination is EXAMINEd — neither probe may SELECT a box writable",
691
- );
692
- assert.deepEqual(called("connection.search").at(-1)?.args[0], [
693
- ["HEADER", "Message-ID", MESSAGE_ID_HEADER],
694
- ]);
695
- });
696
-
697
- // Issue #980. Within the budget the row is marked unsettled and the
698
- // event is re-thrown so the queue redelivers it, exactly as
699
- // `handleMessageMove` does; nothing is reverted and nothing is deleted,
700
- // because a MOVE that ran server-side but dropped before the tagged OK
701
- // is indistinguishable from one that never ran (#655, PR #652).
702
- it("throws to redeliver while the budget still has attempts left", async () => {
703
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
704
- sourceNoLongerHoldsTheUid();
705
- h.destinationSearchUids = [];
706
-
707
- await assert.rejects(
708
- handleMessageDelete(moveEvent, noopLogger, 1, deps()),
709
- /unconfirmed/,
710
- );
711
-
712
- assert.equal(called("message.updateUid").length, 0);
713
- assert.equal(
714
- called("message.delete").length,
715
- 0,
716
- "an unconfirmed move must never delete the local row inside the budget",
717
- );
718
- assert.equal(
719
- called("threadMessage.deleteMany").length,
720
- 0,
721
- "an unconfirmed move must never delete the listing rows inside the budget",
722
- );
723
- assert.equal(
724
- called("threadMessage.update").length,
725
- 0,
726
- "an unconfirmed move must never revert the listing row to the source",
727
- );
728
- assert.equal(attemptMarkerSyncStatus(), "failed");
729
- assert.equal(h.disconnectCount, 1);
730
- });
731
-
732
- it("settles once at the ceiling: reconciles the stale rows and resyncs both folders", async () => {
733
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
734
- sourceNoLongerHoldsTheUid();
735
- h.destinationSearchUids = [];
736
-
737
- await handleMessageDelete(moveEvent, noopLogger, 3, deps());
738
-
739
- assert.equal(
740
- called("threadMessage.deleteMany").length,
741
- 1,
742
- "the ceiling settles the row rather than leaving it claiming a uid the server does not have",
743
- );
744
- assert.equal(called("message.delete").length, 1);
745
- assert.deepEqual(
746
- called("emitEvent").map((c) => c.args[0]),
747
- [
748
- { type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
749
- { type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "trash-mbx" },
750
- ],
751
- "both folders re-project from IMAP so the message reappears where it actually is",
752
- );
753
- assert.equal(h.disconnectCount, 1);
754
- });
755
-
756
- // A message with no Message-ID header on a non-UIDPLUS server can never
757
- // be confirmed at the destination, so redelivering it only burns the
758
- // budget on an answer that cannot change.
759
- it("settles on the first attempt when the row carries no Message-ID header", async () => {
760
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
761
- sourceNoLongerHoldsTheUid();
762
- h.messageRow = { status: "moving" };
763
-
764
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
765
-
766
- assert.equal(
767
- called("connection.search").filter((c) =>
768
- JSON.stringify(c.args).includes("HEADER"),
769
- ).length,
770
- 0,
771
- );
772
- assert.equal(called("message.updateUid").length, 0);
773
- assert.equal(
774
- called("message.delete").length,
775
- 1,
776
- "an unprobeable row settles instead of spending the whole budget",
777
- );
778
- assert.equal(called("emitEvent").length, 2);
779
- assert.equal(
780
- await imapFailures("MESSAGE_DELETE_TRASH_MOVE_UNCONFIRMED"),
781
- 1,
782
- );
783
- });
784
-
785
- // The row this event names was already deleted, so there is nothing to
786
- // settle and nothing to show. Re-throwing would loop a NotFoundError on
787
- // the account's per-group FIFO and block every later mailbox event.
788
- it("acks without touching the database when the local row is already gone", async () => {
789
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
790
- sourceNoLongerHoldsTheUid();
791
- h.messageRow = undefined;
792
-
793
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
794
-
795
- assert.equal(called("message.update").length, 0);
796
- assert.equal(called("message.delete").length, 0);
797
- assert.equal(called("threadMessage.deleteMany").length, 0);
798
- assert.equal(called("emitEvent").length, 0);
799
- });
800
-
801
- // A lost SQS acknowledgement is the ordinary way this happens: the delete
802
- // ran, `settleTrashMoveConfirmed` wrote the Trash uid, and the record came
803
- // back anyway. Without the guard its three siblings carry (MESSAGE_MOVE,
804
- // MESSAGE_COPY, FLAG_PUSH) the redelivery MOVEs a uid the source no longer
805
- // holds, exhausts, and the terminal resolver reads the source's honest
806
- // "gone" as grounds to delete rows that are correct — taking spamReport,
807
- // classificationState, category and the Undo target's `originalMailboxId`
808
- // with them, none of which the resync re-projection can rebuild.
809
- it("skips a redelivery of a delete that already settled, rather than re-running it", async () => {
810
- h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "active" };
811
-
812
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
813
-
814
- assert.equal(h.getConnectionCount, 0, "no IMAP command is issued");
815
- assert.equal(called("message.updateUid").length, 0);
816
- assert.equal(called("message.delete").length, 0);
817
- assert.equal(called("threadMessage.delete").length, 0);
818
- assert.equal(called("threadMessage.deleteMany").length, 0);
819
- assert.equal(called("message.transitionPlacement").length, 0);
820
- assert.equal(called("emitEvent").length, 0);
821
- });
822
-
823
- // The guard asks for the state THIS delete wrote, not for "is anything
824
- // outstanding". A move to Trash records `moving`, a permanent delete
825
- // records `deleting`, and a row in the other one belongs to a different
826
- // mutation — reading it as unsettled is what would let a redelivery
827
- // finish somebody else's work against a uid it never owned.
828
- it("proceeds on a move-to-trash row still marked moving, which is its own work", async () => {
829
- h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "moving" };
830
-
831
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
832
-
833
- assert.equal(h.getConnectionCount, 1, "the delete runs");
834
- });
835
-
836
- it("skips a move-to-trash whose row a permanent delete has claimed", async () => {
837
- h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "deleting" };
838
-
839
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
840
-
841
- assert.equal(h.getConnectionCount, 0, "no IMAP command is issued");
842
- assert.equal(called("message.updateUid").length, 0);
843
- assert.equal(called("emitEvent").length, 0);
844
- });
845
-
846
- it("proceeds on a permanent delete's own deleting row", async () => {
847
- h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "deleting" };
848
-
849
- await handleMessageDelete(permanent(), noopLogger, 1, deps());
850
-
851
- assert.equal(h.getConnectionCount, 1, "the expunge runs");
852
- });
853
-
854
- it("skips a permanent delete whose row a move has claimed", async () => {
855
- h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "moving" };
856
-
857
- await handleMessageDelete(permanentEvent, noopLogger, 1, deps());
858
-
859
- assert.equal(h.getConnectionCount, 0, "no expunge is issued");
860
- assert.equal(called("message.delete").length, 0);
861
- assert.equal(called("emitEvent").length, 0);
862
- });
863
-
864
- it("skips a redelivered permanent delete the same way", async () => {
865
- h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "active" };
866
-
867
- await handleMessageDelete(permanentEvent, noopLogger, 1, deps());
868
-
869
- assert.equal(h.getConnectionCount, 0);
870
- assert.equal(called("message.delete").length, 0);
871
- assert.equal(called("emitEvent").length, 0);
872
- });
873
-
874
- // One Message-ID can have several server copies in one account while
875
- // `deriveMessageId` gives them one local row. A source that still holds
876
- // the uid proves the MOVE did not happen, so any hit at the destination
877
- // is a different copy — including the highest one #1122 now takes, since
878
- // with no fresh copy at all the highest match is still an older copy.
879
- it("never takes a destination hit while the source still holds the uid (duplicate Message-ID)", async () => {
880
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
881
- h.destinationSearchUids = [100];
882
-
883
- await assert.rejects(
884
- handleMessageDelete(moveEvent, noopLogger, 1, deps()),
885
- /unconfirmed/,
886
- );
887
-
888
- assert.equal(
889
- called("message.updateUid").length,
890
- 0,
891
- "an earlier copy's uid must never settle this row",
892
- );
893
- assert.equal(called("threadMessage.update").length, 0);
894
- assert.equal(called("message.delete").length, 0);
895
- assert.equal(attemptMarkerSyncStatus(), "failed");
896
- });
897
-
898
- // Issue #1098. A source that still holds the uid at the ceiling means the
899
- // MOVE never took effect, so the row goes back to the source folder: the
900
- // optimistic transition left `mailboxId` on Trash while `uid` still
901
- // named the source's message, and settling `status` to `active` on top of
902
- // that pair switched off every guard that reads `status === "moving"`.
903
- // The UI then resolved a Trash `mailboxId` to "Delete permanently", an
904
- // expunge of Trash by a uid belonging to a different message. This is not
905
- // a revert on ambiguity (#652, #655): the presence probe has just
906
- // answered, so the pair written here is the placement the server holds.
907
- it("moves a message still at its source back to the source folder at the ceiling", async () => {
908
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
909
- h.destinationSearchUids = [100];
910
- h.allThreadMessages = [
911
- {
912
- ...baseThreadMessage,
913
- accountConfigId: "cfg-1",
914
- threadMessageId: "tm-1",
915
- mailboxId: "trash-mbx",
916
- isDeleted: true,
917
- },
918
- ];
919
-
920
- await handleMessageDelete(moveEvent, noopLogger, 3, deps());
921
-
922
- assert.equal(called("message.delete").length, 0);
923
- assert.equal(called("threadMessage.deleteMany").length, 0);
924
-
925
- // `mailboxId` and `uid` name the same message again, and the same write
926
- // settles `status` out of `moving`, so no later delete of this message
927
- // waits on a mutation that has terminated. It also carries the give-up
928
- // (#1153) and names it a delete (#1229): the user asked for one, it did
929
- // not happen, and nothing is coming to try again.
930
- assert.deepEqual(
931
- called("message.transitionPlacement").at(-1)?.args,
932
- restoredToSource("abandoned"),
933
- );
934
- assert.equal(
935
- called("message.update").length,
936
- 0,
937
- "settling `status` without repairing the pair is what left the row addressable as a Trash expunge",
938
- );
939
-
940
- // The listing row follows the Message row out of Trash, which is what
941
- // makes "Delete permanently" unreachable: the client resolves that
942
- // outcome from a target whose `mailboxId` is the Trash mailbox.
943
- assert.deepEqual(called("threadMessage.update").at(-1)?.args, [
944
- "cfg-1",
945
- "tm-1",
946
- { uid: 10, mailboxId: "src-mbx", isDeleted: false },
947
- {
948
- composites: {
949
- sentDate: baseThreadMessage.sentDate,
950
- mailboxId: "trash-mbx",
951
- isRead: true,
952
- isDeleted: true,
953
- hasStars: true,
954
- hasAttachment: false,
955
- },
956
- },
957
- ]);
958
- assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
959
- });
960
-
961
- // The optimistic transition already pointed the row at Trash. The
962
- // server has now confirmed the message never left the source, so the row
963
- // and the server disagree about where the mail is, and only a resync of
964
- // both folders settles that. Without it the user is shown the message in
965
- // Trash indefinitely while it sits in the source folder.
966
- it("resyncs both folders when the server confirms the message never left the source", async () => {
967
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
968
- h.destinationSearchUids = [100];
969
-
970
- await handleMessageDelete(moveEvent, noopLogger, 3, deps());
971
-
972
- assert.deepEqual(
973
- called("emitEvent").map((c) => c.args[0]),
974
- [
975
- { type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
976
- { type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "trash-mbx" },
977
- ],
978
- "a confirmed divergence resyncs on the broken verdict too, not only on the reconciled one",
979
- );
980
- });
981
-
982
- // Issue #980, the failure the budget exists for: every redelivery
983
- // re-MOVEs a uid the source no longer holds and throws identically. The
984
- // ceiling lives in the error catch, so a throwing `moveMessages` settles
985
- // there rather than running past the budget into the dead-letter queue.
986
- describe("a re-MOVE that throws", () => {
987
- const moveThrows = (): void => {
988
- h.connection.moveMessages = async () => {
989
- throw new Error(
990
- "NO [TRYAGAIN] UID MOVE failed: no matching messages",
991
- );
992
- };
993
- };
994
-
995
- it("re-throws inside the budget so the queue redelivers", async () => {
996
- moveThrows();
997
-
998
- await assert.rejects(
999
- handleMessageDelete(moveEvent, noopLogger, 1, deps()),
1000
- /UID MOVE failed/,
1001
- );
1002
-
1003
- assert.equal(called("message.delete").length, 0);
1004
- assert.equal(called("threadMessage.deleteMany").length, 0);
1005
- assert.equal(attemptMarkerSyncStatus(), "failed");
1006
- });
1007
-
1008
- it("settles at the ceiling instead of dead-lettering undiagnosed", async () => {
1009
- moveThrows();
1010
- sourceNoLongerHoldsTheUid();
1011
-
1012
- await handleMessageDelete(moveEvent, noopLogger, 3, deps());
1013
-
1014
- assert.equal(
1015
- called("threadMessage.deleteMany").length,
1016
- 1,
1017
- "the ceiling must diagnose the failure, not hand it to the DLQ",
1018
- );
1019
- assert.equal(called("message.delete").length, 1);
1020
- assert.equal(called("emitEvent").length, 2);
1021
- });
1022
-
1023
- it("settles a still-present message at the ceiling back onto its source", async () => {
1024
- moveThrows();
1025
-
1026
- await handleMessageDelete(moveEvent, noopLogger, 3, deps());
1027
-
1028
- assert.deepEqual(
1029
- called("message.transitionPlacement").at(-1)?.args,
1030
- restoredToSource("abandoned"),
1031
- );
1032
- assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
1033
- });
1034
- });
1035
-
1036
- // A probe the server refused says nothing either way, so it counts as
1037
- // unconfirmed and spends an attempt. The budget is what makes that safe:
1038
- // without a ceiling the redeliveries would re-MOVE a uid the source no
1039
- // longer holds forever and head-of-line block the account's deletes.
1040
- it("treats an unanswerable probe as unconfirmed and spends an attempt", async () => {
1041
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
1042
- h.connection.openBox = (async (_path: string, readOnly?: boolean) => {
1043
- if (readOnly) throw new Error("NO [SERVERBUG] EXAMINE failed");
1044
- return { uidvalidity: 1 };
1045
- }) as Connection["openBox"];
1046
-
1047
- await assert.rejects(
1048
- handleMessageDelete(moveEvent, noopLogger, 1, deps()),
1049
- /unconfirmed/,
1050
- );
1051
-
1052
- assert.equal(called("message.updateUid").length, 0);
1053
- assert.equal(called("message.delete").length, 0);
1054
- assert.equal(called("threadMessage.deleteMany").length, 0);
1055
- assert.equal(called("threadMessage.update").length, 0);
1056
- assert.equal(attemptMarkerSyncStatus(), "failed");
1057
- assert.equal(h.disconnectCount, 1);
1058
- });
1059
- });
1060
-
1061
- // The queue handler `JSON.parse`s the body and casts it to WorkerEvent with
1062
- // no validation, so an event whose `operation` is missing, misspelled or
1063
- // from a newer producer reaches here. It must never be read as an expunge.
1064
- for (const [name, operation] of [
1065
- ["missing", undefined],
1066
- ["unknown", "trash"],
1067
- ["empty", ""],
1068
- ] as const) {
1069
- it(`abandons the delete and hands the row back when operation is ${name}`, async () => {
1070
- const malformed = {
1071
- ...moveEvent,
1072
- operation,
1073
- } as unknown as MessageDeleteEvent;
1074
-
1075
- await handleMessageDelete(malformed, noopLogger, 1, deps());
1076
-
1077
- assert.equal(
1078
- called("connection.deleteMessages").length,
1079
- 0,
1080
- "nothing may be expunged for an operation nobody wrote",
1081
- );
1082
- assert.equal(called("message.delete").length, 0);
1083
- assert.equal(called("threadMessage.delete").length, 0);
1084
-
1085
- // The row goes back to the mailbox the server still holds it in, so
1086
- // the failure is visible as the message reappearing rather than as an
1087
- // invisible syncStatus on a row that claims Trash. A refusal keeps
1088
- // `failed`: the product turned down something the user asked for.
1089
- assert.deepEqual(
1090
- called("message.transitionPlacement")[0]?.args,
1091
- restoredToSource("abandoned"),
1092
- );
1093
- assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
1094
- uid: 10,
1095
- mailboxId: "src-mbx",
1096
- isDeleted: false,
1097
- });
1098
- });
1099
- }
1100
-
1101
- it("refuses to expunge a move-to-trash that names no destination", async () => {
1102
- const destinationless = {
1103
- ...moveEvent,
1104
- destinationMailboxId: undefined,
1105
- destinationMailboxPath: undefined,
1106
- } as MessageDeleteEvent;
1107
-
1108
- await handleMessageDelete(destinationless, noopLogger, 1, deps());
1109
-
1110
- assert.equal(called("connection.deleteMessages").length, 0);
1111
- assert.equal(called("message.delete").length, 0);
1112
- assert.deepEqual(
1113
- called("message.transitionPlacement")[0]?.args,
1114
- restoredToSource("abandoned"),
1115
- );
1116
- });
1117
-
1118
- it("expunges on the server and removes every thread row before the message row", async () => {
1119
- await handleMessageDelete(permanent(), noopLogger, 1, deps());
1120
-
1121
- assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10]]);
1122
- assert.equal(called("threadMessage.delete").length, 2);
1123
- assert.ok(
1124
- h.calls.findIndex((c) => c.method === "threadMessage.delete") <
1125
- h.calls.findIndex((c) => c.method === "message.delete"),
1126
- "thread rows go first so no row outlives its message",
1127
- );
1128
- });
1129
-
1130
- // Regression on #212: a permanent delete the server has already applied must
1131
- // still reconcile the local rows, now that the text alone no longer decides.
1132
- it("cleans up locally and swallows the error when the message is confirmed gone on IMAP", async () => {
1133
- h.connection.deleteMessages = async () => {
1134
- throw new Error("NONEXISTENT uid");
1135
- };
1136
- sourceNoLongerHoldsTheUid();
1137
-
1138
- await handleMessageDelete(permanent(), noopLogger, 1, deps());
1139
-
1140
- assert.equal(called("message.delete").length, 1);
1141
- assert.equal(called("threadMessage.delete").length, 2);
1142
- });
1143
-
1144
- // Issue #845. The three cases below are the same bug from three directions:
1145
- // "not found" in an error string is never on its own a reason to destroy the
1146
- // only local record of live mail.
1147
-
1148
- it("keeps the rows when a move-to-trash fails after the MOVE landed", async () => {
1149
- // The MOVE succeeded; the ThreadMessage write behind it did not. The
1150
- // message is sitting in Trash, and the NotFoundError names a local row,
1151
- // not a mail-server one.
1152
- h.threadMessageUpdateError = Object.assign(
1153
- new Error("Thread message not found"),
1154
- { name: "NotFoundError" },
1155
- );
1156
- sourceNoLongerHoldsTheUid();
1157
-
1158
- await assert.rejects(
1159
- handleMessageDelete(moveEvent, noopLogger, 1, deps()),
1160
- /not found/,
1161
- );
1162
-
1163
- assert.equal(
1164
- called("message.delete").length,
1165
- 0,
1166
- "the message is in Trash; deleting its row loses the only handle on it",
1167
- );
1168
- assert.equal(called("threadMessage.delete").length, 0);
1169
- assert.equal(
1170
- called("connection.search").length,
1171
- 0,
1172
- "a move-to-trash never probes: absence from the source is its success signature",
1173
- );
1174
- assert.equal(attemptMarkerSyncStatus(), "failed");
1175
- });
1176
-
1177
- it("keeps the rows on a NONEXISTENT move-to-trash whose source still holds the uid", async () => {
1178
- h.connection.moveMessages = async () => {
1179
- throw new Error("NONEXISTENT: mailbox does not exist");
1180
- };
1181
-
1182
- await assert.rejects(
1183
- handleMessageDelete(moveEvent, noopLogger, 1, deps()),
1184
- /NONEXISTENT/,
1185
- );
1186
-
1187
- assert.equal(called("message.delete").length, 0);
1188
- assert.equal(called("threadMessage.delete").length, 0);
1189
- assert.equal(attemptMarkerSyncStatus(), "failed");
1190
- });
1191
-
1192
- it("keeps the rows when a permanent delete's source still holds the uid", async () => {
1193
- h.connection.deleteMessages = async () => {
1194
- throw new Error("NONEXISTENT uid");
1195
- };
1196
- // imapflow drops rows on back-to-back FETCHes (#408); the SEARCH is what
1197
- // the verdict rests on, and it still lists the uid.
1198
- h.connection.fetchMessages = async () => [];
1199
-
1200
- await assert.rejects(
1201
- handleMessageDelete(permanent(), noopLogger, 1, deps()),
1202
- /NONEXISTENT/,
1203
- );
1204
-
1205
- assert.equal(
1206
- called("connection.search").length,
1207
- 1,
1208
- "the error text only selects a candidate; the source box decides",
1209
- );
1210
- assert.equal(called("message.delete").length, 0);
1211
- assert.equal(called("threadMessage.delete").length, 0);
1212
- });
1213
-
1214
- it("keeps the rows and rethrows the original error when the probe cannot answer", async () => {
1215
- // The SELECT is what failed, so there is no open box left for the probe
1216
- // to ask. The IMAP error must still be the one that rejects, and the row
1217
- // must not be stranded mid-delete for the record to carry to the DLQ.
1218
- h.connection.openBox = async () => {
1219
- throw new Error("NONEXISTENT mailbox does not exist");
1220
- };
1221
- h.connection.fetchMessages = async () => {
1222
- throw new Error("No mailbox selected");
1223
- };
1224
-
1225
- await assert.rejects(
1226
- handleMessageDelete(permanent(), noopLogger, 1, deps()),
1227
- /NONEXISTENT mailbox does not exist/,
1228
- );
1229
-
1230
- assert.equal(called("message.delete").length, 0);
1231
- assert.equal(called("threadMessage.delete").length, 0);
1232
- assert.equal(attemptMarkerSyncStatus(), "failed");
1233
- });
1234
-
1235
- it("abandons rather than creating the destination on TRYCREATE", async () => {
1236
- // Creating it resurrects an empty `Trash` beside the real one, and the
1237
- // name-hint rule then has two folders to choose between. The picker
1238
- // offers the ones the account actually has.
1239
- h.connection.moveMessages = async () => {
1240
- throw new Error("TRYCREATE: no such mailbox");
1241
- };
1242
-
1243
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1244
-
1245
- assert.equal(called("connection.createMailbox").length, 0);
1246
- assert.deepEqual(
1247
- called("message.transitionPlacement")[0]?.args,
1248
- restoredToSource("abandoned"),
1249
- );
1250
- assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
1251
- uid: 10,
1252
- mailboxId: "src-mbx",
1253
- isDeleted: false,
1254
- });
1255
- });
1256
-
1257
- // Removing the Message row is destructive, and the refusal paths reach it on
1258
- // whatever the row happens to be — an unrecognised operation is answered on
1259
- // an `active` row as readily as on a `deleting` one. So the removal is
1260
- // claimed first, and a row with no delete outstanding is left alone.
1261
- it("never removes a row no delete is outstanding on", async () => {
1262
- h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "active" };
1263
- h.allThreadMessages = [];
1264
- h.transitionLost = true;
1265
- const malformed = {
1266
- ...moveEvent,
1267
- operation: undefined,
1268
- } as unknown as MessageDeleteEvent;
1269
-
1270
- await handleMessageDelete(malformed, noopLogger, 1, deps());
1271
-
1272
- assert.deepEqual(
1273
- called("message.transitionPlacement")[0]?.args[1],
1274
- { status: ["moving", "deleting"] },
1275
- "the removal is claimed off a placement this delete owns",
1276
- );
1277
- assert.equal(
1278
- called("message.delete").length,
1279
- 0,
1280
- "and a lost claim removes nothing",
1281
- );
1282
- });
1283
-
1284
- it("removes the row once the claim on it wins", async () => {
1285
- h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "deleting" };
1286
- h.allThreadMessages = [];
1287
- const malformed = {
1288
- ...moveEvent,
1289
- operation: undefined,
1290
- } as unknown as MessageDeleteEvent;
1291
-
1292
- await handleMessageDelete(malformed, noopLogger, 1, deps());
1293
-
1294
- assert.deepEqual(
1295
- called("message.delete").map((c) => c.args[0]),
1296
- ["msg-1"],
1297
- );
1298
- });
1299
-
1300
- it("abandons an event minted under an unknown contract, before connecting", async () => {
1301
- const unversioned = {
1302
- ...moveEvent,
1303
- schemaVersion: undefined,
1304
- } as unknown as MessageDeleteEvent;
1305
-
1306
- await handleMessageDelete(unversioned, noopLogger, 1, deps());
1307
-
1308
- assert.equal(h.getConnectionCount, 0);
1309
- assert.equal(called("connection.deleteMessages").length, 0);
1310
- assert.deepEqual(
1311
- called("message.transitionPlacement")[0]?.args,
1312
- restoredToSource("abandoned"),
1313
- );
1314
- assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
1315
- uid: 10,
1316
- mailboxId: "src-mbx",
1317
- isDeleted: false,
1318
- });
1319
- });
1320
-
1321
- it("hands back every listing row a message has, not just the first", async () => {
1322
- const unversioned = {
1323
- ...moveEvent,
1324
- schemaVersion: undefined,
1325
- } as unknown as MessageDeleteEvent;
1326
-
1327
- await handleMessageDelete(unversioned, noopLogger, 1, deps());
1328
-
1329
- assert.deepEqual(
1330
- called("threadMessage.update").map((c) => c.args[1]),
1331
- ["tm-1", "tm-2"],
1332
- );
1333
- });
1334
-
1335
- it("finishes the removal when an abandoned delete has no listing rows left", async () => {
1336
- // A permanent delete removes them before it enqueues, and they cannot be
1337
- // rebuilt here. Restoring the Message alone leaves mail nothing can list,
1338
- // which is worse than either consistent state; the server copy survives
1339
- // and a full sync brings it back.
1340
- h.allThreadMessages = [];
1341
- const unversioned = {
1342
- ...permanent(),
1343
- schemaVersion: undefined,
1344
- } as unknown as MessageDeleteEvent;
1345
-
1346
- await handleMessageDelete(unversioned, noopLogger, 1, deps());
1347
-
1348
- assert.equal(called("connection.deleteMessages").length, 0);
1349
- assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
1350
- assert.equal(
1351
- called("message.update").length,
1352
- 0,
1353
- "never leave a Message row no listing can reach",
1354
- );
1355
- });
1356
-
1357
- it("marks failed and rethrows on an unclassified IMAP error", async () => {
1358
- h.connection.moveMessages = async () => {
1359
- throw new Error("server exploded");
1360
- };
1361
-
1362
- await assert.rejects(
1363
- handleMessageDelete(moveEvent, noopLogger, 1, deps()),
1364
- /server exploded/,
1365
- );
1366
-
1367
- assert.equal(attemptMarkerSyncStatus(), "failed");
1368
- });
1369
-
1370
- // Issue #1203. The pause is thrown by the openBox guard before the MOVE is
1371
- // issued, so acking left the row `moving` on Trash with the source's uid.
1372
- // Nothing re-enqueues a MESSAGE_DELETE, and the cursor rebuild matches rows
1373
- // by `(accountConfigId, mailboxId)`, so a row naming Trash is in neither
1374
- // folder's set — the message stayed undeletable and unmovable for good.
1375
- it("hands the optimistic trash move back when openBox trips a UIDVALIDITY mismatch", async () => {
1376
- h.connection.openBox = async () => ({ uidvalidity: 999 });
1377
-
1378
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1379
-
1380
- assert.equal(
1381
- (called("mailbox.update")[0]?.args[2] as { cursorState?: string })
1382
- ?.cursorState,
1383
- "cursor_invalid",
1384
- );
1385
- assert.deepEqual(
1386
- called("message.transitionPlacement")[0]?.args,
1387
- restoredToSource("synced"),
1388
- "the row goes back to the source pair, which is the set the rebuild adjudicates",
1389
- );
1390
- assert.equal(
1391
- (called("threadMessage.update")[0]?.args[2] as { isDeleted?: boolean })
1392
- ?.isDeleted,
1393
- false,
1394
- "the listing rows lose the deletion mark the optimistic write put on them",
1395
- );
1396
- });
1397
-
1398
- // The terminal resolver opens the source itself, and since #1005 its answer
1399
- // WRITES a placement. A folder deleted and recreated renumbers every uid, so
1400
- // a uid-keyed probe finds whatever now sits at this one, reads it as "still
1401
- // at source", and binds the row to a stranger the next permanent delete
1402
- // expunges. The dropped connection is what makes it reachable: it skips the
1403
- // guarded openBox on the very delivery that goes on to settle.
1404
- it("never settles an exhausted delete off a uid-keyed probe that skipped the cursor guard", async () => {
1405
- h.connectionErrors = [new Error("ECONNRESET")];
1406
- h.connection.openBox = async (path: string) => {
1407
- h.openBoxPath = path;
1408
- return { uidvalidity: 999 };
1409
- };
1410
-
1411
- await handleMessageDelete(
1412
- moveEvent,
1413
- noopLogger,
1414
- MESSAGE_DELETE_MAX_ATTEMPTS,
1415
- deps(),
1416
- );
1417
-
1418
- assert.equal(
1419
- called("connection.fetchMessages").length,
1420
- 0,
1421
- "the guard refused the SELECT, so nothing was settled off a uid on the dead axis",
1422
- );
1423
- assert.equal(
1424
- called("message.delete").length,
1425
- 0,
1426
- "a renumbered folder answers for a different message; nothing is reconciled away off it",
1427
- );
1428
- assert.equal(
1429
- (called("mailbox.update")[0]?.args[2] as { cursorState?: string })
1430
- ?.cursorState,
1431
- "cursor_invalid",
1432
- "the probe trips the mailbox into a rebuild instead",
1433
- );
1434
- // Issue #1203: "leaving it for the cursor rebuild" was never an option —
1435
- // the rebuild matches rows by `(accountConfigId, mailboxId)`, so it only
1436
- // sees this row once the row names the source again. The source still
1437
- // answers to the Message-ID, so the pair the row goes back on is the one
1438
- // the server has just confirmed on the axis a UIDVALIDITY change leaves
1439
- // intact.
1440
- assert.deepEqual(
1441
- called("message.transitionPlacement")[0]?.args,
1442
- restoredToSource("synced"),
1443
- );
1444
- });
1445
-
1446
- // Issue #1203, the redelivery half, and the mail-loss edge in it. INBOX was
1447
- // recreated (UIDVALIDITY bumped, which is why the cursor is paused), the
1448
- // MOVE never ran, and INBOX honestly answers "no" for the Message-ID because
1449
- // it no longer holds anything it used to. Trash holds an older copy of the
1450
- // same Message-ID at uid 77 — a sieve `fileinto` + `keep`, a resend — which
1451
- // `deriveMessageId` folds into this one local row. Settling `moved` on 77
1452
- // binds the row to that copy, and the next permanent delete expunges it:
1453
- // Trash is not paused, so no rebuild is ever coming to adjudicate there.
1454
- it("never settles a redelivered paused trash move as moved off a destination sighting", async () => {
1455
- h.mailbox = {
1456
- mailboxId: "src-mbx",
1457
- uidValidity: 1,
1458
- cursorState: "cursor_invalid",
1459
- };
1460
- h.sourceSearchUids = [];
1461
- h.destinationSearchUids = [77];
1462
-
1463
- await handleMessageDelete(moveEvent, capturingLog(), 2, deps());
1464
-
1465
- assert.equal(
1466
- called("message.updateUid").length,
1467
- 0,
1468
- "a sighting off a source whose uid axis has moved is not proof this delete ran",
1469
- );
1470
- // The source pair is the set its own cursor rebuild walks and adjudicates
1471
- // by Message-ID, so handing the row back there is the reconcile path.
1472
- assert.deepEqual(
1473
- called("message.transitionPlacement")[0]?.args,
1474
- restoredToSource("abandoned"),
1475
- );
1476
- assert.equal(
1477
- loggedWith("alert", "message_delete_paused_placement_unproven").length,
1478
- 1,
1479
- "a placement the server never settled is an operator's to adjudicate",
1480
- );
1481
- });
1482
-
1483
- // Issue #1203 and the taxonomy `message-copy.ts` already applies: an
1484
- // evidence-free redelivery is broken, not settled. With no Message-ID header
1485
- // neither folder was asked, and silence is not an answer to write `synced`
1486
- // on. Route in: attempt 1 issues the MOVE, the confirmation comes back
1487
- // unprobeable, and the exhausted settle's guarded openBox trips the pause.
1488
- // `receiveCount === 1` there is not proof the command never left.
1489
- it("settles a paused delete broken when the MOVE was issued and neither folder can be asked", async () => {
1490
- h.connection.moveMessages = async () => ({ uidMap: new Map() });
1491
- sourceNoLongerHoldsTheUid();
1492
- h.messageRow = { status: "moving" };
1493
- let opened = 0;
1494
- h.connection.openBox = async (path: string) => {
1495
- h.openBoxPath = path;
1496
- opened += 1;
1497
- // The folder was recreated between the confirmation probe and the
1498
- // settle, which is what the terminal resolver's guarded openBox
1499
- // refuses to settle off.
1500
- return { uidvalidity: opened > 2 ? 999 : 1 };
1501
- };
1502
-
1503
- await handleMessageDelete(moveEvent, capturingLog(), 1, deps());
1504
-
1505
- assert.deepEqual(
1506
- called("message.transitionPlacement")[0]?.args,
1507
- restoredToSource("abandoned"),
1508
- "the first attempt reaches this settle with the MOVE already issued, so `synced` claims what nothing confirmed",
1509
- );
1510
- assert.equal(
1511
- loggedWith("alert", "message_delete_paused_placement_unproven").length,
1512
- 1,
1513
- );
1514
- });
1515
-
1516
- // The user's delete was silently dropped. Nothing throws, nothing alerts and
1517
- // the row reads settled, so the only trace it ever leaves is this metric —
1518
- // the counterpart of copy's `message_copy_not_landed`.
1519
- it("counts a paused delete the server was never told about", async () => {
1520
- h.mailbox = {
1521
- mailboxId: "src-mbx",
1522
- uidValidity: 1,
1523
- cursorState: "rebuilding",
1524
- };
1525
-
1526
- await handleMessageDelete(moveEvent, capturingLog(), 1, deps());
1527
-
1528
- const dropped = loggedWith("metric", "message_delete_dropped_on_pause");
1529
- assert.equal(dropped.length, 1);
1530
- assert.equal(dropped[0]?.level, "warn");
1531
- assert.deepEqual(
1532
- called("message.transitionPlacement")[0]?.args,
1533
- restoredToSource("synced"),
1534
- );
1535
- });
1536
-
1537
- // The listing rows decide the outcome, so the caller has already read them.
1538
- // Reading them a second time inside `restoreSourcePlacement` is a query per
1539
- // hand-back for an answer already in hand.
1540
- it("reads the listing rows once per hand-back", async () => {
1541
- h.mailbox = {
1542
- mailboxId: "src-mbx",
1543
- uidValidity: 1,
1544
- cursorState: "rebuilding",
1545
- };
1546
-
1547
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1548
-
1549
- assert.equal(called("threadMessage.findAllByMessageId").length, 1);
1550
- });
1551
-
1552
- // A permanent delete has no destination, so the resync was gated off
1553
- // entirely: `handBackDelete` removed the local row, nothing enqueued a
1554
- // SYNC_MESSAGES, and the mail was invisible until the next scheduled sync
1555
- // while the server still held it. One folder is all this event touches, and
1556
- // SYNC_MESSAGES on a paused mailbox routes to the cursor rebuild.
1557
- it("resyncs the source folder after a paused permanent delete removes the local rows", async () => {
1558
- h.mailbox = {
1559
- mailboxId: "src-mbx",
1560
- uidValidity: 1,
1561
- cursorState: "rebuilding",
1562
- };
1563
- h.allThreadMessages = [];
1564
-
1565
- await handleMessageDelete(permanent(), capturingLog(), 1, deps());
1566
-
1567
- assert.deepEqual(
1568
- called("emitEvent").map((c) => c.args[0]),
1569
- [{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" }],
1570
- "the folder whose rows just went must be told to re-project them",
1571
- );
1572
- });
1573
-
1574
- // The pause is the expected skip the guard doc calls never-a-fault, so it
1575
- // alerted on every routine paused permanent delete. The fact that local mail
1576
- // went while the server copy survived still has to be readable.
1577
- it("does not alert on a routine paused permanent delete", async () => {
1578
- h.mailbox = {
1579
- mailboxId: "src-mbx",
1580
- uidValidity: 1,
1581
- cursorState: "rebuilding",
1582
- };
1583
- h.allThreadMessages = [];
1584
-
1585
- await handleMessageDelete(permanent(), capturingLog(), 1, deps());
1586
-
1587
- assert.deepEqual(
1588
- h.logs.filter((line) => line.level === "error"),
1589
- [],
1590
- "a pause the guard expects never pages anyone",
1591
- );
1592
- const dropped = loggedWith("metric", "message_delete_dropped_on_pause");
1593
- assert.equal(dropped.length, 1);
1594
- assert.equal(
1595
- dropped[0]?.fields.localRowsRemoved,
1596
- true,
1597
- "local mail disappearing while the server still holds it stays visible",
1598
- );
1599
- });
1600
-
1601
- // A refusal is not a pause: the product declined something the user asked
1602
- // for, and that still wakes someone.
1603
- it("keeps alerting when a refused delete removed the last listing rows", async () => {
1604
- h.allThreadMessages = [];
1605
- const unversioned = {
1606
- ...permanent(),
1607
- schemaVersion: undefined,
1608
- } as unknown as MessageDeleteEvent;
1609
-
1610
- await handleMessageDelete(unversioned, capturingLog(), 1, deps());
1611
-
1612
- assert.equal(
1613
- loggedWith("alert", "message_delete_unknown_schema_version").length,
1614
- 1,
1615
- );
1616
- });
1617
-
1618
- // Issue #1122 on the same path. Trash already held an older copy of this
1619
- // Message-ID and the MOVE never ran, so the destination hit is not this
1620
- // message. The source is asked first for exactly this reason, and its answer
1621
- // ends it — settling on uid 12 would hand Empty Trash a stranger's uid.
1622
- it("never binds a redelivered paused trash move to an older copy while the source still answers", async () => {
1623
- h.mailbox = {
1624
- mailboxId: "src-mbx",
1625
- uidValidity: 1,
1626
- cursorState: "cursor_invalid",
1627
- };
1628
- h.sourceSearchUids = [4];
1629
- h.destinationSearchUids = [12];
1630
-
1631
- await handleMessageDelete(moveEvent, noopLogger, 2, deps());
1632
-
1633
- assert.equal(called("message.updateUid").length, 0);
1634
- assert.deepEqual(
1635
- called("message.transitionPlacement")[0]?.args,
1636
- restoredToSource("synced"),
1637
- );
1638
- });
1639
-
1640
- // A paused settle is a settle, so both folders re-read their counts from
1641
- // IMAP — the same resync every other terminal verdict in this handler runs.
1642
- // Without it the repair depended entirely on someone else arming the
1643
- // mailbox's rebuild.
1644
- it("resyncs both folders after a paused delete settles", async () => {
1645
- h.mailbox = {
1646
- mailboxId: "src-mbx",
1647
- uidValidity: 1,
1648
- cursorState: "rebuilding",
1649
- };
1650
-
1651
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1652
-
1653
- assert.deepEqual(
1654
- called("emitEvent").map((c) => c.args[0]),
1655
- [
1656
- { type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
1657
- { type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "trash-mbx" },
1658
- ],
1659
- );
1660
- });
1661
-
1662
- it("skips without opening a connection when the cursor is rebuilding", async () => {
1663
- h.mailbox = {
1664
- mailboxId: "src-mbx",
1665
- uidValidity: 1,
1666
- cursorState: "rebuilding",
1667
- };
1668
-
1669
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1670
-
1671
- assert.equal(h.getConnectionCount, 0);
1672
- assert.deepEqual(
1673
- called("message.transitionPlacement")[0]?.args,
1674
- restoredToSource("synced"),
1675
- );
1676
- });
1677
-
1678
- // A permanent delete drops its listing rows at enqueue and only the sync
1679
- // path can shape them, so restoring the Message alone leaves mail nothing
1680
- // can list. The row is removed instead and the mailbox's own resync
1681
- // re-projects it — leaving it `deleting` made the message invisible locally
1682
- // while the server still held it (issue #1203). The rule is the listing
1683
- // rows, the same one `abandonDelete` resolves this state by, not the
1684
- // operation on the event.
1685
- it("removes a paused delete that has no listing rows left to restore", async () => {
1686
- h.mailbox = {
1687
- mailboxId: "src-mbx",
1688
- uidValidity: 1,
1689
- cursorState: "rebuilding",
1690
- };
1691
- h.allThreadMessages = [];
1692
-
1693
- await handleMessageDelete(permanent(), noopLogger, 1, deps());
1694
-
1695
- assert.equal(h.getConnectionCount, 0);
1696
- assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
1697
- assert.equal(
1698
- restoreCalls().length,
1699
- 0,
1700
- "a row no listing can reach is never restored",
1701
- );
1702
- });
1703
-
1704
- it("removes a paused move to trash whose listing rows are already gone", async () => {
1705
- h.mailbox = {
1706
- mailboxId: "src-mbx",
1707
- uidValidity: 1,
1708
- cursorState: "rebuilding",
1709
- };
1710
- h.allThreadMessages = [];
1711
-
1712
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1713
-
1714
- assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
1715
- assert.equal(
1716
- restoreCalls().length,
1717
- 0,
1718
- "restoring a Message no listing can reach is the silent vanish, not a hand-back",
1719
- );
1720
- });
1721
-
1722
- it("acks terminally without connecting when the mailbox was deleted", async () => {
1723
- h.mailboxError = Object.assign(new Error("Mailbox not found: src-mbx"), {
1724
- name: "NotFoundError",
1725
- });
1726
-
1727
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1728
-
1729
- assert.equal(h.getConnectionCount, 0);
1730
- assert.equal(called("message.updateUid").length, 0);
1731
- assert.equal(called("message.delete").length, 0);
1732
- });
1733
-
1734
- it("returns early without connecting when the account is soft-deleted", async () => {
1735
- h.account = {
1736
- accountId: "acc-1",
1737
- accountConfigId: "cfg-1",
1738
- deletedAt: Date.now(),
1739
- };
1740
-
1741
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1742
-
1743
- assert.equal(h.getConnectionCount, 0);
1744
- });
1745
-
1746
- it("throws when the account no longer exists", async () => {
1747
- h.account = null;
1748
-
1749
- await assert.rejects(
1750
- handleMessageDelete(moveEvent, noopLogger, 1, deps()),
1751
- /not found/,
1752
- );
1753
- });
1754
- });
1755
-
1756
- describe("handleMessageDelete — folder paths come from the mailbox rows", () => {
1757
- beforeEach(() => {
1758
- h = fresh();
1759
- });
1760
-
1761
- const movedTo = (): string[] => {
1762
- const destinations: string[] = [];
1763
- h.connection.moveMessages = async (
1764
- _uids: number[],
1765
- destination: string,
1766
- ) => {
1767
- destinations.push(destination);
1768
- return { uidMap: new Map([[10, 20]]) };
1769
- };
1770
- return destinations;
1771
- };
1772
-
1773
- it("runs a delete enqueued with the version 2 payload that carries folder paths", async () => {
1774
- const destinations = movedTo();
1775
-
1776
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1777
-
1778
- assert.deepEqual(destinations, ["Trash"]);
1779
- assert.deepEqual(called("message.updateUid")[0]?.args, [
1780
- "msg-1",
1781
- 20,
1782
- "trash-mbx",
1783
- ]);
1784
- });
1785
-
1786
- it("runs a delete enqueued with the pathless payload", async () => {
1787
- const destinations = movedTo();
1788
- const pathless: MessageDeleteEvent = {
1789
- type: "MESSAGE_DELETE",
1790
- schemaVersion: 3,
1791
- eventId: "evt-1",
1792
- timestamp: 1,
1793
- accountId: "acc-1",
1794
- messageId: "msg-1",
1795
- mailboxId: "src-mbx",
1796
- uid: 10,
1797
- operation: "move_to_trash",
1798
- destinationMailboxId: "trash-mbx",
1799
- };
1800
-
1801
- await handleMessageDelete(pathless, noopLogger, 1, deps());
1802
-
1803
- assert.deepEqual(destinations, ["Trash"]);
1804
- assert.deepEqual(called("message.updateUid")[0]?.args, [
1805
- "msg-1",
1806
- 20,
1807
- "trash-mbx",
1808
- ]);
1809
- });
1810
-
1811
- it("moves to Trash under its current path when the folder was renamed after enqueue", async () => {
1812
- const destinations = movedTo();
1813
- const opened: string[] = [];
1814
- const openBox = h.connection.openBox;
1815
- h.connection.openBox = async (path: string) => {
1816
- opened.push(path);
1817
- return openBox(path);
1818
- };
1819
- h.folderPaths = { "src-mbx": "Mail/INBOX", "trash-mbx": "Deleted Items" };
1820
-
1821
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1822
-
1823
- assert.equal(opened[0], "Mail/INBOX");
1824
- assert.deepEqual(destinations, ["Deleted Items"]);
1825
- });
1826
-
1827
- it("opens the source under its current path when it was renamed after enqueue", async () => {
1828
- const destinations = movedTo();
1829
- const opened: string[] = [];
1830
- const openBox = h.connection.openBox;
1831
- h.connection.openBox = async (path: string) => {
1832
- opened.push(path);
1833
- return openBox(path);
1834
- };
1835
- h.folderPaths = { "src-mbx": "Old/Inbox-renamed", "trash-mbx": "Trash" };
1836
-
1837
- await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1838
-
1839
- assert.equal(opened[0], "Old/Inbox-renamed");
1840
- assert.deepEqual(destinations, ["Trash"]);
1841
- });
1842
-
1843
- it("resyncs the source when a redelivered delete finds the Trash mailbox deleted", async () => {
1844
- h.goneMailboxIds = ["trash-mbx"];
1845
-
1846
- await handleMessageDelete(moveEvent, noopLogger, 2, deps());
1847
-
1848
- assert.deepEqual(called("emitEvent")[0]?.args, [
1849
- { type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
1850
- ]);
1851
- });
1852
-
1853
- it("hands the row back without connecting when the Trash mailbox was deleted", async () => {
1854
- h.goneMailboxIds = ["trash-mbx"];
1855
- const destinations = movedTo();
1856
-
1857
- await handleMessageDelete(moveEvent, capturingLog(), 1, deps());
1858
-
1859
- assert.equal(h.getConnectionCount, 0);
1860
- assert.deepEqual(destinations, []);
1861
- assert.equal(called("connection.deleteMessages").length, 0);
1862
- assert.equal(called("message.delete").length, 0);
1863
- assert.deepEqual(restoreCalls()[0]?.args, restoredToSource("abandoned"));
1864
- assert.equal(
1865
- loggedWith("alert", "message_delete_destination_gone").length,
1866
- 1,
1867
- );
1868
- });
1869
- });