@remit/imap-worker 0.0.89 → 0.0.91

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 (171) 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 +26 -0
  76. package/dist/scheduler/calendar-subscriptions.js +35 -0
  77. package/dist/scheduler/config.d.ts +27 -0
  78. package/{src/scheduler/config.ts → dist/scheduler/config.js} +7 -26
  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/{src/scheduler/handler.ts → dist/scheduler/handler.js} +24 -36
  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 +62 -0
  86. package/dist/scheduler/run-tick.js +164 -0
  87. package/dist/scheduler/runner.d.ts +2 -0
  88. package/{src/scheduler/runner.ts → dist/scheduler/runner.js} +58 -84
  89. package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
  90. package/dist/scheduler/stranded-placement-redrive.js +172 -0
  91. package/dist/sqs-queues.d.ts +4 -0
  92. package/dist/sqs-queues.js +22 -0
  93. package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
  94. package/dist/with-oauth-lifecycle-deps.js +17 -0
  95. package/dist/with-oauth-lifecycle.d.ts +56 -0
  96. package/dist/with-oauth-lifecycle.js +92 -0
  97. package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
  98. package/dist/worker-version.js +12 -0
  99. package/package.json +75 -69
  100. package/src/account-check.test.ts +0 -149
  101. package/src/account-check.ts +0 -101
  102. package/src/body-sync-gate.test.ts +0 -185
  103. package/src/body-sync-gate.ts +0 -86
  104. package/src/cli.ts +0 -224
  105. package/src/connection-scope.test.ts +0 -266
  106. package/src/connection-scope.ts +0 -314
  107. package/src/credential-less-sync-fence.test.ts +0 -152
  108. package/src/e2e-processor-shim.ts +0 -232
  109. package/src/emit.test.ts +0 -127
  110. package/src/emit.ts +0 -117
  111. package/src/events.ts +0 -252
  112. package/src/failure-kind.test.ts +0 -42
  113. package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
  114. package/src/handlers/append-sent-message.ts +0 -264
  115. package/src/handlers/delete-account-objects.test.ts +0 -128
  116. package/src/handlers/delete-account-objects.ts +0 -94
  117. package/src/handlers/empty-trash.sqlite.test.ts +0 -209
  118. package/src/handlers/empty-trash.ts +0 -352
  119. package/src/handlers/flag-push.ts +0 -285
  120. package/src/handlers/imported-folder.test.ts +0 -63
  121. package/src/handlers/imported-folder.ts +0 -38
  122. package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
  123. package/src/handlers/mailbox-management.ts +0 -486
  124. package/src/handlers/mailbox-sync-order.test.ts +0 -93
  125. package/src/handlers/mailbox-sync-order.ts +0 -65
  126. package/src/handlers/message-copy.sqlite.test.ts +0 -298
  127. package/src/handlers/message-copy.ts +0 -566
  128. package/src/handlers/message-delete-terminal.ts +0 -147
  129. package/src/handlers/message-delete.sqlite.test.ts +0 -291
  130. package/src/handlers/message-delete.ts +0 -885
  131. package/src/handlers/message-move.sqlite.test.ts +0 -271
  132. package/src/handlers/message-move.test.ts +0 -216
  133. package/src/handlers/message-move.ts +0 -752
  134. package/src/handlers/placement-move-push.test.ts +0 -137
  135. package/src/handlers/placement-move-push.ts +0 -475
  136. package/src/handlers/placement-push.sqlite.test.ts +0 -298
  137. package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
  138. package/src/handlers/probe-message-id.ts +0 -15
  139. package/src/handlers/sync-mailboxes.test.ts +0 -183
  140. package/src/handlers/sync-mailboxes.ts +0 -421
  141. package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
  142. package/src/handlers/sync-message-body.test.ts +0 -150
  143. package/src/handlers/sync-message-body.ts +0 -365
  144. package/src/handlers/sync-messages.sqlite.test.ts +0 -183
  145. package/src/handlers/sync-messages.ts +0 -525
  146. package/src/index.test.ts +0 -22
  147. package/src/index.ts +0 -86
  148. package/src/mail-sync-selection.sqlite.test.ts +0 -234
  149. package/src/mailbox-row.ts +0 -20
  150. package/src/pending-sync.sqlite.test.ts +0 -166
  151. package/src/poller.ts +0 -55
  152. package/src/processor.ts +0 -60
  153. package/src/queues.ts +0 -49
  154. package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -75
  155. package/src/scheduler/attachment-sweep.ts +0 -46
  156. package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
  157. package/src/scheduler/calendar-subscriptions.ts +0 -103
  158. package/src/scheduler/config.test.ts +0 -40
  159. package/src/scheduler/decide-due.test.ts +0 -44
  160. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
  161. package/src/scheduler/loop.test.ts +0 -143
  162. package/src/scheduler/run-tick.sqlite.test.ts +0 -191
  163. package/src/scheduler/run-tick.ts +0 -281
  164. package/src/scheduler/stranded-placement-redrive.ts +0 -265
  165. package/src/stranded-placement.sqlite.test.ts +0 -794
  166. package/src/test-helpers/test-world.ts +0 -427
  167. package/src/thread-join.sqlite.test.ts +0 -106
  168. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
  169. package/src/with-oauth-lifecycle-deps.ts +0 -71
  170. package/src/with-oauth-lifecycle.ts +0 -158
  171. 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;