@remit/imap-worker 0.0.13 → 0.0.15

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/imap-worker",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -20,7 +20,7 @@
20
20
  "bundle": "esbuild src/index.ts --sourcemap --bundle --platform=node --format=esm --outfile=dist/index.js",
21
21
  "cli": "node --env-file=../../localhost-dev-aws.env src/cli.ts",
22
22
  "test:typecheck": "tsgo --noEmit",
23
- "test:run": "node --env-file=../../localhost-test-unit.env --test 'src/**/*.test.ts'",
23
+ "test:run": "node --env-file=../../localhost-test-unit.env --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=70 --test 'src/**/*.test.ts'",
24
24
  "test": "npm run test:typecheck && npm run test:run",
25
25
  "dev": "node --import tsx src/e2e-processor-shim.ts"
26
26
  },
@@ -1,81 +1,129 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type { DeleteAccountObjectsEvent } from "./delete-account-objects.js";
4
-
5
- describe("DeleteAccountObjects handler", () => {
6
- it("event shape is correct", () => {
7
- const event: DeleteAccountObjectsEvent = {
8
- type: "DELETE_ACCOUNT_OBJECTS",
9
- accountConfigId: "test-account-config-id-12345",
10
- };
11
-
12
- assert.equal(event.type, "DELETE_ACCOUNT_OBJECTS");
13
- assert.equal(event.accountConfigId, "test-account-config-id-12345");
14
- assert.equal(event.continuationToken, undefined);
2
+ import { afterEach, describe, it } from "node:test";
3
+ import {
4
+ DeleteObjectsCommand,
5
+ ListObjectsV2Command,
6
+ S3Client,
7
+ } from "@aws-sdk/client-s3";
8
+ import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
9
+ import type { Logger } from "@remit/logger-lambda";
10
+ import { mockClient } from "aws-sdk-client-mock";
11
+ import {
12
+ type DeleteAccountObjectsEvent,
13
+ handleDeleteAccountObjects,
14
+ } from "./delete-account-objects.js";
15
+
16
+ const noopLog = {
17
+ info: () => {},
18
+ warn: () => {},
19
+ error: () => {},
20
+ debug: () => {},
21
+ fatal: () => {},
22
+ trace: () => {},
23
+ child: () => noopLog,
24
+ } as unknown as Logger;
25
+
26
+ const s3Mock = mockClient(S3Client);
27
+ const sqsMock = mockClient(SQSClient);
28
+
29
+ const event: DeleteAccountObjectsEvent = {
30
+ type: "DELETE_ACCOUNT_OBJECTS",
31
+ accountConfigId: "cfg-1",
32
+ };
33
+
34
+ describe("handleDeleteAccountObjects", () => {
35
+ afterEach(() => {
36
+ s3Mock.reset();
37
+ sqsMock.reset();
15
38
  });
16
39
 
17
- it("event with continuation token is correct", () => {
18
- const event: DeleteAccountObjectsEvent = {
19
- type: "DELETE_ACCOUNT_OBJECTS",
20
- accountConfigId: "test-account-config-id-12345",
21
- continuationToken: "abc123",
22
- };
23
-
24
- assert.equal(event.continuationToken, "abc123");
40
+ it("lists and deletes every object under the account prefix in one page", async () => {
41
+ s3Mock.on(ListObjectsV2Command).resolves({
42
+ Contents: [
43
+ { Key: "accounts/cfg-1/a.eml" },
44
+ { Key: "accounts/cfg-1/b.eml" },
45
+ ],
46
+ IsTruncated: false,
47
+ });
48
+ s3Mock.on(DeleteObjectsCommand).resolves({});
49
+
50
+ await handleDeleteAccountObjects(event, noopLog);
51
+
52
+ const listCall = s3Mock.commandCalls(ListObjectsV2Command)[0];
53
+ assert.equal(listCall.args[0].input.Prefix, "accounts/cfg-1/");
54
+
55
+ const deleteCall = s3Mock.commandCalls(DeleteObjectsCommand)[0];
56
+ assert.deepEqual(deleteCall.args[0].input.Delete?.Objects, [
57
+ { Key: "accounts/cfg-1/a.eml" },
58
+ { Key: "accounts/cfg-1/b.eml" },
59
+ ]);
60
+ assert.equal(sqsMock.commandCalls(SendMessageCommand).length, 0);
25
61
  });
26
62
 
27
- it("S3 prefix follows expected pattern", () => {
28
- const accountConfigId = "test-account-config-id-12345";
29
- const prefix = `accounts/${accountConfigId}/`;
30
-
31
- assert.ok(prefix.startsWith("accounts/"));
32
- assert.ok(prefix.endsWith("/"));
33
- assert.ok(prefix.includes(accountConfigId));
63
+ it("follows the continuation token across truncated pages", async () => {
64
+ s3Mock
65
+ .on(ListObjectsV2Command)
66
+ .resolvesOnce({
67
+ Contents: [{ Key: "accounts/cfg-1/p1.eml" }],
68
+ IsTruncated: true,
69
+ NextContinuationToken: "tok-2",
70
+ })
71
+ .resolvesOnce({
72
+ Contents: [{ Key: "accounts/cfg-1/p2.eml" }],
73
+ IsTruncated: false,
74
+ });
75
+ s3Mock.on(DeleteObjectsCommand).resolves({});
76
+
77
+ await handleDeleteAccountObjects(event, noopLog);
78
+
79
+ const listCalls = s3Mock.commandCalls(ListObjectsV2Command);
80
+ assert.equal(listCalls.length, 2);
81
+ assert.equal(listCalls[1]?.args[0].input.ContinuationToken, "tok-2");
82
+ assert.equal(s3Mock.commandCalls(DeleteObjectsCommand).length, 2);
34
83
  });
35
84
 
36
- it("batch size calculation respects limit", () => {
37
- const BATCH_SIZE = 1_000;
38
- const keys = Array.from({ length: 2500 }, (_, i) => `key-${i}`);
85
+ it("skips the delete call when a page holds no keys", async () => {
86
+ s3Mock.on(ListObjectsV2Command).resolves({ IsTruncated: false });
39
87
 
40
- // Simulate batching
41
- const batches: string[][] = [];
42
- for (let i = 0; i < keys.length; i += BATCH_SIZE) {
43
- batches.push(keys.slice(i, i + BATCH_SIZE));
44
- }
88
+ await handleDeleteAccountObjects(event, noopLog);
45
89
 
46
- assert.equal(batches.length, 3);
47
- assert.equal(batches[0].length, 1000);
48
- assert.equal(batches[1].length, 1000);
49
- assert.equal(batches[2].length, 500);
90
+ assert.equal(s3Mock.commandCalls(DeleteObjectsCommand).length, 0);
50
91
  });
51
92
 
52
- it("re-enqueue event preserves continuation token", () => {
53
- const accountConfigId = "test-id";
54
- const continuationToken = "next-page-token";
55
-
56
- const reenqueueEvent: DeleteAccountObjectsEvent = {
57
- type: "DELETE_ACCOUNT_OBJECTS",
58
- accountConfigId,
59
- continuationToken,
60
- };
61
-
62
- const body = JSON.stringify(reenqueueEvent);
63
- const parsed = JSON.parse(body) as DeleteAccountObjectsEvent;
64
-
65
- assert.equal(parsed.type, "DELETE_ACCOUNT_OBJECTS");
66
- assert.equal(parsed.accountConfigId, accountConfigId);
67
- assert.equal(parsed.continuationToken, continuationToken);
93
+ it("re-enqueues with the continuation token when time is nearly up", async () => {
94
+ s3Mock.on(ListObjectsV2Command).resolves({ IsTruncated: false });
95
+ sqsMock.on(SendMessageCommand).resolves({});
96
+
97
+ await handleDeleteAccountObjects(
98
+ { ...event, continuationToken: "tok-mid" },
99
+ noopLog,
100
+ () => 5_000,
101
+ );
102
+
103
+ assert.equal(
104
+ s3Mock.commandCalls(ListObjectsV2Command).length,
105
+ 0,
106
+ "bails before starting a new page",
107
+ );
108
+ const send = sqsMock.commandCalls(SendMessageCommand)[0];
109
+ const body = JSON.parse(
110
+ send?.args[0].input.MessageBody ?? "{}",
111
+ ) as DeleteAccountObjectsEvent;
112
+ assert.equal(body.type, "DELETE_ACCOUNT_OBJECTS");
113
+ assert.equal(body.accountConfigId, "cfg-1");
114
+ assert.equal(body.continuationToken, "tok-mid");
68
115
  });
69
116
 
70
- it("timeout detection triggers re-enqueue", () => {
71
- const MIN_REMAINING_MS = 30_000;
117
+ it("keeps paging while time remains", async () => {
118
+ s3Mock.on(ListObjectsV2Command).resolves({
119
+ Contents: [{ Key: "accounts/cfg-1/x.eml" }],
120
+ IsTruncated: false,
121
+ });
122
+ s3Mock.on(DeleteObjectsCommand).resolves({});
72
123
 
73
- // Simulate near-timeout scenario
74
- const getRemainingTimeMs = () => 25_000;
75
- assert.ok(getRemainingTimeMs() < MIN_REMAINING_MS);
124
+ await handleDeleteAccountObjects(event, noopLog, () => 120_000);
76
125
 
77
- // Simulate enough time
78
- const getRemainingTimeMsOk = () => 60_000;
79
- assert.ok(getRemainingTimeMsOk() >= MIN_REMAINING_MS);
126
+ assert.equal(s3Mock.commandCalls(ListObjectsV2Command).length, 1);
127
+ assert.equal(sqsMock.commandCalls(SendMessageCommand).length, 0);
80
128
  });
81
129
  });
@@ -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
+ });
@@ -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,