imapflow 1.6.6 → 1.7.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.
@@ -428,3 +428,91 @@ module.exports['Polling: a break in the initiation tick prevents the first poll'
428
428
  test.done();
429
429
  });
430
430
  };
431
+
432
+ module.exports['Polling: a restarted session resumes the schedule instead of polling again'] = async test => {
433
+ await withFakeTimers(async timers => {
434
+ let connection = createConnection();
435
+
436
+ let first = idleCommand(connection, 60000);
437
+ await timers.drain();
438
+ test.deepEqual(connection.commands, ['NOOP'], 'the first session polls immediately');
439
+
440
+ await connection.preCheck();
441
+ await first;
442
+
443
+ // Auto-IDLE restarts the loop after every caller command. An unconditional first poll here
444
+ // would tie the poll rate to how often the caller runs commands instead of to the poll
445
+ // interval - with a short autoIdleDelay, a command every few seconds means a poll every
446
+ // few seconds.
447
+ let second = idleCommand(connection, 60000);
448
+ await timers.drain();
449
+ test.deepEqual(connection.commands, ['NOOP'], 'the restarted session does not poll again');
450
+ test.equal(timers.count(), 1, 'it waits out the remainder of the interval instead');
451
+ test.ok(timers.pending()[0].delay <= 60000, 'and never longer than a full interval');
452
+
453
+ await connection.preCheck();
454
+ await second;
455
+
456
+ // Once a full interval has elapsed, a fresh session polls at once again
457
+ connection._lastPollAt = Date.now() - 61000;
458
+ let third = idleCommand(connection, 60000);
459
+ await timers.drain();
460
+ test.deepEqual(connection.commands, ['NOOP', 'NOOP'], 'a session starting after the interval polls right away');
461
+
462
+ await connection.preCheck();
463
+ await third;
464
+ test.done();
465
+ });
466
+ };
467
+
468
+ module.exports['Polling: a failed poll does not defer the next session'] = async test => {
469
+ await withFakeTimers(async timers => {
470
+ let failNext = true;
471
+ let connection = createConnection({
472
+ exec: async command => {
473
+ connection.commands.push(command);
474
+ if (failNext) {
475
+ failNext = false;
476
+ throw new Error('poll failed');
477
+ }
478
+ return { next: () => {} };
479
+ }
480
+ });
481
+
482
+ // The failing poll cancels its own session. The attempt checked nothing, so it must not
483
+ // count as a poll: only a completed poll moves the schedule stamp forward.
484
+ let first = idleCommand(connection, 60000);
485
+ await timers.drain();
486
+ await first;
487
+ test.deepEqual(connection.commands, ['NOOP'], 'the first poll ran and failed');
488
+
489
+ let second = idleCommand(connection, 60000);
490
+ await timers.drain();
491
+ test.deepEqual(connection.commands, ['NOOP', 'NOOP'], 'the next session retries immediately instead of waiting out an interval');
492
+
493
+ await connection.preCheck();
494
+ await second;
495
+ test.done();
496
+ });
497
+ };
498
+
499
+ module.exports['Polling: a backward clock step never defers the next poll past one interval'] = async test => {
500
+ await withFakeTimers(async timers => {
501
+ let connection = createConnection();
502
+
503
+ // A last-poll stamp in the future is what an NTP step or a VM clock sync leaves behind.
504
+ // Without the clamp the remainder math would schedule the next poll a full clock jump
505
+ // plus one interval away.
506
+ connection._lastPollAt = Date.now() + 60 * 60 * 1000;
507
+
508
+ let idlePromise = idleCommand(connection, 60000);
509
+ await timers.drain();
510
+ test.deepEqual(connection.commands, [], 'no immediate poll - the schedule is resumed');
511
+ test.equal(timers.count(), 1, 'a poll timer is armed');
512
+ test.ok(timers.pending()[0].delay <= 60000, 'and it is never more than one interval away');
513
+
514
+ await connection.preCheck();
515
+ await idlePromise;
516
+ test.done();
517
+ });
518
+ };
@@ -5,6 +5,7 @@
5
5
  // reader/handler error branches.
6
6
 
7
7
  const { ImapFlow } = require('../lib/imap-flow');
8
+ const { makeSocketStub } = require('./fixtures/test-client');
8
9
 
9
10
  const makeClient = (overrides = {}) => {
10
11
  let client = new ImapFlow({
@@ -293,15 +294,6 @@ module.exports['Coverage: authenticate throws when run yields falsy auth result'
293
294
  // Socket event handlers (built by setSocketHandlers)
294
295
  // ============================================================================
295
296
 
296
- // Minimal socket stub that records listeners so handlers can be invoked directly.
297
- const makeSocketStub = () => ({
298
- destroyed: false,
299
- once() {},
300
- on() {},
301
- removeListener() {},
302
- destroy() {}
303
- });
304
-
305
297
  module.exports['Coverage: setSocketHandlers removes a lingering connect error handler'] = test => {
306
298
  let client = makeClient();
307
299
  let removed = null;
@@ -389,7 +381,9 @@ module.exports['Coverage: _socketTimeout recovers an IDLE connection with NOOP']
389
381
  await drain();
390
382
  await drain();
391
383
  test.ok(noopRun, 'NOOP issued to recover IDLE');
392
- test.ok(idleResumed, 'IDLE resumed after NOOP');
384
+ // Restarting IDLE is autoidle()'s decision once the NOOP settles (run() re-arms it);
385
+ // the watchdog handler itself must not bypass the busy guard by calling idle() directly.
386
+ test.equal(idleResumed, false, 'the handler does not restart IDLE by itself');
393
387
  test.done();
394
388
  };
395
389
 
@@ -9,6 +9,7 @@ const libbase64 = require('libbase64');
9
9
  const libqp = require('libqp');
10
10
  const libmime = require('libmime');
11
11
  const { Writable, finished } = require('stream');
12
+ const { chunkedFetchOne } = require('./fixtures/test-client');
12
13
 
13
14
  const makeClient = (overrides = {}) => {
14
15
  let client = new ImapFlow({
@@ -217,11 +218,7 @@ module.exports['Download: returns empty object without mailbox'] = async test =>
217
218
  module.exports['Download: full message in multiple chunks'] = async test => {
218
219
  let client = makeClient();
219
220
  let body = Buffer.from('A'.repeat(10));
220
- client.fetchOne = async (range, query) => {
221
- let start = query.source.start;
222
- let maxLength = query.source.maxLength;
223
- return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) };
224
- };
221
+ client.fetchOne = chunkedFetchOne(body);
225
222
  let { meta, content } = await client.download('1', false, { chunkSize: 4 });
226
223
  test.equal(meta.contentType, 'message/rfc822');
227
224
  test.equal(meta.expectedSize, 10);
@@ -233,11 +230,7 @@ module.exports['Download: full message in multiple chunks'] = async test => {
233
230
  module.exports['Download: falls back to default chunkSize/maxBytes when zero'] = async test => {
234
231
  let client = makeClient();
235
232
  let body = Buffer.from('tiny');
236
- client.fetchOne = async (range, query) => {
237
- let start = query.source.start;
238
- let maxLength = query.source.maxLength;
239
- return { uid: 1, size: body.length, source: body.slice(start, start + maxLength) };
240
- };
233
+ client.fetchOne = chunkedFetchOne(body);
241
234
  // zero values are falsy -> the (|| default) fallbacks kick in
242
235
  let { content } = await client.download('1', false, { chunkSize: 0, maxBytes: 0 });
243
236
  let data = await collect(content);
@@ -6,17 +6,8 @@
6
6
  // and the untaggedFetch flag/modseq branches.
7
7
 
8
8
  const { ImapFlow } = require('../lib/imap-flow');
9
-
10
- const makeClient = (overrides = {}) => {
11
- let client = new ImapFlow({
12
- host: 'imap.example.com',
13
- port: 993,
14
- auth: { user: 'test', pass: 'test' },
15
- logger: false,
16
- ...overrides
17
- });
18
- return client;
19
- };
9
+ const { withFakeTimers } = require('./fixtures/fake-timers');
10
+ const { makeClient, makeIdleReadyClient } = require('./fixtures/test-client');
20
11
 
21
12
  // ============================================================================
22
13
  // emitError
@@ -179,27 +170,79 @@ module.exports['Internals: write returns false for non-string non-buffer'] = tes
179
170
  test.done();
180
171
  };
181
172
 
182
- module.exports['Internals: write logs raw data when logRaw enabled'] = test => {
173
+ // A writable client whose raw traffic log is captured, for the two logRaw branches
174
+ const makeRawLogClient = rawSensitiveCommand => {
183
175
  let logs = [];
184
- let client = makeClient({ logRaw: true });
185
- client.log = {
186
- trace: o => logs.push(o),
187
- debug: () => {},
188
- warn: () => {},
189
- error: () => {},
190
- info: () => {}
191
- };
192
176
  let written = [];
177
+ let client = makeClient({ logRaw: true });
178
+ client.log = { trace: o => logs.push(o), debug: () => {}, warn: () => {}, error: () => {}, info: () => {} };
193
179
  client.socket = { destroyed: false };
194
180
  client.writeSocket = { destroyed: false, write: c => written.push(c) };
195
- client.state = client.states.AUTHENTICATED;
196
- client.commandParts = [];
181
+ client.rawSensitiveCommand = rawSensitiveCommand;
182
+ return { client, logs, written };
183
+ };
184
+
185
+ module.exports['Internals: write logs raw data when logRaw enabled'] = test => {
186
+ let { client, logs, written } = makeRawLogClient(false);
197
187
  client.write('A NOOP');
198
- test.ok(logs.some(l => l.src === 'c' && l.msg === 'write to socket'));
188
+ let entry = logs.find(l => l.src === 'c' && l.msg === 'write to socket');
189
+ test.ok(entry);
190
+ test.equal(Buffer.from(entry.data, 'base64').toString(), 'A NOOP\r\n');
191
+ test.ok(!entry.hidden);
199
192
  test.equal(written.length, 1);
200
193
  test.done();
201
194
  };
202
195
 
196
+ module.exports['Internals: write withholds raw data for a credential-bearing command'] = test => {
197
+ // send() sets this for LOGIN/AUTHENTICATE before the first frame reaches the socket
198
+ let { client, logs, written } = makeRawLogClient(true);
199
+ client.write('A1 LOGIN "user" "hunter2"');
200
+ let entry = logs.find(l => l.src === 'c' && l.msg === 'write to socket');
201
+ test.ok(entry);
202
+ test.ok(entry.hidden);
203
+ // The placeholder is fixed width, so the entry cannot disclose the password length
204
+ test.equal(Buffer.from(entry.data, 'base64').toString(), '(* value hidden *)\r\n');
205
+ // The frame itself is still written to the socket unchanged
206
+ test.equal(written[0].toString(), 'A1 LOGIN "user" "hunter2"\r\n');
207
+ test.done();
208
+ };
209
+
210
+ module.exports['Internals: send marks credential-bearing commands for the raw log'] = async test => {
211
+ let client = makeClient();
212
+ let written = [];
213
+ client.socket = { destroyed: false };
214
+ client.writeSocket = { destroyed: false, write: c => written.push(c) };
215
+
216
+ // Lower case on purpose: the wire protocol is case-insensitive and exec() passes the
217
+ // caller's spelling through unchanged, so the classification must normalize it
218
+ await client.send({
219
+ tag: 'A1',
220
+ command: 'login',
221
+ attributes: [
222
+ { type: 'STRING', value: 'user' },
223
+ { type: 'STRING', value: 'hunter2', sensitive: true }
224
+ ],
225
+ options: {}
226
+ });
227
+ test.equal(client.rawSensitiveCommand, true);
228
+
229
+ await client.send({ tag: 'A2', command: 'NOOP', attributes: [], options: {} });
230
+ test.equal(client.rawSensitiveCommand, false);
231
+
232
+ // A command outside the list still masks if it marks an attribute sensitive, so the
233
+ // declarative marker alone is enough to keep a new command out of the raw log. Nested
234
+ // because the command compiler honors the marker at any depth.
235
+ await client.send({
236
+ tag: 'A3',
237
+ command: 'SETMETADATA',
238
+ attributes: [{ type: 'ATOM', value: 'INBOX' }, [{ type: 'STRING', value: 'token', sensitive: true }]],
239
+ options: {}
240
+ });
241
+ test.equal(client.rawSensitiveCommand, true);
242
+
243
+ test.done();
244
+ };
245
+
203
246
  module.exports['Internals: write appends CRLF only on final part'] = test => {
204
247
  let client = makeClient();
205
248
  let written = [];
@@ -275,29 +318,20 @@ module.exports['Internals: autoidle does nothing when not selected'] = test => {
275
318
  test.done();
276
319
  };
277
320
 
278
- module.exports['Internals: autoidle schedules idle when selected'] = test => {
279
- let client = makeClient();
280
- client.state = client.states.SELECTED;
321
+ module.exports['Internals: autoidle schedules idle when selected'] = async test => {
322
+ await withFakeTimers(async timers => {
323
+ let client = makeIdleReadyClient();
324
+
325
+ let idleCalled = false;
326
+ client.idle = async () => {
327
+ idleCalled = true;
328
+ };
281
329
 
282
- let realSetTimeout = global.setTimeout;
283
- let idleCalled = false;
284
- client.idle = async () => {
285
- idleCalled = true;
286
- };
287
- // Intercept the 15s idle timer and fire it synchronously
288
- global.setTimeout = (fn, ms) => {
289
- if (ms === 15 * 1000) {
290
- fn();
291
- return { unref() {} };
292
- }
293
- return realSetTimeout(fn, ms);
294
- };
295
- try {
296
330
  client.autoidle();
297
- } finally {
298
- global.setTimeout = realSetTimeout;
299
- }
300
- test.ok(idleCalled);
331
+ await timers.fire();
332
+
333
+ test.ok(idleCalled);
334
+ });
301
335
  test.done();
302
336
  };
303
337
 
@@ -322,7 +356,7 @@ module.exports['Internals: getLogger uses provided logger object'] = test => {
322
356
  };
323
357
 
324
358
  module.exports['Internals: getLogger falls back to console for missing fatal/error level'] = test => {
325
- // Logger object missing the 'error' method -> falls through to console.log
359
+ // Logger object missing the 'error' method -> falls through to console.error
326
360
  let partial = {
327
361
  trace() {},
328
362
  debug() {},
@@ -331,15 +365,99 @@ module.exports['Internals: getLogger falls back to console for missing fatal/err
331
365
  // no error, no fatal
332
366
  };
333
367
  let client = makeClient({ logger: partial });
334
- let origConsoleLog = console.log;
368
+ let origConsoleError = console.error;
335
369
  let logged = [];
336
- console.log = (...args) => logged.push(args);
370
+ console.error = (...args) => logged.push(args);
337
371
  try {
338
- client.log.error({ msg: 'boom' });
372
+ let err = new Error('boom failure');
373
+ err.code = 'XBOOM';
374
+ // The answer is often one level down: this library attaches the underlying failure
375
+ // as an enumerable `_err`
376
+ err._err = Object.assign(new Error('inner failure'), { code: 'ECONNREFUSED' });
377
+ client.log.error({ msg: 'boom', err });
378
+ // A circular structure must not throw out of the log call, and must not be dropped
379
+ let circular = { msg: 'loop' };
380
+ circular.self = circular;
381
+ client.log.error(circular);
339
382
  } finally {
340
- console.log = origConsoleLog;
383
+ console.error = origConsoleError;
341
384
  }
342
- test.ok(logged.length >= 1);
385
+ test.equal(logged.length, 2);
386
+ // The Error was flattened, so message, stack and enumerable fields survive stringify
387
+ let entry = JSON.parse(logged[0][0]);
388
+ test.equal(entry.msg, 'boom');
389
+ test.equal(entry.err.message, 'boom failure');
390
+ test.equal(entry.err.code, 'XBOOM');
391
+ test.ok(entry.err.stack);
392
+ test.equal(entry.err._err.message, 'inner failure');
393
+ test.equal(entry.err._err.code, 'ECONNREFUSED');
394
+ // Unserializable entries still reach console.error, just not as JSON
395
+ test.equal(logged[1][0].msg, 'loop');
396
+ test.done();
397
+ };
398
+
399
+ module.exports['Internals: getLogger keeps cause and AggregateError members'] = test => {
400
+ let client = makeClient({ emitLogs: true });
401
+ let entries = [];
402
+ client.on('log', entry => entries.push(entry));
403
+
404
+ let inner = Object.assign(new Error('inner failure'), { code: 'ECONNREFUSED' });
405
+ client.log.error({ msg: 'wrapped', err: new Error('outer failure', { cause: inner }) });
406
+ // Node reports a multi-address connect failure as an AggregateError
407
+ client.log.error({ msg: 'aggregate', err: new AggregateError([inner], 'all attempts failed') });
408
+
409
+ test.equal(entries[0].err.cause.message, 'inner failure');
410
+ test.equal(entries[0].err.cause.code, 'ECONNREFUSED');
411
+ test.equal(entries[1].err.errors.length, 1);
412
+ test.equal(entries[1].err.errors[0].message, 'inner failure');
413
+ test.done();
414
+ };
415
+
416
+ module.exports['Internals: getLogger bounds a looping and a deep error chain'] = test => {
417
+ let client = makeClient({ emitLogs: true });
418
+ let entries = [];
419
+ client.on('log', entry => entries.push(entry));
420
+
421
+ // A chain that loops back must terminate rather than recurse forever
422
+ let looping = new Error('looping failure');
423
+ looping._err = looping;
424
+ client.log.error({ msg: 'loop', err: looping });
425
+ test.equal(entries[0].err.message, 'looping failure');
426
+ test.equal(entries[0].err._err, 'looping failure');
427
+
428
+ // A chain longer than the depth cap is truncated rather than walked to the end
429
+ let deep = new Error('level 0');
430
+ for (let i = 1; i <= 6; i++) {
431
+ deep = Object.assign(new Error(`level ${i}`), { _err: deep });
432
+ }
433
+ client.log.error({ msg: 'deep', err: deep });
434
+ test.equal(entries[1].err._err._err._err.message, 'level 3');
435
+ // Past the cap the chain collapses to messages instead of being walked to the end
436
+ test.equal(entries[1].err._err._err._err._err, 'level 2');
437
+
438
+ test.done();
439
+ };
440
+
441
+ module.exports['Internals: getLogger never throws out of a log call'] = test => {
442
+ let client = makeClient({ emitLogs: true });
443
+ let entries = [];
444
+ client.on('log', entry => entries.push(entry));
445
+
446
+ // A throwing property getter on the logged error must not escape
447
+ let hostile = {
448
+ get message() {
449
+ throw new Error('getter blew up');
450
+ },
451
+ stack: 'x'
452
+ };
453
+ test.doesNotThrow(() => client.log.warn({ msg: 'hostile', err: hostile }));
454
+
455
+ // Neither must a throwing 'log' listener
456
+ client.on('log', () => {
457
+ throw new Error('listener blew up');
458
+ });
459
+ test.doesNotThrow(() => client.log.warn({ msg: 'still fine' }));
460
+
343
461
  test.done();
344
462
  };
345
463
 
@@ -9,9 +9,9 @@
9
9
  // Asserted through timer identity and cleanup rather than wall-clock sleeps.
10
10
 
11
11
  const net = require('net');
12
- const { ImapFlow } = require('../lib/imap-flow');
13
12
  const idleCommand = require('../lib/commands/idle.js');
14
13
  const { withFakeTimers } = require('./fixtures/fake-timers');
14
+ const { makeClient, makeIdleReadyClient } = require('./fixtures/test-client');
15
15
 
16
16
  const CAPS = 'IMAP4rev1 ID ENABLE NAMESPACE IDLE';
17
17
 
@@ -52,15 +52,6 @@ const createServer = () =>
52
52
 
53
53
  const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
54
54
 
55
- const makeClient = (overrides = {}) =>
56
- new ImapFlow({
57
- host: '127.0.0.1',
58
- port: 993,
59
- logger: false,
60
- auth: { user: 'test', pass: 'secret' },
61
- ...overrides
62
- });
63
-
64
55
  module.exports['Timers: connection and greeting deadlines keep the process alive'] = async test => {
65
56
  let server = createServer();
66
57
  let port = await listen(server);
@@ -99,15 +90,13 @@ module.exports['Timers: connection and greeting deadlines keep the process alive
99
90
 
100
91
  module.exports['Timers: the auto-IDLE timer is unrefd and cleared on close'] = async test => {
101
92
  await withFakeTimers(async timers => {
102
- let client = makeClient();
103
- client.state = client.states.SELECTED;
104
- client.idle = async () => {};
93
+ let client = makeIdleReadyClient();
105
94
 
106
95
  client.autoidle();
107
96
 
108
97
  let armed = timers.pending();
109
98
  test.equal(armed.length, 1, 'exactly one auto-IDLE timer is armed');
110
- test.equal(armed[0].delay, 15 * 1000);
99
+ test.equal(armed[0].delay, client.autoIdleDelay);
111
100
  test.ok(armed[0].unrefd, 'the background auto-IDLE timer does not keep the process alive');
112
101
 
113
102
  client.close();
@@ -119,9 +108,7 @@ module.exports['Timers: the auto-IDLE timer is unrefd and cleared on close'] = a
119
108
 
120
109
  module.exports['Timers: a restarted auto-IDLE timer replaces the previous one'] = async test => {
121
110
  await withFakeTimers(async timers => {
122
- let client = makeClient();
123
- client.state = client.states.SELECTED;
124
- client.idle = async () => {};
111
+ let client = makeIdleReadyClient();
125
112
 
126
113
  client.autoidle();
127
114
  client.autoidle();