imapflow 1.5.0 → 1.6.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 (36) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +22 -0
  3. package/CLAUDE.md +1 -1
  4. package/lib/commands/idle.js +197 -104
  5. package/lib/commands/list.js +15 -7
  6. package/lib/commands/quota.js +3 -0
  7. package/lib/commands/select.js +5 -0
  8. package/lib/commands/status.js +4 -0
  9. package/lib/connection-deadline.js +98 -0
  10. package/lib/handler/imap-compiler.js +8 -5
  11. package/lib/handler/imap-stream.js +141 -50
  12. package/lib/handler/limits.js +43 -0
  13. package/lib/handler/token-parser.js +31 -1
  14. package/lib/imap-flow.d.ts +33 -5
  15. package/lib/imap-flow.js +583 -281
  16. package/lib/proxy-connection.js +393 -98
  17. package/lib/special-use.js +660 -51
  18. package/lib/tools.js +17 -0
  19. package/package.json +2 -2
  20. package/test/commands-branches-test.js +17 -1
  21. package/test/commands-integration-test.js +24 -2
  22. package/test/fixtures/fake-timers.js +115 -0
  23. package/test/handler-branches-test.js +0 -25
  24. package/test/idle-polling-test.js +349 -0
  25. package/test/imap-flow-compress-test.js +12 -0
  26. package/test/imap-flow-coverage-test.js +3 -3
  27. package/test/imap-flow-internals-test.js +23 -0
  28. package/test/imap-flow-proxy-paths-test.js +151 -0
  29. package/test/imap-flow-secure-test.js +159 -0
  30. package/test/imap-flow-server-test.js +186 -0
  31. package/test/parser-limits-test.js +274 -0
  32. package/test/proxy-connection-test.js +553 -442
  33. package/test/reliability-improvements-test.js +87 -0
  34. package/test/special-use-test.js +337 -0
  35. package/test/tag-correlation-test.js +333 -0
  36. package/test/timer-policy-test.js +214 -0
@@ -143,11 +143,23 @@ module.exports['Compress: session negotiates DEFLATE and runs commands'] = async
143
143
  client.write(Buffer.alloc(128 * 1024, 0x61));
144
144
  await new Promise(r => setTimeout(r, 50));
145
145
 
146
+ // Each stream owns and reports its own lifecycle: the compression PassThrough used to proxy
147
+ // the raw socket's `destroyed` getter, which made close() skip destroying it.
148
+ let rawSocket = client.socket;
149
+ let passThrough = client.writeSocket;
150
+ test.notEqual(passThrough, rawSocket, 'compression replaced the write socket with a PassThrough');
151
+ test.equal(passThrough.destroyed, false, 'the PassThrough reports its own state while alive');
152
+
146
153
  await client.logout();
147
154
  client.close();
148
155
  // close() must tear down the compression streams
149
156
  test.ok(!client._deflate, 'deflate cleaned up after close');
150
157
  test.ok(!client._inflate, 'inflate cleaned up after close');
158
+ test.ok(passThrough.destroyed, 'the compression PassThrough was destroyed');
159
+ test.ok(rawSocket.destroyed, 'the raw socket was destroyed');
160
+
161
+ // Repeated close() stays idempotent
162
+ test.doesNotThrow(() => client.close());
151
163
 
152
164
  server.close();
153
165
  test.done();
@@ -524,7 +524,7 @@ module.exports['Coverage: reader swallows a throwing untagged handler'] = async
524
524
  module.exports['Coverage: reader swallows a throwing trySend after a tagged response'] = async test => {
525
525
  let client = makeClient();
526
526
  let resolved = false;
527
- client.currentRequest = { tag: 'A1' };
527
+ client.currentRequest = { tag: 'A1', sent: true };
528
528
  client.requestTagMap.set('A1', {
529
529
  command: 'NOOP',
530
530
  attributes: [],
@@ -549,7 +549,7 @@ module.exports['Coverage: reader handles compiler failure when building executed
549
549
  let client = makeClient();
550
550
  let rejected = null;
551
551
  // A request whose attributes cannot be compiled -> the executedCommand try/catch is hit
552
- client.currentRequest = { tag: 'A1' };
552
+ client.currentRequest = { tag: 'A1', sent: true };
553
553
  client.requestTagMap.set('A1', {
554
554
  command: 'NOOP',
555
555
  // a circular/odd attribute that makes the logging compiler throw
@@ -576,7 +576,7 @@ module.exports['Coverage: reader handles compiler failure when building executed
576
576
  module.exports['Coverage: reader caps very large throttle backoff'] = async test => {
577
577
  let client = makeClient();
578
578
  let rejected = null;
579
- client.currentRequest = { tag: 'A1' };
579
+ client.currentRequest = { tag: 'A1', sent: true };
580
580
  client.requestTagMap.set('A1', {
581
581
  command: 'FETCH',
582
582
  attributes: [],
@@ -38,16 +38,39 @@ module.exports['Internals: emitError routes to upgrade rejector while upgrading'
38
38
  client.socket = { destroyed: true, destroy: () => {} };
39
39
  client.upgrading = true;
40
40
  let rejected = null;
41
+ // Stands in for the upgrade's settle() helper, which owns clearing `upgrading`,
42
+ // the upgrade timer and the temporary handshake handlers.
41
43
  client._upgradeReject = err => {
42
44
  rejected = err;
45
+ client.upgrading = false;
43
46
  };
44
47
  let err = new Error('tls boom');
45
48
  client.emitError(err);
46
49
  test.equal(rejected, err);
50
+ test.equal(client._upgradeReject, null, 'the rejector is consumed exactly once');
47
51
  test.equal(client.upgrading, false);
48
52
  test.done();
49
53
  };
50
54
 
55
+ module.exports['Internals: emitError closes when an upgrade has no rejector'] = test => {
56
+ let client = makeClient();
57
+ client.socket = { destroyed: true, destroy: () => {} };
58
+ client.upgrading = true;
59
+ client._upgradeReject = null;
60
+
61
+ let emitted = false;
62
+ client.on('error', () => {
63
+ emitted = true;
64
+ });
65
+
66
+ client.emitError(new Error('tls boom'));
67
+
68
+ test.equal(client.upgrading, false, 'the upgrade flag is cleared');
69
+ test.equal(emitted, false, 'no duplicate error event while the upgrade owns reporting');
70
+ client.close();
71
+ test.done();
72
+ };
73
+
51
74
  module.exports['Internals: emitError rejects pending connect promise'] = test => {
52
75
  let client = makeClient();
53
76
  client.socket = { destroyed: true, destroy: () => {} };
@@ -8,6 +8,7 @@ const net = require('net');
8
8
  const tls = require('tls');
9
9
  const proxyquire = require('proxyquire');
10
10
  const { cert, key } = require('./fixtures/test-tls');
11
+ const { ImapFlow } = require('../lib/imap-flow');
11
12
 
12
13
  const CAPS = 'IMAP4rev1 ID ENABLE NAMESPACE';
13
14
 
@@ -213,3 +214,153 @@ module.exports['Proxy: proxyConnection throwing rejects connect'] = async test =
213
214
  client.close();
214
215
  test.done();
215
216
  };
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // End to end through a real HTTP CONNECT proxy (ImapFlow's own CONNECT helper)
220
+ // ---------------------------------------------------------------------------
221
+
222
+ // Minimal HTTP CONNECT proxy. `onConnect(socket, headers)` may take over the exchange (used to
223
+ // simulate a proxy that never answers); otherwise the tunnel is established and piped. With
224
+ // `coalesce`, the 200 response is held back until the destination speaks and then written in the
225
+ // same segment as the destination's greeting - which is what a real proxy often does.
226
+ const createHttpProxy = (onConnect, { coalesce = false } = {}) =>
227
+ net.createServer(socket => {
228
+ socket.on('error', () => {});
229
+ let buf = '';
230
+ const onData = data => {
231
+ buf += data.toString('binary');
232
+ let idx = buf.indexOf('\r\n\r\n');
233
+ if (idx < 0) {
234
+ return;
235
+ }
236
+ socket.removeListener('data', onData);
237
+ let headers = buf.slice(0, idx);
238
+ let rest = buf.slice(idx + 4);
239
+ let authority = (headers.split('\r\n')[0].match(/^CONNECT (\S+)/) || [])[1] || '';
240
+
241
+ if (onConnect && onConnect(socket, headers)) {
242
+ return;
243
+ }
244
+
245
+ let [, targetPort] = authority.split(':');
246
+ let upstream = net.connect(Number(targetPort), '127.0.0.1', () => {
247
+ if (rest) {
248
+ upstream.write(Buffer.from(rest, 'binary'));
249
+ }
250
+
251
+ const startTunnel = () => {
252
+ socket.pipe(upstream);
253
+ upstream.pipe(socket);
254
+ };
255
+
256
+ if (!coalesce) {
257
+ socket.write('HTTP/1.1 200 Connection established\r\n\r\n');
258
+ return startTunnel();
259
+ }
260
+
261
+ // Hold the CONNECT response until the destination sends something, then emit both
262
+ // in a single write so the client has to preserve the trailing bytes itself.
263
+ upstream.once('data', firstChunk => {
264
+ socket.write(Buffer.concat([Buffer.from('HTTP/1.1 200 Connection established\r\n\r\n'), firstChunk]));
265
+ startTunnel();
266
+ });
267
+ });
268
+ upstream.on('error', () => socket.destroy());
269
+ };
270
+ socket.on('data', onData);
271
+ });
272
+
273
+ module.exports['Proxy: session established through a real HTTP CONNECT proxy'] = async test => {
274
+ let imapServer = createServer();
275
+ let imapPort = await listen(imapServer);
276
+ let proxy = createHttpProxy();
277
+ let proxyPort = await listen(proxy);
278
+
279
+ let client = new ImapFlow({
280
+ host: '127.0.0.1',
281
+ port: imapPort,
282
+ secure: false,
283
+ proxy: `http://127.0.0.1:${proxyPort}`,
284
+ disableAutoIdle: true,
285
+ disableCompression: true,
286
+ logger: false,
287
+ auth: { user: 'test', pass: 'secret' }
288
+ });
289
+ client.on('error', () => {});
290
+
291
+ await client.connect();
292
+ test.ok(client.usable, 'session established through the CONNECT tunnel');
293
+ test.ok(client.authenticated);
294
+
295
+ await client.logout();
296
+ client.close();
297
+ proxy.close();
298
+ imapServer.close();
299
+ test.done();
300
+ };
301
+
302
+ module.exports['Proxy: a greeting coalesced with the CONNECT response is not lost'] = async test => {
303
+ // The proxy answers CONNECT and the destination greeting in one segment. The bytes after the
304
+ // header terminator belong to the tunnel, so they have to reach the parser - otherwise the
305
+ // greeting is swallowed and connect() only fails at the greeting timeout.
306
+ let imapServer = createServer();
307
+ let imapPort = await listen(imapServer);
308
+ let proxy = createHttpProxy(null, { coalesce: true });
309
+ let proxyPort = await listen(proxy);
310
+
311
+ let client = new ImapFlow({
312
+ host: '127.0.0.1',
313
+ port: imapPort,
314
+ secure: false,
315
+ proxy: `http://127.0.0.1:${proxyPort}`,
316
+ greetingTimeout: 1500,
317
+ disableAutoIdle: true,
318
+ disableCompression: true,
319
+ logger: false,
320
+ auth: { user: 'test', pass: 'secret' }
321
+ });
322
+ client.on('error', () => {});
323
+
324
+ await client.connect();
325
+ test.ok(client.usable, 'the coalesced greeting was parsed, so the session came up');
326
+ test.ok(client.capabilities.has('IMAP4rev1'), 'capabilities from the coalesced greeting are present');
327
+
328
+ await client.logout();
329
+ client.close();
330
+ proxy.close();
331
+ imapServer.close();
332
+ test.done();
333
+ };
334
+
335
+ module.exports['Proxy: a stalled proxy negotiation is bounded by connectionTimeout'] = async test => {
336
+ // The proxy accepts the CONNECT request and then goes silent
337
+ let proxy = createHttpProxy(() => true);
338
+ let proxyPort = await listen(proxy);
339
+
340
+ let client = new ImapFlow({
341
+ host: '127.0.0.1',
342
+ port: 143,
343
+ secure: false,
344
+ proxy: `http://127.0.0.1:${proxyPort}`,
345
+ connectionTimeout: 150,
346
+ disableAutoIdle: true,
347
+ disableCompression: true,
348
+ logger: false,
349
+ auth: { user: 'test', pass: 'secret' }
350
+ });
351
+ client.on('error', () => {});
352
+
353
+ let started = Date.now();
354
+ let err = await client.connect().then(
355
+ () => null,
356
+ e => e
357
+ );
358
+
359
+ test.ok(err, 'connect rejected instead of hanging in proxy negotiation');
360
+ test.equal(err.code, 'CONNECT_TIMEOUT', 'the documented connection timeout covers proxy negotiation');
361
+ test.ok(Date.now() - started < 3000, 'the deadline applied to the proxy phase, not only to the transport');
362
+
363
+ client.close();
364
+ proxy.close();
365
+ test.done();
366
+ };
@@ -135,6 +135,165 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
135
135
  test.done();
136
136
  };
137
137
 
138
+ // Builds the STARTTLS happy-path server used by the watchdog and cleanup tests below.
139
+ const createStartTlsServer = () =>
140
+ net.createServer(rawSocket => {
141
+ rawSocket.on('error', () => {});
142
+
143
+ let detachPlain;
144
+ detachPlain = lineReader(rawSocket, line => {
145
+ handleLine(rawSocket, line, () => {
146
+ detachPlain();
147
+ let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
148
+ tlsSocket.on('error', () => {});
149
+ lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, CAPS));
150
+ });
151
+ });
152
+
153
+ rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
154
+ });
155
+
156
+ // Records every socket that went through configureSocket(), which is the single place the
157
+ // transport options (keepalive + inactivity watchdog) are applied.
158
+ const trackConfiguredSockets = client => {
159
+ let sockets = [];
160
+ let original = client.configureSocket.bind(client);
161
+ client.configureSocket = socket => {
162
+ sockets.push(socket);
163
+ return original(socket);
164
+ };
165
+ return sockets;
166
+ };
167
+
168
+ module.exports['Secure: STARTTLS session keeps the inactivity watchdog'] = async test => {
169
+ // Regression: the inactivity timer was armed on the plain socket only, while the timeout
170
+ // listener was attached to the TLS socket, so an upgraded session had no watchdog at all
171
+ // and a dead connection was never noticed.
172
+ let server = createStartTlsServer();
173
+ let port = await listen(server);
174
+ let client = new ImapFlow({
175
+ host: '127.0.0.1',
176
+ port,
177
+ secure: false,
178
+ doSTARTTLS: true,
179
+ servername: 'localhost',
180
+ tls: { rejectUnauthorized: false },
181
+ socketTimeout: 200,
182
+ disableAutoIdle: true,
183
+ disableCompression: true,
184
+ logger: false,
185
+ auth: { user: 'test', pass: 'secret' }
186
+ });
187
+
188
+ let errors = [];
189
+ client.on('error', err => errors.push(err));
190
+ let closed = new Promise(resolve => client.once('close', resolve));
191
+ let configured = trackConfiguredSockets(client);
192
+
193
+ await client.connect();
194
+ test.ok(client.secureConnection, 'connection upgraded to TLS');
195
+ test.equal(configured.length, 2, 'both the plain socket and the upgraded socket are configured');
196
+ test.equal(client.socket.timeout, 200, 'the upgraded socket carries the configured inactivity timeout');
197
+ test.equal(configured[0].timeout, 0, 'the superseded plain-socket timer is cleared, not left armed');
198
+
199
+ // No traffic from here on: the watchdog has to fire and take the connection down.
200
+ await closed;
201
+
202
+ test.ok(
203
+ errors.some(err => err.code === 'ETIMEOUT'),
204
+ 'the inactivity watchdog reported a socket timeout'
205
+ );
206
+ test.ok(client.isClosed, 'the connection closed after the timeout');
207
+
208
+ client.close();
209
+ server.close();
210
+ test.done();
211
+ };
212
+
213
+ module.exports['Secure: STARTTLS leaves no upgrade state behind on success'] = async test => {
214
+ // Item 8: every terminal path of the upgrade runs through one settlement helper, so after
215
+ // a successful handshake no timer, rejector, flag or temporary handler survives.
216
+ let server = createStartTlsServer();
217
+ let port = await listen(server);
218
+ let client = new ImapFlow({
219
+ host: '127.0.0.1',
220
+ port,
221
+ secure: false,
222
+ doSTARTTLS: true,
223
+ servername: 'localhost',
224
+ tls: { rejectUnauthorized: false },
225
+ disableAutoIdle: true,
226
+ disableCompression: true,
227
+ logger: false,
228
+ auth: { user: 'test', pass: 'secret' }
229
+ });
230
+ client.on('error', () => {});
231
+
232
+ await client.connect();
233
+
234
+ test.equal(client.upgrading, false, 'upgrading flag cleared');
235
+ test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
236
+ test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
237
+ // The handshake-only handler is gone, leaving the generic socket error handler as the single
238
+ // error path (during the handshake it is the other way round, which is what prevents a
239
+ // handshake error from firing two handlers at once).
240
+ test.equal(client.socket.listenerCount('error'), 1, 'the TLS socket keeps exactly one error path');
241
+
242
+ await client.logout();
243
+ client.close();
244
+ server.close();
245
+ test.done();
246
+ };
247
+
248
+ module.exports['Secure: close() during a STARTTLS upgrade settles the upgrade'] = async test => {
249
+ // The server acknowledges STARTTLS but never performs a handshake. close() has to settle
250
+ // the pending upgrade instead of leaving the session promise dangling forever.
251
+ let server = net.createServer(rawSocket => {
252
+ rawSocket.on('error', () => {});
253
+ let detach = lineReader(rawSocket, line => {
254
+ handleLine(rawSocket, line, () => {
255
+ detach(); // silence: no ServerHello, no data at all
256
+ });
257
+ });
258
+ rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
259
+ });
260
+ let port = await listen(server);
261
+ let client = new ImapFlow({
262
+ host: '127.0.0.1',
263
+ port,
264
+ secure: false,
265
+ doSTARTTLS: true,
266
+ servername: 'localhost',
267
+ tls: { rejectUnauthorized: false },
268
+ disableAutoIdle: true,
269
+ disableCompression: true,
270
+ logger: false,
271
+ auth: { user: 'test', pass: 'secret' }
272
+ });
273
+ client.on('error', () => {});
274
+
275
+ let connectResult = client.connect().then(
276
+ () => null,
277
+ err => err
278
+ );
279
+
280
+ // Wait until the upgrade is in flight, then close the connection under it.
281
+ while (!client.upgrading) {
282
+ await new Promise(resolve => setTimeout(resolve, 10));
283
+ }
284
+ client.close();
285
+
286
+ let err = await connectResult;
287
+ test.ok(err, 'connect rejected rather than hanging on the abandoned upgrade');
288
+ test.ok(['ClosedAfterConnectText', 'ClosedAfterConnectTLS', 'NoConnection'].includes(err.code), `connect rejected with ${err.code}`);
289
+ test.equal(client.upgrading, false, 'upgrading flag cleared');
290
+ test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
291
+ test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
292
+
293
+ server.close();
294
+ test.done();
295
+ };
296
+
138
297
  // ---------------------------------------------------------------------------
139
298
  // Direct TLS connection
140
299
  // ---------------------------------------------------------------------------
@@ -615,6 +615,43 @@ module.exports['Server: verifyOnly connect lists mailboxes and logs out'] = asyn
615
615
  test.done();
616
616
  };
617
617
 
618
+ module.exports['Server: verifyOnly keeps the authentication result after logging out'] = async test => {
619
+ // The whole point of verifyOnly is to report whether the credentials work. The mode logs out
620
+ // before connect() resolves, so if close() cleared `authenticated` the caller would read false
621
+ // off a connection that had just authenticated, with no later moment to read the real answer.
622
+ let server = createServer();
623
+ let port = await listen(server);
624
+ let client = makeClient(port, { verifyOnly: true, includeMailboxes: true });
625
+ client.on('error', () => {});
626
+
627
+ await client.connect();
628
+ test.ok(client.authenticated, 'authentication result survives the verifyOnly logout');
629
+
630
+ client.close();
631
+ test.ok(client.authenticated, 'and survives an explicit close as well');
632
+
633
+ server.close();
634
+ test.done();
635
+ };
636
+
637
+ module.exports['Server: an ordinary session clears the authentication state on close'] = async test => {
638
+ // The counterpart: for a session that is not verifyOnly, `authenticated` describes live state
639
+ // and must not survive the connection, or reconnect logic reads it as still signed in.
640
+ let server = createServer();
641
+ let port = await listen(server);
642
+ let client = makeClient(port);
643
+ client.on('error', () => {});
644
+
645
+ await client.connect();
646
+ test.ok(client.authenticated, 'authenticated while the session is up');
647
+
648
+ client.close();
649
+ test.equal(client.authenticated, false, 'cleared once the session ends');
650
+
651
+ server.close();
652
+ test.done();
653
+ };
654
+
618
655
  module.exports['Server: ID is re-requested after login when first response is sparse'] = async test => {
619
656
  let idCalls = 0;
620
657
  let server = createServer({
@@ -1103,6 +1140,155 @@ module.exports['Server: proxy connection failure rejects connect'] = async test
1103
1140
  test.done();
1104
1141
  };
1105
1142
 
1143
+ module.exports['Server: close clears public session state'] = async test => {
1144
+ // Callers inspect these properties in reconnect logic, so they must not keep describing a
1145
+ // session that is gone.
1146
+ let server = createServer();
1147
+ let port = await listen(server);
1148
+ let client = makeClient(port);
1149
+ client.on('error', () => {});
1150
+
1151
+ let events = [];
1152
+ client.on('mailboxClose', mailbox => events.push({ event: 'mailboxClose', path: mailbox.path }));
1153
+ client.on('close', () => events.push({ event: 'close' }));
1154
+
1155
+ await client.connect();
1156
+ await client.mailboxOpen('INBOX');
1157
+
1158
+ test.ok(client.mailbox, 'a mailbox is selected');
1159
+ test.ok(client.authenticated, 'the session is authenticated');
1160
+ test.ok(client.currentSelectCommand, 'the select command is remembered for polling');
1161
+
1162
+ client.close();
1163
+
1164
+ test.equal(client.mailbox, false, 'mailbox state cleared');
1165
+ test.equal(client.currentSelectCommand, false, 'saved select command cleared');
1166
+ test.equal(client.authenticated, false, 'authentication state cleared');
1167
+ test.equal(client.preCheck, false, 'preCheck cleared');
1168
+ test.equal(client.usable, false, 'connection no longer usable');
1169
+ test.equal(client.idling, false, 'idling cleared');
1170
+ test.equal(client.state, client.states.LOGOUT, 'state is LOGOUT');
1171
+
1172
+ // The selected mailbox transitions to closed exactly once, before 'close'
1173
+ test.deepEqual(events, [{ event: 'mailboxClose', path: 'INBOX' }, { event: 'close' }], 'mailboxClose is emitted once, ahead of close');
1174
+
1175
+ // Repeated close() is idempotent and emits nothing more
1176
+ client.close();
1177
+ client.close();
1178
+ test.equal(events.length, 2, 'no duplicate events from repeated close()');
1179
+
1180
+ server.close();
1181
+ test.done();
1182
+ };
1183
+
1184
+ module.exports['Server: close without a selected mailbox emits no mailboxClose'] = async test => {
1185
+ let server = createServer();
1186
+ let port = await listen(server);
1187
+ let client = makeClient(port);
1188
+ client.on('error', () => {});
1189
+
1190
+ let mailboxCloseCount = 0;
1191
+ client.on('mailboxClose', () => mailboxCloseCount++);
1192
+
1193
+ await client.connect();
1194
+ client.close();
1195
+
1196
+ test.equal(mailboxCloseCount, 0, 'nothing to close, nothing emitted');
1197
+ server.close();
1198
+ test.done();
1199
+ };
1200
+
1201
+ module.exports['Server: GETQUOTA fallback releases the parser'] = async test => {
1202
+ // Regression: the GETQUOTA fallback never handed its response back to the reader, so the
1203
+ // parser stayed blocked on its backpressure callback and every later command hung.
1204
+ let server = createServer({
1205
+ capabilities: 'IMAP4rev1 ID ENABLE NAMESPACE QUOTA',
1206
+ handlers: {
1207
+ GETQUOTAROOT(ctx) {
1208
+ // root only, no inline QUOTA response - forces the fallback command
1209
+ ctx.write('* QUOTAROOT "INBOX" "userquota"\r\n');
1210
+ ctx.ok('GETQUOTAROOT completed');
1211
+ },
1212
+ GETQUOTA(ctx) {
1213
+ ctx.write('* QUOTA "userquota" (STORAGE 512 1024)\r\n');
1214
+ ctx.ok('GETQUOTA completed');
1215
+ }
1216
+ }
1217
+ });
1218
+ let port = await listen(server);
1219
+ let client = makeClient(port);
1220
+ client.on('error', () => {});
1221
+
1222
+ await client.connect();
1223
+
1224
+ let quota = await client.getQuota();
1225
+ test.ok(quota, 'quota resolved');
1226
+ test.equal(quota.quotaRoot, 'userquota', 'quota root from the first command');
1227
+ test.equal(quota.storage.usage, 512 * 1024, 'quota usage from the fallback command');
1228
+
1229
+ // The parser must still be live: a following command has to complete.
1230
+ let noopResponse = await client.exec('NOOP', false, {});
1231
+ noopResponse.next();
1232
+ test.ok(noopResponse.response, 'the connection still processes commands after the fallback');
1233
+
1234
+ await client.logout();
1235
+ client.close();
1236
+ server.close();
1237
+ test.done();
1238
+ };
1239
+
1240
+ module.exports['Server: oversized literal cannot inject protocol'] = async test => {
1241
+ // Response-injection regression. With a lowered maxLiteralSize the parser rejects the
1242
+ // literal, and everything after the marker line is ordinary message content chosen by a
1243
+ // third party. None of it may be parsed: no untagged handler may fire and the forged
1244
+ // tagged completion may not settle the in-flight request.
1245
+ let server = createServer({
1246
+ handlers: {
1247
+ NOOP(ctx) {
1248
+ ctx.write(
1249
+ `* 1 FETCH (BODY[] {5000}\r\n` + //
1250
+ `INNOCENT MESSAGE TEXT\r\n` +
1251
+ `* 9999 EXISTS\r\n` +
1252
+ `${ctx.tag} OK forged completion\r\n`
1253
+ );
1254
+ }
1255
+ }
1256
+ });
1257
+ let port = await listen(server);
1258
+ let client = makeClient(port, { maxLiteralSize: 1024 });
1259
+
1260
+ let errors = [];
1261
+ let existsEvents = [];
1262
+ client.on('error', err => errors.push(err));
1263
+ client.on('exists', ev => existsEvents.push(ev));
1264
+
1265
+ await client.connect();
1266
+ let mailbox = await client.mailboxOpen('INBOX');
1267
+ test.equal(mailbox.exists, 3, 'mailbox opened with the server reported count');
1268
+
1269
+ let noopErr = null;
1270
+ try {
1271
+ // exec() surfaces the raw command outcome, so a forged tagged completion would show
1272
+ // up here as a resolved request (client.noop() would swallow it into `false`).
1273
+ await client.exec('NOOP', false, {});
1274
+ test.ok(false, 'the forged tagged completion must not resolve the in-flight command');
1275
+ } catch (err) {
1276
+ noopErr = err;
1277
+ }
1278
+
1279
+ test.ok(noopErr, 'the in-flight command rejected instead of accepting injected content');
1280
+ test.deepEqual(existsEvents, [], 'the injected untagged EXISTS never reached an untagged handler');
1281
+ test.ok(
1282
+ errors.some(err => err.code === 'LiteralTooLarge'),
1283
+ 'the limit violation is surfaced to the caller'
1284
+ );
1285
+ test.ok(client.isClosed || !client.usable, 'the connection failed closed');
1286
+
1287
+ client.close();
1288
+ server.close();
1289
+ test.done();
1290
+ };
1291
+
1106
1292
  module.exports['Server: socket close triggers close handling'] = async test => {
1107
1293
  let server = createServer();
1108
1294
  let port = await listen(server);