imapflow 1.6.3 → 1.6.5

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.
@@ -639,3 +639,171 @@ module.exports['IMAP Compiler: partial range in SECTION'] = test =>
639
639
  ).toString();
640
640
  test.ok(compiled.includes('<0.50>'), 'should contain partial range <0.50>');
641
641
  });
642
+
643
+ // Returns the error a compile attempt raised, or undefined when it succeeded.
644
+ // Several cases below assert that a token can never reach the wire, and the
645
+ // try/catch is the only interesting part of each. Takes either an attributes
646
+ // array (compiled under a default tag/command) or a full response object.
647
+ const compileError = async input => {
648
+ try {
649
+ await compiler(Array.isArray(input) ? { tag: 'A', command: 'CMD', attributes: input } : input);
650
+ } catch (err) {
651
+ return err;
652
+ }
653
+ };
654
+
655
+ module.exports['IMAP Compiler: SEQUENCE rejects values that are not sequence sets'] = test =>
656
+ asyncWrapper(test, async test => {
657
+ // Sequence sets are written verbatim, so a range string that reached the
658
+ // compiler unvalidated would put a second command on the wire
659
+ for (let value of ['1\r\nZZ1 LOGOUT', '1 2', '1;2', 'ALL', '1:2)', '1\t2', Buffer.from('1\r\nZZ NOOP')]) {
660
+ let err = await compileError([{ type: 'SEQUENCE', value }]);
661
+ test.equal(err && err.code, 'InvalidSequenceSet', `${JSON.stringify(value.toString())} must be rejected`);
662
+ }
663
+ });
664
+
665
+ module.exports['IMAP Compiler: SEQUENCE accepts every valid sequence-set form'] = test =>
666
+ asyncWrapper(test, async test => {
667
+ // '$' is the RFC 5182 SEARCHRES saved-result marker, valid as the entire set
668
+ for (let value of ['1', '*', '1:*', '*:1', '1,3,5', '1:3,7,9:*', '4294967295', '$']) {
669
+ const compiled = (await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value }] })).toString();
670
+ test.equal(compiled, `A FETCH ${value}`);
671
+ }
672
+
673
+ // '$' only stands for the whole set, never an element of one
674
+ for (let value of ['$,1', '1,$', '$:2']) {
675
+ let err = await compileError([{ type: 'SEQUENCE', value }]);
676
+ test.equal(err && err.code, 'InvalidSequenceSet', `${JSON.stringify(value)} must be rejected`);
677
+ }
678
+ });
679
+
680
+ module.exports['IMAP Compiler: SEQUENCE validation stays linear on huge valid sets'] = test =>
681
+ asyncWrapper(test, async test => {
682
+ // The regex this replaced overflowed the engine's backtrack stack with an
683
+ // uncoded RangeError at roughly a million comma-separated elements - a set
684
+ // a fetch over a large mailbox can legitimately produce
685
+ const huge = Array.from({ length: 1500000 }, (_, i) => i + 1).join(',');
686
+ const compiled = await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'SEQUENCE', value: huge }] });
687
+ test.ok(compiled.toString().endsWith(huge), 'the huge but valid set must compile');
688
+ });
689
+
690
+ module.exports['IMAP Compiler: SEQUENCE validation is skipped when logging'] = test =>
691
+ asyncWrapper(test, async test => {
692
+ // The incoming token parser accepts sequence-shaped tokens the strict grammar
693
+ // rejects (an ESEARCH set like "1:2:3", a folder name like "12:30:00"). Every
694
+ // parsed server response is re-compiled for the log, so a throw here would let
695
+ // one quirky server line kill the whole connection.
696
+ for (let value of ['1:2:3', '12:30:00', '1,2:3:4']) {
697
+ const compiled = (await compiler({ tag: '*', command: 'OK', attributes: [{ type: 'SEQUENCE', value }] }, { isLogging: true })).toString();
698
+ test.equal(compiled, `* OK ${value}`, 'logging re-compile must pass the token through');
699
+ }
700
+ });
701
+
702
+ module.exports['IMAP Compiler: tag and command cannot carry a line terminator'] = test =>
703
+ asyncWrapper(test, async test => {
704
+ // The tag/command preamble is written verbatim ahead of any token, so it goes
705
+ // through the same choke point as everything else
706
+ let err = await compileError({ tag: 'A\r\nZZ LOGOUT', command: 'NOOP' });
707
+ test.equal(err && err.code, 'InvalidTokenValue', 'CRLF in the tag must be rejected');
708
+
709
+ err = await compileError({ tag: 'A', command: 'NOOP\r\nZZ LOGOUT' });
710
+ test.equal(err && err.code, 'InvalidTokenValue', 'CRLF in the command must be rejected');
711
+ });
712
+
713
+ module.exports['IMAP Compiler: quoted strings use IMAP escaping, not JSON escaping'] = test =>
714
+ asyncWrapper(test, async test => {
715
+ // Only DQUOTE and backslash have escapes in the IMAP grammar - a tab must
716
+ // survive as a raw byte rather than becoming a literal backslash-t
717
+ const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'STRING', value: 'a\tb"c\\d' }] })).toString();
718
+ test.equal(compiled, 'A CMD "a\tb\\"c\\\\d"');
719
+ });
720
+
721
+ module.exports['IMAP Compiler: quoted strings reject CR, LF and NUL'] = test =>
722
+ asyncWrapper(test, async test => {
723
+ for (let value of ['a\rb', 'a\nb', 'a\0b']) {
724
+ let err = await compileError([{ type: 'STRING', value }]);
725
+ test.equal(err && err.code, 'InvalidStringValue', `${JSON.stringify(value)} cannot be sent as a quoted string`);
726
+ }
727
+ });
728
+
729
+ module.exports['IMAP Compiler: a mailbox name with CRLF cannot reach the wire'] = test =>
730
+ asyncWrapper(test, async test => {
731
+ // Paths travel as ATOM tokens and get quoted when they fall outside ATOM-CHAR
732
+ let err = await compileError([{ type: 'ATOM', value: 'INBOX\r\nZZ LOGOUT' }]);
733
+ test.equal(err && err.code, 'InvalidStringValue');
734
+ });
735
+
736
+ module.exports['IMAP Compiler: response text cannot carry a line terminator'] = test =>
737
+ asyncWrapper(test, async test => {
738
+ // TEXT is written verbatim as well, so it gets the same guarantee even though
739
+ // only the parser produces it today
740
+ let err = await compileError([{ type: 'TEXT', value: 'oops\r\nZZ NOOP' }]);
741
+ test.equal(err && err.code, 'InvalidTextValue');
742
+ });
743
+
744
+ module.exports['IMAP Compiler: NUMBER coerces its value instead of writing it through'] = test =>
745
+ asyncWrapper(test, async test => {
746
+ const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value: '1\r\nZZ NOOP' }] })).toString();
747
+ test.equal(compiled, 'A CMD 0', 'a non-numeric value must not reach the wire verbatim');
748
+ test.equal((await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value: '42' }] })).toString(), 'A CMD 42');
749
+ });
750
+
751
+ module.exports['IMAP Compiler: numeric tokens clamp to bounded non-negative integers'] = test =>
752
+ asyncWrapper(test, async test => {
753
+ // Infinity, negatives and unsafe magnitudes would otherwise emit bytes outside
754
+ // the number alphabet ("Infinity", "-5", "1e+300") and get a server BAD blamed
755
+ // on the server instead of the caller
756
+ for (let [value, expected] of [
757
+ [Infinity, '0'],
758
+ ['Infinity', '0'],
759
+ [-5, '0'],
760
+ ['2e21', '0'],
761
+ [4.6, '5']
762
+ ]) {
763
+ const compiled = (await compiler({ tag: 'A', command: 'CMD', attributes: [{ type: 'NUMBER', value }] })).toString();
764
+ test.equal(compiled, `A CMD ${expected}`, `${JSON.stringify(value)} must clamp to ${expected}`);
765
+ }
766
+ });
767
+
768
+ module.exports['IMAP Compiler: partial range coerces its elements'] = test =>
769
+ asyncWrapper(test, async test => {
770
+ // The partial range is the last token component written verbatim, so it is
771
+ // coerced rather than joined as-is
772
+ const attributes = [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: ['0>\r\nZZ NOOP'] }];
773
+ const compiled = (await compiler({ tag: 'A', command: 'FETCH', attributes })).toString();
774
+ test.equal(compiled, 'A FETCH BODY.PEEK[]<0>');
775
+
776
+ const normal = (
777
+ await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 1024] }] })
778
+ ).toString();
779
+ test.equal(normal, 'A FETCH BODY.PEEK[]<0.1024>');
780
+
781
+ // Floats and unbounded numerics clamp the same way NUMBER tokens do, so no
782
+ // non-digit byte ("512.5", "Infinity") can appear inside the partial range
783
+ const float = (
784
+ await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: [0, 512.5] }] })
785
+ ).toString();
786
+ test.equal(float, 'A FETCH BODY.PEEK[]<0.513>');
787
+
788
+ const unbounded = (
789
+ await compiler({ tag: 'A', command: 'FETCH', attributes: [{ type: 'ATOM', value: 'BODY.PEEK', section: [], partial: ['Infinity', '2e21'] }] })
790
+ ).toString();
791
+ test.equal(unbounded, 'A FETCH BODY.PEEK[]<0.0>');
792
+ });
793
+
794
+ module.exports['IMAP Compiler: logging output never throws on unsendable values'] = test =>
795
+ asyncWrapper(test, async test => {
796
+ // The logging pass must survive whatever the wire pass refuses, so a rejected
797
+ // command can still be logged
798
+ const compiled = (
799
+ await compiler(
800
+ {
801
+ tag: 'A',
802
+ command: 'LOGIN',
803
+ attributes: [{ type: 'STRING', value: 'a\r\nb' }]
804
+ },
805
+ { isLogging: true }
806
+ )
807
+ ).toString();
808
+ test.ok(compiled.includes('\\r\\n'), 'control characters stay escaped for the log');
809
+ });
@@ -471,3 +471,137 @@ module.exports['Internals: untaggedFetch includes modseq and flagColor'] = async
471
471
  test.equal(evt.flagColor, 'red');
472
472
  test.done();
473
473
  };
474
+
475
+ // ============================================================================
476
+ // runInternal dispatch guards
477
+ // ============================================================================
478
+
479
+ module.exports['Internals: runInternal returns false for unknown command'] = async test => {
480
+ let client = makeClient();
481
+ client.socket = { destroyed: false };
482
+ let res = await client.runInternal('NOT_A_COMMAND');
483
+ test.equal(res, false);
484
+ test.done();
485
+ };
486
+
487
+ module.exports['Internals: runInternal throws NoConnection when the socket is destroyed'] = async test => {
488
+ let client = makeClient();
489
+ client.socket = { destroyed: true };
490
+ let err = null;
491
+ try {
492
+ await client.runInternal('NOOP');
493
+ } catch (e) {
494
+ err = e;
495
+ }
496
+ test.ok(err);
497
+ test.equal(err.code, 'NoConnection');
498
+ test.done();
499
+ };
500
+
501
+ // ============================================================================
502
+ // send() onSend error containment
503
+ // ============================================================================
504
+
505
+ module.exports['Internals: send contains a throwing onSend callback'] = async test => {
506
+ let client = makeClient();
507
+ let written = [];
508
+ let warnings = [];
509
+ client.socket = { destroyed: false };
510
+ client.writeSocket = { destroyed: false, write: chunk => written.push(chunk) };
511
+ client.state = client.states.AUTHENTICATED;
512
+ client.log.warn = entry => warnings.push(entry);
513
+ client.currentRequest = { tag: 'A1', sent: false };
514
+
515
+ // The command is already on the wire when onSend runs, so a throwing callback
516
+ // must be logged and swallowed instead of rejecting the request
517
+ await client.send({
518
+ tag: 'A1',
519
+ command: 'NOOP',
520
+ attributes: [],
521
+ options: {
522
+ onSend: () => {
523
+ throw new Error('onSend boom');
524
+ }
525
+ }
526
+ });
527
+
528
+ test.equal(written.length, 1, 'the command was written to the socket');
529
+ test.equal(written[0].toString(), 'A1 NOOP\r\n', 'the actual wire bytes went out before onSend ran');
530
+ test.equal(client.currentRequest.sent, true, 'the request is marked as sent');
531
+ test.ok(
532
+ warnings.some(entry => entry && entry.err && entry.err.message === 'onSend boom'),
533
+ 'the callback error was logged'
534
+ );
535
+ test.done();
536
+ };
537
+
538
+ // ============================================================================
539
+ // countUnknownTag teardown guard
540
+ // ============================================================================
541
+
542
+ module.exports['Internals: countUnknownTag ignores tags on a closed connection'] = test => {
543
+ let client = makeClient();
544
+ client.isClosed = true;
545
+ client.countUnknownTag('A1');
546
+ test.equal(client._unknownTagCount, 0, 'teardown crossover is not counted');
547
+ test.done();
548
+ };
549
+
550
+ // ============================================================================
551
+ // rejectUnparsedCompletion
552
+ // ============================================================================
553
+
554
+ // Installs a command that is current and on the wire, returning a holder for
555
+ // the rejection the reader loop would deliver to the caller
556
+ const armInFlight = (client, tag) => {
557
+ let captured = { err: null };
558
+ client.currentRequest = { tag, sent: true };
559
+ client.requestTagMap.set(tag, {
560
+ tag,
561
+ reject: err => {
562
+ captured.err = err;
563
+ }
564
+ });
565
+ return captured;
566
+ };
567
+
568
+ module.exports['Internals: rejectUnparsedCompletion ignores lines without an in-flight command'] = test => {
569
+ let client = makeClient();
570
+
571
+ client.currentRequest = false;
572
+ client.rejectUnparsedCompletion(Buffer.from('A1 OK done'), new Error('parse fail'));
573
+ test.equal(client.currentRequest, false, 'no request to settle');
574
+
575
+ // A command that is current but not yet on the wire must not be settled either
576
+ client.currentRequest = { tag: 'A1', sent: false };
577
+ client.rejectUnparsedCompletion(Buffer.from('A1 OK done'), new Error('parse fail'));
578
+ test.ok(client.currentRequest, 'the unsent request is untouched');
579
+ test.done();
580
+ };
581
+
582
+ module.exports['Internals: rejectUnparsedCompletion recovers the tag from NUL-padded raw bytes'] = async test => {
583
+ let client = makeClient();
584
+ let captured = armInFlight(client, 'A1');
585
+
586
+ // The parser died before extracting a tag - the raw line carries the buggy-server
587
+ // NUL padding, so the fallback must skip it and stop at the first non-tag byte
588
+ let parserError = new Error('parse fail');
589
+ client.rejectUnparsedCompletion(Buffer.from('\x00\x00A1 \x07garbage'), parserError);
590
+
591
+ test.ok(captured.err, 'the in-flight command was failed');
592
+ test.equal(captured.err.code, 'ParserError');
593
+ test.equal(captured.err.parserError, parserError);
594
+ test.equal(client.currentRequest, false, 'the request slot was cleared');
595
+ test.done();
596
+ };
597
+
598
+ module.exports['Internals: rejectUnparsedCompletion ignores a mismatched raw tag'] = test => {
599
+ let client = makeClient();
600
+ let captured = armInFlight(client, 'A1');
601
+
602
+ client.rejectUnparsedCompletion(Buffer.from('A2 NO other'), new Error('parse fail'));
603
+
604
+ test.equal(captured.err, null, 'a line for another tag settles nothing');
605
+ test.ok(client.currentRequest, 'the in-flight request stays current');
606
+ test.done();
607
+ };
@@ -75,37 +75,48 @@ const lineReader = (sock, onLine) => {
75
75
 
76
76
  const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
77
77
 
78
- // ---------------------------------------------------------------------------
79
- // STARTTLS happy path
80
- // ---------------------------------------------------------------------------
81
-
82
- module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
83
- let server = net.createServer(rawSocket => {
78
+ // Builds a STARTTLS mock server. Defaults give the happy path; options tweak a
79
+ // phase without another copy of the upgrade scaffold:
80
+ // - preTlsCaps / postTlsCaps: capability list advertised before / after the upgrade
81
+ // - startTlsOk: tag => string, overrides the response line to the STARTTLS command
82
+ // - onTlsLine: (tlsSocket, line) => boolean, intercepts post-upgrade lines; return
83
+ // true when the line was handled
84
+ const createStartTlsServer = (opts = {}) =>
85
+ net.createServer(rawSocket => {
84
86
  rawSocket.on('error', () => {});
85
87
 
88
+ let preTlsCaps = opts.preTlsCaps || `${CAPS} STARTTLS`;
89
+ let postTlsCaps = opts.postTlsCaps || CAPS;
90
+
86
91
  let detachPlain;
87
92
  detachPlain = lineReader(rawSocket, line => {
88
- handleLine(
89
- rawSocket,
90
- line,
91
- () => {
92
- // Upgrade: stop reading plaintext, wrap the socket in TLS
93
- detachPlain();
94
- let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
95
- tlsSocket.on('error', () => {});
96
- tlsSocket.on('secure', () => {});
97
- // post-TLS phase advertises a different capability set
98
- lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, `${CAPS} POSTTLS-ONLY`));
99
- },
100
- `${CAPS} STARTTLS PRETLS-ONLY`
101
- );
93
+ let parts = line.split(' ');
94
+ let tag = parts[0];
95
+ let cmd = (parts[1] || '').toUpperCase();
96
+
97
+ if (cmd === 'STARTTLS') {
98
+ rawSocket.write(opts.startTlsOk ? opts.startTlsOk(tag) : `${tag} OK Begin TLS\r\n`);
99
+ detachPlain();
100
+ let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
101
+ tlsSocket.on('error', () => {});
102
+ lineReader(tlsSocket, l => {
103
+ if (opts.onTlsLine && opts.onTlsLine(tlsSocket, l)) {
104
+ return;
105
+ }
106
+ handleLine(tlsSocket, l, null, postTlsCaps);
107
+ });
108
+ return;
109
+ }
110
+
111
+ handleLine(rawSocket, line, null, preTlsCaps);
102
112
  });
103
113
 
104
- rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS PRETLS-ONLY] ready\r\n`);
114
+ rawSocket.write(`* OK [CAPABILITY ${preTlsCaps}] ready\r\n`);
105
115
  });
106
116
 
107
- let port = await listen(server);
108
- let client = new ImapFlow({
117
+ // The client options shared by every STARTTLS test.
118
+ const makeStartTlsClient = (port, overrides = {}) =>
119
+ new ImapFlow({
109
120
  host: '127.0.0.1',
110
121
  port,
111
122
  secure: false,
@@ -115,8 +126,30 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
115
126
  disableAutoIdle: true,
116
127
  disableCompression: true,
117
128
  logger: false,
118
- auth: { user: 'test', pass: 'secret' }
129
+ auth: { user: 'test', pass: 'secret' },
130
+ ...overrides
131
+ });
132
+
133
+ // Every terminal upgrade path must leave no upgrade state behind
134
+ const assertUpgradeSettled = (test, client) => {
135
+ test.equal(client.upgrading, false, 'upgrading flag cleared');
136
+ test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
137
+ test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
138
+ };
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // STARTTLS happy path
142
+ // ---------------------------------------------------------------------------
143
+
144
+ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
145
+ // post-TLS phase advertises a different capability set
146
+ let server = createStartTlsServer({
147
+ preTlsCaps: `${CAPS} STARTTLS PRETLS-ONLY`,
148
+ postTlsCaps: `${CAPS} POSTTLS-ONLY`
119
149
  });
150
+
151
+ let port = await listen(server);
152
+ let client = makeStartTlsClient(port);
120
153
  client.on('error', () => {});
121
154
 
122
155
  await client.connect();
@@ -135,24 +168,69 @@ module.exports['Secure: STARTTLS upgrade completes a session'] = async test => {
135
168
  test.done();
136
169
  };
137
170
 
138
- // Builds the STARTTLS happy-path server used by the watchdog and cleanup tests below.
139
- const createStartTlsServer = () =>
140
- net.createServer(rawSocket => {
141
- rawSocket.on('error', () => {});
171
+ module.exports['Secure: STARTTLS discards capabilities even when the OK carries a CAPABILITY code'] = async test => {
172
+ // A server (or a MITM rewriting the plaintext stream) may stamp [CAPABILITY ...]
173
+ // on the STARTTLS OK itself. That marks the capability set as freshly updated, so
174
+ // a discard conditioned on "an update is still pending" would keep exactly the
175
+ // pre-TLS list an attacker controls - the list that then chooses the AUTH
176
+ // mechanism and answers LOGINDISABLED. RFC 9051 6.2.1 makes the discard mandatory.
177
+ let server = createStartTlsServer({
178
+ preTlsCaps: `${CAPS} STARTTLS PRETLS-ONLY`,
179
+ postTlsCaps: `${CAPS} POSTTLS-ONLY`,
180
+ // the OK itself carries a (pre-TLS, attacker-rewritable) CAPABILITY code
181
+ startTlsOk: tag => `${tag} OK [CAPABILITY ${CAPS} PRETLS-ONLY] Begin TLS\r\n`
182
+ });
142
183
 
143
- let detachPlain;
144
- detachPlain = lineReader(rawSocket, line => {
145
- handleLine(rawSocket, line, () => {
146
- detachPlain();
147
- let tlsSocket = new tls.TLSSocket(rawSocket, { isServer: true, key, cert });
148
- tlsSocket.on('error', () => {});
149
- lineReader(tlsSocket, l => handleLine(tlsSocket, l, null, CAPS));
150
- });
151
- });
184
+ let port = await listen(server);
185
+ let client = makeStartTlsClient(port);
186
+ client.on('error', () => {});
152
187
 
153
- rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
188
+ await client.connect();
189
+ test.ok(client.secureConnection, 'connection upgraded to TLS');
190
+ test.ok(client.capabilities.has('POSTTLS-ONLY'), 'post-TLS capabilities were re-fetched');
191
+ test.ok(!client.capabilities.has('PRETLS-ONLY'), 'pre-TLS capabilities were discarded');
192
+
193
+ await client.logout();
194
+ client.close();
195
+ server.close();
196
+ test.done();
197
+ };
198
+
199
+ module.exports['Secure: pre-TLS rawCapabilities do not survive a failed re-fetch'] = async test => {
200
+ // capabilities/authCapabilities are discarded at the upgrade, but rawCapabilities
201
+ // is public surface external consumers read. If the post-TLS CAPABILITY re-fetch
202
+ // fails, the pre-TLS list - the one an active attacker can rewrite - must not
203
+ // linger there either.
204
+ let server = createStartTlsServer({
205
+ preTlsCaps: `${CAPS} STARTTLS PRETLS-ONLY`,
206
+ postTlsCaps: `${CAPS} POSTTLS-ONLY`,
207
+ onTlsLine: (tlsSocket, line) => {
208
+ let parts = line.split(' ');
209
+ if ((parts[1] || '').toUpperCase() === 'CAPABILITY') {
210
+ // the re-fetch over TLS fails
211
+ tlsSocket.write(`${parts[0]} NO CAPABILITY not available\r\n`);
212
+ return true;
213
+ }
214
+ return false;
215
+ }
154
216
  });
155
217
 
218
+ let port = await listen(server);
219
+ let client = makeStartTlsClient(port);
220
+ client.on('error', () => {});
221
+
222
+ await client.connect();
223
+ test.ok(client.secureConnection, 'connection upgraded to TLS');
224
+ test.ok(!client.capabilities.has('PRETLS-ONLY'), 'pre-TLS capabilities were discarded');
225
+ let raw = [].concat(client.rawCapabilities || []);
226
+ test.ok(!raw.some(entry => entry && /PRETLS-ONLY/.test(entry.value || entry)), 'pre-TLS rawCapabilities were discarded');
227
+
228
+ await client.logout();
229
+ client.close();
230
+ server.close();
231
+ test.done();
232
+ };
233
+
156
234
  // Records every socket that went through configureSocket(), which is the single place the
157
235
  // transport options (keepalive + inactivity watchdog) are applied.
158
236
  const trackConfiguredSockets = client => {
@@ -171,19 +249,7 @@ module.exports['Secure: STARTTLS session keeps the inactivity watchdog'] = async
171
249
  // and a dead connection was never noticed.
172
250
  let server = createStartTlsServer();
173
251
  let port = await listen(server);
174
- let client = new ImapFlow({
175
- host: '127.0.0.1',
176
- port,
177
- secure: false,
178
- doSTARTTLS: true,
179
- servername: 'localhost',
180
- tls: { rejectUnauthorized: false },
181
- socketTimeout: 200,
182
- disableAutoIdle: true,
183
- disableCompression: true,
184
- logger: false,
185
- auth: { user: 'test', pass: 'secret' }
186
- });
252
+ let client = makeStartTlsClient(port, { socketTimeout: 200 });
187
253
 
188
254
  let errors = [];
189
255
  client.on('error', err => errors.push(err));
@@ -215,25 +281,12 @@ module.exports['Secure: STARTTLS leaves no upgrade state behind on success'] = a
215
281
  // a successful handshake no timer, rejector, flag or temporary handler survives.
216
282
  let server = createStartTlsServer();
217
283
  let port = await listen(server);
218
- let client = new ImapFlow({
219
- host: '127.0.0.1',
220
- port,
221
- secure: false,
222
- doSTARTTLS: true,
223
- servername: 'localhost',
224
- tls: { rejectUnauthorized: false },
225
- disableAutoIdle: true,
226
- disableCompression: true,
227
- logger: false,
228
- auth: { user: 'test', pass: 'secret' }
229
- });
284
+ let client = makeStartTlsClient(port);
230
285
  client.on('error', () => {});
231
286
 
232
287
  await client.connect();
233
288
 
234
- test.equal(client.upgrading, false, 'upgrading flag cleared');
235
- test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
236
- test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
289
+ assertUpgradeSettled(test, client);
237
290
  // The handshake-only handler is gone, leaving the generic socket error handler as the single
238
291
  // error path (during the handshake it is the other way round, which is what prevents a
239
292
  // handshake error from firing two handlers at once).
@@ -258,18 +311,7 @@ module.exports['Secure: close() during a STARTTLS upgrade settles the upgrade']
258
311
  rawSocket.write(`* OK [CAPABILITY ${CAPS} STARTTLS] ready\r\n`);
259
312
  });
260
313
  let port = await listen(server);
261
- let client = new ImapFlow({
262
- host: '127.0.0.1',
263
- port,
264
- secure: false,
265
- doSTARTTLS: true,
266
- servername: 'localhost',
267
- tls: { rejectUnauthorized: false },
268
- disableAutoIdle: true,
269
- disableCompression: true,
270
- logger: false,
271
- auth: { user: 'test', pass: 'secret' }
272
- });
314
+ let client = makeStartTlsClient(port);
273
315
  client.on('error', () => {});
274
316
 
275
317
  let connectResult = client.connect().then(
@@ -281,15 +323,46 @@ module.exports['Secure: close() during a STARTTLS upgrade settles the upgrade']
281
323
  while (!client.upgrading) {
282
324
  await new Promise(resolve => setTimeout(resolve, 10));
283
325
  }
326
+ // A late socket event would invoke the same settle helper the rejector exposes -
327
+ // capture it before close() consumes it
328
+ let settle = client._upgradeReject;
284
329
  client.close();
285
330
 
286
331
  let err = await connectResult;
287
332
  test.ok(err, 'connect rejected rather than hanging on the abandoned upgrade');
288
333
  test.ok(['ClosedAfterConnectText', 'ClosedAfterConnectTLS', 'NoConnection'].includes(err.code), `connect rejected with ${err.code}`);
289
- test.equal(client.upgrading, false, 'upgrading flag cleared');
290
- test.equal(client._upgradeReject, null, 'upgrade rejector cleared');
291
- test.equal(client.upgradeTimeout, null, 'upgrade timer cleared');
334
+ assertUpgradeSettled(test, client);
335
+
336
+ // The late event lands after the upgrade already settled - it must be a no-op:
337
+ // a settled upgrade neither claims the error as a TLS failure nor re-arms any state
338
+ let lateErr = new Error('late socket error');
339
+ settle(lateErr);
340
+ test.ok(!lateErr.tlsFailed, 'the late error was not marked as a TLS upgrade failure');
341
+ assertUpgradeSettled(test, client);
342
+
343
+ server.close();
344
+ test.done();
345
+ };
346
+
347
+ module.exports['Secure: STARTTLS handshake failure rejects connect'] = async test => {
348
+ let server = createStartTlsServer();
349
+ let port = await listen(server);
350
+ // The tls option is erased entirely, not merely relaxed: certificate validation
351
+ // stays on, so the mock server's self-signed certificate must fail the handshake,
352
+ // and the upgrade builds its TLS options from the no-options fallback
353
+ let client = makeStartTlsClient(port, { tls: undefined });
354
+ client.on('error', () => {});
355
+
356
+ let err = await client.connect().then(
357
+ () => null,
358
+ connectErr => connectErr
359
+ );
292
360
 
361
+ test.ok(err, 'connect rejected on the failed handshake');
362
+ test.ok(err.tlsFailed, 'the error is marked as a TLS upgrade failure');
363
+ assertUpgradeSettled(test, client);
364
+
365
+ client.close();
293
366
  server.close();
294
367
  test.done();
295
368
  };