@remit/imap-worker 0.0.1

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 (54) hide show
  1. package/README.md +100 -0
  2. package/build.mjs +17 -0
  3. package/package.json +50 -0
  4. package/src/account-check.test.ts +122 -0
  5. package/src/account-check.ts +88 -0
  6. package/src/body-sync-gate.test.ts +185 -0
  7. package/src/body-sync-gate.ts +86 -0
  8. package/src/cli.ts +211 -0
  9. package/src/connection-scope.test.ts +266 -0
  10. package/src/connection-scope.ts +335 -0
  11. package/src/e2e-processor-shim.ts +248 -0
  12. package/src/emit.test.ts +44 -0
  13. package/src/emit.ts +142 -0
  14. package/src/events.ts +221 -0
  15. package/src/handlers/append-sent-message.ts +163 -0
  16. package/src/handlers/delete-account-objects.test.ts +81 -0
  17. package/src/handlers/delete-account-objects.ts +116 -0
  18. package/src/handlers/empty-trash.ts +136 -0
  19. package/src/handlers/flag-push.test.ts +25 -0
  20. package/src/handlers/flag-push.ts +224 -0
  21. package/src/handlers/mailbox-management.ts +266 -0
  22. package/src/handlers/mailbox-sync-order.test.ts +93 -0
  23. package/src/handlers/mailbox-sync-order.ts +65 -0
  24. package/src/handlers/message-copy.ts +219 -0
  25. package/src/handlers/message-delete.test.ts +176 -0
  26. package/src/handlers/message-delete.ts +283 -0
  27. package/src/handlers/message-move.test.ts +168 -0
  28. package/src/handlers/message-move.ts +298 -0
  29. package/src/handlers/placement-move-push.test.ts +234 -0
  30. package/src/handlers/placement-move-push.ts +434 -0
  31. package/src/handlers/sync-mailboxes.ts +241 -0
  32. package/src/handlers/sync-message-body.test.ts +375 -0
  33. package/src/handlers/sync-message-body.ts +337 -0
  34. package/src/handlers/sync-messages-deleted-account.test.ts +141 -0
  35. package/src/handlers/sync-messages.test.ts +204 -0
  36. package/src/handlers/sync-messages.ts +412 -0
  37. package/src/handlers/sync-reserved-host.test.ts +97 -0
  38. package/src/index.test.ts +22 -0
  39. package/src/index.ts +70 -0
  40. package/src/poller.ts +49 -0
  41. package/src/processor.test.ts +58 -0
  42. package/src/processor.ts +66 -0
  43. package/src/scheduler/config.test.ts +40 -0
  44. package/src/scheduler/config.ts +52 -0
  45. package/src/scheduler/decide-due.test.ts +44 -0
  46. package/src/scheduler/decide-due.ts +26 -0
  47. package/src/scheduler/handler.ts +52 -0
  48. package/src/scheduler/local-runner.ts +76 -0
  49. package/src/scheduler/run-tick.test.ts +248 -0
  50. package/src/scheduler/run-tick.ts +141 -0
  51. package/src/with-oauth-lifecycle-deps.ts +62 -0
  52. package/src/with-oauth-lifecycle.test.ts +227 -0
  53. package/src/with-oauth-lifecycle.ts +125 -0
  54. package/tsconfig.json +8 -0
package/src/cli.ts ADDED
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { emitEvent } from "./emit.js";
4
+
5
+ const HELP = `
6
+ remit-imap-worker - Process IMAP sync events
7
+
8
+ USAGE:
9
+ remit-worker -t <type> -a <accountId> [options]
10
+
11
+ OPTIONS:
12
+ -t, --type <type> Event type (required)
13
+ -a, --accountId <id> Account ID (required)
14
+ -m, --mailboxId <id> Mailbox ID (required for some event types)
15
+ --messageIds <ids> Comma-separated message IDs (required for SYNC_MESSAGE_BODY)
16
+ --fullSync Force full sync, ignoring lastSyncUid (SYNC_MESSAGES only)
17
+ --path <path> Mailbox path (required for MAILBOX_CREATE, MAILBOX_DELETE)
18
+ --oldPath <path> Old mailbox path (required for MAILBOX_RENAME)
19
+ --newPath <path> New mailbox path (required for MAILBOX_RENAME)
20
+ --subscribe Subscribe to mailbox after creation (MAILBOX_CREATE only)
21
+ -h, --help Show this help message
22
+
23
+ EVENT TYPES:
24
+ SYNC_MAILBOXES Sync all mailboxes for an account
25
+ SYNC_MESSAGES Sync messages in a specific mailbox
26
+ SYNC_MESSAGE_BODY Fetch and store message bodies in batch
27
+ MAILBOX_CREATE Create a new mailbox
28
+ MAILBOX_RENAME Rename a mailbox
29
+ MAILBOX_DELETE Delete a mailbox
30
+
31
+ EXAMPLES:
32
+ # Sync all mailboxes for an account
33
+ remit-worker -t SYNC_MAILBOXES -a account-123
34
+
35
+ # Sync messages in a mailbox
36
+ remit-worker -t SYNC_MESSAGES -a account-123 -m mailbox-456
37
+
38
+ # Force a full sync of messages (ignore lastSyncUid)
39
+ remit-worker -t SYNC_MESSAGES -a account-123 -m mailbox-456 --fullSync
40
+
41
+ # Sync message bodies for specific messages
42
+ remit-worker -t SYNC_MESSAGE_BODY -a account-123 -m mailbox-456 --messageIds id1,id2,id3
43
+
44
+ # Create a new mailbox
45
+ remit-worker -t MAILBOX_CREATE -a account-123 -m mailbox-id --path Work/Projects --subscribe
46
+
47
+ # Rename a mailbox
48
+ remit-worker -t MAILBOX_RENAME -a account-123 -m mailbox-id --oldPath Work/Projects --newPath Archive/Projects
49
+
50
+ # Delete a mailbox
51
+ remit-worker -t MAILBOX_DELETE -a account-123 -m mailbox-id --path Work/Projects
52
+ `;
53
+
54
+ const { values } = parseArgs({
55
+ options: {
56
+ type: { type: "string", short: "t" },
57
+ accountId: { type: "string", short: "a" },
58
+ mailboxId: { type: "string", short: "m" },
59
+ messageIds: { type: "string" },
60
+ fullSync: { type: "boolean", default: false },
61
+ path: { type: "string" },
62
+ oldPath: { type: "string" },
63
+ newPath: { type: "string" },
64
+ subscribe: { type: "boolean", default: false },
65
+ help: { type: "boolean", short: "h", default: false },
66
+ },
67
+ });
68
+
69
+ if (values.help) {
70
+ console.log(HELP);
71
+ process.exit(0);
72
+ }
73
+
74
+ if (!values.type || !values.accountId) {
75
+ console.error("Error: --type and --accountId are required\n");
76
+ console.log(HELP);
77
+ process.exit(0);
78
+ }
79
+
80
+ const validTypes = [
81
+ "SYNC_MAILBOXES",
82
+ "SYNC_MESSAGES",
83
+ "SYNC_MESSAGE_BODY",
84
+ "MAILBOX_CREATE",
85
+ "MAILBOX_RENAME",
86
+ "MAILBOX_DELETE",
87
+ ];
88
+ if (!validTypes.includes(values.type)) {
89
+ console.error(
90
+ `Error: Invalid type "${values.type}". Must be one of: ${validTypes.join(", ")}\n`,
91
+ );
92
+ process.exit(1);
93
+ }
94
+
95
+ if (
96
+ ["SYNC_MESSAGES", "SYNC_MESSAGE_BODY"].includes(values.type) &&
97
+ !values.mailboxId
98
+ ) {
99
+ console.error(`Error: --mailboxId is required for ${values.type}\n`);
100
+ process.exit(1);
101
+ }
102
+
103
+ if (values.type === "SYNC_MESSAGE_BODY" && !values.messageIds) {
104
+ console.error(`Error: --messageIds is required for ${values.type}\n`);
105
+ process.exit(1);
106
+ }
107
+
108
+ // Validation for mailbox management events
109
+ if (
110
+ ["MAILBOX_CREATE", "MAILBOX_DELETE"].includes(values.type) &&
111
+ !values.path
112
+ ) {
113
+ console.error(`Error: --path is required for ${values.type}\n`);
114
+ process.exit(1);
115
+ }
116
+
117
+ if (
118
+ ["MAILBOX_CREATE", "MAILBOX_RENAME", "MAILBOX_DELETE"].includes(
119
+ values.type,
120
+ ) &&
121
+ !values.mailboxId
122
+ ) {
123
+ console.error(`Error: --mailboxId is required for ${values.type}\n`);
124
+ process.exit(1);
125
+ }
126
+
127
+ if (values.type === "MAILBOX_RENAME" && (!values.oldPath || !values.newPath)) {
128
+ console.error(
129
+ `Error: --oldPath and --newPath are required for ${values.type}\n`,
130
+ );
131
+ process.exit(1);
132
+ }
133
+
134
+ // Build the event based on type
135
+ const buildEvent = () => {
136
+ switch (values.type) {
137
+ case "SYNC_MAILBOXES":
138
+ return {
139
+ type: "SYNC_MAILBOXES" as const,
140
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
141
+ accountId: values.accountId!,
142
+ };
143
+ case "SYNC_MESSAGES":
144
+ return {
145
+ type: "SYNC_MESSAGES" as const,
146
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
147
+ accountId: values.accountId!,
148
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
149
+ mailboxId: values.mailboxId!,
150
+ fullSync: values.fullSync,
151
+ };
152
+ case "SYNC_MESSAGE_BODY":
153
+ return {
154
+ type: "SYNC_MESSAGE_BODY" as const,
155
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
156
+ accountId: values.accountId!,
157
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
158
+ mailboxId: values.mailboxId!,
159
+ messageIds: values.messageIds?.split(",").map((id) => id.trim()),
160
+ };
161
+ case "MAILBOX_CREATE":
162
+ return {
163
+ type: "MAILBOX_CREATE" as const,
164
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
165
+ accountId: values.accountId!,
166
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
167
+ mailboxId: values.mailboxId!,
168
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
169
+ path: values.path!,
170
+ subscribe: values.subscribe,
171
+ };
172
+ case "MAILBOX_RENAME":
173
+ return {
174
+ type: "MAILBOX_RENAME" as const,
175
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
176
+ accountId: values.accountId!,
177
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
178
+ mailboxId: values.mailboxId!,
179
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
180
+ oldPath: values.oldPath!,
181
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
182
+ newPath: values.newPath!,
183
+ };
184
+ case "MAILBOX_DELETE":
185
+ return {
186
+ type: "MAILBOX_DELETE" as const,
187
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
188
+ accountId: values.accountId!,
189
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
190
+ mailboxId: values.mailboxId!,
191
+ // biome-ignore lint/style/noNonNullAssertion: value is guaranteed by caller contract
192
+ path: values.path!,
193
+ };
194
+ default:
195
+ throw new Error(`Unknown event type: ${values.type}`);
196
+ }
197
+ };
198
+
199
+ const event = buildEvent();
200
+
201
+ console.log(`Enqueueing ${event.type} event for account ${event.accountId}...`);
202
+
203
+ emitEvent(event)
204
+ .then(() => {
205
+ console.log("Event enqueued successfully");
206
+ process.exit(0);
207
+ })
208
+ .catch((error) => {
209
+ console.error("Failed to enqueue event:", error);
210
+ process.exit(1);
211
+ });
@@ -0,0 +1,266 @@
1
+ import assert from "node:assert";
2
+ import { afterEach, beforeEach, describe, test } from "node:test";
3
+ import type { IImapConnection } from "@remit/mailbox-service";
4
+ import {
5
+ __evictWarmConnectionsForTest,
6
+ __resetWarmPoolsForTest,
7
+ __warmPoolSizeForTest,
8
+ borrowWarmConnection,
9
+ type ConnectionScope,
10
+ } from "./connection-scope.js";
11
+
12
+ /**
13
+ * A fake connection whose liveness can be toggled, recording connect/disconnect
14
+ * so tests can assert reuse (no reconnect) and leak-freedom (every connect is
15
+ * eventually disconnected or reused, never dangling).
16
+ */
17
+ interface FakeScope extends ConnectionScope {
18
+ id: number;
19
+ connectCount: number;
20
+ disconnectCount: number;
21
+ kill: () => void;
22
+ }
23
+
24
+ let nextId = 0;
25
+
26
+ const makeFakeScope = (): FakeScope => {
27
+ const id = nextId++;
28
+ let alive = true;
29
+ let conn: IImapConnection | null = null;
30
+
31
+ const scope: FakeScope = {
32
+ id,
33
+ connectCount: 0,
34
+ disconnectCount: 0,
35
+ kill: () => {
36
+ alive = false;
37
+ },
38
+ getConnection: async () => {
39
+ if (!conn) {
40
+ scope.connectCount++;
41
+ conn = {
42
+ get isConnected() {
43
+ return alive;
44
+ },
45
+ } as IImapConnection;
46
+ }
47
+ return conn;
48
+ },
49
+ disconnect: async () => {
50
+ scope.disconnectCount++;
51
+ conn = null;
52
+ },
53
+ };
54
+ return scope;
55
+ };
56
+
57
+ describe("borrowWarmConnection — cross-invocation warm reuse", () => {
58
+ const accountId = "acct-warm-1";
59
+
60
+ beforeEach(() => {
61
+ __resetWarmPoolsForTest();
62
+ });
63
+
64
+ afterEach(async () => {
65
+ await __evictWarmConnectionsForTest(accountId);
66
+ __resetWarmPoolsForTest();
67
+ });
68
+
69
+ test("warm invocation reuses the live connection without reconnecting", async () => {
70
+ const scopes: FakeScope[] = [];
71
+ const createScope = (): ConnectionScope => {
72
+ const s = makeFakeScope();
73
+ scopes.push(s);
74
+ return s;
75
+ };
76
+
77
+ const first = borrowWarmConnection(accountId, createScope);
78
+ const connA = await first.getConnection();
79
+ await first.release();
80
+
81
+ const second = borrowWarmConnection(accountId, createScope);
82
+ const connB = await second.getConnection();
83
+ await second.release();
84
+
85
+ assert.strictEqual(
86
+ connA,
87
+ connB,
88
+ "second invocation reuses same connection",
89
+ );
90
+ assert.strictEqual(scopes.length, 1, "no second scope created");
91
+ assert.strictEqual(scopes[0].connectCount, 1, "connected exactly once");
92
+ assert.strictEqual(scopes[0].disconnectCount, 0, "never disconnected");
93
+ });
94
+
95
+ test("dead connection triggers reconnect and replaces the cache entry", async () => {
96
+ const scopes: FakeScope[] = [];
97
+ const createScope = (): ConnectionScope => {
98
+ const s = makeFakeScope();
99
+ scopes.push(s);
100
+ return s;
101
+ };
102
+
103
+ const first = borrowWarmConnection(accountId, createScope);
104
+ await first.getConnection();
105
+ await first.release();
106
+
107
+ // Provider drops the idle socket: imapflow flips isConnected to false.
108
+ scopes[0].kill();
109
+
110
+ const second = borrowWarmConnection(accountId, createScope);
111
+ const connB = await second.getConnection();
112
+ await second.release();
113
+
114
+ assert.strictEqual(scopes.length, 2, "a replacement scope was created");
115
+ assert.strictEqual(scopes[0].disconnectCount, 1, "dead conn cleaned up");
116
+ assert.strictEqual(scopes[1].connectCount, 1, "replacement connected once");
117
+ assert.ok(connB.isConnected, "replacement connection is live");
118
+ assert.strictEqual(
119
+ __warmPoolSizeForTest(accountId),
120
+ 1,
121
+ "pool did not grow when replacing a dead entry",
122
+ );
123
+ });
124
+
125
+ test("no connection leak: pool size stays bounded across many invocations", async () => {
126
+ const scopes: FakeScope[] = [];
127
+ const createScope = (): ConnectionScope => {
128
+ const s = makeFakeScope();
129
+ scopes.push(s);
130
+ return s;
131
+ };
132
+
133
+ for (let i = 0; i < 20; i++) {
134
+ const borrowed = borrowWarmConnection(accountId, createScope);
135
+ await borrowed.getConnection();
136
+ await borrowed.release();
137
+ }
138
+
139
+ assert.strictEqual(scopes.length, 1, "serial reuse never re-dials");
140
+ assert.strictEqual(
141
+ __warmPoolSizeForTest(accountId),
142
+ 1,
143
+ "pool stays at one for serial same-account work",
144
+ );
145
+ });
146
+
147
+ test("concurrent same-account borrows get distinct pooled connections", async () => {
148
+ const scopes: FakeScope[] = [];
149
+ const createScope = (): ConnectionScope => {
150
+ const s = makeFakeScope();
151
+ scopes.push(s);
152
+ return s;
153
+ };
154
+
155
+ const a = borrowWarmConnection(accountId, createScope);
156
+ const b = borrowWarmConnection(accountId, createScope);
157
+ const connA = await a.getConnection();
158
+ const connB = await b.getConnection();
159
+
160
+ assert.notStrictEqual(
161
+ connA,
162
+ connB,
163
+ "concurrent borrows must not share one imapflow connection",
164
+ );
165
+
166
+ await a.release();
167
+ await b.release();
168
+
169
+ // default connectionsPerAccount is 2 — both stay pooled, none leaked.
170
+ assert.strictEqual(__warmPoolSizeForTest(accountId), 2);
171
+ assert.ok(scopes.every((s) => s.disconnectCount === 0));
172
+ });
173
+
174
+ test("overflow connection (pool saturated) is disconnected on release", async () => {
175
+ const scopes: FakeScope[] = [];
176
+ const createScope = (): ConnectionScope => {
177
+ const s = makeFakeScope();
178
+ scopes.push(s);
179
+ return s;
180
+ };
181
+
182
+ // Saturate the pool (default size 2) with two held borrows.
183
+ const a = borrowWarmConnection(accountId, createScope);
184
+ const b = borrowWarmConnection(accountId, createScope);
185
+ await a.getConnection();
186
+ await b.getConnection();
187
+
188
+ // Third concurrent borrow overflows the pool.
189
+ const c = borrowWarmConnection(accountId, createScope);
190
+ await c.getConnection();
191
+ await c.release();
192
+
193
+ assert.strictEqual(scopes.length, 3, "overflow created a third scope");
194
+ assert.strictEqual(scopes[2].disconnectCount, 1, "overflow torn down");
195
+ assert.strictEqual(
196
+ __warmPoolSizeForTest(accountId),
197
+ 2,
198
+ "overflow did not grow the pool",
199
+ );
200
+
201
+ await a.release();
202
+ await b.release();
203
+ });
204
+
205
+ test("concurrent borrows racing on a dead pooled entry get distinct connections", async () => {
206
+ const scopes: FakeScope[] = [];
207
+ const createScope = (): ConnectionScope => {
208
+ const s = makeFakeScope();
209
+ scopes.push(s);
210
+ return s;
211
+ };
212
+
213
+ // Seed the pool with a single connection, then kill it so it is a dead
214
+ // FREE entry — the recycle path that recreates a scope across an awaited
215
+ // disconnect (the race window).
216
+ const seed = borrowWarmConnection(accountId, createScope);
217
+ await seed.getConnection();
218
+ await seed.release();
219
+ scopes[0].kill();
220
+
221
+ // Two borrows started before either resolves: both run their synchronous
222
+ // claim phase, then A awaits disconnectQuietly while B's claim runs. The
223
+ // dead entry must be claimed atomically so they never adopt the same slot.
224
+ const a = borrowWarmConnection(accountId, createScope);
225
+ const b = borrowWarmConnection(accountId, createScope);
226
+ const [connA, connB] = await Promise.all([
227
+ a.getConnection(),
228
+ b.getConnection(),
229
+ ]);
230
+
231
+ assert.notStrictEqual(
232
+ connA,
233
+ connB,
234
+ "racing borrows must not share one imapflow connection",
235
+ );
236
+ assert.ok(connA.isConnected && connB.isConnected, "both are live");
237
+
238
+ await a.release();
239
+ await b.release();
240
+
241
+ // One slot recycled in place + one grown = pool of 2 (default cap), no leak.
242
+ assert.strictEqual(__warmPoolSizeForTest(accountId), 2);
243
+ });
244
+
245
+ test("__evictWarmConnectionsForTest disconnects and clears the pool", async () => {
246
+ const scopes: FakeScope[] = [];
247
+ const createScope = (): ConnectionScope => {
248
+ const s = makeFakeScope();
249
+ scopes.push(s);
250
+ return s;
251
+ };
252
+
253
+ const borrowed = borrowWarmConnection(accountId, createScope);
254
+ await borrowed.getConnection();
255
+ await borrowed.release();
256
+
257
+ await __evictWarmConnectionsForTest(accountId);
258
+
259
+ assert.strictEqual(__warmPoolSizeForTest(accountId), 0);
260
+ assert.strictEqual(
261
+ scopes[0].disconnectCount,
262
+ 1,
263
+ "evicted conn disconnected",
264
+ );
265
+ });
266
+ });