imapflow 1.3.6 → 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.
@@ -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
+ };
@@ -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, [
@@ -392,3 +392,67 @@ module.exports['Line length cap allows line within limit'] = test => {
392
392
 
393
393
  stream.end(Buffer.from('A NOOP\r\n'));
394
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
+ };
@@ -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);
@@ -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
  // ============================================
@@ -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 = [
@@ -390,3 +390,67 @@ module.exports['Token Parser: malformed REFERRAL with no closing bracket consume
390
390
  test.equal(section[0].value, 'REFERRAL');
391
391
  test.equal(section[1].value, 'imap://user@host/INBOX');
392
392
  });
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // Additional error-path and edge-case coverage (E11, E12, E16, E17, E19-E22,
396
+ // E24, E27) plus the bare-"*" sequence and "~"-as-atom normalization paths.
397
+ // All use a non-status command (FETCH) so the TokenParser is actually invoked.
398
+ // ---------------------------------------------------------------------------
399
+
400
+ const expectParserError = (input, code) => test =>
401
+ asyncWrapper(test, async test => {
402
+ let err;
403
+ try {
404
+ await parser(input);
405
+ } catch (e) {
406
+ err = e;
407
+ }
408
+ if (!err) throw new Error('Expected parser to throw but it did not');
409
+ test.equal(err.code, code);
410
+ });
411
+
412
+ module.exports['Token Parser: E11: unexpected ] throws ParserError11'] = expectParserError('* 1 FETCH (])', 'ParserError11');
413
+ module.exports['Token Parser: E12: ~ not followed by { throws ParserError12'] = expectParserError('* 1 FETCH ~%', 'ParserError12');
414
+ module.exports['Token Parser: E16: \\* followed by char throws ParserError17'] = expectParserError('* 1 FETCH \\*x', 'ParserError17');
415
+ module.exports['Token Parser: E19: partial ending with . throws ParserError19'] = expectParserError('* 1 FETCH BODY[]<0.>', 'ParserError19');
416
+ module.exports['Token Parser: E20: partial leading . throws ParserError20'] = expectParserError('* 1 FETCH BODY[]<.>', 'ParserError20');
417
+ module.exports['Token Parser: E21: non-digit in partial throws ParserError21'] = expectParserError('* 1 FETCH BODY[]<0.x>', 'ParserError21');
418
+ module.exports['Token Parser: E22: invalid leading-zero partial throws ParserError22'] = expectParserError('* 1 FETCH BODY[]<00>', 'ParserError22');
419
+ module.exports['Token Parser: E24: literal prefix not followed by CRLF throws ParserError24'] = expectParserError('* 1 FETCH {3}xyz', 'ParserError24');
420
+ module.exports['Token Parser: E27: whitespace mid-sequence throws ParserError27'] = expectParserError('* 1 FETCH 1: 2', 'ParserError27');
421
+
422
+ module.exports['Token Parser: bare * is normalized to an ATOM'] = test =>
423
+ asyncWrapper(test, async test => {
424
+ let r = await parser('* 1 FETCH *');
425
+ let last = r.attributes[r.attributes.length - 1];
426
+ test.equal(last.type, 'ATOM');
427
+ test.equal(last.value, '*');
428
+ });
429
+
430
+ module.exports['Token Parser: ~ followed by atom chars becomes an ATOM'] = test =>
431
+ asyncWrapper(test, async test => {
432
+ let r = await parser('* 1 FETCH ~abc');
433
+ let last = r.attributes[r.attributes.length - 1];
434
+ test.equal(last.type, 'ATOM');
435
+ test.equal(last.value, '~abc');
436
+ });
437
+
438
+ module.exports['Token Parser: E16: invalid char in atom throws ParserError16'] = expectParserError('* 1 FETCH 3* 4', 'ParserError16');
439
+
440
+ module.exports['Token Parser: literal+ (non-synchronizing) marker is accepted'] = test =>
441
+ asyncWrapper(test, async test => {
442
+ // {3+} is a LITERAL+ prefix; with literalPlus enabled and no pre-parsed
443
+ // literals, the parser reads the literal bytes inline.
444
+ let r = await parser('* 1 FETCH {3+}\r\nabc', { literalPlus: true });
445
+ let lit = r.attributes[r.attributes.length - 1];
446
+ test.equal(lit.type, 'LITERAL');
447
+ test.equal(lit.value.toString(), 'abc');
448
+ });
449
+
450
+ module.exports['Token Parser: literal prefix terminated by bare LF is accepted'] = test =>
451
+ asyncWrapper(test, async test => {
452
+ let r = await parser('* 1 FETCH {3}\nabc');
453
+ let lit = r.attributes[r.attributes.length - 1];
454
+ test.equal(lit.type, 'LITERAL');
455
+ test.equal(lit.value.toString(), 'abc');
456
+ });