@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
@@ -0,0 +1,209 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, it } from "node:test";
3
+ import type { MailboxItem } from "@remit/data-ports";
4
+ import {
5
+ MailboxCursorState,
6
+ MailboxSpecialUse,
7
+ MessageStatus,
8
+ } from "@remit/domain-enums";
9
+ import type { EmptyTrashEvent } from "../events.js";
10
+ import {
11
+ openStore,
12
+ type SeededMessage,
13
+ type World,
14
+ } from "../test-helpers/test-world.js";
15
+ import { handleEmptyTrash } from "./empty-trash.js";
16
+
17
+ let store: Awaited<ReturnType<typeof openStore>>;
18
+
19
+ before(async () => {
20
+ store = await openStore();
21
+ });
22
+
23
+ after(() => store.close());
24
+
25
+ interface Arranged {
26
+ w: World;
27
+ inbox: MailboxItem;
28
+ trash: MailboxItem;
29
+ trashed: SeededMessage[];
30
+ }
31
+
32
+ const arrange = async (count = 2): Promise<Arranged> => {
33
+ const w = await store.world();
34
+ const inbox = await w.folder("INBOX");
35
+ const trash = await w.folder("Trash", {
36
+ specialUse: MailboxSpecialUse.Trash,
37
+ });
38
+ const trashed: SeededMessage[] = [];
39
+ for (let i = 0; i < count; i += 1) trashed.push(await w.message(trash));
40
+ return { w, inbox, trash, trashed };
41
+ };
42
+
43
+ /** Empty Trash through the service the API calls, and hand back its event. */
44
+ const emptyTrash = async (a: Arranged): Promise<EmptyTrashEvent> => {
45
+ await a.w.client.messageMove.emptyTrash(
46
+ a.w.account.accountConfigId,
47
+ a.w.account.accountId,
48
+ );
49
+ const event = a.w
50
+ .queued()
51
+ .find((body) => body.type === "EMPTY_TRASH") as unknown as EmptyTrashEvent;
52
+ assert.ok(event, "Empty Trash enqueued an EMPTY_TRASH");
53
+ return event;
54
+ };
55
+
56
+ const run = (a: Arranged, event: EmptyTrashEvent) =>
57
+ handleEmptyTrash(event, a.w.log, a.w.scope);
58
+
59
+ const visible = async (a: Arranged): Promise<boolean[]> =>
60
+ Promise.all(
61
+ a.trashed.map(async (message) => {
62
+ const row = await a.w.row(message.messageId);
63
+ const listing = await a.w.threadRows(message.messageId);
64
+ return (
65
+ row?.status === MessageStatus.active &&
66
+ listing.length > 0 &&
67
+ listing.every((tm) => !tm.isDeleted)
68
+ );
69
+ }),
70
+ );
71
+
72
+ describe("Empty Trash", () => {
73
+ it("expunges every message on the server and removes both local rows", async () => {
74
+ const a = await arrange();
75
+ const event = await emptyTrash(a);
76
+
77
+ await run(a, event);
78
+
79
+ assert.deepEqual(a.w.imap.uids("Trash"), []);
80
+ for (const message of a.trashed) {
81
+ assert.equal(await a.w.row(message.messageId), undefined);
82
+ assert.deepEqual(await a.w.threadRows(message.messageId), []);
83
+ }
84
+ });
85
+
86
+ it("hands every row back when another client emptied the Trash first", async () => {
87
+ const a = await arrange();
88
+ const event = await emptyTrash(a);
89
+ a.w.imap.empty("Trash");
90
+
91
+ await run(a, event);
92
+
93
+ assert.deepEqual(await visible(a), [true, true]);
94
+ });
95
+
96
+ it("keeps the row of a message the expunge never reached", async () => {
97
+ const a = await arrange();
98
+ const event = await emptyTrash(a);
99
+ const [kept, gone] = a.trashed;
100
+ a.w.imap.expunge("Trash", kept?.uid as number);
101
+
102
+ await run(a, event);
103
+
104
+ assert.equal(await a.w.row(gone?.messageId ?? ""), undefined);
105
+ assert.equal(
106
+ (await a.w.row(kept?.messageId ?? ""))?.status,
107
+ MessageStatus.active,
108
+ );
109
+ });
110
+
111
+ // A move into Trash that has not settled names Trash while still carrying
112
+ // INBOX's uid. Matching it against the expunge would delete whatever Trash
113
+ // holds at that number.
114
+ it("leaves a message whose move into Trash has not settled", async () => {
115
+ const a = await arrange(1);
116
+ const moving = await a.w.message(a.inbox);
117
+ await a.w.client.messageMove.deleteMessage(
118
+ a.w.account.accountConfigId,
119
+ moving.messageId,
120
+ a.w.account.accountId,
121
+ );
122
+ const event = await emptyTrash(a);
123
+
124
+ await run(a, event);
125
+
126
+ const row = await a.w.row(moving.messageId);
127
+ assert.equal(row?.status, MessageStatus.moving);
128
+ assert.deepEqual(a.w.imap.uids("INBOX"), [moving.uid]);
129
+ assert.equal(await a.w.row(a.trashed[0]?.messageId ?? ""), undefined);
130
+ });
131
+
132
+ it("expunges nothing and hands the rows back when the folder at the path is not the one emptied", async () => {
133
+ const a = await arrange();
134
+ const event = await emptyTrash(a);
135
+ a.w.imap.setUidValidity("Trash", 99);
136
+
137
+ await run(a, event);
138
+
139
+ assert.deepEqual(a.w.imap.commands("EXPUNGE"), []);
140
+ assert.deepEqual(await visible(a), [true, true]);
141
+ });
142
+
143
+ it("expunges nothing when the Trash role no longer rests on confirmed evidence", async () => {
144
+ const a = await arrange();
145
+ const event = await emptyTrash(a);
146
+ await a.w.client.mailboxSpecialUse.deleteByMailboxId(a.trash.mailboxId);
147
+
148
+ await run(a, event);
149
+
150
+ assert.equal(a.w.imap.connections, 0);
151
+ assert.deepEqual(await visible(a), [true, true]);
152
+ });
153
+
154
+ it("hands the rows back without connecting while the cursor is rebuilding", async () => {
155
+ const a = await arrange();
156
+ const event = await emptyTrash(a);
157
+ await a.w.client.mailbox.update(a.w.account.accountId, a.trash.mailboxId, {
158
+ cursorState: MailboxCursorState.rebuilding,
159
+ });
160
+
161
+ await run(a, event);
162
+
163
+ assert.equal(a.w.imap.connections, 0);
164
+ assert.deepEqual(await visible(a), [true, true]);
165
+ });
166
+
167
+ it("refuses an event minted under an unknown contract and hands the rows back", async () => {
168
+ const a = await arrange();
169
+ const event = await emptyTrash(a);
170
+
171
+ await run(a, { ...event, schemaVersion: 99 } as unknown as EmptyTrashEvent);
172
+
173
+ assert.equal(a.w.imap.connections, 0);
174
+ assert.deepEqual(await visible(a), [true, true]);
175
+ });
176
+
177
+ it("acks without connecting when the Trash mailbox was deleted", async () => {
178
+ const a = await arrange();
179
+ const event = await emptyTrash(a);
180
+ await a.w.client.mailbox.delete(a.w.account.accountId, a.trash.mailboxId);
181
+
182
+ await run(a, event);
183
+
184
+ assert.equal(a.w.imap.connections, 0);
185
+ });
186
+
187
+ it("empties Trash under its current path when the folder was renamed after enqueue", async () => {
188
+ const a = await arrange();
189
+ const event = await emptyTrash(a);
190
+ a.w.imap.renameFolder("Trash", "Deleted Items");
191
+ await a.w.client.mailbox.update(a.w.account.accountId, a.trash.mailboxId, {
192
+ fullPath: "Deleted Items",
193
+ });
194
+
195
+ await run(a, event);
196
+
197
+ assert.deepEqual(a.w.imap.uids("Deleted Items"), []);
198
+ });
199
+
200
+ it("rethrows a failed expunge so the event is retried", async () => {
201
+ const a = await arrange();
202
+ const event = await emptyTrash(a);
203
+ a.w.imap.failNext.EXPUNGE = new Error("Connection reset");
204
+
205
+ await assert.rejects(run(a, event), /Connection reset/);
206
+
207
+ assert.equal(a.w.imap.uids("Trash").length, 2);
208
+ });
209
+ });
@@ -3,7 +3,7 @@ import type { MessageItem } from "@remit/data-ports";
3
3
  import { trashMailboxAt } from "@remit/data-ports/folder-role";
4
4
  import { isAcceptedSchemaVersion } from "@remit/data-ports/mutation-events";
5
5
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
6
- import type { Logger } from "@remit/logger-lambda";
6
+ import type { Logger } from "@remit/logger";
7
7
  import {
8
8
  buildThreadMessageUndelete,
9
9
  carriesForeignUid,
@@ -18,20 +18,6 @@ import { findMailboxRow } from "../mailbox-row.js";
18
18
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
19
19
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
20
20
 
21
- export interface EmptyTrashDeps {
22
- getClient: typeof getClient;
23
- buildLifecycleDeps: typeof buildLifecycleDeps;
24
- withOAuthLifecycle: typeof withOAuthLifecycle;
25
- createConnectionScope: typeof createConnectionScopeWithCredentials;
26
- }
27
-
28
- const defaultDeps: EmptyTrashDeps = {
29
- getClient,
30
- buildLifecycleDeps,
31
- withOAuthLifecycle,
32
- createConnectionScope: createConnectionScopeWithCredentials,
33
- };
34
-
35
21
  /**
36
22
  * Handle EMPTY_TRASH events.
37
23
  * Permanently deletes all messages in the Trash mailbox.
@@ -46,15 +32,8 @@ const defaultDeps: EmptyTrashDeps = {
46
32
  export const handleEmptyTrash = async (
47
33
  event: EmptyTrashEvent,
48
34
  log: Logger,
49
- deps: EmptyTrashDeps = defaultDeps,
35
+ createConnectionScope = createConnectionScopeWithCredentials,
50
36
  ): Promise<void> => {
51
- const {
52
- getClient,
53
- buildLifecycleDeps,
54
- withOAuthLifecycle,
55
- createConnectionScope: createConnectionScopeWithCredentials,
56
- } = deps;
57
-
58
37
  const {
59
38
  account: accountService,
60
39
  message: messageService,
@@ -219,7 +198,7 @@ export const handleEmptyTrash = async (
219
198
  return;
220
199
  }
221
200
 
222
- const scope = createConnectionScopeWithCredentials(account, credentials);
201
+ const scope = createConnectionScope(account, credentials);
223
202
 
224
203
  await scope
225
204
  .getConnection()
@@ -1,7 +1,7 @@
1
1
  import { getClient } from "@remit/backend/client";
2
2
  import { isNotFoundError } from "@remit/data-ports/errors";
3
- import type { Logger } from "@remit/logger-lambda";
4
- import { recordImapFailure } from "@remit/logger-lambda";
3
+ import type { Logger } from "@remit/logger";
4
+ import { recordImapFailure } from "@remit/logger-lambda/metrics";
5
5
  import {
6
6
  guardConnectionCursor,
7
7
  isCursorRebuildNeeded,
@@ -30,20 +30,7 @@ export const FLAG_PUSH_MAX_ATTEMPTS = getFlagPushMaxAttempts();
30
30
  * cycle one SQS round trip per sync tick forever instead of surfacing the
31
31
  * stall.
32
32
  */
33
- const DEFAULT_FLAG_PUSH_DEFER_MAX_MS = 10 * 60 * 1000;
34
-
35
- export const getFlagPushDeferMaxMs = (
36
- processEnv: NodeJS.ProcessEnv = process.env,
37
- ): number => {
38
- const raw = processEnv.FLAG_PUSH_DEFER_MAX_MS;
39
- if (!raw) return DEFAULT_FLAG_PUSH_DEFER_MAX_MS;
40
- const parsed = Number.parseInt(raw, 10);
41
- return Number.isFinite(parsed) && parsed > 0
42
- ? parsed
43
- : DEFAULT_FLAG_PUSH_DEFER_MAX_MS;
44
- };
45
-
46
- export const FLAG_PUSH_DEFER_MAX_MS = getFlagPushDeferMaxMs();
33
+ export const FLAG_PUSH_DEFER_MAX_MS = 10 * 60 * 1000;
47
34
 
48
35
  /**
49
36
  * Handle FLAG_PUSH events (issue #1273, epic #1281). Drains ONE pending
@@ -75,6 +62,7 @@ export const handleFlagPush = async (
75
62
  event: FlagPushEvent,
76
63
  log: Logger,
77
64
  receiveCount = 1,
65
+ createConnectionScope = createConnectionScopeWithCredentials,
78
66
  ): Promise<void> => {
79
67
  const {
80
68
  account: accountService,
@@ -202,7 +190,7 @@ export const handleFlagPush = async (
202
190
  account,
203
191
  log,
204
192
  async (credentials) => {
205
- const scope = createConnectionScopeWithCredentials(account, credentials);
193
+ const scope = createConnectionScope(account, credentials);
206
194
 
207
195
  await scope
208
196
  .getConnection()
@@ -261,8 +249,13 @@ export const handleFlagPush = async (
261
249
  // two terminal outcomes (epic invariant 3) instead of
262
250
  // dead-lettering with no diagnosis.
263
251
  const { outcome } = await resolveExhaustedFlagPushFailure(
264
- { markerService, messageService, threadMessageService, log },
252
+ { markerService, messageService, log },
265
253
  {
254
+ placement: {
255
+ status: message.status,
256
+ mailboxId: message.mailboxId,
257
+ uid: message.uid,
258
+ },
266
259
  accountId,
267
260
  accountConfigId,
268
261
  messageId,
@@ -0,0 +1,265 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, it } from "node:test";
3
+ import type { MailboxItem } from "@remit/data-ports";
4
+ import { MailboxSyncStatus } from "@remit/domain-enums";
5
+ import { imapError } from "@remit/mailbox-service/test-helpers/fake-imap";
6
+ import type { MailboxManagementEvent } from "../events.js";
7
+ import { openStore, type World } from "../test-helpers/test-world.js";
8
+ import { processMailboxManagement } from "./mailbox-management.js";
9
+
10
+ let store: Awaited<ReturnType<typeof openStore>>;
11
+
12
+ before(async () => {
13
+ store = await openStore();
14
+ });
15
+
16
+ after(() => store.close());
17
+
18
+ const lastEvent = (w: World, type: string): MailboxManagementEvent => {
19
+ const event = w
20
+ .queued()
21
+ .filter((body) => body.type === type)
22
+ .at(-1) as unknown as MailboxManagementEvent | undefined;
23
+ assert.ok(event, `a ${type} was enqueued`);
24
+ return event;
25
+ };
26
+
27
+ const run = (w: World, event: MailboxManagementEvent) =>
28
+ processMailboxManagement(event, w.log, w.scope);
29
+
30
+ const mailbox = (
31
+ w: World,
32
+ mailboxId: string,
33
+ ): Promise<MailboxItem | undefined> =>
34
+ w.client.mailbox
35
+ .listAllByAccount(w.account.accountId)
36
+ .then((rows) => rows.find((row) => row.mailboxId === mailboxId));
37
+
38
+ /** A folder created through the service the API calls, not yet on the server. */
39
+ const requestCreate = async (
40
+ w: World,
41
+ path: string,
42
+ subscribe?: boolean,
43
+ ): Promise<{ mailboxId: string; event: MailboxManagementEvent }> => {
44
+ const created = await w.client.mailboxQueue.createMailbox(
45
+ {
46
+ accountId: w.account.accountId,
47
+ namespacePrefix: "",
48
+ hierarchyDelimiter: "/",
49
+ fullPath: path,
50
+ uidValidity: 0,
51
+ uidNext: 0,
52
+ highestModseq: "0",
53
+ messageCount: 0,
54
+ unseenCount: 0,
55
+ deletedCount: 0,
56
+ totalSize: 0,
57
+ lastSyncUid: 0,
58
+ highWaterMarkUid: 0,
59
+ lastMessageSyncAt: 0,
60
+ },
61
+ w.account.accountId,
62
+ subscribe,
63
+ );
64
+ assert.equal(created.outcome, "Created");
65
+ if (created.outcome !== "Created") throw new Error("path taken");
66
+ return {
67
+ mailboxId: created.mailbox.mailboxId,
68
+ event: lastEvent(w, "MAILBOX_CREATE"),
69
+ };
70
+ };
71
+
72
+ describe("creating a folder", () => {
73
+ it("creates it on the server and settles the row on the server's UIDVALIDITY", async () => {
74
+ const w = await store.world();
75
+ const { mailboxId, event } = await requestCreate(w, "Receipts");
76
+
77
+ await run(w, event);
78
+
79
+ assert.ok(w.imap.hasFolder("Receipts"));
80
+ const row = await mailbox(w, mailboxId);
81
+ assert.equal(row?.syncStatus, MailboxSyncStatus.synced);
82
+ assert.equal(row?.uidValidity, w.imap.uidValidityOf("Receipts"));
83
+ });
84
+
85
+ it("subscribes only when the request asks for it", async () => {
86
+ const w = await store.world();
87
+ const quiet = await requestCreate(w, "Quiet");
88
+ const loud = await requestCreate(w, "Loud", true);
89
+
90
+ await run(w, quiet.event);
91
+ await run(w, loud.event);
92
+
93
+ assert.deepEqual(w.imap.commands("SUBSCRIBE"), ["SUBSCRIBE Loud"]);
94
+ });
95
+
96
+ it("settles a folder the server already has as created", async () => {
97
+ const w = await store.world();
98
+ const { mailboxId, event } = await requestCreate(w, "Receipts");
99
+ w.imap.addFolder("Receipts", { uidValidity: 7 });
100
+
101
+ await run(w, event);
102
+
103
+ assert.equal(
104
+ (await mailbox(w, mailboxId))?.syncStatus,
105
+ MailboxSyncStatus.synced,
106
+ );
107
+ });
108
+
109
+ it("marks the folder failed with the server's reason and acks a refused create", async () => {
110
+ const w = await store.world();
111
+ const { mailboxId, event } = await requestCreate(w, "Receipts");
112
+ w.imap.failNext.CREATE = imapError("NOPERM", "Permission denied");
113
+
114
+ await run(w, event);
115
+
116
+ const row = await mailbox(w, mailboxId);
117
+ assert.equal(row?.syncStatus, MailboxSyncStatus.failed);
118
+ assert.equal(row?.syncFailureReason, "Permission denied");
119
+ });
120
+
121
+ it("marks the folder failed and redelivers when the create fails for another reason", async () => {
122
+ const w = await store.world();
123
+ const { mailboxId, event } = await requestCreate(w, "Receipts");
124
+ w.imap.failNext.CREATE = new Error("Connection reset");
125
+
126
+ await assert.rejects(run(w, event), /Connection reset/);
127
+
128
+ assert.equal(
129
+ (await mailbox(w, mailboxId))?.syncStatus,
130
+ MailboxSyncStatus.failed,
131
+ );
132
+ });
133
+
134
+ it("acks when the folder row was deleted before the create ran", async () => {
135
+ const w = await store.world();
136
+ const { mailboxId, event } = await requestCreate(w, "Receipts");
137
+ await w.client.mailbox.delete(w.account.accountId, mailboxId);
138
+
139
+ await run(w, event);
140
+ });
141
+ });
142
+
143
+ describe("renaming a folder", () => {
144
+ const arrange = async () => {
145
+ const w = await store.world();
146
+ const work = await w.folder("Work");
147
+ const child = await w.folder("Work/Clients");
148
+ const message = await w.message(child);
149
+ await w.client.mailboxQueue.renameMailbox(
150
+ work.mailboxId,
151
+ "Projects",
152
+ w.account.accountId,
153
+ );
154
+ return { w, work, child, message, event: lastEvent(w, "MAILBOX_RENAME") };
155
+ };
156
+
157
+ it("renames on the server and settles the folder and its children onto the new path", async () => {
158
+ const { w, work, child, event } = await arrange();
159
+
160
+ await run(w, event);
161
+
162
+ assert.ok(w.imap.hasFolder("Projects/Clients"));
163
+ assert.equal((await mailbox(w, work.mailboxId))?.fullPath, "Projects");
164
+ const settledChild = await mailbox(w, child.mailboxId);
165
+ assert.equal(settledChild?.fullPath, "Projects/Clients");
166
+ assert.equal(settledChild?.syncStatus, MailboxSyncStatus.synced);
167
+ });
168
+
169
+ it("removes the folder, its children and their mail when the source is gone on the server", async () => {
170
+ const { w, work, child, message, event } = await arrange();
171
+ w.imap.removeFolder("Work");
172
+ w.imap.removeFolder("Work/Clients");
173
+
174
+ await run(w, event);
175
+
176
+ assert.equal(await mailbox(w, work.mailboxId), undefined);
177
+ assert.equal(await mailbox(w, child.mailboxId), undefined);
178
+ assert.equal(await w.row(message.messageId), undefined);
179
+ });
180
+
181
+ it("keeps the confirmed path and acks a refused rename", async () => {
182
+ const { w, work, event } = await arrange();
183
+ w.imap.failNext.RENAME = imapError("NOPERM", "Permission denied");
184
+
185
+ await run(w, event);
186
+
187
+ const row = await mailbox(w, work.mailboxId);
188
+ assert.equal(row?.fullPath, "Work");
189
+ assert.equal(row?.syncStatus, MailboxSyncStatus.failed);
190
+ assert.ok(w.imap.hasFolder("Work"));
191
+ });
192
+
193
+ it("issues no RENAME when a redelivery finds the rename already settled", async () => {
194
+ const { w, event } = await arrange();
195
+ await run(w, event);
196
+
197
+ await run(w, event);
198
+
199
+ assert.equal(w.imap.commands("RENAME").length, 1);
200
+ });
201
+ });
202
+
203
+ describe("deleting a folder", () => {
204
+ const arrange = async () => {
205
+ const w = await store.world();
206
+ const old = await w.folder("Old");
207
+ const message = await w.message(old);
208
+ await w.client.mailboxQueue.deleteMailbox(
209
+ old.mailboxId,
210
+ w.account.accountId,
211
+ );
212
+ return { w, old, message, event: lastEvent(w, "MAILBOX_DELETE") };
213
+ };
214
+
215
+ it("deletes it on the server and removes the folder and its mail", async () => {
216
+ const { w, old, message, event } = await arrange();
217
+
218
+ await run(w, event);
219
+
220
+ assert.equal(w.imap.hasFolder("Old"), false);
221
+ assert.equal(await mailbox(w, old.mailboxId), undefined);
222
+ assert.equal(await w.row(message.messageId), undefined);
223
+ });
224
+
225
+ it("removes the folder and its mail when the server no longer has it", async () => {
226
+ const { w, old, message, event } = await arrange();
227
+ w.imap.removeFolder("Old");
228
+
229
+ await run(w, event);
230
+
231
+ assert.equal(await mailbox(w, old.mailboxId), undefined);
232
+ assert.equal(await w.row(message.messageId), undefined);
233
+ });
234
+
235
+ it("marks the folder failed, keeps its mail and acks a refused delete", async () => {
236
+ const { w, old, message, event } = await arrange();
237
+ w.imap.failNext.DELETE = imapError("NOPERM", "Permission denied");
238
+
239
+ await run(w, event);
240
+
241
+ assert.equal(
242
+ (await mailbox(w, old.mailboxId))?.syncStatus,
243
+ MailboxSyncStatus.failed,
244
+ );
245
+ assert.ok(await w.row(message.messageId));
246
+ assert.ok(w.imap.hasFolder("Old"));
247
+ });
248
+
249
+ it("issues no DELETE for a folder that is not being deleted", async () => {
250
+ const w = await store.world();
251
+ const kept = await w.folder("Kept");
252
+
253
+ await run(w, {
254
+ type: "MAILBOX_DELETE",
255
+ eventId: "evt-stale",
256
+ timestamp: Date.now(),
257
+ accountId: w.account.accountId,
258
+ mailboxId: kept.mailboxId,
259
+ path: "Kept",
260
+ } as MailboxManagementEvent);
261
+
262
+ assert.deepEqual(w.imap.commands("DELETE"), []);
263
+ assert.ok(await mailbox(w, kept.mailboxId));
264
+ });
265
+ });