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
package/lib/tools.js
CHANGED
|
@@ -48,6 +48,23 @@ class AuthenticationFailure extends Error {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const tools = {
|
|
51
|
+
/**
|
|
52
|
+
* Detaches a background timer from the event loop, so it cannot keep the process alive on its
|
|
53
|
+
* own. Applied to every background timer (auto-IDLE, IDLE restart, fallback polling, throttle
|
|
54
|
+
* back-off, held-lock diagnostics); connection and greeting deadlines are deliberately left
|
|
55
|
+
* attached, because a caller is waiting for connect() to settle.
|
|
56
|
+
*
|
|
57
|
+
* @param {Object} timer - Timer handle returned by setTimeout
|
|
58
|
+
* @returns {Object} The same timer handle
|
|
59
|
+
*/
|
|
60
|
+
unrefTimer(timer) {
|
|
61
|
+
/* c8 ignore next 3 */ // node timers always expose unref(); the guard covers replaced globals in tests
|
|
62
|
+
if (timer && typeof timer.unref === 'function') {
|
|
63
|
+
timer.unref();
|
|
64
|
+
}
|
|
65
|
+
return timer;
|
|
66
|
+
},
|
|
67
|
+
|
|
51
68
|
/**
|
|
52
69
|
* Checks whether IMAP4rev2 semantics are active for the connection: either the
|
|
53
70
|
* client enabled IMAP4rev2 explicitly, or the server is rev2-only (advertises
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"main": "lib/imap-flow.js",
|
|
6
6
|
"types": "lib/imap-flow.d.ts",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"test:rev2": "bash test/integration/run-rev2-tests.sh",
|
|
11
11
|
"update": "rm -rf node_modules package-lock.json && ncu -u && npm install",
|
|
12
12
|
"format": "prettier --write \"**/*.{js,json,md,yml,yaml}\" --ignore-path .prettierignore",
|
|
13
|
+
"format:check": "prettier --check \"**/*.{js,json,md,yml,yaml}\" --ignore-path .prettierignore",
|
|
13
14
|
"lint": "eslint ."
|
|
14
15
|
},
|
|
15
16
|
"repository": {
|
|
@@ -49,7 +50,6 @@
|
|
|
49
50
|
"libbase64": "1.3.0",
|
|
50
51
|
"libmime": "5.4.1",
|
|
51
52
|
"libqp": "2.1.1",
|
|
52
|
-
"nodemailer": "9.0.3",
|
|
53
53
|
"pino": "10.3.1",
|
|
54
54
|
"socks": "2.8.9"
|
|
55
55
|
}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// The mock connection factory mirrors the one in commands-integration-test.js.
|
|
11
11
|
// ============================================================================
|
|
12
12
|
|
|
13
|
+
const imapCommands = require('../lib/imap-commands.js');
|
|
14
|
+
|
|
13
15
|
const createMockConnection = (overrides = {}) => {
|
|
14
16
|
const states = {
|
|
15
17
|
NOT_AUTHENTICATED: 1,
|
|
@@ -29,7 +31,7 @@ const createMockConnection = (overrides = {}) => {
|
|
|
29
31
|
noModseq: false
|
|
30
32
|
};
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
const connection = {
|
|
33
35
|
states,
|
|
34
36
|
state: overrides.state || states.SELECTED,
|
|
35
37
|
id: 'test-connection-id',
|
|
@@ -51,10 +53,22 @@ const createMockConnection = (overrides = {}) => {
|
|
|
51
53
|
emit: overrides.emit || (() => {}),
|
|
52
54
|
write: overrides.write || (() => {}),
|
|
53
55
|
currentSelectCommand: false,
|
|
56
|
+
// A live transport: command implementations that guard against polling or writing on a
|
|
57
|
+
// dead connection (idle.js) need this to look established.
|
|
58
|
+
socket: overrides.socket || { destroyed: false },
|
|
54
59
|
messageFlagsAdd: overrides.messageFlagsAdd || (async () => {}),
|
|
55
60
|
messageCopy: overrides.messageCopy || (async () => {}),
|
|
56
61
|
messageDelete: overrides.messageDelete || (async () => {}),
|
|
57
62
|
run: overrides.run || (async () => {}),
|
|
63
|
+
// Mirrors ImapFlow.runInternal(): dispatch through the command registry without the
|
|
64
|
+
// preCheck/auto-IDLE handshake that run() performs, so a fallback poll runs the real
|
|
65
|
+
// SELECT/STATUS implementation.
|
|
66
|
+
runInternal:
|
|
67
|
+
overrides.runInternal ||
|
|
68
|
+
(async (command, ...args) => {
|
|
69
|
+
let handler = imapCommands.get(command.toUpperCase());
|
|
70
|
+
return handler ? await handler(connection, ...args) : false;
|
|
71
|
+
}),
|
|
58
72
|
exec:
|
|
59
73
|
overrides.exec ||
|
|
60
74
|
(async () => ({
|
|
@@ -63,6 +77,8 @@ const createMockConnection = (overrides = {}) => {
|
|
|
63
77
|
})),
|
|
64
78
|
...overrides
|
|
65
79
|
};
|
|
80
|
+
|
|
81
|
+
return connection;
|
|
66
82
|
};
|
|
67
83
|
|
|
68
84
|
// ============================================================================
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// Mock Connection Factory
|
|
8
8
|
// ============================================
|
|
9
9
|
|
|
10
|
+
const imapCommands = require('../lib/imap-commands.js');
|
|
11
|
+
|
|
10
12
|
const createMockConnection = (overrides = {}) => {
|
|
11
13
|
const states = {
|
|
12
14
|
NOT_AUTHENTICATED: 1,
|
|
@@ -26,7 +28,7 @@ const createMockConnection = (overrides = {}) => {
|
|
|
26
28
|
noModseq: false
|
|
27
29
|
};
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
const connection = {
|
|
30
32
|
states,
|
|
31
33
|
state: overrides.state || states.SELECTED,
|
|
32
34
|
id: 'test-connection-id',
|
|
@@ -48,6 +50,9 @@ const createMockConnection = (overrides = {}) => {
|
|
|
48
50
|
},
|
|
49
51
|
close: overrides.close || (() => {}),
|
|
50
52
|
emit: overrides.emit || (() => {}),
|
|
53
|
+
// A live transport: command implementations that guard against polling or writing on a
|
|
54
|
+
// dead connection (idle.js) need this to look established.
|
|
55
|
+
socket: overrides.socket || { destroyed: false },
|
|
51
56
|
currentSelectCommand: false,
|
|
52
57
|
skipListSubscribedArg: false,
|
|
53
58
|
skipListStatusArgs: false,
|
|
@@ -55,6 +60,15 @@ const createMockConnection = (overrides = {}) => {
|
|
|
55
60
|
skipLsub: false,
|
|
56
61
|
messageFlagsAdd: overrides.messageFlagsAdd || (async () => {}),
|
|
57
62
|
run: overrides.run || (async () => {}),
|
|
63
|
+
// Mirrors ImapFlow.runInternal(): dispatch through the command registry without the
|
|
64
|
+
// preCheck/auto-IDLE handshake that run() performs, so a fallback poll runs the real
|
|
65
|
+
// SELECT/STATUS implementation.
|
|
66
|
+
runInternal:
|
|
67
|
+
overrides.runInternal ||
|
|
68
|
+
(async (command, ...args) => {
|
|
69
|
+
let handler = imapCommands.get(command.toUpperCase());
|
|
70
|
+
return handler ? await handler(connection, ...args) : false;
|
|
71
|
+
}),
|
|
58
72
|
exec:
|
|
59
73
|
overrides.exec ||
|
|
60
74
|
(async () => ({
|
|
@@ -63,6 +77,8 @@ const createMockConnection = (overrides = {}) => {
|
|
|
63
77
|
})),
|
|
64
78
|
...overrides
|
|
65
79
|
};
|
|
80
|
+
|
|
81
|
+
return connection;
|
|
66
82
|
};
|
|
67
83
|
|
|
68
84
|
// Decodes the base64 SASL payload that authenticate() hands to exec().
|
|
@@ -7312,10 +7328,14 @@ module.exports['Commands: idle NOOP fallback uses STATUS when configured'] = asy
|
|
|
7312
7328
|
};
|
|
7313
7329
|
|
|
7314
7330
|
module.exports['Commands: idle NOOP fallback uses SELECT when configured'] = async test => {
|
|
7331
|
+
// SELECT polling goes through the real select implementation, so it applies the same
|
|
7332
|
+
// mailbox state transitions as a caller-issued select instead of replaying wire arguments.
|
|
7315
7333
|
let selectCalled = false;
|
|
7316
7334
|
const connection = createMockConnection({
|
|
7317
7335
|
state: 3,
|
|
7318
7336
|
capabilities: new Map(),
|
|
7337
|
+
// The mailbox is already open, so its folder metadata is cached (no LIST round trip)
|
|
7338
|
+
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
7319
7339
|
currentSelectCommand: { command: 'SELECT', arguments: [{ value: 'INBOX' }] },
|
|
7320
7340
|
missingIdleCommand: 'SELECT',
|
|
7321
7341
|
exec: async cmd => {
|
|
@@ -7325,12 +7345,14 @@ module.exports['Commands: idle NOOP fallback uses SELECT when configured'] = asy
|
|
|
7325
7345
|
await connection.preCheck();
|
|
7326
7346
|
}
|
|
7327
7347
|
}
|
|
7328
|
-
return { next: () => {} };
|
|
7348
|
+
return { next: () => {}, response: { attributes: [{ value: 'OK' }] } };
|
|
7329
7349
|
}
|
|
7330
7350
|
});
|
|
7331
7351
|
|
|
7332
7352
|
await idleCommand(connection);
|
|
7333
7353
|
test.equal(selectCalled, true);
|
|
7354
|
+
test.equal(connection.mailbox.path, 'INBOX', 'mailbox state was reapplied by the select implementation');
|
|
7355
|
+
test.equal(connection.mailbox.delimiter, '/', 'cached folder metadata was merged in, as with a normal SELECT');
|
|
7334
7356
|
test.done();
|
|
7335
7357
|
};
|
|
7336
7358
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Deterministic stand-in for global.setTimeout/clearTimeout, so tests can drive timer-based
|
|
4
|
+
// loops (auto-IDLE, IDLE restart, fallback polling) without wall-clock sleeps.
|
|
5
|
+
//
|
|
6
|
+
// It is the suite's existing "swap global.setTimeout for the duration of a test and restore it
|
|
7
|
+
// in finally" idiom, made reusable and able to handle recursively scheduled timers: firing a
|
|
8
|
+
// timer whose callback schedules the next one records the new timer instead of losing it.
|
|
9
|
+
//
|
|
10
|
+
// Only timers scheduled while installed are faked. Everything else (setImmediate, socket
|
|
11
|
+
// timeouts, nodeunit's own bookkeeping) is untouched, and clearTimeout still forwards real
|
|
12
|
+
// handles to the real implementation. Install for as short a window as possible and always
|
|
13
|
+
// restore in a finally block.
|
|
14
|
+
const installFakeTimers = () => {
|
|
15
|
+
const realSetTimeout = global.setTimeout;
|
|
16
|
+
const realClearTimeout = global.clearTimeout;
|
|
17
|
+
|
|
18
|
+
let seq = 0;
|
|
19
|
+
const timers = new Map();
|
|
20
|
+
// Every timer ever scheduled, including cleared and fired ones, so tests can assert timer
|
|
21
|
+
// identity and cleanup (which timer was armed, was it unref'd, was it cleared) without sleeps.
|
|
22
|
+
const history = [];
|
|
23
|
+
|
|
24
|
+
global.setTimeout = (fn, delay, ...args) => {
|
|
25
|
+
const id = ++seq;
|
|
26
|
+
const timer = { id, fn, delay, args, unrefd: false, cleared: false, fired: false };
|
|
27
|
+
timers.set(id, timer);
|
|
28
|
+
history.push(timer);
|
|
29
|
+
return {
|
|
30
|
+
_fakeTimerId: id,
|
|
31
|
+
unref() {
|
|
32
|
+
const timer = timers.get(id);
|
|
33
|
+
if (timer) {
|
|
34
|
+
timer.unrefd = true;
|
|
35
|
+
}
|
|
36
|
+
return this;
|
|
37
|
+
},
|
|
38
|
+
ref() {
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
global.clearTimeout = handle => {
|
|
45
|
+
if (handle && handle._fakeTimerId) {
|
|
46
|
+
const timer = timers.get(handle._fakeTimerId);
|
|
47
|
+
if (timer) {
|
|
48
|
+
timer.cleared = true;
|
|
49
|
+
timers.delete(timer.id);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
return realClearTimeout(handle);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Lets pending asynchronous work (a poll's promise chain) settle between timer firings.
|
|
57
|
+
const drain = async (turns = 3) => {
|
|
58
|
+
for (let i = 0; i < turns; i++) {
|
|
59
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
// Scheduled timers that have not fired or been cleared yet, in scheduling order
|
|
65
|
+
pending: () => Array.from(timers.values()).map(timer => ({ id: timer.id, delay: timer.delay, unrefd: timer.unrefd })),
|
|
66
|
+
count: () => timers.size,
|
|
67
|
+
|
|
68
|
+
// Every timer scheduled while installed, in scheduling order, with its final state
|
|
69
|
+
history: () => history.map(timer => ({ id: timer.id, delay: timer.delay, unrefd: timer.unrefd, cleared: timer.cleared, fired: timer.fired })),
|
|
70
|
+
|
|
71
|
+
// Fires every currently pending timer once, oldest first, letting async callbacks settle.
|
|
72
|
+
// Timers scheduled by those callbacks stay pending for the next fire() call, which is what
|
|
73
|
+
// makes a recursive loop (poll -> schedule -> poll) observable one step at a time.
|
|
74
|
+
fire: async () => {
|
|
75
|
+
const due = Array.from(timers.values());
|
|
76
|
+
for (const timer of due) {
|
|
77
|
+
if (!timers.has(timer.id)) {
|
|
78
|
+
// cleared by an earlier callback in this batch
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
timers.delete(timer.id);
|
|
82
|
+
timer.fired = true;
|
|
83
|
+
timer.fn(...timer.args);
|
|
84
|
+
await drain();
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
drain,
|
|
89
|
+
|
|
90
|
+
restore: () => {
|
|
91
|
+
global.setTimeout = realSetTimeout;
|
|
92
|
+
global.clearTimeout = realClearTimeout;
|
|
93
|
+
timers.clear();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Runs `fn` with faked timers installed, restoring the real ones afterwards. Always use this
|
|
100
|
+
* rather than installing by hand: leaking faked globals into the rest of the run breaks
|
|
101
|
+
* unrelated tests.
|
|
102
|
+
*
|
|
103
|
+
* @param {Function} fn - Receives the timer controller.
|
|
104
|
+
* @returns {Promise<*>} Whatever `fn` returns.
|
|
105
|
+
*/
|
|
106
|
+
const withFakeTimers = async fn => {
|
|
107
|
+
const timers = installFakeTimers();
|
|
108
|
+
try {
|
|
109
|
+
return await fn(timers);
|
|
110
|
+
} finally {
|
|
111
|
+
timers.restore();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
module.exports = { installFakeTimers, withFakeTimers };
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
-
const proxyquire = require('proxyquire').noCallThru();
|
|
6
|
-
|
|
7
5
|
const { parser, compiler } = require('../lib/handler/imap-handler');
|
|
8
6
|
const { ImapStream } = require('../lib/handler/imap-stream');
|
|
9
7
|
const { ParserInstance } = require('../lib/handler/parser-instance');
|
|
@@ -304,29 +302,6 @@ module.exports['search-compiler: keyword already present in mailbox flags is acc
|
|
|
304
302
|
};
|
|
305
303
|
|
|
306
304
|
// ============================================================================
|
|
307
|
-
// proxy-connection.js
|
|
308
|
-
// ============================================================================
|
|
309
|
-
|
|
310
|
-
// attachEarlyErrorHandler guard (lines 23-25): the proxy client returns a truthy socket
|
|
311
|
-
// that lacks an `.on` method, so the guard returns early.
|
|
312
|
-
module.exports['proxy-connection: socket without .on skips early error handler attach'] = async test => {
|
|
313
|
-
let logger = { info: () => {}, error: () => {} };
|
|
314
|
-
let bareSocket = { write() {}, end() {} }; // truthy, but no .on
|
|
315
|
-
const { proxyConnection } = proxyquire('../lib/proxy-connection', {
|
|
316
|
-
'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
|
|
317
|
-
cb(null, bareSocket);
|
|
318
|
-
},
|
|
319
|
-
socks: { SocksClient: {} },
|
|
320
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
321
|
-
net: { isIP: () => true }
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
let socket = await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
|
|
325
|
-
test.equal(socket, bareSocket, 'socket returned unchanged');
|
|
326
|
-
test.ok(!('_earlyErrorHandler' in socket), 'no early error handler was attached to a socket without .on');
|
|
327
|
-
test.done();
|
|
328
|
-
};
|
|
329
|
-
|
|
330
305
|
// Sanity: untagged parse still round-trips, just to keep the parser import exercised.
|
|
331
306
|
module.exports['sanity: parser/compiler round-trip'] = test =>
|
|
332
307
|
asyncWrapper(test, async test => {
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Fallback polling lifecycle (servers without IDLE): one explicitly identified polling session
|
|
4
|
+
// that cannot run or reschedule after cancellation, reuses the real STATUS/SELECT state
|
|
5
|
+
// handling, and always leaves `idling` describing reality.
|
|
6
|
+
|
|
7
|
+
const idleCommand = require('../lib/commands/idle.js');
|
|
8
|
+
const imapCommands = require('../lib/imap-commands.js');
|
|
9
|
+
const { withFakeTimers } = require('./fixtures/fake-timers');
|
|
10
|
+
|
|
11
|
+
const createConnection = (overrides = {}) => {
|
|
12
|
+
const states = { NOT_AUTHENTICATED: 1, AUTHENTICATED: 2, SELECTED: 3, LOGOUT: 4 };
|
|
13
|
+
|
|
14
|
+
const connection = {
|
|
15
|
+
states,
|
|
16
|
+
state: states.SELECTED,
|
|
17
|
+
id: 'polling-test',
|
|
18
|
+
capabilities: new Map(overrides.capabilities || []), // no IDLE -> polling fallback
|
|
19
|
+
enabled: new Set(overrides.enabled || []),
|
|
20
|
+
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
21
|
+
mailbox: {
|
|
22
|
+
path: 'INBOX',
|
|
23
|
+
exists: 3,
|
|
24
|
+
uidNext: 10,
|
|
25
|
+
uidValidity: BigInt(1),
|
|
26
|
+
highestModseq: BigInt(1)
|
|
27
|
+
},
|
|
28
|
+
namespace: { delimiter: '/', prefix: '' },
|
|
29
|
+
socket: { destroyed: false },
|
|
30
|
+
currentSelectCommand: { command: 'SELECT', arguments: [{ type: 'ATOM', value: 'INBOX' }] },
|
|
31
|
+
missingIdleCommand: 'NOOP',
|
|
32
|
+
idling: false,
|
|
33
|
+
preCheck: false,
|
|
34
|
+
events: [],
|
|
35
|
+
commands: [],
|
|
36
|
+
log: { warn: () => {}, info: () => {}, error: () => {}, debug: () => {}, trace: () => {} },
|
|
37
|
+
emit(event, payload) {
|
|
38
|
+
connection.events.push({ event, payload });
|
|
39
|
+
},
|
|
40
|
+
run: async () => false,
|
|
41
|
+
// Fallback polling dispatches through runInternal(), which ImapFlow resolves against the
|
|
42
|
+
// command registry - so a poll runs the real SELECT/STATUS implementation.
|
|
43
|
+
runInternal: async (command, ...args) => {
|
|
44
|
+
let handler = imapCommands.get(command.toUpperCase());
|
|
45
|
+
return handler ? await handler(connection, ...args) : false;
|
|
46
|
+
},
|
|
47
|
+
exec: async (command, attributes, options) => {
|
|
48
|
+
connection.commands.push(command);
|
|
49
|
+
return connection.respond(command, attributes, options);
|
|
50
|
+
},
|
|
51
|
+
respond: async () => ({ next: () => {}, response: { attributes: [{ value: 'OK' }] } })
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// capabilities/enabled are already converted into a Map/Set above
|
|
55
|
+
let rest = Object.assign({}, overrides);
|
|
56
|
+
delete rest.capabilities;
|
|
57
|
+
delete rest.enabled;
|
|
58
|
+
|
|
59
|
+
return Object.assign(connection, rest);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
module.exports['Polling: break before the first poll stops the loop'] = async test => {
|
|
63
|
+
await withFakeTimers(async timers => {
|
|
64
|
+
let connection = createConnection({
|
|
65
|
+
exec: async command => {
|
|
66
|
+
connection.commands.push(command);
|
|
67
|
+
// break while the very first poll is still in flight
|
|
68
|
+
await connection.preCheck();
|
|
69
|
+
return { next: () => {} };
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
74
|
+
await timers.drain();
|
|
75
|
+
await idlePromise;
|
|
76
|
+
|
|
77
|
+
test.deepEqual(connection.commands, ['NOOP'], 'only the immediate first poll ran');
|
|
78
|
+
test.equal(connection.idling, false, 'idling reset after the break');
|
|
79
|
+
test.equal(connection.preCheck, false, 'the session released preCheck');
|
|
80
|
+
test.equal(timers.count(), 0, 'no polling timer is left armed');
|
|
81
|
+
|
|
82
|
+
// Advancing several intervals must not produce another command
|
|
83
|
+
await timers.fire();
|
|
84
|
+
await timers.fire();
|
|
85
|
+
test.deepEqual(connection.commands, ['NOOP'], 'a cancelled session never polls again');
|
|
86
|
+
test.done();
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
module.exports['Polling: break during an in-flight poll cannot reschedule'] = async test => {
|
|
91
|
+
await withFakeTimers(async timers => {
|
|
92
|
+
let releasePoll;
|
|
93
|
+
let pollStarted;
|
|
94
|
+
let connection = createConnection({
|
|
95
|
+
exec: async command => {
|
|
96
|
+
connection.commands.push(command);
|
|
97
|
+
await new Promise(resolve => {
|
|
98
|
+
releasePoll = resolve;
|
|
99
|
+
if (pollStarted) {
|
|
100
|
+
pollStarted();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
return { next: () => {} };
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
let started = new Promise(resolve => (pollStarted = resolve));
|
|
108
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
109
|
+
await started;
|
|
110
|
+
|
|
111
|
+
// Cancel while the poll is still awaiting its response, then let it finish
|
|
112
|
+
await connection.preCheck();
|
|
113
|
+
releasePoll();
|
|
114
|
+
await idlePromise;
|
|
115
|
+
|
|
116
|
+
test.deepEqual(connection.commands, ['NOOP'], 'the in-flight poll completed but scheduled nothing');
|
|
117
|
+
test.equal(timers.count(), 0, 'the completing poll did not arm a new timer');
|
|
118
|
+
test.equal(connection.idling, false, 'idling reset');
|
|
119
|
+
|
|
120
|
+
await timers.fire();
|
|
121
|
+
test.deepEqual(connection.commands, ['NOOP'], 'no later command is sent');
|
|
122
|
+
test.done();
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
module.exports['Polling: break after a poll but before the next timer fires stops the loop'] = async test => {
|
|
127
|
+
await withFakeTimers(async timers => {
|
|
128
|
+
let connection = createConnection();
|
|
129
|
+
|
|
130
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
131
|
+
await timers.drain();
|
|
132
|
+
|
|
133
|
+
test.deepEqual(connection.commands, ['NOOP'], 'the immediate first poll ran');
|
|
134
|
+
test.equal(timers.count(), 1, 'the next poll is scheduled');
|
|
135
|
+
test.ok(timers.pending()[0].unrefd, 'the background polling timer does not keep the process alive');
|
|
136
|
+
|
|
137
|
+
await connection.preCheck();
|
|
138
|
+
await idlePromise;
|
|
139
|
+
|
|
140
|
+
test.equal(timers.count(), 0, 'the armed timer was cleared on cancellation');
|
|
141
|
+
|
|
142
|
+
await timers.fire();
|
|
143
|
+
await timers.fire();
|
|
144
|
+
test.deepEqual(connection.commands, ['NOOP'], 'no command after cancellation');
|
|
145
|
+
test.done();
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
module.exports['Polling: an old session does not clear a newer session preCheck'] = async test => {
|
|
150
|
+
await withFakeTimers(async timers => {
|
|
151
|
+
let releasePoll;
|
|
152
|
+
let pollStarted;
|
|
153
|
+
let connection = createConnection({
|
|
154
|
+
exec: async command => {
|
|
155
|
+
connection.commands.push(command);
|
|
156
|
+
if (connection.commands.length === 1) {
|
|
157
|
+
await new Promise(resolve => {
|
|
158
|
+
releasePoll = resolve;
|
|
159
|
+
pollStarted();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return { next: () => {} };
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
let started = new Promise(resolve => (pollStarted = resolve));
|
|
167
|
+
let firstSession = idleCommand(connection, 60000);
|
|
168
|
+
await started;
|
|
169
|
+
|
|
170
|
+
// Cancel the first session while its poll is in flight, then immediately re-enter IDLE
|
|
171
|
+
await connection.preCheck();
|
|
172
|
+
let secondSession = idleCommand(connection, 60000);
|
|
173
|
+
await timers.drain();
|
|
174
|
+
|
|
175
|
+
let newPreCheck = connection.preCheck;
|
|
176
|
+
test.equal(typeof newPreCheck, 'function', 'the new session installed its own preCheck');
|
|
177
|
+
|
|
178
|
+
// The stale poll now finishes: it must not touch the new session's ownership
|
|
179
|
+
releasePoll();
|
|
180
|
+
await firstSession;
|
|
181
|
+
|
|
182
|
+
test.equal(connection.preCheck, newPreCheck, 'the stale session left the newer preCheck in place');
|
|
183
|
+
test.equal(connection.idling, true, 'the stale session did not clear the newer idling state');
|
|
184
|
+
|
|
185
|
+
await connection.preCheck();
|
|
186
|
+
await secondSession;
|
|
187
|
+
test.equal(connection.idling, false, 'the newer session cleaned up on its own break');
|
|
188
|
+
test.done();
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
module.exports['Polling: STATUS polling applies mailbox state and emits exists only on change'] = async test => {
|
|
193
|
+
await withFakeTimers(async timers => {
|
|
194
|
+
let messages = 3;
|
|
195
|
+
let connection = createConnection({
|
|
196
|
+
capabilities: [['CONDSTORE', true]],
|
|
197
|
+
missingIdleCommand: 'STATUS',
|
|
198
|
+
respond: async (command, attributes, options) => {
|
|
199
|
+
if (command === 'STATUS') {
|
|
200
|
+
let statusHandler = options.untagged.STATUS;
|
|
201
|
+
await statusHandler({
|
|
202
|
+
attributes: [
|
|
203
|
+
{ type: 'STRING', value: 'INBOX' },
|
|
204
|
+
[
|
|
205
|
+
{ type: 'ATOM', value: 'MESSAGES' },
|
|
206
|
+
{ type: 'ATOM', value: String(messages) },
|
|
207
|
+
{ type: 'ATOM', value: 'UIDNEXT' },
|
|
208
|
+
{ type: 'ATOM', value: '11' },
|
|
209
|
+
{ type: 'ATOM', value: 'HIGHESTMODSEQ' },
|
|
210
|
+
{ type: 'ATOM', value: '42' }
|
|
211
|
+
]
|
|
212
|
+
]
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return { next: () => {}, response: { attributes: [{ value: 'OK' }] } };
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
220
|
+
await timers.drain();
|
|
221
|
+
|
|
222
|
+
test.deepEqual(connection.commands, ['STATUS'], 'STATUS was used for polling');
|
|
223
|
+
test.equal(connection.mailbox.uidNext, 11, 'uidNext updated from the poll');
|
|
224
|
+
test.equal(connection.mailbox.highestModseq, BigInt(42), 'highestModseq updated on a CONDSTORE session');
|
|
225
|
+
test.deepEqual(connection.events, [], 'an unchanged message count emits no event');
|
|
226
|
+
|
|
227
|
+
// Second poll reports a new count -> exactly one exists event
|
|
228
|
+
messages = 5;
|
|
229
|
+
await timers.fire();
|
|
230
|
+
|
|
231
|
+
test.equal(connection.mailbox.exists, 5, 'exists updated from the poll');
|
|
232
|
+
test.equal(connection.events.length, 1, 'one event for the changed count');
|
|
233
|
+
test.equal(connection.events[0].event, 'exists');
|
|
234
|
+
test.equal(connection.events[0].payload.count, 5);
|
|
235
|
+
test.equal(connection.events[0].payload.prevCount, 3);
|
|
236
|
+
|
|
237
|
+
await connection.preCheck();
|
|
238
|
+
await idlePromise;
|
|
239
|
+
test.done();
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
module.exports['Polling: STATUS omits HIGHESTMODSEQ without CONDSTORE'] = async test => {
|
|
244
|
+
await withFakeTimers(async timers => {
|
|
245
|
+
let requestedItems = [];
|
|
246
|
+
let connection = createConnection({
|
|
247
|
+
missingIdleCommand: 'STATUS',
|
|
248
|
+
respond: async (command, attributes) => {
|
|
249
|
+
if (command === 'STATUS') {
|
|
250
|
+
requestedItems = attributes[1].map(entry => entry.value);
|
|
251
|
+
}
|
|
252
|
+
return { next: () => {}, response: { attributes: [{ value: 'OK' }] } };
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
257
|
+
await timers.drain();
|
|
258
|
+
|
|
259
|
+
test.ok(requestedItems.includes('MESSAGES'), 'MESSAGES is polled');
|
|
260
|
+
test.ok(requestedItems.includes('UIDNEXT'), 'UIDNEXT is polled');
|
|
261
|
+
test.ok(!requestedItems.includes('HIGHESTMODSEQ'), 'HIGHESTMODSEQ is not requested without CONDSTORE');
|
|
262
|
+
|
|
263
|
+
await connection.preCheck();
|
|
264
|
+
await idlePromise;
|
|
265
|
+
test.done();
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
module.exports['Polling: failed SELECT polling deselects and stops the loop'] = async test => {
|
|
270
|
+
await withFakeTimers(async timers => {
|
|
271
|
+
let connection = createConnection({
|
|
272
|
+
missingIdleCommand: 'SELECT',
|
|
273
|
+
respond: async command => {
|
|
274
|
+
if (command === 'SELECT') {
|
|
275
|
+
let err = new Error('Command failed');
|
|
276
|
+
err.responseStatus = 'NO';
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
return { next: () => {}, response: { attributes: [{ value: 'OK' }] } };
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
284
|
+
await timers.drain();
|
|
285
|
+
await idlePromise;
|
|
286
|
+
|
|
287
|
+
test.equal(connection.state, connection.states.AUTHENTICATED, 'a failed reselect drops to AUTHENTICATED');
|
|
288
|
+
test.equal(connection.mailbox, false, 'mailbox state cleared');
|
|
289
|
+
test.equal(connection.currentSelectCommand, false, 'the saved select command is cleared');
|
|
290
|
+
test.equal(connection.idling, false, 'idling reset');
|
|
291
|
+
test.equal(connection.preCheck, false, 'preCheck released');
|
|
292
|
+
test.equal(timers.count(), 0, 'polling stopped');
|
|
293
|
+
test.ok(
|
|
294
|
+
connection.events.some(entry => entry.event === 'mailboxClose'),
|
|
295
|
+
'the mailbox close transition was emitted, as with a caller-issued SELECT'
|
|
296
|
+
);
|
|
297
|
+
test.done();
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
module.exports['Polling: a closed connection stops the loop instead of polling'] = async test => {
|
|
302
|
+
await withFakeTimers(async timers => {
|
|
303
|
+
let connection = createConnection();
|
|
304
|
+
|
|
305
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
306
|
+
await timers.drain();
|
|
307
|
+
test.deepEqual(connection.commands, ['NOOP'], 'first poll ran on a live connection');
|
|
308
|
+
|
|
309
|
+
// Transport dies between polls
|
|
310
|
+
connection.socket.destroyed = true;
|
|
311
|
+
await timers.fire();
|
|
312
|
+
await idlePromise;
|
|
313
|
+
|
|
314
|
+
test.deepEqual(connection.commands, ['NOOP'], 'no command is sent on a dead transport');
|
|
315
|
+
test.equal(connection.idling, false, 'idling reset when the loop stops');
|
|
316
|
+
test.equal(connection.preCheck, false, 'preCheck released');
|
|
317
|
+
test.done();
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
module.exports['Polling: a rejected poll resets idling'] = async test => {
|
|
322
|
+
await withFakeTimers(async timers => {
|
|
323
|
+
let connection = createConnection({
|
|
324
|
+
exec: async command => {
|
|
325
|
+
connection.commands.push(command);
|
|
326
|
+
let err = new Error('Connection not available');
|
|
327
|
+
err.code = 'NoConnection';
|
|
328
|
+
throw err;
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
333
|
+
await timers.drain();
|
|
334
|
+
await idlePromise;
|
|
335
|
+
|
|
336
|
+
test.equal(connection.idling, false, 'idling reset after a rejected poll');
|
|
337
|
+
test.equal(connection.preCheck, false, 'preCheck released after a rejected poll');
|
|
338
|
+
test.equal(timers.count(), 0, 'nothing left scheduled');
|
|
339
|
+
test.done();
|
|
340
|
+
});
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
module.exports['Polling: no polling session without a saved select command'] = async test => {
|
|
344
|
+
let connection = createConnection({ currentSelectCommand: false });
|
|
345
|
+
await idleCommand(connection, 60000);
|
|
346
|
+
test.deepEqual(connection.commands, [], 'nothing polled');
|
|
347
|
+
test.equal(connection.idling, false, 'idling untouched');
|
|
348
|
+
test.done();
|
|
349
|
+
};
|