imapflow 1.3.5 → 1.3.7
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/.github/codeql/codeql-config.yml +12 -0
- package/.github/workflows/codeql.yml +102 -0
- package/.github/workflows/stale.yml +5 -0
- package/.github/workflows/test.yml +3 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/lib/commands/authenticate.js +5 -5
- package/lib/commands/fetch.js +1 -0
- package/lib/commands/logout.js +1 -0
- package/lib/commands/select.js +2 -1
- package/lib/commands/starttls.js +3 -0
- package/lib/commands/store.js +1 -0
- package/lib/handler/imap-stream.js +23 -6
- package/lib/handler/token-parser.js +0 -5
- package/lib/imap-flow.d.ts +5 -0
- package/lib/imap-flow.js +205 -29
- package/lib/proxy-connection.js +26 -1
- package/lib/tools.js +12 -0
- package/package.json +1 -1
- package/test/commands-branches-test.js +1068 -0
- package/test/connection-edge-cases-test.js +138 -1
- package/test/fixtures/test-tls.js +8 -0
- package/test/handler-branches-test.js +334 -0
- package/test/imap-compiler-test.js +47 -0
- package/test/imap-flow-compress-test.js +154 -0
- package/test/imap-flow-coverage-test.js +609 -0
- package/test/imap-flow-fetch-download-test.js +801 -0
- package/test/imap-flow-internals-test.js +450 -0
- package/test/imap-flow-methods-test.js +738 -0
- package/test/imap-flow-proxy-paths-test.js +215 -0
- package/test/imap-flow-secure-test.js +327 -0
- package/test/imap-flow-server-test.js +1046 -0
- package/test/imap-formal-syntax-test.js +18 -0
- package/test/imap-parser-test.js +52 -0
- package/test/imap-stream-edge-cases-test.js +122 -0
- package/test/jp-decoder-test.js +48 -0
- package/test/limited-passthrough-test.js +17 -0
- package/test/proxy-connection-test.js +86 -0
- package/test/reliability-improvements-test.js +88 -0
- package/test/search-compiler-test.js +31 -0
- package/test/search-test.js +61 -0
- package/test/starttls-injection-test.js +181 -0
- package/test/token-parser-test.js +64 -0
- package/test/tools-test.js +360 -0
|
@@ -273,3 +273,21 @@ module.exports['Formal Syntax: CHAR() returns same string reference on repeated
|
|
|
273
273
|
test.strictEqual(first, second);
|
|
274
274
|
test.done();
|
|
275
275
|
};
|
|
276
|
+
|
|
277
|
+
// Memoization: the second invocation must return the cached value via the
|
|
278
|
+
// rewritten getter (covers the memoized inner functions).
|
|
279
|
+
module.exports['Formal Syntax: atom-specials is memoized across calls'] = test => {
|
|
280
|
+
let first = imapFormalSyntax['atom-specials']();
|
|
281
|
+
let second = imapFormalSyntax['atom-specials']();
|
|
282
|
+
test.equal(first, second);
|
|
283
|
+
test.ok(second.includes('('));
|
|
284
|
+
test.done();
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
module.exports['Formal Syntax: quoted-specials is memoized across calls'] = test => {
|
|
288
|
+
let first = imapFormalSyntax['quoted-specials']();
|
|
289
|
+
let second = imapFormalSyntax['quoted-specials']();
|
|
290
|
+
test.equal(first, second);
|
|
291
|
+
test.ok(second.includes('"'));
|
|
292
|
+
test.done();
|
|
293
|
+
};
|
package/test/imap-parser-test.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
5
|
const { parser } = require('../lib/handler/imap-handler');
|
|
6
|
+
const { ParserInstance } = require('../lib/handler/parser-instance');
|
|
6
7
|
const mimetorture = require('./fixtures/serialized-mimetorture');
|
|
7
8
|
|
|
8
9
|
let asyncWrapper = (test, handler) => {
|
|
@@ -26,6 +27,20 @@ let asyncWrapperFail = (test, handler) => {
|
|
|
26
27
|
});
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
// Asserts that `fn` throws (or rejects with) an error carrying the expected `code`,
|
|
31
|
+
// not merely that *some* error was thrown.
|
|
32
|
+
let expectErrorCode = (test, code, fn) =>
|
|
33
|
+
asyncWrapper(test, async test => {
|
|
34
|
+
let err;
|
|
35
|
+
try {
|
|
36
|
+
await fn();
|
|
37
|
+
} catch (e) {
|
|
38
|
+
err = e;
|
|
39
|
+
}
|
|
40
|
+
test.ok(err, 'expected an error to be thrown');
|
|
41
|
+
test.equal(err && err.code, code);
|
|
42
|
+
});
|
|
43
|
+
|
|
29
44
|
module.exports['IMAP Parser: Tags: get TAG'] = test => asyncWrapper(test, async test => test.equal((await parser('TAG1 CMD')).tag, 'TAG1'));
|
|
30
45
|
module.exports['IMAP Parser: Tags: space before TAG'] = test => asyncWrapperFail(test, async test => test.ok(await parser(' TAG CMD')));
|
|
31
46
|
module.exports['IMAP Parser: Tags: fail empty TAG'] = test => asyncWrapperFail(test, async test => test.ok(await parser('')));
|
|
@@ -319,6 +334,43 @@ module.exports['IMAP Parser: Literals: literal with NULL'] = test =>
|
|
|
319
334
|
])
|
|
320
335
|
);
|
|
321
336
|
|
|
337
|
+
module.exports['IMAP Parser: empty continuation (+) response'] = test =>
|
|
338
|
+
asyncWrapper(test, async test => {
|
|
339
|
+
let parsed = await parser('+');
|
|
340
|
+
test.equal(parsed.tag, '+');
|
|
341
|
+
test.equal(parsed.command, '');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
module.exports['IMAP Parser: non-space after command throws ParserError5'] = test =>
|
|
345
|
+
expectErrorCode(test, 'ParserError5', () => parser('TAG1 CMD\tx'));
|
|
346
|
+
|
|
347
|
+
module.exports['IMAP Parser: leading whitespace in attributes throws ParserError7'] = test =>
|
|
348
|
+
expectErrorCode(test, 'ParserError7', () => parser('TAG1 CMD \tx'));
|
|
349
|
+
|
|
350
|
+
module.exports['IMAP Parser: getAttributes on empty input throws ParserError6'] = test =>
|
|
351
|
+
expectErrorCode(test, 'ParserError6', () => {
|
|
352
|
+
// constructed without options to also exercise the options default branch
|
|
353
|
+
let pi = new ParserInstance('');
|
|
354
|
+
return pi.getAttributes();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
module.exports['IMAP Parser: strips leading NUL padding and records count'] = test =>
|
|
358
|
+
asyncWrapper(test, async test => {
|
|
359
|
+
let buf = Buffer.concat([Buffer.from([0, 0, 0]), Buffer.from('* OK ready')]);
|
|
360
|
+
let parsed = await parser(buf);
|
|
361
|
+
test.equal(parsed.tag, '*');
|
|
362
|
+
test.equal(parsed.command, 'OK');
|
|
363
|
+
test.equal(parsed.nullBytesRemoved, 3);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
module.exports['IMAP Parser: all-NUL input becomes BAD response'] = test =>
|
|
367
|
+
asyncWrapper(test, async test => {
|
|
368
|
+
let parsed = await parser(Buffer.from([0, 0, 0, 0]));
|
|
369
|
+
test.equal(parsed.tag, '*');
|
|
370
|
+
test.equal(parsed.command, 'BAD');
|
|
371
|
+
test.deepEqual(parsed.attributes, []);
|
|
372
|
+
});
|
|
373
|
+
|
|
322
374
|
module.exports['IMAP Parser: Literals: literal8'] = test =>
|
|
323
375
|
asyncWrapper(test, async test =>
|
|
324
376
|
test.deepEqual((await parser('TAG1 CMD ~{4}\r\n', { literals: [Buffer.from('ab\x00d')] })).attributes, [
|
|
@@ -131,6 +131,64 @@ module.exports['LiteralTooLarge error'] = test => {
|
|
|
131
131
|
stream.write(Buffer.from('A APPEND {1073741825}\r\n'));
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
+
module.exports['LiteralTooLarge error honors configured maxLiteralSize'] = test => {
|
|
135
|
+
const cap = 1024; // 1KB cap
|
|
136
|
+
const stream = new ImapStream({ cid: 'test', maxLiteralSize: cap });
|
|
137
|
+
|
|
138
|
+
stream.on('error', err => {
|
|
139
|
+
test.equal(err.code, 'LiteralTooLarge', 'error code should be LiteralTooLarge');
|
|
140
|
+
test.equal(err.maxSize, cap, 'maxSize should reflect the configured cap');
|
|
141
|
+
test.equal(err.literalSize, 2048, 'literalSize should be the offending value');
|
|
142
|
+
stream.destroy();
|
|
143
|
+
test.done();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
stream.write(Buffer.from('A APPEND {2048}\r\n'));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
module.exports['maxLiteralSize: 0 is honored (not swallowed into the default)'] = test => {
|
|
150
|
+
// Regression: `this.options.maxLiteralSize || MAX_LITERAL_SIZE` turned an explicit 0 into
|
|
151
|
+
// the 1GB default. An explicit 0 must mean "reject any non-empty literal".
|
|
152
|
+
test.expect(2);
|
|
153
|
+
|
|
154
|
+
const stream = new ImapStream({ cid: 'test', maxLiteralSize: 0 });
|
|
155
|
+
test.equal(stream.maxLiteralSize, 0, 'an explicit 0 cap is preserved, not replaced by the default');
|
|
156
|
+
|
|
157
|
+
stream.on('error', err => {
|
|
158
|
+
test.equal(err.code, 'LiteralTooLarge', 'a 1-byte literal exceeds the 0 cap');
|
|
159
|
+
stream.destroy();
|
|
160
|
+
test.done();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
stream.write(Buffer.from('A APPEND {1}\r\n'));
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
module.exports['Literal within configured maxLiteralSize parses cleanly'] = test => {
|
|
167
|
+
// Require both literal assertions to actually run: 'end' fires even if the parser never
|
|
168
|
+
// emits the command, so without expect() a dropped-literal regression would pass green.
|
|
169
|
+
test.expect(2);
|
|
170
|
+
|
|
171
|
+
const stream = new ImapStream({ cid: 'test', maxLiteralSize: 1024 });
|
|
172
|
+
const literal = Buffer.alloc(512, 0x61); // 512 * 'a'
|
|
173
|
+
|
|
174
|
+
stream.on('readable', () => {
|
|
175
|
+
let cmd;
|
|
176
|
+
while ((cmd = stream.read()) !== null) {
|
|
177
|
+
test.equal(cmd.literals.length, 1, 'should have one literal');
|
|
178
|
+
test.equal(cmd.literals[0].length, 512, 'literal length should be 512');
|
|
179
|
+
cmd.next();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
stream.on('error', err => test.ifError(err));
|
|
184
|
+
|
|
185
|
+
stream.on('end', () => test.done());
|
|
186
|
+
|
|
187
|
+
stream.write(Buffer.from('A APPEND {512}\r\n'));
|
|
188
|
+
stream.write(literal);
|
|
189
|
+
stream.end(Buffer.from('\r\n'));
|
|
190
|
+
};
|
|
191
|
+
|
|
134
192
|
module.exports['Incomplete line continued in next chunk'] = test => {
|
|
135
193
|
runStreamTest(
|
|
136
194
|
test,
|
|
@@ -334,3 +392,67 @@ module.exports['Line length cap allows line within limit'] = test => {
|
|
|
334
392
|
|
|
335
393
|
stream.end(Buffer.from('A NOOP\r\n'));
|
|
336
394
|
};
|
|
395
|
+
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// checkLiteralMarker direct edge cases + _transform string handling + _destroy
|
|
398
|
+
// draining (lines that the normal streaming flow does not exercise).
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
module.exports['ImapStream: checkLiteralMarker returns false for empty line'] = test => {
|
|
402
|
+
const stream = new ImapStream({ cid: 't' });
|
|
403
|
+
test.equal(stream.checkLiteralMarker(Buffer.alloc(0)), false);
|
|
404
|
+
test.equal(stream.checkLiteralMarker(null), false);
|
|
405
|
+
test.done();
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
module.exports['ImapStream: checkLiteralMarker returns false when no trailing LF'] = test => {
|
|
409
|
+
const stream = new ImapStream({ cid: 't' });
|
|
410
|
+
test.equal(stream.checkLiteralMarker(Buffer.from('A1 OK no newline')), false);
|
|
411
|
+
test.done();
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
module.exports['ImapStream: checkLiteralMarker returns false for non-numeric marker'] = test => {
|
|
415
|
+
const stream = new ImapStream({ cid: 't' });
|
|
416
|
+
// '{' present but contains a non-digit, and an empty {} marker
|
|
417
|
+
test.equal(stream.checkLiteralMarker(Buffer.from('A1 CMD {x}\r\n')), false);
|
|
418
|
+
test.equal(stream.checkLiteralMarker(Buffer.from('A1 CMD {}\r\n')), false);
|
|
419
|
+
test.done();
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
module.exports['ImapStream: checkLiteralMarker activates literal state for valid marker'] = test => {
|
|
423
|
+
const stream = new ImapStream({ cid: 't' });
|
|
424
|
+
test.equal(stream.checkLiteralMarker(Buffer.from('A1 CMD {5}\r\n')), true);
|
|
425
|
+
test.equal(stream.literalWaiting, 5);
|
|
426
|
+
test.done();
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
module.exports['ImapStream: _transform converts string chunks to Buffer'] = test => {
|
|
430
|
+
const stream = new ImapStream({ cid: 't' });
|
|
431
|
+
let commands = [];
|
|
432
|
+
stream.on('readable', () => {
|
|
433
|
+
let cmd;
|
|
434
|
+
while ((cmd = stream.read()) !== null) {
|
|
435
|
+
commands.push(cmd.payload.toString());
|
|
436
|
+
cmd.next();
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
stream.on('end', () => {
|
|
440
|
+
test.ok(commands.some(c => /A1 OK/.test(c)));
|
|
441
|
+
test.done();
|
|
442
|
+
});
|
|
443
|
+
// write a string (not a Buffer) to exercise the string->Buffer branch
|
|
444
|
+
stream.write('A1 OK done\r\n');
|
|
445
|
+
stream.end();
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
module.exports['ImapStream: _destroy drains pending input queue callbacks'] = test => {
|
|
449
|
+
const stream = new ImapStream({ cid: 't' });
|
|
450
|
+
let nextCalled = false;
|
|
451
|
+
// Stage a pending queue item with a next() callback, then destroy.
|
|
452
|
+
stream.inputQueue.push({ chunk: Buffer.from('x'), next: () => (nextCalled = true) });
|
|
453
|
+
stream.destroy();
|
|
454
|
+
setImmediate(() => {
|
|
455
|
+
test.ok(nextCalled, 'pending next() invoked during destroy');
|
|
456
|
+
test.done();
|
|
457
|
+
});
|
|
458
|
+
};
|
package/test/jp-decoder-test.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { JPDecoder } = require('../lib/jp-decoder');
|
|
4
4
|
const { PassThrough, Transform } = require('stream');
|
|
5
|
+
const encodingJapanese = require('encoding-japanese');
|
|
5
6
|
|
|
6
7
|
// Helper to collect stream output
|
|
7
8
|
const collectStream = stream =>
|
|
@@ -126,6 +127,53 @@ module.exports['JPDecoder: _flush handles conversion errors gracefully'] = async
|
|
|
126
127
|
test.done();
|
|
127
128
|
};
|
|
128
129
|
|
|
130
|
+
module.exports['JPDecoder: _flush falls back to raw input when convert throws'] = async test => {
|
|
131
|
+
// encoding-japanese rarely throws on its own, so force it: the module object
|
|
132
|
+
// is shared by reference with the copy held inside jp-decoder.js, so patching
|
|
133
|
+
// .convert here affects the decoder's call too.
|
|
134
|
+
let originalConvert = encodingJapanese.convert;
|
|
135
|
+
encodingJapanese.convert = () => {
|
|
136
|
+
throw new Error('forced conversion failure');
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
let decoder = new JPDecoder('iso-2022-jp');
|
|
141
|
+
let output = collectStream(decoder);
|
|
142
|
+
|
|
143
|
+
let input = Buffer.from('raw bytes preserved');
|
|
144
|
+
decoder.write(input);
|
|
145
|
+
decoder.end();
|
|
146
|
+
|
|
147
|
+
let result = await output;
|
|
148
|
+
// On a thrown error the catch block pushes the original, untouched input
|
|
149
|
+
test.equal(result.toString(), 'raw bytes preserved');
|
|
150
|
+
} finally {
|
|
151
|
+
encodingJapanese.convert = originalConvert;
|
|
152
|
+
}
|
|
153
|
+
test.done();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
module.exports['JPDecoder: _flush converts non-string convert output to buffer'] = async test => {
|
|
157
|
+
// When convert returns a non-string (e.g. an array of code units), it must be
|
|
158
|
+
// pushed as-is without the Buffer.from(string) branch.
|
|
159
|
+
let originalConvert = encodingJapanese.convert;
|
|
160
|
+
encodingJapanese.convert = () => Buffer.from('converted buffer');
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
let decoder = new JPDecoder('iso-2022-jp');
|
|
164
|
+
let output = collectStream(decoder);
|
|
165
|
+
|
|
166
|
+
decoder.write(Buffer.from('whatever'));
|
|
167
|
+
decoder.end();
|
|
168
|
+
|
|
169
|
+
let result = await output;
|
|
170
|
+
test.equal(result.toString(), 'converted buffer');
|
|
171
|
+
} finally {
|
|
172
|
+
encodingJapanese.convert = originalConvert;
|
|
173
|
+
}
|
|
174
|
+
test.done();
|
|
175
|
+
};
|
|
176
|
+
|
|
129
177
|
module.exports['JPDecoder: _flush handles empty input'] = async test => {
|
|
130
178
|
let decoder = new JPDecoder('iso-2022-jp');
|
|
131
179
|
let output = collectStream(decoder);
|
|
@@ -142,6 +142,23 @@ module.exports['LimitedPassthrough: zero maxBytes treated as Infinity'] = async
|
|
|
142
142
|
test.done();
|
|
143
143
|
};
|
|
144
144
|
|
|
145
|
+
module.exports['LimitedPassthrough: drops chunk when no remaining budget but not yet limited'] = async test => {
|
|
146
|
+
// Force the remainingBytes<1 guard (distinct from the `this.limited` fast path):
|
|
147
|
+
// override maxBytes to 0 after construction so the very first chunk hits it
|
|
148
|
+
// while `limited` is still false.
|
|
149
|
+
let stream = new LimitedPassthrough();
|
|
150
|
+
stream.maxBytes = 0;
|
|
151
|
+
let output = collectStream(stream);
|
|
152
|
+
|
|
153
|
+
stream.write(Buffer.from('dropped'));
|
|
154
|
+
stream.end();
|
|
155
|
+
|
|
156
|
+
let result = await output;
|
|
157
|
+
test.equal(result.length, 0, 'chunk dropped when no budget remains');
|
|
158
|
+
test.equal(stream.limited, false, 'limited flag never set via this path');
|
|
159
|
+
test.done();
|
|
160
|
+
};
|
|
161
|
+
|
|
145
162
|
module.exports['LimitedPassthrough: handles limit of 1 byte'] = async test => {
|
|
146
163
|
let stream = new LimitedPassthrough({ maxBytes: 1 });
|
|
147
164
|
let output = collectStream(stream);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const proxyquire = require('proxyquire').noCallThru();
|
|
4
|
+
const { EventEmitter } = require('events');
|
|
4
5
|
|
|
5
6
|
// Mock socket object
|
|
6
7
|
const createMockSocket = () => ({
|
|
@@ -10,6 +11,16 @@ const createMockSocket = () => ({
|
|
|
10
11
|
destroy: () => {}
|
|
11
12
|
});
|
|
12
13
|
|
|
14
|
+
// Real EventEmitter-backed mock socket, for tests that need listener tracking / emit.
|
|
15
|
+
const createMockEmitterSocket = () => Object.assign(new EventEmitter(), { write() {}, end() {}, destroy() {} });
|
|
16
|
+
|
|
17
|
+
// Asserts proxyConnection returned the socket and guarded it with an error listener.
|
|
18
|
+
const assertEarlyErrorHandler = (test, socket, mockSocket) => {
|
|
19
|
+
test.equal(socket, mockSocket);
|
|
20
|
+
test.ok(socket.listenerCount('error') >= 1, 'proxy socket carries an error listener before being returned');
|
|
21
|
+
test.doesNotThrow(() => socket.emit('error', new Error('early proxy error')), 'an early proxy socket error does not throw');
|
|
22
|
+
};
|
|
23
|
+
|
|
13
24
|
// Mock logger
|
|
14
25
|
const createMockLogger = () => {
|
|
15
26
|
const logs = { info: [], error: [] };
|
|
@@ -539,3 +550,78 @@ module.exports['Proxy Connection: SOCKS with username only'] = async test => {
|
|
|
539
550
|
await proxyConnection(logger, 'socks5://testuser@proxy.example.com:1080', '192.168.1.1', 993);
|
|
540
551
|
test.done();
|
|
541
552
|
};
|
|
553
|
+
|
|
554
|
+
// ============================================
|
|
555
|
+
// Early error handler (no unhandled 'error' before ImapFlow takes over)
|
|
556
|
+
// ============================================
|
|
557
|
+
|
|
558
|
+
module.exports['Proxy Connection: HTTP socket has early error handler before return'] = async test => {
|
|
559
|
+
const mockSocket = createMockEmitterSocket();
|
|
560
|
+
const logger = createMockLogger();
|
|
561
|
+
|
|
562
|
+
const { proxyConnection } = proxyquire('../lib/proxy-connection', {
|
|
563
|
+
'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
|
|
564
|
+
cb(null, mockSocket);
|
|
565
|
+
},
|
|
566
|
+
socks: { SocksClient: {} },
|
|
567
|
+
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
568
|
+
net: { isIP: () => true }
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const socket = await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
|
|
572
|
+
|
|
573
|
+
assertEarlyErrorHandler(test, socket, mockSocket);
|
|
574
|
+
test.done();
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
module.exports['Proxy Connection: SOCKS socket has early error handler before return'] = async test => {
|
|
578
|
+
const mockSocket = createMockEmitterSocket();
|
|
579
|
+
const logger = createMockLogger();
|
|
580
|
+
|
|
581
|
+
const { proxyConnection } = proxyquire('../lib/proxy-connection', {
|
|
582
|
+
'nodemailer/lib/smtp-connection/http-proxy-client': () => {},
|
|
583
|
+
socks: {
|
|
584
|
+
SocksClient: {
|
|
585
|
+
createConnection: async () => ({ socket: mockSocket })
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
589
|
+
net: { isIP: () => true }
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const socket = await proxyConnection(logger, 'socks5://proxy.example.com:1080', '192.168.1.1', 993);
|
|
593
|
+
|
|
594
|
+
assertEarlyErrorHandler(test, socket, mockSocket);
|
|
595
|
+
test.done();
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// detachEarlyErrorHandler must remove the guard once the caller takes ownership of the socket,
|
|
599
|
+
// so it no longer swallows/logs errors meant for the new owner's handlers.
|
|
600
|
+
module.exports['Proxy Connection: detachEarlyErrorHandler removes the early guard'] = async test => {
|
|
601
|
+
const mockSocket = createMockEmitterSocket();
|
|
602
|
+
const logger = createMockLogger();
|
|
603
|
+
|
|
604
|
+
const { proxyConnection, detachEarlyErrorHandler } = proxyquire('../lib/proxy-connection', {
|
|
605
|
+
'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
|
|
606
|
+
cb(null, mockSocket);
|
|
607
|
+
},
|
|
608
|
+
socks: { SocksClient: {} },
|
|
609
|
+
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
610
|
+
net: { isIP: () => true }
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
const socket = await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
|
|
614
|
+
test.ok(socket.listenerCount('error') >= 1, 'early error handler attached on return');
|
|
615
|
+
test.ok(typeof socket._earlyErrorHandler === 'function', 'handler reference stored on the socket');
|
|
616
|
+
|
|
617
|
+
detachEarlyErrorHandler(socket);
|
|
618
|
+
|
|
619
|
+
test.equal(socket.listenerCount('error'), 0, 'early error handler removed after detach');
|
|
620
|
+
test.equal(socket._earlyErrorHandler, null, 'stored handler reference cleared');
|
|
621
|
+
|
|
622
|
+
// Detaching again (or on a bare socket) must be a safe no-op.
|
|
623
|
+
test.doesNotThrow(() => detachEarlyErrorHandler(socket));
|
|
624
|
+
test.doesNotThrow(() => detachEarlyErrorHandler(createMockEmitterSocket()));
|
|
625
|
+
|
|
626
|
+
test.done();
|
|
627
|
+
};
|
|
@@ -368,3 +368,91 @@ module.exports['Reliability: decoder emit(error) does not crash when user has no
|
|
|
368
368
|
|
|
369
369
|
test.done();
|
|
370
370
|
};
|
|
371
|
+
|
|
372
|
+
// ============================================================================
|
|
373
|
+
// Throttle back-off timer is tracked and abortable on close()
|
|
374
|
+
// ============================================================================
|
|
375
|
+
|
|
376
|
+
// Feed a single throttling BAD response into reader() once, then null.
|
|
377
|
+
const stubThrottleResponse = (client, backoffMs) => {
|
|
378
|
+
let request = { tag: 'A001', command: 'FETCH', resolve: () => {}, reject: () => {} };
|
|
379
|
+
client.requestTagMap = new Map([['A001', request]]);
|
|
380
|
+
|
|
381
|
+
let done = false;
|
|
382
|
+
client.streamer.read = () => {
|
|
383
|
+
if (done) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
done = true;
|
|
387
|
+
return {
|
|
388
|
+
payload: Buffer.from(`A001 BAD Request is throttled. Suggested Backoff Time: ${backoffMs} milliseconds`),
|
|
389
|
+
literals: [],
|
|
390
|
+
next: () => {}
|
|
391
|
+
};
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
return request;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
module.exports['Reliability: throttle back-off aborts promptly on close()'] = async test => {
|
|
398
|
+
let client = new ImapFlow({
|
|
399
|
+
host: 'imap.example.com',
|
|
400
|
+
port: 993,
|
|
401
|
+
auth: { user: 'test', pass: 'test' },
|
|
402
|
+
logger: false
|
|
403
|
+
});
|
|
404
|
+
client.socket = { destroyed: false, destroy: () => {} };
|
|
405
|
+
client.writeSocket = client.socket;
|
|
406
|
+
|
|
407
|
+
let rejected = null;
|
|
408
|
+
let request = stubThrottleResponse(client, 300000); // 5 min back-off
|
|
409
|
+
request.reject = err => {
|
|
410
|
+
rejected = err;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
let start = Date.now();
|
|
414
|
+
let readerDone = client.reader().catch(() => {});
|
|
415
|
+
|
|
416
|
+
// Let reader() reach the (tracked) back-off wait.
|
|
417
|
+
await new Promise(r => setTimeout(r, 50));
|
|
418
|
+
test.ok(client._throttleTimer, 'back-off timer is tracked while waiting');
|
|
419
|
+
|
|
420
|
+
client.close();
|
|
421
|
+
await new Promise(r => setImmediate(r));
|
|
422
|
+
|
|
423
|
+
test.ok(rejected, 'request rejected promptly after close()');
|
|
424
|
+
test.equal(rejected.code, 'NoConnection', 'rejected with connection error, not ETHROTTLE');
|
|
425
|
+
test.equal(client._throttleTimer, null, 'throttle timer cleared on close()');
|
|
426
|
+
test.ok(Date.now() - start < 5000, 'settled well under the 5-minute cap');
|
|
427
|
+
|
|
428
|
+
await readerDone;
|
|
429
|
+
test.done();
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
module.exports['Reliability: throttle back-off still rejects ETHROTTLE on normal expiry'] = async test => {
|
|
433
|
+
let client = new ImapFlow({
|
|
434
|
+
host: 'imap.example.com',
|
|
435
|
+
port: 993,
|
|
436
|
+
auth: { user: 'test', pass: 'test' },
|
|
437
|
+
logger: false
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
let rejected = null;
|
|
441
|
+
let request = stubThrottleResponse(client, 50); // 50ms back-off
|
|
442
|
+
request.reject = err => {
|
|
443
|
+
rejected = err;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
let readerDone = client.reader().catch(() => {});
|
|
447
|
+
|
|
448
|
+
await new Promise(r => setTimeout(r, 250));
|
|
449
|
+
|
|
450
|
+
test.ok(rejected, 'request rejected after the back-off elapses');
|
|
451
|
+
test.equal(rejected.code, 'ETHROTTLE', 'normal expiry still rejects ETHROTTLE');
|
|
452
|
+
test.equal(rejected.throttleReset, 50, 'throttleReset preserved');
|
|
453
|
+
test.equal(client._throttleTimer, null, 'throttle timer cleared after normal expiry');
|
|
454
|
+
|
|
455
|
+
await readerDone;
|
|
456
|
+
client.close();
|
|
457
|
+
test.done();
|
|
458
|
+
};
|
|
@@ -844,6 +844,37 @@ module.exports['Search Compiler: HEADER with Unicode adds CHARSET'] = test => {
|
|
|
844
844
|
test.done();
|
|
845
845
|
};
|
|
846
846
|
|
|
847
|
+
module.exports['Search Compiler: non-ASCII text field adds CHARSET UTF-8'] = test => {
|
|
848
|
+
let connection = createMockConnection({
|
|
849
|
+
enabled: new Set() // UTF8=ACCEPT not enabled
|
|
850
|
+
});
|
|
851
|
+
// Non-ASCII subject must flip hasUnicode and prepend CHARSET UTF-8
|
|
852
|
+
let compiled = searchCompiler(connection, { subject: 'résumé café' });
|
|
853
|
+
test.ok(hasAttr(compiled, 'CHARSET'));
|
|
854
|
+
test.ok(hasAttr(compiled, 'UTF-8'));
|
|
855
|
+
test.done();
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
module.exports['Search Compiler: non-ASCII body field adds CHARSET UTF-8'] = test => {
|
|
859
|
+
let connection = createMockConnection({
|
|
860
|
+
enabled: new Set()
|
|
861
|
+
});
|
|
862
|
+
let compiled = searchCompiler(connection, { body: 'Grüße' });
|
|
863
|
+
test.ok(hasAttr(compiled, 'CHARSET'));
|
|
864
|
+
test.done();
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
module.exports['Search Compiler: non-ASCII GMRAW adds CHARSET UTF-8'] = test => {
|
|
868
|
+
let connection = createMockConnection({
|
|
869
|
+
capabilities: [['X-GM-EXT-1', true]],
|
|
870
|
+
enabled: new Set()
|
|
871
|
+
});
|
|
872
|
+
let compiled = searchCompiler(connection, { gmraw: 'subject:café' });
|
|
873
|
+
test.ok(hasAttr(compiled, 'CHARSET'));
|
|
874
|
+
test.ok(hasAttr(compiled, 'X-GM-RAW'));
|
|
875
|
+
test.done();
|
|
876
|
+
};
|
|
877
|
+
|
|
847
878
|
// ============================================
|
|
848
879
|
// Complex query tests
|
|
849
880
|
// ============================================
|
package/test/search-test.js
CHANGED
|
@@ -160,6 +160,67 @@ module.exports['ESEARCH: no RETURN clause when server lacks ESEARCH capability']
|
|
|
160
160
|
}).catch(err => test.done(err));
|
|
161
161
|
};
|
|
162
162
|
|
|
163
|
+
module.exports['ESEARCH: parseEsearchResponse skips non-ATOM tokens'] = test => {
|
|
164
|
+
const attrs = [
|
|
165
|
+
[{ type: 'ATOM', value: 'stray' }], // non-ATOM (Array) token is skipped
|
|
166
|
+
{ type: 'ATOM', value: 'COUNT' },
|
|
167
|
+
{ type: 'ATOM', value: '9' }
|
|
168
|
+
];
|
|
169
|
+
const result = parseEsearchResponse(attrs);
|
|
170
|
+
test.equal(result.count, 9);
|
|
171
|
+
test.done();
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
module.exports['ESEARCH: parseEsearchResponse skips trailing key with no value'] = test => {
|
|
175
|
+
const attrs = [{ type: 'ATOM', value: 'COUNT' }]; // dangling key, no value
|
|
176
|
+
const result = parseEsearchResponse(attrs);
|
|
177
|
+
test.deepEqual(result, {});
|
|
178
|
+
test.done();
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
module.exports['ESEARCH: untagged handler parses ESEARCH response'] = test => {
|
|
182
|
+
const conn = makeConnection({ hasEsearch: true });
|
|
183
|
+
conn.exec = async (command, attributes, handlers) => {
|
|
184
|
+
const esearch = handlers && handlers.untagged && handlers.untagged.ESEARCH;
|
|
185
|
+
// leading (TAG "A1") list + UID atom are stripped before parsing
|
|
186
|
+
await esearch({
|
|
187
|
+
attributes: [
|
|
188
|
+
[
|
|
189
|
+
{ type: 'ATOM', value: 'TAG' },
|
|
190
|
+
{ type: 'STRING', value: 'A1' }
|
|
191
|
+
],
|
|
192
|
+
{ type: 'ATOM', value: 'UID' },
|
|
193
|
+
{ type: 'ATOM', value: 'COUNT' },
|
|
194
|
+
{ type: 'ATOM', value: '7' }
|
|
195
|
+
]
|
|
196
|
+
});
|
|
197
|
+
// also exercise the empty-attributes guard
|
|
198
|
+
await esearch({ attributes: null });
|
|
199
|
+
return { next: () => {} };
|
|
200
|
+
};
|
|
201
|
+
searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT'] })
|
|
202
|
+
.then(result => {
|
|
203
|
+
test.equal(result.count, 7);
|
|
204
|
+
test.done();
|
|
205
|
+
})
|
|
206
|
+
.catch(err => test.done(err));
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
module.exports['ESEARCH: command path returns false on exec error'] = test => {
|
|
210
|
+
const conn = makeConnection({ hasEsearch: true });
|
|
211
|
+
conn.exec = async () => {
|
|
212
|
+
let err = new Error('search failed');
|
|
213
|
+
err.responseStatus = 'NO';
|
|
214
|
+
throw err;
|
|
215
|
+
};
|
|
216
|
+
searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT'] })
|
|
217
|
+
.then(result => {
|
|
218
|
+
test.equal(result, false);
|
|
219
|
+
test.done();
|
|
220
|
+
})
|
|
221
|
+
.catch(err => test.done(err));
|
|
222
|
+
};
|
|
223
|
+
|
|
163
224
|
module.exports['ESEARCH: parseEsearchResponse ignores unknown keywords'] = test => {
|
|
164
225
|
// Dovecot with CONDSTORE may append MODSEQ to ESEARCH responses
|
|
165
226
|
const attrs = [
|