imapflow 1.6.4 → 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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.6.4"
2
+ ".": "1.6.5"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.5](https://github.com/postalsys/imapflow/compare/v1.6.4...v1.6.5) (2026-07-29)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * resolve regressions from the rev2 hardening commit and harden further ([0739df9](https://github.com/postalsys/imapflow/commit/0739df93709d2c7d572e977c2b37b6d219e69e30))
9
+
3
10
  ## [1.6.4](https://github.com/postalsys/imapflow/compare/v1.6.3...v1.6.4) (2026-07-29)
4
11
 
5
12
 
@@ -19,26 +19,37 @@ module.exports = async connection => {
19
19
  let response;
20
20
  try {
21
21
  response = await connection.exec('COMPRESS', [{ type: 'ATOM', value: 'DEFLATE' }]);
22
- // Everything after the tagged OK is already deflate-framed (RFC 4978 section 4).
23
- // The socket stays piped into the plaintext parser until this call returns, so
24
- // bytes that arrived in the same chunk as the OK have been consumed as cleartext
25
- // and are missing from the head of the deflate stream - the inflater would then
26
- // fail and take the connection with it. Rare (it needs the server to write again
27
- // before we re-pipe), and staying uncompressed is a cheaper outcome than a dead
28
- // connection, so decline the upgrade instead.
29
- // This only covers the bytes the parser had already buffered when the OK was
30
- // handled. Closing the remaining window, and keeping compression rather than
31
- // dropping it, needs the stream to hand back its unconsumed tail on unpipe so
32
- // the transport can feed it into the inflater - not something a command module
33
- // can reach from here.
34
- response.next();
35
- if (response.hasTrailingData) {
36
- connection.log.warn({ msg: 'Server sent data immediately after the COMPRESS response, skipping compression', cid: connection.id });
37
- return false;
38
- }
39
- return true;
40
22
  } catch (err) {
23
+ // The server declined (NO/BAD): nothing switched, staying uncompressed is safe.
41
24
  connection.log.warn({ err, cid: connection.id });
42
25
  return false;
43
26
  }
27
+
28
+ // Everything after the tagged OK is already deflate-framed (RFC 4978 section 4) -
29
+ // the server switches at the OK, so declining the upgrade at this point is not a
30
+ // protocol option. The socket stays piped into the plaintext parser until the
31
+ // transport swaps in the inflater, so bytes that arrived in the same chunk as the
32
+ // OK have been consumed as cleartext and are missing from the head of the deflate
33
+ // stream: the session is unrecoverable in both directions. Fail it immediately
34
+ // (the same way STARTTLS treats post-OK trailing data) instead of letting it die
35
+ // slowly on garbage. Closing this window without failing needs the stream to hand
36
+ // back its unconsumed tail on unpipe so the transport can feed it into the
37
+ // inflater - not something a command module can reach from here.
38
+ if (response.hasTrailingData) {
39
+ let error = new Error('Server sent data between the COMPRESS response and the compression layer switch');
40
+ error.code = 'COMPRESS_TRAILING_DATA';
41
+ connection.log.error({ err: error, cid: connection.id });
42
+ // Schedule the close before releasing parser backpressure, so the buffered
43
+ // deflate-framed bytes cannot settle anything before teardown begins. This is
44
+ // why the decision lives here rather than at the connection layer the way the
45
+ // STARTTLS guard does (starttls.js records a flag, upgradeToSTARTTLS decides):
46
+ // only the command module holds the response before its backpressure release,
47
+ // so only it can order teardown ahead of that release.
48
+ connection.closeAfter();
49
+ response.next();
50
+ throw error;
51
+ }
52
+
53
+ response.next();
54
+ return true;
44
55
  };
@@ -4,11 +4,27 @@
4
4
 
5
5
  const imapFormalSyntax = require('./imap-formal-syntax');
6
6
 
7
- // A sequence-set as defined by the RFC 9051 grammar: comma-separated numbers and
8
- // ranges, where "*" stands for the largest number in use. Digit strings are not
7
+ // A single element of a sequence-set as defined by the RFC 9051 grammar: a number
8
+ // or a range, where "*" stands for the largest number in use. Digit strings are not
9
9
  // range-checked here (a server rejects "0" or an overlong number on its own); the
10
10
  // point of the check is that nothing outside this alphabet can reach the wire.
11
- const SEQUENCE_SET = /^(\d+|\*)(:(\d+|\*))?(,(\d+|\*)(:(\d+|\*))?)*$/;
11
+ const SEQ_RANGE = /^(\d+|\*)(:(\d+|\*))?$/;
12
+
13
+ // Validates a full sequence-set: comma-separated SEQ_RANGE elements, or "$"
14
+ // (RFC 5182 SEARCHRES), which references the previous SEARCH result and is only
15
+ // valid as the entire set. Split into per-element tests on purpose - a whole-set
16
+ // regex with an unbounded repeat group overflows the regex engine's backtrack
17
+ // stack with an uncoded RangeError on valid sets in the million-element range,
18
+ // while the per-element regex is bounded.
19
+ const isValidSequenceSet = value => value === '$' || value.split(',').every(part => SEQ_RANGE.test(part));
20
+
21
+ // Numeric tokens may only put the digit alphabet on the wire. Anything that does
22
+ // not round to a bounded non-negative integer (NaN, Infinity, negatives, unsafe
23
+ // magnitudes) degrades to 0 - the fallback the NaN coercion has always used.
24
+ const safeNumber = value => {
25
+ let num = Math.round(Number(value));
26
+ return Number.isSafeInteger(num) && num >= 0 ? num : 0;
27
+ };
12
28
 
13
29
  // Characters that cannot appear in an IMAP quoted string: CR and LF terminate a
14
30
  // command line, and NUL is outside the CHAR production entirely. A value carrying
@@ -37,33 +53,6 @@ const quoteString = value => {
37
53
  return '"' + value.replace(/["\\]/g, char => '\\' + char) + '"';
38
54
  };
39
55
 
40
- /**
41
- * Formats a response entry into a Buffer.
42
- *
43
- * @param {string|number|Buffer} entry - The value to convert to a Buffer.
44
- * @param {boolean} [returnEmpty] - If true, returns null instead of an empty Buffer when the entry is not a recognized type.
45
- * @returns {Buffer|null} The entry as a Buffer, or null if returnEmpty is true and the entry is not a recognized type.
46
- */
47
- const formatRespEntry = (entry, returnEmpty) => {
48
- if (typeof entry === 'string') {
49
- return Buffer.from(entry);
50
- }
51
-
52
- if (typeof entry === 'number') {
53
- return Buffer.from(entry.toString());
54
- }
55
-
56
- if (Buffer.isBuffer(entry)) {
57
- return entry;
58
- }
59
-
60
- if (returnEmpty) {
61
- return null;
62
- }
63
-
64
- return Buffer.alloc(0);
65
- };
66
-
67
56
  /**
68
57
  * Compiles an input object into a sequence of Buffers representing an IMAP protocol response string.
69
58
  * Handles various node types including literals, strings, atoms, sections, sequences, and nested lists.
@@ -83,7 +72,43 @@ module.exports = async (response, options) => {
83
72
  let { asArray, isLogging, literalPlus, literalMinus } = options || {};
84
73
  const respParts = [];
85
74
 
86
- let resp = [].concat(formatRespEntry(response.tag, true) || []).concat(response.command ? formatRespEntry(' ' + response.command) : []);
75
+ // Formats an entry (string, number or Buffer) into the Buffer that is written to
76
+ // the wire, and is the choke point every emission passes through: a line
77
+ // terminator ends an IMAP command, so no token - the tag and command name
78
+ // included - may put one on the wire. The literal size marker and literal data
79
+ // are the only emissions where CRLF is legitimate; those call sites opt out with
80
+ // `raw`. Never enforced when logging: re-encoding an incoming server response for
81
+ // the log or for error text must not throw, whatever the server sent. With
82
+ // `returnEmpty`, an unrecognized entry type yields null instead of an empty
83
+ // Buffer.
84
+ const emitEntry = (entry, opts) => {
85
+ let { returnEmpty, raw } = opts || {};
86
+ if (!raw && !isLogging && (typeof entry === 'string' || Buffer.isBuffer(entry)) && CRLF.test(entry.toString('latin1'))) {
87
+ let error = new Error('Line terminator in IMAP token');
88
+ error.code = 'InvalidTokenValue';
89
+ throw error;
90
+ }
91
+
92
+ if (typeof entry === 'string') {
93
+ return Buffer.from(entry);
94
+ }
95
+
96
+ if (typeof entry === 'number') {
97
+ return Buffer.from(entry.toString());
98
+ }
99
+
100
+ if (Buffer.isBuffer(entry)) {
101
+ return entry;
102
+ }
103
+
104
+ if (returnEmpty) {
105
+ return null;
106
+ }
107
+
108
+ return Buffer.alloc(0);
109
+ };
110
+
111
+ let resp = [].concat(emitEntry(response.tag, { returnEmpty: true }) || []).concat(response.command ? emitEntry(' ' + response.command) : []);
87
112
  let val;
88
113
  let lastType;
89
114
 
@@ -108,7 +133,7 @@ module.exports = async (response, options) => {
108
133
  // adjacent lists).
109
134
  if (lastType === 'LITERAL' || (!['(', '<', '['].includes(lastRespByte) && resp.length)) {
110
135
  if (!options.subArray) {
111
- resp.push(formatRespEntry(' '));
136
+ resp.push(emitEntry(' '));
112
137
  }
113
138
  }
114
139
 
@@ -119,7 +144,7 @@ module.exports = async (response, options) => {
119
144
 
120
145
  if (Array.isArray(node)) {
121
146
  lastType = 'LIST';
122
- resp.push(formatRespEntry('('));
147
+ resp.push(emitEntry('('));
123
148
 
124
149
  // check if we need to skip separator WS between two arrays
125
150
  let subArray = node.length > 1 && Array.isArray(node[0]);
@@ -131,40 +156,40 @@ module.exports = async (response, options) => {
131
156
  await walk(child, { subArray });
132
157
  }
133
158
 
134
- resp.push(formatRespEntry(')'));
159
+ resp.push(emitEntry(')'));
135
160
  return;
136
161
  }
137
162
 
138
163
  if (!node && typeof node !== 'string' && typeof node !== 'number' && !Buffer.isBuffer(node)) {
139
- resp.push(formatRespEntry('NIL'));
164
+ resp.push(emitEntry('NIL'));
140
165
  return;
141
166
  }
142
167
 
143
168
  if (typeof node === 'string' || Buffer.isBuffer(node)) {
144
169
  if (isLogging && node.length > 100) {
145
- resp.push(formatRespEntry('"(* ' + node.length + 'B string *)"'));
170
+ resp.push(emitEntry('"(* ' + node.length + 'B string *)"'));
146
171
  } else {
147
- resp.push(formatRespEntry(isLogging ? JSON.stringify(node.toString()) : quoteString(node.toString())));
172
+ resp.push(emitEntry(isLogging ? JSON.stringify(node.toString()) : quoteString(node.toString())));
148
173
  }
149
174
  return;
150
175
  }
151
176
 
152
177
  if (typeof node === 'number') {
153
- resp.push(formatRespEntry(Math.round(node) || 0)); // Only integers allowed
178
+ resp.push(emitEntry(safeNumber(node))); // Only bounded non-negative integers allowed
154
179
  return;
155
180
  }
156
181
 
157
182
  lastType = node.type;
158
183
 
159
184
  if (isLogging && node.sensitive) {
160
- resp.push(formatRespEntry('"(* value hidden *)"'));
185
+ resp.push(emitEntry('"(* value hidden *)"'));
161
186
  return;
162
187
  }
163
188
 
164
189
  switch (node.type.toUpperCase()) {
165
190
  case 'LITERAL':
166
191
  if (isLogging) {
167
- resp.push(formatRespEntry('"(* ' + node.value.length + 'B literal *)"'));
192
+ resp.push(emitEntry('"(* ' + node.value.length + 'B literal *)"'));
168
193
  } else {
169
194
  // The literal size marker counts octets - string values are written as
170
195
  // UTF-8, so their UTF-16 .length would undercount multi-byte characters
@@ -180,29 +205,29 @@ module.exports = async (response, options) => {
180
205
  let canAppend = !asArray || usePlus;
181
206
 
182
207
  // Emit the literal header: optional '~' prefix for literal8, then {size[+]}\r\n
183
- resp.push(formatRespEntry(`${node.isLiteral8 ? '~' : ''}{${literalLength}${usePlus ? '+' : ''}}\r\n`));
208
+ resp.push(emitEntry(`${node.isLiteral8 ? '~' : ''}{${literalLength}${usePlus ? '+' : ''}}\r\n`, { raw: true }));
184
209
 
185
210
  if (canAppend) {
186
211
  // Literal data follows immediately in the same buffer segment
187
212
  if (node.value && node.value.length) {
188
- resp.push(formatRespEntry(node.value));
213
+ resp.push(emitEntry(node.value, { raw: true }));
189
214
  }
190
215
  } else {
191
216
  // For synchronizing literals in asArray mode, split output into separate
192
217
  // parts. The caller must send each part and wait for a continuation
193
218
  // response from the server before sending the next.
194
219
  respParts.push(resp);
195
- resp = [].concat(formatRespEntry(node.value, true) || []);
220
+ resp = [].concat(emitEntry(node.value, { returnEmpty: true, raw: true }) || []);
196
221
  }
197
222
  }
198
223
  break;
199
224
 
200
225
  case 'STRING':
201
226
  if (isLogging && node.value.length > 100) {
202
- resp.push(formatRespEntry('"(* ' + node.value.length + 'B string *)"'));
227
+ resp.push(emitEntry('"(* ' + node.value.length + 'B string *)"'));
203
228
  } else {
204
229
  val = (node.value || '').toString();
205
- resp.push(formatRespEntry(isLogging ? JSON.stringify(val) : quoteString(val)));
230
+ resp.push(emitEntry(isLogging ? JSON.stringify(val) : quoteString(val)));
206
231
  }
207
232
  break;
208
233
 
@@ -210,34 +235,39 @@ module.exports = async (response, options) => {
210
235
  // Sequence sets are written verbatim - they are the one token type with
211
236
  // no quoting to fall back on. Callers build them from user-supplied
212
237
  // ranges, so validate here, at the single point every outgoing sequence
213
- // set passes through, rather than trusting each command module. Values
214
- // the parser produced for a server response are already within this
215
- // alphabet, so re-compiling a response (logging, error text) is unaffected.
216
- if (typeof node.value === 'string' || typeof node.value === 'number' || Buffer.isBuffer(node.value)) {
238
+ // set passes through, rather than trusting each command module. Skipped
239
+ // when logging: the incoming token parser accepts sequence-shaped tokens
240
+ // this strict grammar rejects (an ESEARCH set like "1:2:3", a folder
241
+ // name like "12:30:00"), and re-compiling a server response for the log
242
+ // or for error text must never throw.
243
+ if (!isLogging && (typeof node.value === 'string' || typeof node.value === 'number' || Buffer.isBuffer(node.value))) {
217
244
  val = node.value.toString();
218
- if (val && !SEQUENCE_SET.test(val)) {
245
+ if (val && !isValidSequenceSet(val)) {
219
246
  let error = new Error('Invalid sequence set value');
220
247
  error.code = 'InvalidSequenceSet';
221
248
  throw error;
222
249
  }
223
250
  }
224
251
  if (node.value) {
225
- resp.push(formatRespEntry(node.value));
252
+ // raw: the validated alphabet cannot contain a line terminator, and
253
+ // re-scanning a potentially multi-megabyte set in the choke point
254
+ // would double the cost of exactly the sets this branch exists for
255
+ resp.push(emitEntry(node.value, { raw: true }));
226
256
  }
227
257
  break;
228
258
 
229
259
  case 'TEXT':
230
260
  // Response text is written verbatim. Only the parser produces it today, for
231
261
  // incoming lines, so this is a re-encoding path rather than a command-building
232
- // one - but the choke point is only worth anything if it holds for every token
233
- // type, so a line terminator is refused here too.
262
+ // one. The emitEntry choke point would refuse a line terminator here too;
263
+ // this check runs first only to raise the more specific InvalidTextValue code.
234
264
  if (node.value) {
235
265
  if (!isLogging && CRLF.test(node.value.toString())) {
236
266
  let error = new Error('Line terminator in IMAP text value');
237
267
  error.code = 'InvalidTextValue';
238
268
  throw error;
239
269
  }
240
- resp.push(formatRespEntry(node.value));
270
+ resp.push(emitEntry(node.value));
241
271
  }
242
272
  break;
243
273
 
@@ -245,7 +275,7 @@ module.exports = async (response, options) => {
245
275
  // Coerced rather than written through: formatRespEntry passes a string or
246
276
  // Buffer straight to the wire, so a numeric token carrying a string value
247
277
  // would be another verbatim channel
248
- resp.push(formatRespEntry(Math.round(Number(node.value)) || 0));
278
+ resp.push(emitEntry(safeNumber(node.value)));
249
279
  break;
250
280
 
251
281
  case 'ATOM':
@@ -255,24 +285,25 @@ module.exports = async (response, options) => {
255
285
  if (!node.section || val) {
256
286
  // Verify the value contains only valid ATOM-CHAR characters.
257
287
  // Strip a leading backslash before checking (system flags like \Seen start with '\').
258
- // If any character fails verification, quote-escape the entire value with JSON.stringify.
288
+ // If any character fails verification, fall back to an IMAP quoted string
289
+ // (JSON.stringify is used only for log output, where values are display-escaped).
259
290
  if (node.value === '' || imapFormalSyntax.verify(val.charAt(0) === '\\' ? val.substr(1) : val, imapFormalSyntax['ATOM-CHAR']()) >= 0) {
260
291
  val = isLogging ? JSON.stringify(val) : quoteString(val);
261
292
  }
262
293
 
263
- resp.push(formatRespEntry(val));
294
+ resp.push(emitEntry(val));
264
295
  }
265
296
 
266
297
  // Section bracket handling: emit [section-contents] after the ATOM value
267
298
  // e.g., BODY[HEADER.FIELDS (Subject)] or BODY[1.MIME]
268
299
  if (node.section) {
269
- resp.push(formatRespEntry('['));
300
+ resp.push(emitEntry('['));
270
301
 
271
302
  for (let child of node.section) {
272
303
  await walk(child);
273
304
  }
274
305
 
275
- resp.push(formatRespEntry(']'));
306
+ resp.push(emitEntry(']'));
276
307
  }
277
308
  // Partial range: emit <origin.length> after the section brackets. Coerced
278
309
  // rather than joined as-is: this is the last token component written
@@ -280,7 +311,7 @@ module.exports = async (response, options) => {
280
311
  // all of them. Every producer already passes numbers, so nothing changes
281
312
  // for them.
282
313
  if (node.partial) {
283
- resp.push(formatRespEntry(`<${node.partial.map(entry => Number(entry) || 0).join('.')}>`));
314
+ resp.push(emitEntry(`<${node.partial.map(safeNumber).join('.')}>`));
284
315
  }
285
316
  break;
286
317
  }
@@ -81,6 +81,13 @@ module.exports = async (command, options) => {
81
81
  if (err.code === 'ParserErrorExchange' && err.parserContext && err.parserContext.value) {
82
82
  return err.parserContext.value;
83
83
  }
84
+ if (response.tag) {
85
+ // The tag had already been parsed when the rest of the line failed. Expose it
86
+ // so the connection can settle the command this line was addressed to - unlike
87
+ // re-deriving the tag from the raw bytes, this inherits the leading-NUL
88
+ // workaround above.
89
+ err.parsedTag = response.tag;
90
+ }
84
91
  throw err;
85
92
  }
86
93
 
@@ -166,23 +166,35 @@ class ImapStream extends Transform {
166
166
 
167
167
  // Scan backwards through the line to find an IMAP literal marker: {size}\r\n
168
168
  // The format is: '{' followed by one or more ASCII digits followed by '}'.
169
- // Only the digit run's bounds are tracked, and the run is capped: the size is
170
- // a number64 at most, so anything longer cannot be a valid marker. Collecting
171
- // the digits into a growing array instead would make a line of n digits cost
172
- // O(n^2), letting a few hundred KB of digits block the event loop for seconds
173
- // before the size is even known - well inside the default line-length cap.
174
- const MAX_SIZE_DIGITS = 19;
169
+ // Only the digit run's bounds are tracked - a single linear pass, unlike
170
+ // collecting digits into a growing array, which would make a line of n digits
171
+ // cost O(n^2). The run length is deliberately not capped: the RFC "number"
172
+ // production permits leading zeros, so a long digit run can still denote a
173
+ // small, valid size, and treating the marker as an ordinary line instead
174
+ // would feed the announced literal body to the line parser and desynchronize
175
+ // the session.
175
176
  let digitsEnd = pos;
176
177
  for (; pos >= 0; pos--) {
177
178
  let c = line[pos];
178
179
  if (c >= NUM_0 && c <= NUM_9) {
179
- if (digitsEnd - pos >= MAX_SIZE_DIGITS) {
180
- return false;
181
- }
182
180
  continue;
183
181
  }
184
182
  if (c === CURLY_OPEN && pos < digitsEnd) {
185
- const literalSize = Number(line.toString('latin1', pos + 1, digitsEnd + 1));
183
+ // Skip leading zeros so only the significant digits are converted: a
184
+ // marker padded with megabytes of zeros must not cost a string
185
+ // allocation and Number() parse of the whole run.
186
+ let digitsStart = pos + 1;
187
+ while (digitsStart < digitsEnd && line[digitsStart] === NUM_0) {
188
+ digitsStart++;
189
+ }
190
+
191
+ // More significant digits than any number64 has cannot fit any
192
+ // permissible maxLiteralSize; fail closed without materializing them
193
+ if (digitsEnd + 1 - digitsStart > 19) {
194
+ return this.failStream(createLiteralTooLargeError(Infinity, this.maxLiteralSize, 'the widest permissible literal size (19 digits)'));
195
+ }
196
+
197
+ const literalSize = Number(line.toString('latin1', digitsStart, digitsEnd + 1));
186
198
 
187
199
  if (literalSize > this.maxLiteralSize) {
188
200
  return this.failStream(createLiteralTooLargeError(literalSize, this.maxLiteralSize));
package/lib/imap-flow.js CHANGED
@@ -643,22 +643,40 @@ class ImapFlow extends EventEmitter {
643
643
  }
644
644
 
645
645
  if (typeof options.onSend === 'function') {
646
- options.onSend();
646
+ // The command is already on the wire, so a throwing onSend callback must not
647
+ // reach trySend()'s catch - that would reject the request and dispatch the
648
+ // next command into the server's pending state for this one.
649
+ try {
650
+ options.onSend();
651
+ } catch (err) {
652
+ this.log.warn({ err, cid: this.id });
653
+ }
647
654
  }
648
655
  }
649
656
 
650
657
  async trySend() {
651
- if (this.currentRequest || !this.requestQueue.length) {
652
- return;
653
- }
654
- this.currentRequest = this.requestQueue.shift();
658
+ while (!this.currentRequest && this.requestQueue.length) {
659
+ this.currentRequest = this.requestQueue.shift();
655
660
 
656
- await this.send({
657
- tag: this.currentRequest.tag,
658
- command: this.currentRequest.command,
659
- attributes: this.currentRequest.attributes,
660
- options: this.currentRequest.options
661
- });
661
+ try {
662
+ await this.send({
663
+ tag: this.currentRequest.tag,
664
+ command: this.currentRequest.command,
665
+ attributes: this.currentRequest.attributes,
666
+ options: this.currentRequest.options
667
+ });
668
+ return;
669
+ } catch (err) {
670
+ // A failure here (most likely the compiler refusing an invalid
671
+ // user-supplied value) belongs to the command that was being dispatched.
672
+ // Without this the shifted request would stay currentRequest forever:
673
+ // nothing reached the wire, so no tagged response ever clears it, and
674
+ // every later command would queue behind it until the socket timeout.
675
+ // Reject the failed command and keep draining the queue.
676
+ this.commandParts = [];
677
+ this.rejectCurrentRequest(err);
678
+ }
679
+ }
662
680
  }
663
681
 
664
682
  exec(command, attributes, options) {
@@ -685,10 +703,10 @@ class ImapFlow extends EventEmitter {
685
703
  let promise = new Promise((resolve, reject) => {
686
704
  this.requestTagMap.set(tag, { command, attributes, options, resolve, reject });
687
705
  this.requestQueue.push({ tag, command, attributes, options });
688
- this.trySend().catch(err => {
689
- this.requestTagMap.delete(tag);
690
- reject(err);
691
- });
706
+ // trySend() settles dispatch failures itself, by rejecting the affected
707
+ // command through requestTagMap; this catch exists only so a throw from the
708
+ // dispatch machinery itself can never surface as a floating rejection.
709
+ this.trySend().catch(err => this.log.warn({ err, cid: this.id }));
692
710
  });
693
711
 
694
712
  // Prevent unhandled promise rejection if close() rejects this request
@@ -848,8 +866,17 @@ class ImapFlow extends EventEmitter {
848
866
  return;
849
867
  }
850
868
 
851
- let tag = payload.toString('latin1', 0, 64).match(/^(\S+)/);
852
- if (!tag || tag[1] !== this.currentRequest.tag) {
869
+ // Prefer the tag the parser had already extracted before it failed - it went
870
+ // through the same leading-NUL workaround as every parsed response. Fall back
871
+ // to the raw bytes for lines whose tag itself was unparseable: skip the NUL
872
+ // padding buggy servers prepend and stop at the first byte a tag cannot contain.
873
+ let tag = parserError && parserError.parsedTag;
874
+ if (!tag) {
875
+ // eslint-disable-next-line no-control-regex
876
+ let match = payload.toString('latin1', 0, 64).match(/^\0*([^\s\x00-\x1f\x7f]+)/);
877
+ tag = match && match[1];
878
+ }
879
+ if (!tag || tag !== this.currentRequest.tag) {
853
880
  return;
854
881
  }
855
882
 
@@ -873,20 +900,6 @@ class ImapFlow extends EventEmitter {
873
900
 
874
901
  try {
875
902
  parsed = await parser(data.payload, { literals: data.literals });
876
- if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
877
- let payload = { response: parsed.command };
878
-
879
- if (
880
- parsed.attributes &&
881
- parsed.attributes[0] &&
882
- parsed.attributes[0].section &&
883
- parsed.attributes[0].section[0] &&
884
- parsed.attributes[0].section[0].type === 'ATOM'
885
- ) {
886
- payload.code = parsed.attributes[0].section[0].value;
887
- }
888
- this.emit('response', payload);
889
- }
890
903
  } catch (err) {
891
904
  // can not make sense of this
892
905
  this.log.error({ src: 's', msg: data.payload.toString(), err, cid: this.id });
@@ -901,6 +914,28 @@ class ImapFlow extends EventEmitter {
901
914
  return true;
902
915
  }
903
916
 
917
+ if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
918
+ let payload = { response: parsed.command };
919
+
920
+ if (
921
+ parsed.attributes &&
922
+ parsed.attributes[0] &&
923
+ parsed.attributes[0].section &&
924
+ parsed.attributes[0].section[0] &&
925
+ parsed.attributes[0].section[0].type === 'ATOM'
926
+ ) {
927
+ payload.code = parsed.attributes[0].section[0].value;
928
+ }
929
+ // Outside the parse try/catch on purpose: a throwing user 'response' listener
930
+ // is not a parse failure and must not settle the in-flight command or fail the
931
+ // connection - the same contract untagged handlers get.
932
+ try {
933
+ this.emit('response', payload);
934
+ } catch (err) {
935
+ this.log.warn({ err, cid: this.id });
936
+ }
937
+ }
938
+
904
939
  let logCompiled = await compiler(parsed, {
905
940
  isLogging: true
906
941
  });
@@ -1672,8 +1707,7 @@ class ImapFlow extends EventEmitter {
1672
1707
  // STARTTLS OK itself clears expectCapabilityUpdate, so keying the discard
1673
1708
  // on that flag would keep exactly the pre-TLS list an attacker controls -
1674
1709
  // the list that then picks the AUTH mechanism and answers LOGINDISABLED.
1675
- this.capabilities.clear();
1676
- this.authCapabilities.clear();
1710
+ this.clearCapabilities();
1677
1711
  await this.run('CAPABILITY');
1678
1712
  }
1679
1713
 
@@ -1818,6 +1852,17 @@ class ImapFlow extends EventEmitter {
1818
1852
  this.state = this.states.LOGOUT;
1819
1853
  }
1820
1854
 
1855
+ // Drops every capability-derived field together - the counterpart of
1856
+ // updateCapabilitiesFromRaw() below, which sets them together. rawCapabilities is
1857
+ // public surface external consumers read, so a discard (RFC 9051 6.2.1 requires
1858
+ // one after STARTTLS) that missed it would leave the stale list visible if the
1859
+ // re-fetch fails.
1860
+ clearCapabilities() {
1861
+ this.capabilities.clear();
1862
+ this.authCapabilities.clear();
1863
+ this.rawCapabilities = null;
1864
+ }
1865
+
1821
1866
  updateCapabilitiesFromRaw(rawCapabilities) {
1822
1867
  this.rawCapabilities = rawCapabilities;
1823
1868
  this.capabilities = updateCapabilities(rawCapabilities);