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
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.5.0",
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
- return {
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
- return {
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
+ };