@remit/imap-worker 0.0.85 → 0.0.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) 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.test.ts +32 -4
  8. package/src/emit.ts +58 -23
  9. package/src/failure-kind.ts +1 -1
  10. package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
  11. package/src/handlers/append-sent-message.ts +3 -24
  12. package/src/handlers/delete-account-objects.test.ts +1 -1
  13. package/src/handlers/delete-account-objects.ts +1 -1
  14. package/src/handlers/empty-trash.sqlite.test.ts +209 -0
  15. package/src/handlers/empty-trash.ts +3 -24
  16. package/src/handlers/flag-push.ts +11 -18
  17. package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
  18. package/src/handlers/mailbox-management.ts +12 -48
  19. package/src/handlers/message-copy.sqlite.test.ts +298 -0
  20. package/src/handlers/message-copy.ts +31 -49
  21. package/src/handlers/message-delete-terminal.ts +12 -10
  22. package/src/handlers/message-delete.sqlite.test.ts +291 -0
  23. package/src/handlers/message-delete.ts +26 -32
  24. package/src/handlers/message-move-terminal.ts +12 -10
  25. package/src/handlers/message-move.sqlite.test.ts +271 -0
  26. package/src/handlers/message-move.test.ts +76 -863
  27. package/src/handlers/message-move.ts +49 -39
  28. package/src/handlers/placement-move-push.test.ts +96 -329
  29. package/src/handlers/placement-move-push.ts +75 -29
  30. package/src/handlers/placement-push.sqlite.test.ts +298 -0
  31. package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
  32. package/src/handlers/probe-message-id.ts +15 -0
  33. package/src/handlers/sync-mailboxes.test.ts +0 -1
  34. package/src/handlers/sync-mailboxes.ts +1 -1
  35. package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
  36. package/src/handlers/sync-message-body.test.ts +3 -299
  37. package/src/handlers/sync-message-body.ts +21 -11
  38. package/src/handlers/sync-messages.sqlite.test.ts +183 -0
  39. package/src/handlers/sync-messages.ts +35 -34
  40. package/src/index.ts +5 -3
  41. package/src/mail-sync-selection.sqlite.test.ts +20 -18
  42. package/src/pending-sync.sqlite.test.ts +167 -0
  43. package/src/poller.ts +5 -4
  44. package/src/processor.test.ts +1 -1
  45. package/src/processor.ts +1 -1
  46. package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
  47. package/src/scheduler/attachment-sweep.ts +1 -1
  48. package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
  49. package/src/scheduler/calendar-subscriptions.ts +6 -6
  50. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
  51. package/src/scheduler/handler.ts +12 -1
  52. package/src/scheduler/loop.test.ts +2 -0
  53. package/src/scheduler/run-tick.sqlite.test.ts +192 -0
  54. package/src/scheduler/run-tick.ts +39 -1
  55. package/src/scheduler/runner.ts +21 -1
  56. package/src/scheduler/stranded-placement-redrive.ts +265 -0
  57. package/src/stranded-placement.sqlite.test.ts +795 -0
  58. package/src/test-helpers/test-world.ts +398 -0
  59. package/src/thread-join.sqlite.test.ts +115 -0
  60. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
  61. package/src/with-oauth-lifecycle.ts +1 -1
  62. package/src/handlers/append-sent-message.test.ts +0 -529
  63. package/src/handlers/empty-trash.test.ts +0 -728
  64. package/src/handlers/flag-push-placement-race.test.ts +0 -385
  65. package/src/handlers/flag-push.test.ts +0 -325
  66. package/src/handlers/mailbox-management.test.ts +0 -850
  67. package/src/handlers/message-copy.test.ts +0 -654
  68. package/src/handlers/message-delete.test.ts +0 -1869
  69. package/src/handlers/message-move-terminal.test.ts +0 -412
  70. package/src/handlers/sync-messages.test.ts +0 -567
  71. package/src/scheduler/attachment-sweep.test.ts +0 -169
  72. package/src/scheduler/run-tick.test.ts +0 -478
  73. package/src/with-oauth-lifecycle.test.ts +0 -318
@@ -1,850 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { beforeEach, describe, it } from "node:test";
3
- import { noopLogger } from "@remit/logger-lambda/noop-logger";
4
- import type {
5
- MailboxCreateEvent,
6
- MailboxDeleteEvent,
7
- MailboxRenameEvent,
8
- } from "../events.js";
9
- import {
10
- type MailboxManagementDeps,
11
- processMailboxManagement,
12
- } from "./mailbox-management.js";
13
-
14
- interface Call {
15
- method: string;
16
- args: unknown[];
17
- }
18
-
19
- interface Connection {
20
- createMailbox: (path: string) => Promise<{ created: boolean }>;
21
- subscribeMailbox: (path: string) => Promise<void>;
22
- listMailboxes: () => Promise<{ fullPath: string }[]>;
23
- openBox: (
24
- path: string,
25
- readOnly?: boolean,
26
- ) => Promise<{
27
- uidvalidity: number;
28
- uidnext: number;
29
- messages: { total: number };
30
- }>;
31
- closeBox: () => Promise<void>;
32
- renameMailbox: (
33
- oldPath: string,
34
- newPath: string,
35
- ) => Promise<{ path: string; newPath: string }>;
36
- deleteMailbox: (path: string) => Promise<void>;
37
- }
38
-
39
- /** The folder rows the handler reads its guards and its settle row-set from. */
40
- interface Row {
41
- mailboxId: string;
42
- accountId: string;
43
- fullPath: string;
44
- hierarchyDelimiter: string;
45
- namespacePrefix: string;
46
- syncStatus: string;
47
- pendingPath?: string;
48
- }
49
-
50
- interface Harness {
51
- calls: Call[];
52
- account: {
53
- accountId: string;
54
- accountConfigId: string;
55
- deletedAt?: number;
56
- } | null;
57
- connection: Connection;
58
- rows: Map<string, Row>;
59
- mailboxUpdateError?: Error;
60
- mailboxRowGone?: boolean;
61
- disconnectCount: number;
62
- }
63
-
64
- let h: Harness;
65
-
66
- const record =
67
- (method: string) =>
68
- async (...args: unknown[]) => {
69
- h.calls.push({ method, args });
70
- };
71
-
72
- const buildConnection = (): Connection => ({
73
- createMailbox: async (path: string) => {
74
- h.calls.push({ method: "connection.createMailbox", args: [path] });
75
- return { created: true };
76
- },
77
- subscribeMailbox: record("connection.subscribeMailbox"),
78
- listMailboxes: async () => [{ fullPath: "Archive" }],
79
- openBox: async () => ({
80
- uidvalidity: 7,
81
- uidnext: 42,
82
- messages: { total: 3 },
83
- }),
84
- closeBox: record("connection.closeBox"),
85
- renameMailbox: async (oldPath: string, newPath: string) => {
86
- h.calls.push({
87
- method: "connection.renameMailbox",
88
- args: [oldPath, newPath],
89
- });
90
- return { path: oldPath, newPath };
91
- },
92
- deleteMailbox: record("connection.deleteMailbox"),
93
- });
94
-
95
- const row = (over: Partial<Row> & { mailboxId: string }): Row => ({
96
- accountId: "acc-1",
97
- fullPath: "Archive",
98
- hierarchyDelimiter: "/",
99
- namespacePrefix: "",
100
- syncStatus: "pending",
101
- ...over,
102
- });
103
-
104
- /** The server holding neither path is what confirms the folder is gone. */
105
- const listsNeitherPath = (): void => {
106
- h.connection.listMailboxes = async () => [{ fullPath: "Iets anders" }];
107
- };
108
-
109
- const fresh = (): Harness => ({
110
- calls: [],
111
- account: { accountId: "acc-1", accountConfigId: "cfg-1" },
112
- connection: buildConnection(),
113
- // A create in flight: `pending` with no recorded rename target, which is
114
- // what tells it apart from a rename in flight (D3).
115
- rows: new Map([["mbx-1", row({ mailboxId: "mbx-1" })]]),
116
- disconnectCount: 0,
117
- });
118
-
119
- const deps = (): MailboxManagementDeps =>
120
- ({
121
- getClient: async () => ({
122
- account: {
123
- get: async (accountId: string) => {
124
- h.calls.push({ method: "account.get", args: [accountId] });
125
- return h.account;
126
- },
127
- },
128
- accountSetting: {
129
- get: async () => undefined,
130
- upsert: record("accountSetting.upsert"),
131
- delete: record("accountSetting.delete"),
132
- },
133
- mailbox: {
134
- get: async (accountId: string, mailboxId: string) => {
135
- h.calls.push({ method: "mailbox.get", args: [accountId, mailboxId] });
136
- const found = h.mailboxRowGone
137
- ? undefined
138
- : h.rows.get(mailboxId as string);
139
- if (!found) {
140
- throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
141
- name: "NotFoundError",
142
- });
143
- }
144
- return found;
145
- },
146
- update: async (...args: unknown[]) => {
147
- h.calls.push({ method: "mailbox.update", args });
148
- if (h.mailboxUpdateError) throw h.mailboxUpdateError;
149
- },
150
- // The conditional write the folder state now goes through, with the
151
- // predicate honoured: a row that is gone or that somebody else moved
152
- // matches nothing, so the loser gets a null rather than a throw and
153
- // the handler raises the NotFoundError itself.
154
- transition: async (...args: unknown[]) => {
155
- h.calls.push({ method: "mailbox.transition", args });
156
- if (h.mailboxRowGone) return null;
157
- const mailboxId = args[1] as string;
158
- const intent = args[2] as {
159
- from: readonly string[];
160
- wherePendingPath?: string | null;
161
- to: string;
162
- set?: { fullPath?: string };
163
- };
164
- const current = h.rows.get(mailboxId);
165
- if (!current) return null;
166
- if (!intent.from.includes(current.syncStatus)) return null;
167
- if (
168
- intent.wherePendingPath !== undefined &&
169
- (intent.wherePendingPath ?? undefined) !== current.pendingPath
170
- ) {
171
- return null;
172
- }
173
- const next: Row = {
174
- ...current,
175
- ...(intent.set?.fullPath !== undefined
176
- ? { fullPath: intent.set.fullPath }
177
- : {}),
178
- syncStatus: intent.to,
179
- };
180
- if (intent.to !== "pending" && intent.to !== "failed") {
181
- next.pendingPath = undefined;
182
- }
183
- h.rows.set(mailboxId, next);
184
- return next;
185
- },
186
- findByPathPrefix: async (...args: unknown[]) => {
187
- h.calls.push({ method: "mailbox.findByPathPrefix", args });
188
- return [];
189
- },
190
- findBySyncStatus: async (accountId: string, syncStatus: string) => {
191
- h.calls.push({
192
- method: "mailbox.findBySyncStatus",
193
- args: [accountId, syncStatus],
194
- });
195
- if (h.mailboxRowGone) return [];
196
- return [...h.rows.values()].filter(
197
- (r) => r.syncStatus === syncStatus,
198
- );
199
- },
200
- delete: record("mailbox.delete"),
201
- deleteMailboxWithMail: record("mailbox.deleteMailboxWithMail"),
202
- },
203
- secrets: {},
204
- }),
205
- buildLifecycleDeps: () => ({}),
206
- withOAuthLifecycle: async (
207
- _deps: unknown,
208
- _account: unknown,
209
- _log: unknown,
210
- cb: (credentials: unknown) => Promise<void>,
211
- ) => cb({}),
212
- createConnectionScope: () => ({
213
- getConnection: async () => h.connection,
214
- disconnect: async () => {
215
- h.disconnectCount += 1;
216
- },
217
- }),
218
- }) as unknown as MailboxManagementDeps;
219
-
220
- const createEvent: MailboxCreateEvent = {
221
- type: "MAILBOX_CREATE",
222
- accountId: "acc-1",
223
- mailboxId: "mbx-1",
224
- path: "Archive",
225
- } as MailboxCreateEvent;
226
-
227
- const renameEvent: MailboxRenameEvent = {
228
- type: "MAILBOX_RENAME",
229
- accountId: "acc-1",
230
- mailboxId: "mbx-1",
231
- oldPath: "Archive",
232
- newPath: "Archive 2024",
233
- } as MailboxRenameEvent;
234
-
235
- const deleteEvent: MailboxDeleteEvent = {
236
- type: "MAILBOX_DELETE",
237
- accountId: "acc-1",
238
- mailboxId: "mbx-1",
239
- path: "Archive",
240
- } as MailboxDeleteEvent;
241
-
242
- const called = (method: string): Call[] =>
243
- h.calls.filter((c) => c.method === method);
244
-
245
- const lastUpdate = (): Record<string, unknown> =>
246
- (called("mailbox.update").at(-1)?.args[2] ?? {}) as Record<string, unknown>;
247
-
248
- const lastSettle = (): {
249
- to?: string;
250
- set?: Record<string, unknown>;
251
- wherePendingPath?: string | null;
252
- } =>
253
- (called("mailbox.transition").at(-1)?.args[2] ?? {}) as {
254
- to?: string;
255
- set?: Record<string, unknown>;
256
- wherePendingPath?: string | null;
257
- };
258
-
259
- describe("processMailboxManagement — MAILBOX_CREATE", () => {
260
- beforeEach(() => {
261
- h = fresh();
262
- });
263
-
264
- it("creates the folder and writes back the server's UIDVALIDITY and counts", async () => {
265
- await processMailboxManagement(createEvent, noopLogger, deps());
266
-
267
- assert.equal(called("connection.createMailbox")[0]?.args[0], "Archive");
268
- assert.deepEqual(lastUpdate(), {
269
- uidValidity: 7,
270
- uidNext: 42,
271
- messageCount: 3,
272
- });
273
- assert.equal(lastSettle().to, "synced");
274
- assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
275
- });
276
-
277
- it("subscribes only when the event asks for it", async () => {
278
- await processMailboxManagement(createEvent, noopLogger, deps());
279
- assert.equal(called("connection.subscribeMailbox").length, 0);
280
-
281
- h = fresh();
282
- await processMailboxManagement(
283
- { ...createEvent, subscribe: true },
284
- noopLogger,
285
- deps(),
286
- );
287
- assert.equal(called("connection.subscribeMailbox")[0]?.args[0], "Archive");
288
- });
289
-
290
- it("still marks the mailbox synced when the server does not list the new folder", async () => {
291
- h.connection.listMailboxes = async () => [];
292
-
293
- await processMailboxManagement(createEvent, noopLogger, deps());
294
-
295
- assert.equal(called("mailbox.update").length, 0);
296
- assert.equal(lastSettle().to, "synced");
297
- });
298
-
299
- it("treats an already-existing folder as success rather than a failure", async () => {
300
- h.connection.createMailbox = async () => {
301
- throw new Error("Mailbox already exists");
302
- };
303
-
304
- await processMailboxManagement(createEvent, noopLogger, deps());
305
-
306
- assert.equal(lastSettle().to, "synced");
307
- });
308
-
309
- it("marks the mailbox failed and rethrows on any other create error", async () => {
310
- h.connection.createMailbox = async () => {
311
- throw new Error("server exploded");
312
- };
313
-
314
- await assert.rejects(
315
- processMailboxManagement(createEvent, noopLogger, deps()),
316
- /server exploded/,
317
- );
318
-
319
- assert.equal(lastSettle().to, "failed");
320
- assert.equal(h.disconnectCount, 1);
321
- });
322
-
323
- it("acks terminally without rethrowing when the mailbox row was deleted mid-create (#289)", async () => {
324
- // The settle matches no row because the row is gone; the create is moot
325
- // and must not poison the account's FIFO.
326
- h.mailboxRowGone = true;
327
-
328
- await processMailboxManagement(createEvent, noopLogger, deps());
329
-
330
- assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
331
- });
332
-
333
- it("returns early without connecting when the account is soft-deleted", async () => {
334
- h.account = {
335
- accountId: "acc-1",
336
- accountConfigId: "cfg-1",
337
- deletedAt: Date.now(),
338
- };
339
-
340
- await processMailboxManagement(createEvent, noopLogger, deps());
341
-
342
- assert.equal(called("connection.createMailbox").length, 0);
343
- });
344
-
345
- it("throws when the account no longer exists", async () => {
346
- h.account = null;
347
-
348
- await assert.rejects(
349
- processMailboxManagement(createEvent, noopLogger, deps()),
350
- /not found/,
351
- );
352
- });
353
- });
354
-
355
- /**
356
- * The rename intent a MAILBOX_RENAME is settled against: the folder keeps the
357
- * path the server holds, and carries the target the intent recorded (D2).
358
- */
359
- const recordRenameIntent = (pendingPath = "Archive 2024"): void => {
360
- h.rows.set(
361
- "mbx-1",
362
- row({ mailboxId: "mbx-1", fullPath: "Archive", pendingPath }),
363
- );
364
- };
365
-
366
- describe("processMailboxManagement — MAILBOX_RENAME", () => {
367
- beforeEach(() => {
368
- h = fresh();
369
- recordRenameIntent();
370
- });
371
-
372
- it("renames on the server and settles the row onto the confirmed path", async () => {
373
- await processMailboxManagement(renameEvent, noopLogger, deps());
374
-
375
- assert.deepEqual(called("connection.renameMailbox")[0]?.args, [
376
- "Archive",
377
- "Archive 2024",
378
- ]);
379
- assert.equal(lastSettle().to, "synced");
380
- assert.equal(lastSettle().set?.fullPath, "Archive 2024");
381
- });
382
-
383
- it("settles onto the path ImapFlow resolved, not the one that was asked for", async () => {
384
- h.connection.renameMailbox = async (oldPath: string) => {
385
- h.calls.push({ method: "connection.renameMailbox", args: [oldPath] });
386
- return { path: oldPath, newPath: "INBOX/Archive 2024" };
387
- };
388
-
389
- await processMailboxManagement(renameEvent, noopLogger, deps());
390
-
391
- assert.equal(lastSettle().set?.fullPath, "INBOX/Archive 2024");
392
- });
393
-
394
- it("takes the folder's mail with it when the source is gone on the server", async () => {
395
- // The folder was deleted under us. Dropping the row on its own is the
396
- // orphaning bug: its mail stays keyed to a dead mailboxId, out of every
397
- // reader and still in the search index (D8).
398
- listsNeitherPath();
399
- h.connection.renameMailbox = async () => {
400
- throw new Error("Mailbox not found");
401
- };
402
-
403
- await processMailboxManagement(renameEvent, noopLogger, deps());
404
-
405
- assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
406
- "acc-1",
407
- "mbx-1",
408
- ]);
409
- assert.equal(called("mailbox.delete").length, 0);
410
- });
411
-
412
- it("takes every descendant's mail with it too, not just the renamed folder's", async () => {
413
- // The whole subtree recorded this rename's intent (D6); dropping the
414
- // root alone strands each descendant `pending` at a path the server
415
- // never held, with nothing left to revisit it.
416
- h.rows.set(
417
- "mbx-2",
418
- row({
419
- mailboxId: "mbx-2",
420
- fullPath: "Archive/Sub",
421
- pendingPath: "Archive 2024/Sub",
422
- }),
423
- );
424
- listsNeitherPath();
425
- h.connection.renameMailbox = async () => {
426
- throw new Error("Mailbox not found");
427
- };
428
-
429
- await processMailboxManagement(renameEvent, noopLogger, deps());
430
-
431
- const deleted = called("mailbox.deleteMailboxWithMail").map(
432
- (c) => c.args[1],
433
- );
434
- assert.deepEqual(new Set(deleted), new Set(["mbx-1", "mbx-2"]));
435
- });
436
-
437
- it("refuses the rename rather than deleting when the source is still on the server", async () => {
438
- // The server said NONEXISTENT and its own listing still holds the folder,
439
- // so it was talking about something else. Reading that as an upstream
440
- // delete would destroy the folder's mail over an answer nothing confirms.
441
- h.connection.renameMailbox = async () => {
442
- throw new Error("Mailbox not found");
443
- };
444
-
445
- await assert.rejects(
446
- processMailboxManagement(renameEvent, noopLogger, deps()),
447
- /Mailbox not found/,
448
- );
449
-
450
- assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
451
- assert.equal(lastSettle().to, "failed");
452
- });
453
-
454
- it("acks terminally without connecting when the folder row is gone", async () => {
455
- h.mailboxRowGone = true;
456
-
457
- await processMailboxManagement(renameEvent, noopLogger, deps());
458
-
459
- assert.equal(called("connection.renameMailbox").length, 0);
460
- assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
461
- });
462
-
463
- it("keeps the confirmed path and the target on a refused rename", async () => {
464
- // T6. `fullPath` was never written, so there is nothing to restore; the
465
- // target is kept so the client can name what the rename was aiming at.
466
- h.connection.renameMailbox = async () => {
467
- throw new Error("server exploded");
468
- };
469
-
470
- await assert.rejects(
471
- processMailboxManagement(renameEvent, noopLogger, deps()),
472
- /server exploded/,
473
- );
474
-
475
- assert.equal(lastSettle().to, "failed");
476
- assert.equal(
477
- lastSettle().set?.fullPath,
478
- undefined,
479
- "nothing is restored: the row never left the path the server holds",
480
- );
481
- assert.equal(lastSettle().wherePendingPath, "Archive 2024");
482
- });
483
-
484
- it("settles instead of deleting when NONEXISTENT means the rename already landed", async () => {
485
- // A redelivery after a lost settle re-issues RENAME from a path that has
486
- // moved. Reading that as an upstream delete drops the folder and its mail
487
- // while the folder is alive at the target.
488
- h.connection.renameMailbox = async () => {
489
- throw Object.assign(new Error("Command failed"), {
490
- serverResponseCode: "NONEXISTENT",
491
- responseText: "Mailbox doesn't exist: Archive",
492
- });
493
- };
494
- h.connection.listMailboxes = async () => [{ fullPath: "Archive 2024" }];
495
-
496
- await processMailboxManagement(renameEvent, noopLogger, deps());
497
-
498
- assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
499
- assert.equal(lastSettle().to, "synced");
500
- assert.equal(lastSettle().set?.fullPath, "Archive 2024");
501
- });
502
-
503
- it("rethrows a settle failure without marking the rename refused", async () => {
504
- // The server executed the rename. Marking the rows failed would offer a
505
- // retry of work already done, on a path the server no longer holds.
506
- let settles = 0;
507
- const broken = deps();
508
- const inner = broken.getClient as unknown as () => Promise<
509
- Record<string, unknown>
510
- >;
511
- broken.getClient = (async () => {
512
- const client = await inner();
513
- return {
514
- ...client,
515
- mailbox: {
516
- ...(client.mailbox as Record<string, unknown>),
517
- transition: async () => {
518
- settles += 1;
519
- throw new Error("database is locked");
520
- },
521
- },
522
- };
523
- }) as unknown as MailboxManagementDeps["getClient"];
524
-
525
- await assert.rejects(
526
- processMailboxManagement(renameEvent, noopLogger, broken),
527
- /local settle did not finish/,
528
- );
529
- assert.equal(settles, 1, "the refusal path never ran a second write");
530
- });
531
-
532
- it("issues no RENAME when the row has already settled", async () => {
533
- h.rows.set(
534
- "mbx-1",
535
- row({
536
- mailboxId: "mbx-1",
537
- fullPath: "Archive 2024",
538
- syncStatus: "synced",
539
- }),
540
- );
541
-
542
- await processMailboxManagement(renameEvent, noopLogger, deps());
543
-
544
- assert.equal(called("connection.renameMailbox").length, 0);
545
- assert.equal(called("mailbox.transition").length, 0);
546
- });
547
-
548
- it("issues no RENAME when the row is pending for a different target", async () => {
549
- h.rows.set("mbx-1", row({ mailboxId: "mbx-1", pendingPath: "Archief" }));
550
-
551
- await processMailboxManagement(renameEvent, noopLogger, deps());
552
-
553
- assert.equal(called("connection.renameMailbox").length, 0);
554
- assert.equal(called("mailbox.transition").length, 0);
555
- });
556
- });
557
-
558
- describe("processMailboxManagement — the seventh state (#363, D3)", () => {
559
- beforeEach(() => {
560
- h = fresh();
561
- recordRenameIntent();
562
- });
563
-
564
- /**
565
- * A settled create whose acknowledgement was lost, redelivered against a row
566
- * this rename has since claimed. A `pending`-only guard passes it; CREATE
567
- * collides and ImapFlow reports `{created: false}`, which #346 correctly
568
- * reads as success; the settle then writes `synced` with the rename target
569
- * still on the row — and the rename's own settle no longer matches, so the
570
- * RENAME never runs and nothing is ever marked failed.
571
- */
572
- it("lets a redelivered create settle nothing on a row a rename has claimed", async () => {
573
- await processMailboxManagement(createEvent, noopLogger, deps());
574
-
575
- const settle = called("mailbox.transition").at(-1)?.args[2] as {
576
- wherePendingPath?: string | null;
577
- to?: string;
578
- };
579
- assert.equal(
580
- settle?.wherePendingPath,
581
- null,
582
- "the create settle requires the row to carry no rename target",
583
- );
584
- assert.equal(h.rows.get("mbx-1")?.pendingPath, "Archive 2024");
585
- });
586
-
587
- it("then settles the rename normally", async () => {
588
- await processMailboxManagement(createEvent, noopLogger, deps());
589
- h.calls = [];
590
-
591
- await processMailboxManagement(renameEvent, noopLogger, deps());
592
-
593
- assert.equal(called("connection.renameMailbox").length, 1);
594
- assert.equal(lastSettle().to, "synced");
595
- assert.equal(lastSettle().set?.fullPath, "Archive 2024");
596
- });
597
- });
598
-
599
- /** The delete intent a MAILBOX_DELETE is settled against. */
600
- const recordDeleteIntent = (): void => {
601
- h.rows.set(
602
- "mbx-1",
603
- row({ mailboxId: "mbx-1", fullPath: "Archive", syncStatus: "deleting" }),
604
- );
605
- };
606
-
607
- describe("processMailboxManagement — MAILBOX_DELETE", () => {
608
- beforeEach(() => {
609
- h = fresh();
610
- recordDeleteIntent();
611
- });
612
-
613
- it("deletes on the server and takes the folder's mail with it", async () => {
614
- await processMailboxManagement(deleteEvent, noopLogger, deps());
615
-
616
- assert.equal(called("connection.deleteMailbox")[0]?.args[0], "Archive");
617
- assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
618
- "acc-1",
619
- "mbx-1",
620
- ]);
621
- assert.equal(
622
- called("mailbox.delete").length,
623
- 0,
624
- "the bare row delete is what orphaned the folder's mail",
625
- );
626
- });
627
-
628
- it("removes the folder's mail when the folder is already gone on the server", async () => {
629
- h.connection.deleteMailbox = async () => {
630
- throw new Error("Mailbox not found");
631
- };
632
-
633
- await processMailboxManagement(deleteEvent, noopLogger, deps());
634
-
635
- assert.equal(called("mailbox.deleteMailboxWithMail").length, 1);
636
- });
637
-
638
- it("marks the delete failed rather than synced when the server refuses INBOX", async () => {
639
- // The API refuses a delete of INBOX, so this backstop is unreachable from
640
- // it. Reached another way, the honest outcome is `failed`: the folder was
641
- // never deleted, and nothing was undone.
642
- h.connection.deleteMailbox = async () => {
643
- throw new Error("Cannot delete INBOX");
644
- };
645
-
646
- await processMailboxManagement(deleteEvent, noopLogger, deps());
647
-
648
- assert.equal(lastSettle().to, "failed");
649
- assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
650
- });
651
-
652
- it("marks the mailbox failed and rethrows on any other delete error", async () => {
653
- h.connection.deleteMailbox = async () => {
654
- throw new Error("server exploded");
655
- };
656
-
657
- await assert.rejects(
658
- processMailboxManagement(deleteEvent, noopLogger, deps()),
659
- /server exploded/,
660
- );
661
-
662
- assert.equal(lastSettle().to, "failed");
663
- });
664
-
665
- it("issues no DELETE when the row is not deleting", async () => {
666
- h.rows.set("mbx-1", row({ mailboxId: "mbx-1", syncStatus: "synced" }));
667
-
668
- await processMailboxManagement(deleteEvent, noopLogger, deps());
669
-
670
- assert.equal(called("connection.deleteMailbox").length, 0);
671
- assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
672
- });
673
-
674
- it("acks terminally without connecting when the folder row is gone", async () => {
675
- h.mailboxRowGone = true;
676
-
677
- await processMailboxManagement(deleteEvent, noopLogger, deps());
678
-
679
- assert.equal(called("connection.deleteMailbox").length, 0);
680
- assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
681
- });
682
- });
683
-
684
- describe("processMailboxManagement — a tagged NO the server means as success (#339)", () => {
685
- beforeEach(() => {
686
- h = fresh();
687
- });
688
-
689
- /**
690
- * Dovecot answers `NO [NONEXISTENT] Mailbox doesn't exist`, which ImapFlow
691
- * raises as a bare "Command failed" carrying the code on the error. Reading
692
- * only the message treated an already-absent folder as a failure: the row was
693
- * marked failed and the event left poisoning the account's queue.
694
- */
695
- it("treats it as the delete already having happened, mail and all", async () => {
696
- recordDeleteIntent();
697
- h.connection.deleteMailbox = async () => {
698
- throw Object.assign(new Error("Command failed"), {
699
- serverResponseCode: "NONEXISTENT",
700
- responseText: "Mailbox doesn't exist: Archive",
701
- });
702
- };
703
-
704
- await assert.doesNotReject(
705
- processMailboxManagement(deleteEvent, noopLogger, deps()),
706
- );
707
- assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
708
- "acc-1",
709
- "mbx-1",
710
- ]);
711
- assert.equal(called("mailbox.transition").length, 0);
712
- });
713
-
714
- it("marks failed and acks when the server refuses the DELETE with NOPERM", async () => {
715
- recordDeleteIntent();
716
- h.connection.deleteMailbox = async () => {
717
- throw Object.assign(new Error("Command failed"), {
718
- serverResponseCode: "NOPERM",
719
- responseText: "Permission denied",
720
- });
721
- };
722
-
723
- await assert.doesNotReject(
724
- processMailboxManagement(deleteEvent, noopLogger, deps()),
725
- );
726
- assert.equal(lastSettle().to, "failed");
727
- assert.equal(lastSettle().set?.syncFailureReason, "Permission denied");
728
- assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
729
- });
730
-
731
- it("still marks failed and rethrows when the server fails for any other reason", async () => {
732
- recordDeleteIntent();
733
- h.connection.deleteMailbox = async () => {
734
- throw Object.assign(new Error("Command failed"), {
735
- serverResponseCode: "SERVERBUG",
736
- responseText: "Internal error",
737
- });
738
- };
739
-
740
- await assert.rejects(
741
- processMailboxManagement(deleteEvent, noopLogger, deps()),
742
- /Command failed/,
743
- );
744
- assert.equal(lastSettle().to, "failed");
745
- });
746
-
747
- /**
748
- * Dovecot answers `NO [ALREADYEXISTS]` when the folder is already there — a
749
- * folder another client made, or a redelivered create. That is the create
750
- * having happened. Reading it as a failure marked the row `failed` and
751
- * rethrew, holding back every later sync on the account's FIFO group.
752
- */
753
- it("reads ALREADYEXISTS on a CREATE as the folder already being there", async () => {
754
- h.connection.createMailbox = async () => {
755
- throw Object.assign(new Error("Command failed"), {
756
- serverResponseCode: "ALREADYEXISTS",
757
- responseText: "Mailbox already exists: Archive",
758
- });
759
- };
760
-
761
- await assert.doesNotReject(
762
- processMailboxManagement(createEvent, noopLogger, deps()),
763
- );
764
- assert.equal(lastSettle().to, "synced");
765
- });
766
-
767
- it("marks failed with the server's reason and acks when a CREATE is refused", async () => {
768
- h.connection.createMailbox = async () => {
769
- throw Object.assign(new Error("Command failed"), {
770
- serverResponseCode: "NOPERM",
771
- responseText: "Permission denied",
772
- });
773
- };
774
-
775
- await assert.doesNotReject(
776
- processMailboxManagement(createEvent, noopLogger, deps()),
777
- );
778
- assert.equal(lastSettle().to, "failed");
779
- assert.equal(lastSettle().set?.syncFailureReason, "Permission denied");
780
- });
781
-
782
- it("still marks failed and rethrows when a CREATE fails for any other reason", async () => {
783
- h.connection.createMailbox = async () => {
784
- throw Object.assign(new Error("Command failed"), {
785
- serverResponseCode: "SERVERBUG",
786
- responseText: "Internal error",
787
- });
788
- };
789
-
790
- await assert.rejects(
791
- processMailboxManagement(createEvent, noopLogger, deps()),
792
- /Command failed/,
793
- );
794
- assert.equal(lastSettle().to, "failed");
795
- });
796
-
797
- it("reads NONEXISTENT on a RENAME as the source folder being gone, mail and all", async () => {
798
- recordRenameIntent();
799
- listsNeitherPath();
800
- h.connection.renameMailbox = async () => {
801
- throw Object.assign(new Error("Command failed"), {
802
- serverResponseCode: "NONEXISTENT",
803
- responseText: "Mailbox doesn't exist: Archive",
804
- });
805
- };
806
-
807
- await assert.doesNotReject(
808
- processMailboxManagement(renameEvent, noopLogger, deps()),
809
- );
810
- assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
811
- "acc-1",
812
- "mbx-1",
813
- ]);
814
- });
815
-
816
- it("marks failed with the server's reason and acks when a RENAME is refused", async () => {
817
- recordRenameIntent();
818
- h.connection.renameMailbox = async () => {
819
- throw Object.assign(new Error("Command failed"), {
820
- serverResponseCode: "CANNOT",
821
- responseText: "Can't rename mailbox with its children",
822
- });
823
- };
824
-
825
- await assert.doesNotReject(
826
- processMailboxManagement(renameEvent, noopLogger, deps()),
827
- );
828
- assert.equal(lastSettle().to, "failed");
829
- assert.equal(
830
- lastSettle().set?.syncFailureReason,
831
- "Can't rename mailbox with its children",
832
- );
833
- });
834
-
835
- it("still refuses the intent and rethrows when a RENAME fails for any other reason", async () => {
836
- recordRenameIntent();
837
- h.connection.renameMailbox = async () => {
838
- throw Object.assign(new Error("Command failed"), {
839
- serverResponseCode: "SERVERBUG",
840
- responseText: "Internal error",
841
- });
842
- };
843
-
844
- await assert.rejects(
845
- processMailboxManagement(renameEvent, noopLogger, deps()),
846
- /Command failed/,
847
- );
848
- assert.equal(lastSettle().to, "failed");
849
- });
850
- });