@remit/imap-worker 0.0.14 → 0.0.16

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.
@@ -11,6 +11,20 @@ import type { EmptyTrashEvent } from "../events.js";
11
11
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
12
12
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
13
13
 
14
+ export interface EmptyTrashDeps {
15
+ getClient: typeof getClient;
16
+ buildLifecycleDeps: typeof buildLifecycleDeps;
17
+ withOAuthLifecycle: typeof withOAuthLifecycle;
18
+ createConnectionScope: typeof createConnectionScopeWithCredentials;
19
+ }
20
+
21
+ const defaultDeps: EmptyTrashDeps = {
22
+ getClient,
23
+ buildLifecycleDeps,
24
+ withOAuthLifecycle,
25
+ createConnectionScope: createConnectionScopeWithCredentials,
26
+ };
27
+
14
28
  /**
15
29
  * Handle EMPTY_TRASH events.
16
30
  * Permanently deletes all messages in the Trash mailbox.
@@ -18,7 +32,15 @@ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
18
32
  export const handleEmptyTrash = async (
19
33
  event: EmptyTrashEvent,
20
34
  log: Logger,
35
+ deps: EmptyTrashDeps = defaultDeps,
21
36
  ): Promise<void> => {
37
+ const {
38
+ getClient,
39
+ buildLifecycleDeps,
40
+ withOAuthLifecycle,
41
+ createConnectionScope: createConnectionScopeWithCredentials,
42
+ } = deps;
43
+
22
44
  const {
23
45
  account: accountService,
24
46
  message: messageService,
@@ -0,0 +1,324 @@
1
+ import assert from "node:assert/strict";
2
+ import { beforeEach, describe, it } from "node:test";
3
+ import type { Logger } from "@remit/logger-lambda";
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
+ const noopLog = {
15
+ info: () => {},
16
+ warn: () => {},
17
+ error: () => {},
18
+ debug: () => {},
19
+ fatal: () => {},
20
+ trace: () => {},
21
+ child: () => noopLog,
22
+ } as unknown as Logger;
23
+
24
+ interface Call {
25
+ method: string;
26
+ args: unknown[];
27
+ }
28
+
29
+ interface Connection {
30
+ createMailbox: (path: string) => Promise<{ created: boolean }>;
31
+ subscribeMailbox: (path: string) => Promise<void>;
32
+ listMailboxes: () => Promise<{ fullPath: string }[]>;
33
+ openBox: (
34
+ path: string,
35
+ readOnly?: boolean,
36
+ ) => Promise<{
37
+ uidvalidity: number;
38
+ uidnext: number;
39
+ messages: { total: number };
40
+ }>;
41
+ closeBox: () => Promise<void>;
42
+ renameMailbox: (oldPath: string, newPath: string) => Promise<void>;
43
+ deleteMailbox: (path: string) => Promise<void>;
44
+ }
45
+
46
+ interface Harness {
47
+ calls: Call[];
48
+ account: {
49
+ accountId: string;
50
+ accountConfigId: string;
51
+ deletedAt?: number;
52
+ } | null;
53
+ connection: Connection;
54
+ disconnectCount: number;
55
+ }
56
+
57
+ let h: Harness;
58
+
59
+ const record =
60
+ (method: string) =>
61
+ async (...args: unknown[]) => {
62
+ h.calls.push({ method, args });
63
+ };
64
+
65
+ const buildConnection = (): Connection => ({
66
+ createMailbox: async (path: string) => {
67
+ h.calls.push({ method: "connection.createMailbox", args: [path] });
68
+ return { created: true };
69
+ },
70
+ subscribeMailbox: record("connection.subscribeMailbox"),
71
+ listMailboxes: async () => [{ fullPath: "Archive" }],
72
+ openBox: async () => ({
73
+ uidvalidity: 7,
74
+ uidnext: 42,
75
+ messages: { total: 3 },
76
+ }),
77
+ closeBox: record("connection.closeBox"),
78
+ renameMailbox: record("connection.renameMailbox"),
79
+ deleteMailbox: record("connection.deleteMailbox"),
80
+ });
81
+
82
+ const fresh = (): Harness => ({
83
+ calls: [],
84
+ account: { accountId: "acc-1", accountConfigId: "cfg-1" },
85
+ connection: buildConnection(),
86
+ disconnectCount: 0,
87
+ });
88
+
89
+ const deps = (): MailboxManagementDeps =>
90
+ ({
91
+ getClient: async () => ({
92
+ account: {
93
+ get: async (accountId: string) => {
94
+ h.calls.push({ method: "account.get", args: [accountId] });
95
+ return h.account;
96
+ },
97
+ },
98
+ mailbox: {
99
+ update: record("mailbox.update"),
100
+ delete: record("mailbox.delete"),
101
+ },
102
+ secrets: {},
103
+ }),
104
+ buildLifecycleDeps: () => ({}),
105
+ withOAuthLifecycle: async (
106
+ _deps: unknown,
107
+ _account: unknown,
108
+ _log: unknown,
109
+ cb: (credentials: unknown) => Promise<void>,
110
+ ) => cb({}),
111
+ createConnectionScope: () => ({
112
+ getConnection: async () => h.connection,
113
+ disconnect: async () => {
114
+ h.disconnectCount += 1;
115
+ },
116
+ }),
117
+ }) as unknown as MailboxManagementDeps;
118
+
119
+ const createEvent: MailboxCreateEvent = {
120
+ type: "MAILBOX_CREATE",
121
+ accountId: "acc-1",
122
+ mailboxId: "mbx-1",
123
+ path: "Archive",
124
+ } as MailboxCreateEvent;
125
+
126
+ const renameEvent: MailboxRenameEvent = {
127
+ type: "MAILBOX_RENAME",
128
+ accountId: "acc-1",
129
+ mailboxId: "mbx-1",
130
+ oldPath: "Archive",
131
+ newPath: "Archive 2024",
132
+ } as MailboxRenameEvent;
133
+
134
+ const deleteEvent: MailboxDeleteEvent = {
135
+ type: "MAILBOX_DELETE",
136
+ accountId: "acc-1",
137
+ mailboxId: "mbx-1",
138
+ path: "Archive",
139
+ } as MailboxDeleteEvent;
140
+
141
+ const called = (method: string): Call[] =>
142
+ h.calls.filter((c) => c.method === method);
143
+
144
+ const lastUpdate = (): Record<string, unknown> =>
145
+ (called("mailbox.update").at(-1)?.args[2] ?? {}) as Record<string, unknown>;
146
+
147
+ describe("processMailboxManagement — MAILBOX_CREATE", () => {
148
+ beforeEach(() => {
149
+ h = fresh();
150
+ });
151
+
152
+ it("creates the folder and writes back the server's UIDVALIDITY and counts", async () => {
153
+ await processMailboxManagement(createEvent, noopLog, deps());
154
+
155
+ assert.equal(called("connection.createMailbox")[0]?.args[0], "Archive");
156
+ assert.deepEqual(lastUpdate(), {
157
+ uidValidity: 7,
158
+ uidNext: 42,
159
+ messageCount: 3,
160
+ syncStatus: "synced",
161
+ });
162
+ assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
163
+ });
164
+
165
+ it("subscribes only when the event asks for it", async () => {
166
+ await processMailboxManagement(createEvent, noopLog, deps());
167
+ assert.equal(called("connection.subscribeMailbox").length, 0);
168
+
169
+ h = fresh();
170
+ await processMailboxManagement(
171
+ { ...createEvent, subscribe: true },
172
+ noopLog,
173
+ deps(),
174
+ );
175
+ assert.equal(called("connection.subscribeMailbox")[0]?.args[0], "Archive");
176
+ });
177
+
178
+ it("still marks the mailbox synced when the server does not list the new folder", async () => {
179
+ h.connection.listMailboxes = async () => [];
180
+
181
+ await processMailboxManagement(createEvent, noopLog, deps());
182
+
183
+ assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
184
+ });
185
+
186
+ it("treats an already-existing folder as success rather than a failure", async () => {
187
+ h.connection.createMailbox = async () => {
188
+ throw new Error("Mailbox already exists");
189
+ };
190
+
191
+ await processMailboxManagement(createEvent, noopLog, deps());
192
+
193
+ assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
194
+ });
195
+
196
+ it("marks the mailbox failed and rethrows on any other create error", async () => {
197
+ h.connection.createMailbox = async () => {
198
+ throw new Error("server exploded");
199
+ };
200
+
201
+ await assert.rejects(
202
+ processMailboxManagement(createEvent, noopLog, deps()),
203
+ /server exploded/,
204
+ );
205
+
206
+ assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
207
+ assert.equal(h.disconnectCount, 1);
208
+ });
209
+
210
+ it("returns early without connecting when the account is soft-deleted", async () => {
211
+ h.account = {
212
+ accountId: "acc-1",
213
+ accountConfigId: "cfg-1",
214
+ deletedAt: Date.now(),
215
+ };
216
+
217
+ await processMailboxManagement(createEvent, noopLog, deps());
218
+
219
+ assert.equal(called("connection.createMailbox").length, 0);
220
+ });
221
+
222
+ it("throws when the account no longer exists", async () => {
223
+ h.account = null;
224
+
225
+ await assert.rejects(
226
+ processMailboxManagement(createEvent, noopLog, deps()),
227
+ /not found/,
228
+ );
229
+ });
230
+ });
231
+
232
+ describe("processMailboxManagement — MAILBOX_RENAME", () => {
233
+ beforeEach(() => {
234
+ h = fresh();
235
+ });
236
+
237
+ it("renames on the server and clears the pending oldPath", async () => {
238
+ await processMailboxManagement(renameEvent, noopLog, deps());
239
+
240
+ assert.deepEqual(called("connection.renameMailbox")[0]?.args, [
241
+ "Archive",
242
+ "Archive 2024",
243
+ ]);
244
+ assert.deepEqual(lastUpdate(), {
245
+ oldPath: undefined,
246
+ syncStatus: "synced",
247
+ });
248
+ });
249
+
250
+ it("drops the local row when the source folder is gone on the server", async () => {
251
+ h.connection.renameMailbox = async () => {
252
+ throw new Error("Mailbox not found");
253
+ };
254
+
255
+ await processMailboxManagement(renameEvent, noopLog, deps());
256
+
257
+ assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
258
+ assert.equal(called("mailbox.update").length, 0);
259
+ });
260
+
261
+ it("rolls the local path back and rethrows on any other rename error", async () => {
262
+ h.connection.renameMailbox = async () => {
263
+ throw new Error("server exploded");
264
+ };
265
+
266
+ await assert.rejects(
267
+ processMailboxManagement(renameEvent, noopLog, deps()),
268
+ /server exploded/,
269
+ );
270
+
271
+ assert.deepEqual(lastUpdate(), {
272
+ fullPath: "Archive",
273
+ oldPath: undefined,
274
+ syncStatus: "failed",
275
+ });
276
+ });
277
+ });
278
+
279
+ describe("processMailboxManagement — MAILBOX_DELETE", () => {
280
+ beforeEach(() => {
281
+ h = fresh();
282
+ });
283
+
284
+ it("deletes on the server and drops the local row", async () => {
285
+ await processMailboxManagement(deleteEvent, noopLog, deps());
286
+
287
+ assert.equal(called("connection.deleteMailbox")[0]?.args[0], "Archive");
288
+ assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
289
+ });
290
+
291
+ it("drops the local row when the folder is already gone on the server", async () => {
292
+ h.connection.deleteMailbox = async () => {
293
+ throw new Error("Mailbox not found");
294
+ };
295
+
296
+ await processMailboxManagement(deleteEvent, noopLog, deps());
297
+
298
+ assert.equal(called("mailbox.delete").length, 1);
299
+ });
300
+
301
+ it("restores the mailbox and swallows the error when the server refuses to delete INBOX", async () => {
302
+ h.connection.deleteMailbox = async () => {
303
+ throw new Error("Cannot delete INBOX");
304
+ };
305
+
306
+ await processMailboxManagement(deleteEvent, noopLog, deps());
307
+
308
+ assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
309
+ assert.equal(called("mailbox.delete").length, 0);
310
+ });
311
+
312
+ it("marks the mailbox failed and rethrows on any other delete error", async () => {
313
+ h.connection.deleteMailbox = async () => {
314
+ throw new Error("server exploded");
315
+ };
316
+
317
+ await assert.rejects(
318
+ processMailboxManagement(deleteEvent, noopLog, deps()),
319
+ /server exploded/,
320
+ );
321
+
322
+ assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
323
+ });
324
+ });
@@ -13,13 +13,35 @@ import type {
13
13
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
14
14
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
15
15
 
16
+ export interface MailboxManagementDeps {
17
+ getClient: typeof getClient;
18
+ buildLifecycleDeps: typeof buildLifecycleDeps;
19
+ withOAuthLifecycle: typeof withOAuthLifecycle;
20
+ createConnectionScope: typeof createConnectionScopeWithCredentials;
21
+ }
22
+
23
+ const defaultDeps: MailboxManagementDeps = {
24
+ getClient,
25
+ buildLifecycleDeps,
26
+ withOAuthLifecycle,
27
+ createConnectionScope: createConnectionScopeWithCredentials,
28
+ };
29
+
16
30
  /**
17
31
  * Handle MAILBOX_CREATE event
18
32
  */
19
33
  const handleCreate = async (
20
34
  event: MailboxCreateEvent,
21
35
  log: Logger,
36
+ deps: MailboxManagementDeps,
22
37
  ): Promise<void> => {
38
+ const {
39
+ getClient,
40
+ buildLifecycleDeps,
41
+ withOAuthLifecycle,
42
+ createConnectionScope: createConnectionScopeWithCredentials,
43
+ } = deps;
44
+
23
45
  const {
24
46
  account: accountService,
25
47
  mailbox: mailboxService,
@@ -93,7 +115,15 @@ const handleCreate = async (
93
115
  const handleRename = async (
94
116
  event: MailboxRenameEvent,
95
117
  log: Logger,
118
+ deps: MailboxManagementDeps,
96
119
  ): Promise<void> => {
120
+ const {
121
+ getClient,
122
+ buildLifecycleDeps,
123
+ withOAuthLifecycle,
124
+ createConnectionScope: createConnectionScopeWithCredentials,
125
+ } = deps;
126
+
97
127
  const {
98
128
  account: accountService,
99
129
  mailbox: mailboxService,
@@ -171,7 +201,15 @@ const handleRename = async (
171
201
  const handleDelete = async (
172
202
  event: MailboxDeleteEvent,
173
203
  log: Logger,
204
+ deps: MailboxManagementDeps,
174
205
  ): Promise<void> => {
206
+ const {
207
+ getClient,
208
+ buildLifecycleDeps,
209
+ withOAuthLifecycle,
210
+ createConnectionScope: createConnectionScopeWithCredentials,
211
+ } = deps;
212
+
175
213
  const {
176
214
  account: accountService,
177
215
  mailbox: mailboxService,
@@ -254,13 +292,14 @@ const handleDelete = async (
254
292
  export const processMailboxManagement = async (
255
293
  event: MailboxManagementEvent,
256
294
  log: Logger,
295
+ deps: MailboxManagementDeps = defaultDeps,
257
296
  ): Promise<void> => {
258
297
  switch (event.type) {
259
298
  case "MAILBOX_CREATE":
260
- return handleCreate(event, log);
299
+ return handleCreate(event, log, deps);
261
300
  case "MAILBOX_RENAME":
262
- return handleRename(event, log);
301
+ return handleRename(event, log, deps);
263
302
  case "MAILBOX_DELETE":
264
- return handleDelete(event, log);
303
+ return handleDelete(event, log, deps);
265
304
  }
266
305
  };
@@ -0,0 +1,262 @@
1
+ import assert from "node:assert/strict";
2
+ import { beforeEach, describe, it } from "node:test";
3
+ import type { Logger } from "@remit/logger-lambda";
4
+ import type { MessageCopyEvent } from "../events.js";
5
+ import { handleMessageCopy, type MessageCopyDeps } from "./message-copy.js";
6
+
7
+ const noopLog = {
8
+ info: () => {},
9
+ warn: () => {},
10
+ error: () => {},
11
+ debug: () => {},
12
+ fatal: () => {},
13
+ trace: () => {},
14
+ child: () => noopLog,
15
+ } as unknown as Logger;
16
+
17
+ interface Call {
18
+ method: string;
19
+ args: unknown[];
20
+ }
21
+
22
+ interface Connection {
23
+ openBox: (
24
+ path: string,
25
+ readOnly?: boolean,
26
+ ) => Promise<{ uidvalidity: number }>;
27
+ copyMessages: (
28
+ uids: number[],
29
+ dest: string,
30
+ ) => Promise<{ uidMap: Map<number, number> }>;
31
+ createMailbox: (path: string) => Promise<void>;
32
+ }
33
+
34
+ interface Harness {
35
+ calls: Call[];
36
+ account: {
37
+ accountId: string;
38
+ accountConfigId: string;
39
+ deletedAt?: number;
40
+ } | null;
41
+ mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
42
+ connection: Connection;
43
+ getConnectionCount: number;
44
+ disconnectCount: number;
45
+ }
46
+
47
+ let h: Harness;
48
+
49
+ const record =
50
+ (method: string) =>
51
+ async (...args: unknown[]) => {
52
+ h.calls.push({ method, args });
53
+ };
54
+
55
+ const buildConnection = (): Connection => ({
56
+ openBox: async () => ({ uidvalidity: 1 }),
57
+ copyMessages: async () => ({ uidMap: new Map([[10, 20]]) }),
58
+ createMailbox: record("createMailbox") as Connection["createMailbox"],
59
+ });
60
+
61
+ const fresh = (): Harness => ({
62
+ calls: [],
63
+ account: { accountId: "acc-1", accountConfigId: "cfg-1" },
64
+ mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
65
+ connection: buildConnection(),
66
+ getConnectionCount: 0,
67
+ disconnectCount: 0,
68
+ });
69
+
70
+ const deps = (): MessageCopyDeps =>
71
+ ({
72
+ getClient: async () => ({
73
+ account: {
74
+ get: async (accountId: string) => {
75
+ h.calls.push({ method: "account.get", args: [accountId] });
76
+ return h.account;
77
+ },
78
+ },
79
+ message: {
80
+ updateUid: record("message.updateUid"),
81
+ update: record("message.update"),
82
+ },
83
+ threadMessage: {
84
+ findByMessageId: async (cfg: string) => ({
85
+ accountConfigId: cfg,
86
+ threadMessageId: "tm-1",
87
+ sentDate: 1,
88
+ mailboxId: "src-mbx",
89
+ isRead: false,
90
+ isDeleted: false,
91
+ hasStars: false,
92
+ hasAttachment: false,
93
+ }),
94
+ update: record("threadMessage.update"),
95
+ },
96
+ mailbox: {
97
+ get: async () => h.mailbox,
98
+ update: record("mailbox.update"),
99
+ },
100
+ secrets: {},
101
+ }),
102
+ buildLifecycleDeps: () => ({}),
103
+ withOAuthLifecycle: async (
104
+ _deps: unknown,
105
+ _account: unknown,
106
+ _log: unknown,
107
+ cb: (credentials: unknown) => Promise<void>,
108
+ ) => cb({}),
109
+ createConnectionScope: () => ({
110
+ getConnection: async () => {
111
+ h.getConnectionCount += 1;
112
+ return h.connection;
113
+ },
114
+ disconnect: async () => {
115
+ h.disconnectCount += 1;
116
+ },
117
+ }),
118
+ }) as unknown as MessageCopyDeps;
119
+
120
+ const event: MessageCopyEvent = {
121
+ type: "MESSAGE_COPY",
122
+ accountId: "acc-1",
123
+ sourceMessageId: "src-msg",
124
+ newMessageId: "new-msg",
125
+ sourceMailboxId: "src-mbx",
126
+ sourceMailboxPath: "INBOX",
127
+ destinationMailboxPath: "Archive",
128
+ destinationMailboxId: "dst-mbx",
129
+ uid: 10,
130
+ } as MessageCopyEvent;
131
+
132
+ const called = (method: string): Call[] =>
133
+ h.calls.filter((c) => c.method === method);
134
+
135
+ describe("handleMessageCopy", () => {
136
+ beforeEach(() => {
137
+ h = fresh();
138
+ });
139
+
140
+ it("writes the new UID, marks the copy synced, and updates the thread row", async () => {
141
+ await handleMessageCopy(event, noopLog, deps());
142
+
143
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
144
+ "new-msg",
145
+ 20,
146
+ "dst-mbx",
147
+ ]);
148
+ const statusUpdate = called("message.update")[0];
149
+ assert.equal(
150
+ (statusUpdate?.args[1] as { syncStatus?: string })?.syncStatus,
151
+ "synced",
152
+ );
153
+ assert.equal(called("threadMessage.update").length, 1);
154
+ assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
155
+ });
156
+
157
+ it("marks the copy failed when the COPYUID response omits the source uid", async () => {
158
+ h.connection.copyMessages = async () => ({ uidMap: new Map() });
159
+
160
+ await handleMessageCopy(event, noopLog, deps());
161
+
162
+ assert.equal(called("message.updateUid").length, 0);
163
+ const update = called("message.update")[0];
164
+ assert.equal(
165
+ (update?.args[1] as { syncStatus?: string })?.syncStatus,
166
+ "failed",
167
+ );
168
+ assert.equal(called("threadMessage.update").length, 0);
169
+ });
170
+
171
+ it("returns early without connecting when the account is soft-deleted", async () => {
172
+ h.account = {
173
+ accountId: "acc-1",
174
+ accountConfigId: "cfg-1",
175
+ deletedAt: Date.now(),
176
+ };
177
+
178
+ await handleMessageCopy(event, noopLog, deps());
179
+
180
+ assert.equal(h.getConnectionCount, 0);
181
+ });
182
+
183
+ it("throws when the account no longer exists", async () => {
184
+ h.account = null;
185
+
186
+ await assert.rejects(
187
+ handleMessageCopy(event, noopLog, deps()),
188
+ /not found/,
189
+ );
190
+ });
191
+
192
+ it("skips the copy without opening a connection when the cursor is rebuilding", async () => {
193
+ h.mailbox = {
194
+ mailboxId: "src-mbx",
195
+ uidValidity: 1,
196
+ cursorState: "rebuilding",
197
+ };
198
+
199
+ await handleMessageCopy(event, noopLog, deps());
200
+
201
+ assert.equal(h.getConnectionCount, 0);
202
+ assert.equal(called("message.updateUid").length, 0);
203
+ });
204
+
205
+ it("pauses quietly when openBox trips a UIDVALIDITY mismatch", async () => {
206
+ h.connection.openBox = async () => ({ uidvalidity: 999 });
207
+
208
+ await handleMessageCopy(event, noopLog, deps());
209
+
210
+ assert.equal(
211
+ (called("mailbox.update")[0]?.args[2] as { cursorState?: string })
212
+ ?.cursorState,
213
+ "cursor_invalid",
214
+ "the mismatch trips the mailbox cursor",
215
+ );
216
+ assert.equal(called("message.updateUid").length, 0);
217
+ assert.equal(h.disconnectCount, 1);
218
+ });
219
+
220
+ it("creates the destination and rethrows on a TRYCREATE error", async () => {
221
+ h.connection.copyMessages = async () => {
222
+ throw new Error("TRYCREATE: mailbox does not exist");
223
+ };
224
+
225
+ await assert.rejects(
226
+ handleMessageCopy(event, noopLog, deps()),
227
+ /TRYCREATE/,
228
+ );
229
+
230
+ assert.equal(called("createMailbox")[0]?.args[0], "Archive");
231
+ assert.equal(h.getConnectionCount, 2, "reconnects to create the mailbox");
232
+ });
233
+
234
+ it("marks the copy deleted-and-failed when the source is gone on the server", async () => {
235
+ h.connection.copyMessages = async () => {
236
+ throw new Error("NONEXISTENT source message");
237
+ };
238
+
239
+ await handleMessageCopy(event, noopLog, deps());
240
+
241
+ const update = called("message.update")[0];
242
+ assert.equal((update?.args[1] as { status?: string })?.status, "deleted");
243
+ assert.equal(called("createMailbox").length, 0);
244
+ });
245
+
246
+ it("marks failed and rethrows on an unclassified IMAP error", async () => {
247
+ h.connection.copyMessages = async () => {
248
+ throw new Error("server exploded");
249
+ };
250
+
251
+ await assert.rejects(
252
+ handleMessageCopy(event, noopLog, deps()),
253
+ /server exploded/,
254
+ );
255
+
256
+ const update = called("message.update")[0];
257
+ assert.equal(
258
+ (update?.args[1] as { syncStatus?: string })?.syncStatus,
259
+ "failed",
260
+ );
261
+ });
262
+ });