@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.
- package/package.json +2 -2
- package/src/handlers/append-sent-message.test.ts +216 -0
- package/src/handlers/append-sent-message.ts +22 -0
- package/src/handlers/delete-account-objects.test.ts +112 -64
- package/src/handlers/empty-trash.test.ts +220 -0
- package/src/handlers/empty-trash.ts +22 -0
- package/src/handlers/mailbox-management.test.ts +324 -0
- package/src/handlers/mailbox-management.ts +42 -3
- package/src/handlers/message-copy.test.ts +262 -0
- package/src/handlers/message-copy.ts +22 -0
- package/src/handlers/message-delete.test.ts +290 -1
- package/src/handlers/message-delete.ts +22 -0
|
@@ -12,6 +12,20 @@ import type { MessageCopyEvent } from "../events.js";
|
|
|
12
12
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
13
13
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
14
14
|
|
|
15
|
+
export interface MessageCopyDeps {
|
|
16
|
+
getClient: typeof getClient;
|
|
17
|
+
buildLifecycleDeps: typeof buildLifecycleDeps;
|
|
18
|
+
withOAuthLifecycle: typeof withOAuthLifecycle;
|
|
19
|
+
createConnectionScope: typeof createConnectionScopeWithCredentials;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const defaultDeps: MessageCopyDeps = {
|
|
23
|
+
getClient,
|
|
24
|
+
buildLifecycleDeps,
|
|
25
|
+
withOAuthLifecycle,
|
|
26
|
+
createConnectionScope: createConnectionScopeWithCredentials,
|
|
27
|
+
};
|
|
28
|
+
|
|
15
29
|
/**
|
|
16
30
|
* Handle MESSAGE_COPY events.
|
|
17
31
|
* Executes IMAP COPY command and updates local state with new UID.
|
|
@@ -19,7 +33,15 @@ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
|
19
33
|
export const handleMessageCopy = async (
|
|
20
34
|
event: MessageCopyEvent,
|
|
21
35
|
log: Logger,
|
|
36
|
+
deps: MessageCopyDeps = defaultDeps,
|
|
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
|
message: messageService,
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
|
-
import { describe, it, mock } from "node:test";
|
|
2
|
+
import { beforeEach, describe, it, mock } from "node:test";
|
|
3
3
|
import type { ThreadMessageItem } from "@remit/data-ports";
|
|
4
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
5
|
+
import type { MessageDeleteEvent } from "../events.js";
|
|
4
6
|
import {
|
|
5
7
|
buildThreadMessageTrashUpdate,
|
|
6
8
|
deleteAllThreadMessagesForMessage,
|
|
9
|
+
handleMessageDelete,
|
|
10
|
+
type MessageDeleteDeps,
|
|
7
11
|
} from "./message-delete.js";
|
|
8
12
|
|
|
9
13
|
const sourceMailboxId = "source-mailbox-id-aaaaaaaaa";
|
|
@@ -174,3 +178,288 @@ describe("deleteAllThreadMessagesForMessage (#212)", () => {
|
|
|
174
178
|
assert.equal(deleteRow.mock.calls.length, 0);
|
|
175
179
|
});
|
|
176
180
|
});
|
|
181
|
+
|
|
182
|
+
const noopLog = {
|
|
183
|
+
info: () => {},
|
|
184
|
+
warn: () => {},
|
|
185
|
+
error: () => {},
|
|
186
|
+
debug: () => {},
|
|
187
|
+
fatal: () => {},
|
|
188
|
+
trace: () => {},
|
|
189
|
+
child: () => noopLog,
|
|
190
|
+
} as unknown as Logger;
|
|
191
|
+
|
|
192
|
+
interface Call {
|
|
193
|
+
method: string;
|
|
194
|
+
args: unknown[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface Connection {
|
|
198
|
+
openBox: (
|
|
199
|
+
path: string,
|
|
200
|
+
readOnly?: boolean,
|
|
201
|
+
) => Promise<{ uidvalidity: number }>;
|
|
202
|
+
moveMessages: (
|
|
203
|
+
uids: number[],
|
|
204
|
+
dest: string,
|
|
205
|
+
) => Promise<{ uidMap: Map<number, number> }>;
|
|
206
|
+
deleteMessages: (uids: number[]) => Promise<void>;
|
|
207
|
+
createMailbox: (path: string) => Promise<void>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface Harness {
|
|
211
|
+
calls: Call[];
|
|
212
|
+
account: {
|
|
213
|
+
accountId: string;
|
|
214
|
+
accountConfigId: string;
|
|
215
|
+
deletedAt?: number;
|
|
216
|
+
} | null;
|
|
217
|
+
mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
|
|
218
|
+
connection: Connection;
|
|
219
|
+
threadMessage: Record<string, unknown> | null;
|
|
220
|
+
allThreadMessages: { accountConfigId: string; threadMessageId: string }[];
|
|
221
|
+
getConnectionCount: number;
|
|
222
|
+
disconnectCount: number;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let h: Harness;
|
|
226
|
+
|
|
227
|
+
const record =
|
|
228
|
+
(method: string) =>
|
|
229
|
+
async (...args: unknown[]) => {
|
|
230
|
+
h.calls.push({ method, args });
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const buildConnection = (): Connection => ({
|
|
234
|
+
openBox: async () => ({ uidvalidity: 1 }),
|
|
235
|
+
moveMessages: async () => ({ uidMap: new Map([[10, 20]]) }),
|
|
236
|
+
deleteMessages: record(
|
|
237
|
+
"connection.deleteMessages",
|
|
238
|
+
) as Connection["deleteMessages"],
|
|
239
|
+
createMailbox: record(
|
|
240
|
+
"connection.createMailbox",
|
|
241
|
+
) as Connection["createMailbox"],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const fresh = (): Harness => ({
|
|
245
|
+
calls: [],
|
|
246
|
+
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
247
|
+
mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
|
|
248
|
+
connection: buildConnection(),
|
|
249
|
+
threadMessage: {
|
|
250
|
+
...baseThreadMessage,
|
|
251
|
+
accountConfigId: "cfg-1",
|
|
252
|
+
threadMessageId: "tm-1",
|
|
253
|
+
},
|
|
254
|
+
allThreadMessages: [
|
|
255
|
+
{ accountConfigId: "cfg-1", threadMessageId: "tm-1" },
|
|
256
|
+
{ accountConfigId: "cfg-1", threadMessageId: "tm-2" },
|
|
257
|
+
],
|
|
258
|
+
getConnectionCount: 0,
|
|
259
|
+
disconnectCount: 0,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const deps = (): MessageDeleteDeps =>
|
|
263
|
+
({
|
|
264
|
+
getClient: async () => ({
|
|
265
|
+
account: {
|
|
266
|
+
get: async (accountId: string) => {
|
|
267
|
+
h.calls.push({ method: "account.get", args: [accountId] });
|
|
268
|
+
return h.account;
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
message: {
|
|
272
|
+
updateUid: record("message.updateUid"),
|
|
273
|
+
update: record("message.update"),
|
|
274
|
+
delete: record("message.delete"),
|
|
275
|
+
},
|
|
276
|
+
threadMessage: {
|
|
277
|
+
findByMessageId: async () => h.threadMessage,
|
|
278
|
+
findAllByMessageId: async () => h.allThreadMessages,
|
|
279
|
+
update: record("threadMessage.update"),
|
|
280
|
+
delete: record("threadMessage.delete"),
|
|
281
|
+
},
|
|
282
|
+
mailbox: {
|
|
283
|
+
get: async () => h.mailbox,
|
|
284
|
+
update: record("mailbox.update"),
|
|
285
|
+
},
|
|
286
|
+
secrets: {},
|
|
287
|
+
}),
|
|
288
|
+
buildLifecycleDeps: () => ({}),
|
|
289
|
+
withOAuthLifecycle: async (
|
|
290
|
+
_deps: unknown,
|
|
291
|
+
_account: unknown,
|
|
292
|
+
_log: unknown,
|
|
293
|
+
cb: (credentials: unknown) => Promise<void>,
|
|
294
|
+
) => cb({}),
|
|
295
|
+
createConnectionScope: () => ({
|
|
296
|
+
getConnection: async () => {
|
|
297
|
+
h.getConnectionCount += 1;
|
|
298
|
+
return h.connection;
|
|
299
|
+
},
|
|
300
|
+
disconnect: async () => {
|
|
301
|
+
h.disconnectCount += 1;
|
|
302
|
+
},
|
|
303
|
+
}),
|
|
304
|
+
}) as unknown as MessageDeleteDeps;
|
|
305
|
+
|
|
306
|
+
const moveEvent: MessageDeleteEvent = {
|
|
307
|
+
type: "MESSAGE_DELETE",
|
|
308
|
+
accountId: "acc-1",
|
|
309
|
+
messageId: "msg-1",
|
|
310
|
+
mailboxId: "src-mbx",
|
|
311
|
+
mailboxPath: "INBOX",
|
|
312
|
+
uid: 10,
|
|
313
|
+
operation: "move_to_trash",
|
|
314
|
+
destinationMailboxId: "trash-mbx",
|
|
315
|
+
destinationMailboxPath: "Trash",
|
|
316
|
+
} as MessageDeleteEvent;
|
|
317
|
+
|
|
318
|
+
const permanentEvent: MessageDeleteEvent = {
|
|
319
|
+
type: "MESSAGE_DELETE",
|
|
320
|
+
accountId: "acc-1",
|
|
321
|
+
messageId: "msg-1",
|
|
322
|
+
mailboxId: "src-mbx",
|
|
323
|
+
mailboxPath: "INBOX",
|
|
324
|
+
uid: 10,
|
|
325
|
+
operation: "permanent_delete",
|
|
326
|
+
} as MessageDeleteEvent;
|
|
327
|
+
|
|
328
|
+
const called = (method: string): Call[] =>
|
|
329
|
+
h.calls.filter((c) => c.method === method);
|
|
330
|
+
|
|
331
|
+
describe("handleMessageDelete", () => {
|
|
332
|
+
beforeEach(() => {
|
|
333
|
+
h = fresh();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("moves to trash, rewrites the uid, and flips the thread row to deleted", async () => {
|
|
337
|
+
await handleMessageDelete(moveEvent, noopLog, deps());
|
|
338
|
+
|
|
339
|
+
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
340
|
+
"msg-1",
|
|
341
|
+
20,
|
|
342
|
+
"trash-mbx",
|
|
343
|
+
]);
|
|
344
|
+
const update = called("threadMessage.update")[0];
|
|
345
|
+
assert.deepEqual(update?.args[2], {
|
|
346
|
+
uid: 20,
|
|
347
|
+
mailboxId: "trash-mbx",
|
|
348
|
+
isDeleted: true,
|
|
349
|
+
});
|
|
350
|
+
assert.equal(h.disconnectCount, 1);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("marks the message failed when the MOVE returns no new uid", async () => {
|
|
354
|
+
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
355
|
+
|
|
356
|
+
await handleMessageDelete(moveEvent, noopLog, deps());
|
|
357
|
+
|
|
358
|
+
assert.equal(called("message.updateUid").length, 0);
|
|
359
|
+
assert.equal(
|
|
360
|
+
(called("message.update")[0]?.args[1] as { syncStatus?: string })
|
|
361
|
+
?.syncStatus,
|
|
362
|
+
"failed",
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("expunges on the server and removes every thread row before the message row", async () => {
|
|
367
|
+
await handleMessageDelete(permanentEvent, noopLog, deps());
|
|
368
|
+
|
|
369
|
+
assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10]]);
|
|
370
|
+
assert.equal(called("threadMessage.delete").length, 2);
|
|
371
|
+
assert.ok(
|
|
372
|
+
h.calls.findIndex((c) => c.method === "threadMessage.delete") <
|
|
373
|
+
h.calls.findIndex((c) => c.method === "message.delete"),
|
|
374
|
+
"thread rows go first so no row outlives its message",
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("cleans up locally and swallows the error when the message is already gone on IMAP", async () => {
|
|
379
|
+
h.connection.deleteMessages = async () => {
|
|
380
|
+
throw new Error("NONEXISTENT uid");
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
await handleMessageDelete(permanentEvent, noopLog, deps());
|
|
384
|
+
|
|
385
|
+
assert.equal(called("message.delete").length, 1);
|
|
386
|
+
assert.equal(called("threadMessage.delete").length, 2);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("creates the trash mailbox and rethrows on TRYCREATE", async () => {
|
|
390
|
+
h.connection.moveMessages = async () => {
|
|
391
|
+
throw new Error("TRYCREATE: no such mailbox");
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
await assert.rejects(
|
|
395
|
+
handleMessageDelete(moveEvent, noopLog, deps()),
|
|
396
|
+
/TRYCREATE/,
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
assert.equal(called("connection.createMailbox")[0]?.args[0], "Trash");
|
|
400
|
+
assert.equal(h.getConnectionCount, 2, "reconnects to create the mailbox");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("marks failed and rethrows on an unclassified IMAP error", async () => {
|
|
404
|
+
h.connection.moveMessages = async () => {
|
|
405
|
+
throw new Error("server exploded");
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
await assert.rejects(
|
|
409
|
+
handleMessageDelete(moveEvent, noopLog, deps()),
|
|
410
|
+
/server exploded/,
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
assert.equal(
|
|
414
|
+
(called("message.update")[0]?.args[1] as { syncStatus?: string })
|
|
415
|
+
?.syncStatus,
|
|
416
|
+
"failed",
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("pauses quietly when openBox trips a UIDVALIDITY mismatch", async () => {
|
|
421
|
+
h.connection.openBox = async () => ({ uidvalidity: 999 });
|
|
422
|
+
|
|
423
|
+
await handleMessageDelete(moveEvent, noopLog, deps());
|
|
424
|
+
|
|
425
|
+
assert.equal(
|
|
426
|
+
(called("mailbox.update")[0]?.args[2] as { cursorState?: string })
|
|
427
|
+
?.cursorState,
|
|
428
|
+
"cursor_invalid",
|
|
429
|
+
);
|
|
430
|
+
assert.equal(called("message.updateUid").length, 0);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("skips without opening a connection when the cursor is rebuilding", async () => {
|
|
434
|
+
h.mailbox = {
|
|
435
|
+
mailboxId: "src-mbx",
|
|
436
|
+
uidValidity: 1,
|
|
437
|
+
cursorState: "rebuilding",
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
await handleMessageDelete(moveEvent, noopLog, deps());
|
|
441
|
+
|
|
442
|
+
assert.equal(h.getConnectionCount, 0);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("returns early without connecting when the account is soft-deleted", async () => {
|
|
446
|
+
h.account = {
|
|
447
|
+
accountId: "acc-1",
|
|
448
|
+
accountConfigId: "cfg-1",
|
|
449
|
+
deletedAt: Date.now(),
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
await handleMessageDelete(moveEvent, noopLog, deps());
|
|
453
|
+
|
|
454
|
+
assert.equal(h.getConnectionCount, 0);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("throws when the account no longer exists", async () => {
|
|
458
|
+
h.account = null;
|
|
459
|
+
|
|
460
|
+
await assert.rejects(
|
|
461
|
+
handleMessageDelete(moveEvent, noopLog, deps()),
|
|
462
|
+
/not found/,
|
|
463
|
+
);
|
|
464
|
+
});
|
|
465
|
+
});
|
|
@@ -83,6 +83,20 @@ export const buildThreadMessageTrashUpdate = (
|
|
|
83
83
|
},
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
export interface MessageDeleteDeps {
|
|
87
|
+
getClient: typeof getClient;
|
|
88
|
+
buildLifecycleDeps: typeof buildLifecycleDeps;
|
|
89
|
+
withOAuthLifecycle: typeof withOAuthLifecycle;
|
|
90
|
+
createConnectionScope: typeof createConnectionScopeWithCredentials;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const defaultDeps: MessageDeleteDeps = {
|
|
94
|
+
getClient,
|
|
95
|
+
buildLifecycleDeps,
|
|
96
|
+
withOAuthLifecycle,
|
|
97
|
+
createConnectionScope: createConnectionScopeWithCredentials,
|
|
98
|
+
};
|
|
99
|
+
|
|
86
100
|
/**
|
|
87
101
|
* Handle MESSAGE_DELETE events.
|
|
88
102
|
* Either moves to Trash (IMAP MOVE) or permanently deletes (IMAP DELETE).
|
|
@@ -90,7 +104,15 @@ export const buildThreadMessageTrashUpdate = (
|
|
|
90
104
|
export const handleMessageDelete = async (
|
|
91
105
|
event: MessageDeleteEvent,
|
|
92
106
|
log: Logger,
|
|
107
|
+
deps: MessageDeleteDeps = defaultDeps,
|
|
93
108
|
): Promise<void> => {
|
|
109
|
+
const {
|
|
110
|
+
getClient,
|
|
111
|
+
buildLifecycleDeps,
|
|
112
|
+
withOAuthLifecycle,
|
|
113
|
+
createConnectionScope: createConnectionScopeWithCredentials,
|
|
114
|
+
} = deps;
|
|
115
|
+
|
|
94
116
|
const {
|
|
95
117
|
account: accountService,
|
|
96
118
|
message: messageService,
|