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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +22 -0
- package/CLAUDE.md +1 -1
- package/lib/commands/idle.js +197 -104
- package/lib/commands/list.js +15 -7
- package/lib/commands/quota.js +3 -0
- package/lib/commands/select.js +5 -0
- package/lib/commands/status.js +4 -0
- package/lib/connection-deadline.js +98 -0
- package/lib/handler/imap-compiler.js +8 -5
- package/lib/handler/imap-stream.js +141 -50
- package/lib/handler/limits.js +43 -0
- package/lib/handler/token-parser.js +31 -1
- package/lib/imap-flow.d.ts +33 -5
- package/lib/imap-flow.js +583 -281
- package/lib/proxy-connection.js +393 -98
- package/lib/special-use.js +660 -51
- package/lib/tools.js +17 -0
- package/package.json +2 -2
- package/test/commands-branches-test.js +17 -1
- package/test/commands-integration-test.js +24 -2
- package/test/fixtures/fake-timers.js +115 -0
- package/test/handler-branches-test.js +0 -25
- package/test/idle-polling-test.js +349 -0
- package/test/imap-flow-compress-test.js +12 -0
- package/test/imap-flow-coverage-test.js +3 -3
- package/test/imap-flow-internals-test.js +23 -0
- package/test/imap-flow-proxy-paths-test.js +151 -0
- package/test/imap-flow-secure-test.js +159 -0
- package/test/imap-flow-server-test.js +186 -0
- package/test/parser-limits-test.js +274 -0
- package/test/proxy-connection-test.js +553 -442
- package/test/reliability-improvements-test.js +87 -0
- package/test/special-use-test.js +337 -0
- package/test/tag-correlation-test.js +333 -0
- 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);
|