@remit/imap-worker 0.0.90 → 0.0.92

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 (176) hide show
  1. package/dist/account-check.d.ts +27 -0
  2. package/dist/account-check.js +70 -0
  3. package/dist/body-sync-gate.d.ts +12 -0
  4. package/dist/body-sync-gate.js +53 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +193 -0
  7. package/dist/config-env.d.ts +5 -0
  8. package/dist/config-env.js +37 -0
  9. package/dist/connection-pool.d.ts +22 -0
  10. package/dist/connection-pool.js +97 -0
  11. package/dist/connection-scope.d.ts +8 -0
  12. package/dist/connection-scope.js +10 -0
  13. package/dist/context.d.ts +46 -0
  14. package/dist/context.js +15 -0
  15. package/dist/e2e-processor-shim.d.ts +2 -0
  16. package/dist/e2e-processor-shim.js +181 -0
  17. package/dist/emit.d.ts +15 -0
  18. package/dist/emit.js +76 -0
  19. package/dist/events.d.ts +203 -0
  20. package/dist/events.js +1 -0
  21. package/dist/failure-kind.d.ts +10 -0
  22. package/{src/failure-kind.ts → dist/failure-kind.js} +6 -6
  23. package/dist/handlers/append-sent-message.d.ts +3 -0
  24. package/dist/handlers/append-sent-message.js +152 -0
  25. package/dist/handlers/delete-account-objects.d.ts +3 -0
  26. package/dist/handlers/delete-account-objects.js +35 -0
  27. package/dist/handlers/empty-trash.d.ts +14 -0
  28. package/dist/handlers/empty-trash.js +217 -0
  29. package/dist/handlers/flag-push.d.ts +38 -0
  30. package/dist/handlers/flag-push.js +195 -0
  31. package/dist/handlers/imported-folder.d.ts +4 -0
  32. package/dist/handlers/imported-folder.js +23 -0
  33. package/dist/handlers/mailbox-management.d.ts +6 -0
  34. package/dist/handlers/mailbox-management.js +294 -0
  35. package/dist/handlers/mailbox-sync-order.d.ts +21 -0
  36. package/dist/handlers/mailbox-sync-order.js +50 -0
  37. package/dist/handlers/message-copy.d.ts +40 -0
  38. package/dist/handlers/message-copy.js +371 -0
  39. package/dist/handlers/message-delete-terminal.d.ts +60 -0
  40. package/dist/handlers/message-delete-terminal.js +77 -0
  41. package/dist/handlers/message-delete.d.ts +57 -0
  42. package/dist/handlers/message-delete.js +520 -0
  43. package/{src/handlers/message-move-terminal.ts → dist/handlers/message-move-terminal.d.ts} +18 -105
  44. package/dist/handlers/message-move-terminal.js +94 -0
  45. package/dist/handlers/message-move.d.ts +146 -0
  46. package/dist/handlers/message-move.js +490 -0
  47. package/dist/handlers/placement-move-push.d.ts +62 -0
  48. package/dist/handlers/placement-move-push.js +307 -0
  49. package/dist/handlers/probe-message-id.d.ts +2 -0
  50. package/dist/handlers/probe-message-id.js +6 -0
  51. package/dist/handlers/sync-mailboxes.d.ts +45 -0
  52. package/dist/handlers/sync-mailboxes.js +233 -0
  53. package/dist/handlers/sync-message-body.d.ts +23 -0
  54. package/dist/handlers/sync-message-body.js +182 -0
  55. package/dist/handlers/sync-messages.d.ts +29 -0
  56. package/dist/handlers/sync-messages.js +285 -0
  57. package/dist/index.d.ts +9 -0
  58. package/dist/index.js +67 -0
  59. package/dist/lambda-context.d.ts +8 -0
  60. package/dist/lambda-context.js +33 -0
  61. package/dist/mailbox-row.d.ts +9 -0
  62. package/dist/mailbox-row.js +6 -0
  63. package/dist/poller.d.ts +1 -0
  64. package/dist/poller.js +51 -0
  65. package/dist/process.d.ts +6 -0
  66. package/dist/process.js +3 -0
  67. package/dist/processor.d.ts +3 -0
  68. package/dist/processor.js +44 -0
  69. package/dist/queues.d.ts +10 -0
  70. package/dist/queues.js +1 -0
  71. package/dist/s3-account-objects.d.ts +3 -0
  72. package/dist/s3-account-objects.js +26 -0
  73. package/dist/scheduler/attachment-sweep.d.ts +11 -0
  74. package/dist/scheduler/attachment-sweep.js +23 -0
  75. package/dist/scheduler/calendar-subscriptions.d.ts +22 -0
  76. package/dist/scheduler/calendar-subscriptions.js +35 -0
  77. package/dist/scheduler/config.d.ts +29 -0
  78. package/{src/scheduler/config.ts → dist/scheduler/config.js} +9 -28
  79. package/{src/scheduler/decide-due.ts → dist/scheduler/decide-due.d.ts} +3 -12
  80. package/dist/scheduler/decide-due.js +17 -0
  81. package/dist/scheduler/handler.d.ts +21 -0
  82. package/dist/scheduler/handler.js +50 -0
  83. package/{src/scheduler/loop.ts → dist/scheduler/loop.d.ts} +7 -23
  84. package/dist/scheduler/loop.js +8 -0
  85. package/dist/scheduler/run-tick.d.ts +66 -0
  86. package/dist/scheduler/run-tick.js +164 -0
  87. package/dist/scheduler/runner.d.ts +2 -0
  88. package/dist/scheduler/runner.js +102 -0
  89. package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
  90. package/dist/scheduler/stranded-placement-redrive.js +172 -0
  91. package/dist/scheduler/tick.d.ts +5 -0
  92. package/dist/scheduler/tick.js +5 -0
  93. package/dist/sqs-queues.d.ts +4 -0
  94. package/dist/sqs-queues.js +22 -0
  95. package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
  96. package/dist/with-oauth-lifecycle-deps.js +17 -0
  97. package/dist/with-oauth-lifecycle.d.ts +56 -0
  98. package/dist/with-oauth-lifecycle.js +92 -0
  99. package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
  100. package/dist/worker-version.js +12 -0
  101. package/package.json +79 -69
  102. package/src/account-check.test.ts +0 -149
  103. package/src/account-check.ts +0 -101
  104. package/src/body-sync-gate.test.ts +0 -185
  105. package/src/body-sync-gate.ts +0 -86
  106. package/src/cli.ts +0 -218
  107. package/src/connection-scope.test.ts +0 -266
  108. package/src/connection-scope.ts +0 -314
  109. package/src/credential-less-sync-fence.test.ts +0 -152
  110. package/src/e2e-processor-shim.ts +0 -232
  111. package/src/emit.test.ts +0 -145
  112. package/src/emit.ts +0 -115
  113. package/src/events.ts +0 -252
  114. package/src/failure-kind.test.ts +0 -42
  115. package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
  116. package/src/handlers/append-sent-message.ts +0 -264
  117. package/src/handlers/delete-account-objects.test.ts +0 -126
  118. package/src/handlers/delete-account-objects.ts +0 -96
  119. package/src/handlers/empty-trash.sqlite.test.ts +0 -209
  120. package/src/handlers/empty-trash.ts +0 -352
  121. package/src/handlers/flag-push.ts +0 -285
  122. package/src/handlers/imported-folder.test.ts +0 -63
  123. package/src/handlers/imported-folder.ts +0 -38
  124. package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
  125. package/src/handlers/mailbox-management.ts +0 -486
  126. package/src/handlers/mailbox-sync-order.test.ts +0 -93
  127. package/src/handlers/mailbox-sync-order.ts +0 -65
  128. package/src/handlers/message-copy.sqlite.test.ts +0 -298
  129. package/src/handlers/message-copy.ts +0 -569
  130. package/src/handlers/message-delete-terminal.ts +0 -147
  131. package/src/handlers/message-delete.sqlite.test.ts +0 -291
  132. package/src/handlers/message-delete.ts +0 -888
  133. package/src/handlers/message-move.sqlite.test.ts +0 -271
  134. package/src/handlers/message-move.test.ts +0 -216
  135. package/src/handlers/message-move.ts +0 -755
  136. package/src/handlers/placement-move-push.test.ts +0 -137
  137. package/src/handlers/placement-move-push.ts +0 -478
  138. package/src/handlers/placement-push.sqlite.test.ts +0 -299
  139. package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
  140. package/src/handlers/probe-message-id.ts +0 -15
  141. package/src/handlers/sync-mailboxes.test.ts +0 -183
  142. package/src/handlers/sync-mailboxes.ts +0 -425
  143. package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
  144. package/src/handlers/sync-message-body.test.ts +0 -150
  145. package/src/handlers/sync-message-body.ts +0 -366
  146. package/src/handlers/sync-messages.sqlite.test.ts +0 -195
  147. package/src/handlers/sync-messages.ts +0 -537
  148. package/src/index.test.ts +0 -22
  149. package/src/index.ts +0 -82
  150. package/src/mail-sync-selection.sqlite.test.ts +0 -227
  151. package/src/mailbox-row.ts +0 -20
  152. package/src/pending-sync.sqlite.test.ts +0 -165
  153. package/src/poller.ts +0 -55
  154. package/src/processor.ts +0 -62
  155. package/src/queues.ts +0 -40
  156. package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -74
  157. package/src/scheduler/attachment-sweep.ts +0 -46
  158. package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
  159. package/src/scheduler/calendar-subscriptions.ts +0 -103
  160. package/src/scheduler/config.test.ts +0 -40
  161. package/src/scheduler/decide-due.test.ts +0 -44
  162. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
  163. package/src/scheduler/handler.ts +0 -69
  164. package/src/scheduler/loop.test.ts +0 -143
  165. package/src/scheduler/run-tick.sqlite.test.ts +0 -191
  166. package/src/scheduler/run-tick.ts +0 -281
  167. package/src/scheduler/runner.ts +0 -125
  168. package/src/scheduler/stranded-placement-redrive.ts +0 -265
  169. package/src/stranded-placement.sqlite.test.ts +0 -799
  170. package/src/test-helpers/test-world.ts +0 -425
  171. package/src/test-helpers/worker-queues.ts +0 -11
  172. package/src/thread-join.sqlite.test.ts +0 -106
  173. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
  174. package/src/with-oauth-lifecycle-deps.ts +0 -71
  175. package/src/with-oauth-lifecycle.ts +0 -158
  176. package/tsconfig.json +0 -8
@@ -1,266 +0,0 @@
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
- });
@@ -1,314 +0,0 @@
1
- /**
2
- * Connection scope utility for managing IMAP connections across event processing.
3
- *
4
- * Creates a lazily-connected, cached connection that can be shared across
5
- * multiple operations during a single event's lifetime.
6
- *
7
- * On top of the per-event scope, this module keeps a MODULE-scoped pool of warm
8
- * connections keyed by accountId so a warm Lambda container reuses live IMAP
9
- * connections across invocations instead of paying TCP+TLS+LOGIN+SELECT every
10
- * time (see #605).
11
- */
12
-
13
- import {
14
- createConnection,
15
- createConnectionWithCredentials,
16
- type IImapConnection,
17
- type ImapConnectionConfig,
18
- type MailCredentials,
19
- } from "@remit/mailbox-service";
20
-
21
- export interface ConnectionScope {
22
- /**
23
- * Get the connection, connecting lazily if not already connected.
24
- * Returns the same connection instance on subsequent calls.
25
- */
26
- getConnection: () => Promise<IImapConnection>;
27
-
28
- /**
29
- * Disconnect the connection if it was ever connected.
30
- * Safe to call multiple times.
31
- */
32
- disconnect: () => Promise<void>;
33
- }
34
-
35
- /**
36
- * Create a connection scope that manages a single IMAP connection's lifecycle.
37
- *
38
- * The connection is created lazily on first call to getConnection() and
39
- * reused for all subsequent calls. Call disconnect() when done to clean up.
40
- *
41
- * @example
42
- * ```typescript
43
- * const scope = createConnectionScope(config);
44
- *
45
- * await doWork(scope.getConnection)
46
- * .finally(() => scope.disconnect());
47
- * ```
48
- */
49
- export const createConnectionScope = (
50
- config: ImapConnectionConfig,
51
- ): ConnectionScope => {
52
- let connection: IImapConnection | null = null;
53
- let connectPromise: Promise<IImapConnection> | null = null;
54
-
55
- const getConnection = async (): Promise<IImapConnection> => {
56
- if (connectPromise) {
57
- return connectPromise;
58
- }
59
-
60
- const conn = createConnection(config);
61
- connection = conn;
62
- connectPromise = conn.connect().then(() => conn);
63
-
64
- return connectPromise;
65
- };
66
-
67
- const disconnect = async (): Promise<void> => {
68
- if (connection) {
69
- await connection.disconnect();
70
- connection = null;
71
- connectPromise = null;
72
- }
73
- };
74
-
75
- return { getConnection, disconnect };
76
- };
77
-
78
- /**
79
- * Create a connection scope from account data and a MailCredentials union.
80
- * Use this for all handlers that support both password and OAuth accounts.
81
- */
82
- export const createConnectionScopeWithCredentials = (
83
- account: {
84
- username: string;
85
- imapHost: string;
86
- imapPort: number;
87
- imapTls: boolean;
88
- },
89
- credentials: MailCredentials,
90
- ): ConnectionScope => {
91
- let connection: IImapConnection | null = null;
92
- let connectPromise: Promise<IImapConnection> | null = null;
93
-
94
- const getConnection = async (): Promise<IImapConnection> => {
95
- if (connectPromise) {
96
- return connectPromise;
97
- }
98
-
99
- const conn = createConnectionWithCredentials(account, credentials);
100
- connection = conn;
101
- connectPromise = conn.connect().then(() => conn);
102
-
103
- return connectPromise;
104
- };
105
-
106
- const disconnect = async (): Promise<void> => {
107
- if (connection) {
108
- await connection.disconnect();
109
- connection = null;
110
- connectPromise = null;
111
- }
112
- };
113
-
114
- return { getConnection, disconnect };
115
- };
116
-
117
- /**
118
- * Number of warm connections to hold per account in a single container.
119
- *
120
- * Default 2 (Hostnet-conservative). imapflow runs one command at a time, so a
121
- * single cached connection cannot be shared by concurrent invocations of the
122
- * same account in one container (the worker runs handlers under p-map). The
123
- * pool lets concurrent same-account invocations each borrow a live connection
124
- * while still reusing them across invocations.
125
- *
126
- * CAVEAT: warm reuse only helps WITHIN one container. Lambda may run many
127
- * containers (each with its own pool) and recycle them, so this cuts login
128
- * churn but does NOT by itself bound the connections an account opens against
129
- * the provider — the queue concurrency cap (#610) does that.
130
- */
131
- const connectionsPerAccount = (() => {
132
- const raw = Number(process.env.CONNECTIONS_PER_ACCOUNT);
133
- return Number.isInteger(raw) && raw > 0 ? raw : 2;
134
- })();
135
-
136
- /**
137
- * A pooled, lazily-connected connection plus its liveness/borrow bookkeeping.
138
- */
139
- interface PooledConnection {
140
- scope: ConnectionScope;
141
- /** The established connection once getConnection() has resolved. */
142
- connection: IImapConnection | null;
143
- /** Borrowed by an in-flight invocation; must not be lent out concurrently. */
144
- busy: boolean;
145
- }
146
-
147
- /** accountId -> warm pool. Module scope so it survives across invocations. */
148
- const warmPools = new Map<string, PooledConnection[]>();
149
-
150
- /**
151
- * A connection borrowed from the warm pool for the duration of one invocation.
152
- */
153
- export interface BorrowedConnection {
154
- /** Get the (possibly cached, liveness-checked) live connection. */
155
- getConnection: () => Promise<IImapConnection>;
156
- /**
157
- * Return the connection to the pool. Pooled connections stay connected for
158
- * reuse; overflow connections (created when the pool is saturated) are
159
- * disconnected so the pool never grows past connectionsPerAccount.
160
- */
161
- release: () => Promise<void>;
162
- }
163
-
164
- /**
165
- * A live connection passes liveness when the underlying socket is still
166
- * authenticated. imapflow flips `isConnected` to false on its `close`/`error`
167
- * events, which fire when the provider drops an idle socket — the dominant
168
- * warm-container failure mode. A connection that fails this check is replaced.
169
- */
170
- const isLive = (entry: PooledConnection): boolean =>
171
- entry.connection?.isConnected ?? false;
172
-
173
- const disconnectQuietly = async (entry: PooledConnection): Promise<void> => {
174
- try {
175
- await entry.scope.disconnect();
176
- } catch {
177
- // A dead connection may already be gone; reclaiming it must never throw.
178
- }
179
- };
180
-
181
- /**
182
- * Borrow a warm connection for one invocation, keyed by accountId.
183
- *
184
- * Reuses a free, live pooled connection when one exists (no reconnect). A free
185
- * connection that has gone dead is disconnected and replaced. When every pooled
186
- * connection is busy and the pool is full, an overflow connection is created and
187
- * torn down on release so the steady-state pool size stays at
188
- * connectionsPerAccount.
189
- *
190
- * The caller MUST call release() (e.g. in a finally) so the connection returns
191
- * to the pool; releasing does NOT disconnect a pooled connection.
192
- */
193
- export const borrowWarmConnection = (
194
- accountId: string,
195
- createScope: () => ConnectionScope,
196
- ): BorrowedConnection => {
197
- const pool = warmPools.get(accountId) ?? [];
198
- if (!warmPools.has(accountId)) {
199
- warmPools.set(accountId, pool);
200
- }
201
-
202
- let entry: PooledConnection | undefined;
203
- let isOverflow = false;
204
-
205
- const claimFreeLiveEntry = (): PooledConnection | undefined => {
206
- for (const candidate of pool) {
207
- if (candidate.busy) {
208
- continue;
209
- }
210
- if (isLive(candidate)) {
211
- candidate.busy = true;
212
- return candidate;
213
- }
214
- }
215
- return undefined;
216
- };
217
-
218
- const claimDeadOrEmptyEntry = (): PooledConnection | undefined => {
219
- for (const candidate of pool) {
220
- if (!candidate.busy && !isLive(candidate)) {
221
- // Claim synchronously BEFORE the awaited disconnect in the recycle
222
- // branch, mirroring claimFreeLiveEntry. Without this, a concurrent
223
- // same-account borrow could claim the same dead entry during that
224
- // await and two invocations would share one imapflow connection.
225
- candidate.busy = true;
226
- return candidate;
227
- }
228
- }
229
- return undefined;
230
- };
231
-
232
- const getConnection = async (): Promise<IImapConnection> => {
233
- if (entry) {
234
- const conn = await entry.scope.getConnection();
235
- entry.connection = conn;
236
- return conn;
237
- }
238
-
239
- const reused = claimFreeLiveEntry();
240
- if (reused) {
241
- entry = reused;
242
- const conn = await reused.scope.getConnection();
243
- reused.connection = conn;
244
- return conn;
245
- }
246
-
247
- // No free live connection: reuse a dead/never-connected slot if one is
248
- // free, otherwise grow the pool, otherwise go overflow.
249
- // claimDeadOrEmptyEntry already set busy=true synchronously, so the
250
- // recycle slot is fenced off across the awaited disconnect below.
251
- const recyclable = claimDeadOrEmptyEntry();
252
- if (recyclable) {
253
- await disconnectQuietly(recyclable);
254
- recyclable.scope = createScope();
255
- recyclable.connection = null;
256
- entry = recyclable;
257
- } else if (pool.length < connectionsPerAccount) {
258
- const fresh: PooledConnection = {
259
- scope: createScope(),
260
- connection: null,
261
- busy: true,
262
- };
263
- pool.push(fresh);
264
- entry = fresh;
265
- } else {
266
- isOverflow = true;
267
- entry = { scope: createScope(), connection: null, busy: true };
268
- }
269
-
270
- const conn = await entry.scope.getConnection();
271
- entry.connection = conn;
272
- return conn;
273
- };
274
-
275
- const release = async (): Promise<void> => {
276
- if (!entry) {
277
- return;
278
- }
279
- if (isOverflow) {
280
- await disconnectQuietly(entry);
281
- entry = undefined;
282
- return;
283
- }
284
- entry.busy = false;
285
- entry = undefined;
286
- };
287
-
288
- return { getConnection, release };
289
- };
290
-
291
- /**
292
- * Test-only: disconnect and drop an account's warm pool so a test never leaks a
293
- * live connection across cases. Not part of the production lifecycle — in
294
- * steady state dead entries are recycled lazily on the next borrow.
295
- */
296
- export const __evictWarmConnectionsForTest = async (
297
- accountId: string,
298
- ): Promise<void> => {
299
- const pool = warmPools.get(accountId);
300
- if (!pool) {
301
- return;
302
- }
303
- warmPools.delete(accountId);
304
- await Promise.all(pool.map(disconnectQuietly));
305
- };
306
-
307
- /** Test-only: clear all warm pools without disconnecting (fixtures own teardown). */
308
- export const __resetWarmPoolsForTest = (): void => {
309
- warmPools.clear();
310
- };
311
-
312
- /** Test-only: inspect the live pool size for an account. */
313
- export const __warmPoolSizeForTest = (accountId: string): number =>
314
- warmPools.get(accountId)?.length ?? 0;